From 78b722e12030081eb67415226f0286e7ab6e7354 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 1 Oct 2026 15:42:52 +0700
Subject: [PATCH] x
---
.../page-builder/AddSectionModal.tsx | 15 +-
.../components/page-builder/DividerForm.tsx | 161 +++++++++
.../products/ProductDividerBlockForm.tsx | 159 +++++++++
.../products/ProductHeroCarouselBlockForm.tsx | 277 +++++++++++++++
.../products/productHeroCarouselValidation.ts | 25 ++
apps/admin/src/pages/PageBuilderPage.tsx | 63 +++-
apps/admin/src/pages/ProductCmsPage.tsx | 120 ++++++-
.../productHeroCarouselValidation.test.ts | 34 ++
.../src/lib/productDetailSections.test.ts | 318 ++++++++++++++++++
apps/backend/src/lib/productDetailSections.ts | 171 ++++++++++
.../src/routes/pages.localization.test.ts | 60 ++++
apps/backend/src/routes/pages.ts | 4 +
apps/backend/src/routes/products.ts | 71 ++--
.../src/components/DynamicPageRenderer.tsx | 5 +-
apps/spa/src/components/PageDivider.tsx | 55 +++
apps/spa/src/components/ProductDivider.tsx | 55 +++
.../src/components/ProductHeroCarousel.tsx | 217 ++++++++++++
apps/spa/src/pages/ProductDetailPage.tsx | 16 +
.../src/app/[lang]/products/[slug]/page.tsx | 11 +-
apps/website/src/components/PageDivider.tsx | 55 +++
.../website/src/components/ProductDivider.tsx | 55 +++
.../src/components/ProductHeroCarousel.tsx | 225 +++++++++++++
.../dynamic/DynamicPageRenderer.tsx | 5 +-
packages/types/index.ts | 18 +-
24 files changed, 2130 insertions(+), 65 deletions(-)
create mode 100644 apps/admin/src/components/page-builder/DividerForm.tsx
create mode 100644 apps/admin/src/components/products/ProductDividerBlockForm.tsx
create mode 100644 apps/admin/src/components/products/ProductHeroCarouselBlockForm.tsx
create mode 100644 apps/admin/src/components/products/productHeroCarouselValidation.ts
create mode 100644 apps/admin/tests/productHeroCarouselValidation.test.ts
create mode 100644 apps/backend/src/lib/productDetailSections.test.ts
create mode 100644 apps/backend/src/lib/productDetailSections.ts
create mode 100644 apps/spa/src/components/PageDivider.tsx
create mode 100644 apps/spa/src/components/ProductDivider.tsx
create mode 100644 apps/spa/src/components/ProductHeroCarousel.tsx
create mode 100644 apps/website/src/components/PageDivider.tsx
create mode 100644 apps/website/src/components/ProductDivider.tsx
create mode 100644 apps/website/src/components/ProductHeroCarousel.tsx
diff --git a/apps/admin/src/components/page-builder/AddSectionModal.tsx b/apps/admin/src/components/page-builder/AddSectionModal.tsx
index 28e09c7..de860c8 100644
--- a/apps/admin/src/components/page-builder/AddSectionModal.tsx
+++ b/apps/admin/src/components/page-builder/AddSectionModal.tsx
@@ -706,7 +706,20 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
)
},
- // 30. CUSTOM
+ // 30. DIVIDER
+ {
+ type: 'DIVIDER',
+ category: 'custom',
+ title: 'DIVIDER (Section Separator)',
+ description: 'A configurable horizontal line for separating adjacent CMS blocks.',
+ badge: 'Utility',
+ renderPreview: () => (
+
+ )
+ },
+ // 31. CUSTOM
{
type: 'CUSTOM',
category: 'custom',
diff --git a/apps/admin/src/components/page-builder/DividerForm.tsx b/apps/admin/src/components/page-builder/DividerForm.tsx
new file mode 100644
index 0000000..565b158
--- /dev/null
+++ b/apps/admin/src/components/page-builder/DividerForm.tsx
@@ -0,0 +1,161 @@
+import { type PageSection } from '@homaroma/types'
+
+interface DividerFormProps {
+ editingSection: Partial
+ updatePayloadValue: (field: string, value: unknown) => void
+}
+
+const WIDTH_OPTIONS = [
+ { value: 'full', label: 'Full width' },
+ { value: 'container', label: 'Container' },
+ { value: 'narrow', label: 'Narrow' },
+] as const
+
+const STYLE_OPTIONS = [
+ { value: 'solid', label: 'Solid' },
+ { value: 'dashed', label: 'Dashed' },
+ { value: 'dotted', label: 'Dotted' },
+] as const
+
+const SPACING_OPTIONS = [
+ { value: 'none', label: 'None' },
+ { value: 'sm', label: 'Small' },
+ { value: 'md', label: 'Medium' },
+ { value: 'lg', label: 'Large' },
+ { value: 'xl', label: 'Extra large' },
+] as const
+
+const getString = (value: unknown, fallback: string) =>
+ typeof value === 'string' ? value : fallback
+
+const getThickness = (value: unknown) =>
+ typeof value === 'number' && value >= 1 && value <= 4 ? value : 1
+
+const getLineStyle = (value: unknown): 'solid' | 'dashed' | 'dotted' =>
+ value === 'dashed' || value === 'dotted' ? value : 'solid'
+
+const DEFAULT_DIVIDER_COLOR = '#94a3b8'
+const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
+
+const toColorPickerValue = (color: string) => {
+ if (!HEX_COLOR_PATTERN.test(color)) return DEFAULT_DIVIDER_COLOR
+
+ const digits = color.slice(1)
+ if (digits.length === 3 || digits.length === 4) {
+ return `#${digits.slice(0, 3).split('').map((digit) => `${digit}${digit}`).join('')}`
+ }
+
+ return `#${digits.slice(0, 6)}`
+}
+
+export default function DividerForm({ editingSection, updatePayloadValue }: DividerFormProps) {
+ const payload = editingSection.payload ?? {}
+ const width = getString(payload.width, 'container')
+ const style = getLineStyle(payload.style)
+ const thickness = getThickness(payload.thickness)
+ const color = getString(payload.color, DEFAULT_DIVIDER_COLOR)
+ const isColorValid = HEX_COLOR_PATTERN.test(color)
+ const previewColor = isColorValid ? color : DEFAULT_DIVIDER_COLOR
+ const pickerColor = toColorPickerValue(color)
+
+ return (
+
+
+
Page Section Divider
+
+ Add a visual separator between Page CMS blocks. Spacing is applied above and below the line.
+
+
+
+
+
+ Width
+ updatePayloadValue('width', event.target.value)}
+ className="admin-input w-full bg-slate-900 border-white/10"
+ >
+ {WIDTH_OPTIONS.map((option) => {option.label} )}
+
+
+
+
+ Line style
+ updatePayloadValue('style', event.target.value)}
+ className="admin-input w-full bg-slate-900 border-white/10"
+ >
+ {STYLE_OPTIONS.map((option) => {option.label} )}
+
+
+
+
+ Thickness
+ updatePayloadValue('thickness', Number(event.target.value))}
+ className="admin-input w-full bg-slate-900 border-white/10"
+ >
+ {[1, 2, 3, 4].map((value) => {value}px )}
+
+
+
+
+ Vertical spacing
+ updatePayloadValue('spacing', event.target.value)}
+ className="admin-input w-full bg-slate-900 border-white/10"
+ >
+ {SPACING_OPTIONS.map((option) => {option.label} )}
+
+
+
+
+
+ Line color
+
+
+ Choose divider color
+ updatePayloadValue('color', event.target.value)}
+ className="h-11 w-14 cursor-pointer rounded-lg border border-white/10 bg-slate-900 p-1"
+ />
+ updatePayloadValue('color', event.target.value)}
+ placeholder={DEFAULT_DIVIDER_COLOR}
+ pattern="#[0-9A-Fa-f]{3}(?:[0-9A-Fa-f])?|#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?"
+ spellCheck={false}
+ aria-invalid={!isColorValid}
+ aria-describedby="page-divider-color-help"
+ className="admin-input min-w-0 flex-1 bg-slate-900 border-white/10 font-mono"
+ />
+
+
+ {isColorValid ? 'Accepted formats: #RGB, #RGBA, #RRGGBB, or #RRGGBBAA.' : 'Enter a valid hex color. The preview keeps a safe fallback until the value is complete.'}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/components/products/ProductDividerBlockForm.tsx b/apps/admin/src/components/products/ProductDividerBlockForm.tsx
new file mode 100644
index 0000000..acc3079
--- /dev/null
+++ b/apps/admin/src/components/products/ProductDividerBlockForm.tsx
@@ -0,0 +1,159 @@
+interface ProductDividerBlockFormProps {
+ payload: Record
+ onChange: (payload: Record) => void
+}
+
+const WIDTH_OPTIONS = [
+ { value: 'full', label: 'Full width' },
+ { value: 'container', label: 'Container' },
+ { value: 'narrow', label: 'Narrow' },
+] as const
+
+const STYLE_OPTIONS = [
+ { value: 'solid', label: 'Solid' },
+ { value: 'dashed', label: 'Dashed' },
+ { value: 'dotted', label: 'Dotted' },
+] as const
+
+const SPACING_OPTIONS = [
+ { value: 'none', label: 'None' },
+ { value: 'sm', label: 'Small' },
+ { value: 'md', label: 'Medium' },
+ { value: 'lg', label: 'Large' },
+ { value: 'xl', label: 'Extra large' },
+] as const
+
+const getString = (value: unknown, fallback: string) =>
+ typeof value === 'string' ? value : fallback
+
+const getThickness = (value: unknown) =>
+ typeof value === 'number' && value >= 1 && value <= 4 ? value : 1
+
+const getLineStyle = (value: unknown): 'solid' | 'dashed' | 'dotted' =>
+ value === 'dashed' || value === 'dotted' ? value : 'solid'
+
+const DEFAULT_DIVIDER_COLOR = '#94a3b8'
+const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
+
+const toColorPickerValue = (color: string) => {
+ if (!HEX_COLOR_PATTERN.test(color)) return DEFAULT_DIVIDER_COLOR
+
+ const digits = color.slice(1)
+ if (digits.length === 3 || digits.length === 4) {
+ return `#${digits.slice(0, 3).split('').map((digit) => `${digit}${digit}`).join('')}`
+ }
+
+ return `#${digits.slice(0, 6)}`
+}
+
+export default function ProductDividerBlockForm({ payload, onChange }: ProductDividerBlockFormProps) {
+ const width = getString(payload.width, 'container')
+ const style = getLineStyle(payload.style)
+ const thickness = getThickness(payload.thickness)
+ const color = getString(payload.color, DEFAULT_DIVIDER_COLOR)
+ const isColorValid = HEX_COLOR_PATTERN.test(color)
+ const previewColor = isColorValid ? color : DEFAULT_DIVIDER_COLOR
+ const pickerColor = toColorPickerValue(color)
+ const updatePayload = (field: string, value: unknown) => onChange({ ...payload, [field]: value })
+
+ return (
+
+
+
Product Section Divider
+
+ Add a visual separator between blocks on this product detail page. This form is scoped to Product CMS.
+
+
+
+
+
+ Width
+ updatePayload('width', event.target.value)}
+ className="admin-input w-full bg-slate-900 border-white/10"
+ >
+ {WIDTH_OPTIONS.map((option) => {option.label} )}
+
+
+
+
+ Line style
+ updatePayload('style', event.target.value)}
+ className="admin-input w-full bg-slate-900 border-white/10"
+ >
+ {STYLE_OPTIONS.map((option) => {option.label} )}
+
+
+
+
+ Thickness
+ updatePayload('thickness', Number(event.target.value))}
+ className="admin-input w-full bg-slate-900 border-white/10"
+ >
+ {[1, 2, 3, 4].map((value) => {value}px )}
+
+
+
+
+ Vertical spacing
+ updatePayload('spacing', event.target.value)}
+ className="admin-input w-full bg-slate-900 border-white/10"
+ >
+ {SPACING_OPTIONS.map((option) => {option.label} )}
+
+
+
+
+
+ Line color
+
+
+ Choose divider color
+ updatePayload('color', event.target.value)}
+ className="h-11 w-14 cursor-pointer rounded-lg border border-white/10 bg-slate-900 p-1"
+ />
+ updatePayload('color', event.target.value)}
+ placeholder={DEFAULT_DIVIDER_COLOR}
+ pattern="#[0-9A-Fa-f]{3}(?:[0-9A-Fa-f])?|#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?"
+ spellCheck={false}
+ aria-invalid={!isColorValid}
+ aria-describedby="product-divider-color-help"
+ className="admin-input min-w-0 flex-1 bg-slate-900 border-white/10 font-mono"
+ />
+
+
+ {isColorValid ? 'Accepted formats: #RGB, #RGBA, #RRGGBB, or #RRGGBBAA.' : 'Enter a valid hex color. The preview keeps a safe fallback until the value is complete.'}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/components/products/ProductHeroCarouselBlockForm.tsx b/apps/admin/src/components/products/ProductHeroCarouselBlockForm.tsx
new file mode 100644
index 0000000..9e5fc67
--- /dev/null
+++ b/apps/admin/src/components/products/ProductHeroCarouselBlockForm.tsx
@@ -0,0 +1,277 @@
+import {
+ ChevronDown,
+ ChevronUp,
+ ImagePlus,
+ Monitor,
+ Plus,
+ Smartphone,
+ Trash2,
+} from 'lucide-react'
+
+type ProductHeroCarouselBlockFormProps = {
+ payload: Record
+ activeLanguage: string
+ onChange: (payload: Record) => void
+ onBrowseMedia: (onSelect: (url: string) => void) => void
+}
+
+type SlidePayload = Record
+
+const DESKTOP_ASPECT_RATIOS = ['21/6', '21/7', '21/9', '16/5', '16/6', '4/1', '5/1', 'auto'] as const
+const MOBILE_ASPECT_RATIOS = ['16/9', '4/3', '1/1', '21/9', 'auto'] as const
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+
+const createSlide = (): SlidePayload => ({
+ imageUrl: '',
+ mobileImageUrl: '',
+ linkUrl: '',
+ alt: {},
+ title: {},
+ subtitle: {},
+ buttonText: {},
+ focalPoint: { x: '50%', y: '50%' },
+})
+
+const getSlides = (payload: Record): SlidePayload[] =>
+ Array.isArray(payload.slides) ? payload.slides.filter(isRecord) : []
+
+const getLocalizedValue = (value: unknown, language: string) => {
+ if (typeof value === 'string') return value
+ if (!isRecord(value)) return ''
+ const localized = value[language]
+ return typeof localized === 'string' ? localized : ''
+}
+
+const updateLocalizedValue = (value: unknown, language: string, nextValue: string) => ({
+ ...(typeof value === 'string' ? { th: value } : isRecord(value) ? value : {}),
+ [language]: nextValue,
+})
+
+const getString = (value: unknown, fallback = '') => typeof value === 'string' ? value : fallback
+
+export default function ProductHeroCarouselBlockForm({
+ payload,
+ activeLanguage,
+ onChange,
+ onBrowseMedia,
+}: ProductHeroCarouselBlockFormProps) {
+ const slides = getSlides(payload)
+
+ const updateRoot = (field: string, value: unknown) => {
+ onChange({ ...payload, [field]: value })
+ }
+
+ const updateSlide = (index: number, update: SlidePayload) => {
+ updateRoot('slides', slides.map((slide, slideIndex) => slideIndex === index ? { ...slide, ...update } : slide))
+ }
+
+ const addSlide = () => updateRoot('slides', [...slides, createSlide()])
+
+ const removeSlide = (index: number) => {
+ if (slides.length <= 1) return
+ updateRoot('slides', slides.filter((_, slideIndex) => slideIndex !== index))
+ }
+
+ const moveSlide = (index: number, direction: -1 | 1) => {
+ const targetIndex = index + direction
+ if (targetIndex < 0 || targetIndex >= slides.length) return
+ const reordered = [...slides]
+ const [movedSlide] = reordered.splice(index, 1)
+ reordered.splice(targetIndex, 0, movedSlide)
+ updateRoot('slides', reordered)
+ }
+
+ return (
+
+
+
Product Wide Promo Slider
+
This form is scoped to Product CMS and does not share UI code with Page CMS.
+
+
+
+
+ Desktop ratio
+ updateRoot('aspectRatio', event.target.value)}
+ className="admin-input w-full bg-slate-900 border-white/10"
+ >
+ {DESKTOP_ASPECT_RATIOS.map((ratio) => {ratio} )}
+
+
+
+ Mobile ratio
+ updateRoot('mobileAspectRatio', event.target.value)}
+ className="admin-input w-full bg-slate-900 border-white/10"
+ >
+ {MOBILE_ASPECT_RATIOS.map((ratio) => {ratio} )}
+
+
+
+ Interval (ms)
+ updateRoot('interval', Number(event.target.value))}
+ className="admin-input w-full disabled:opacity-40"
+ />
+
+ {([
+ ['Autoplay', 'autoplay'],
+ ['Show arrows', 'showArrows'],
+ ['Show dots', 'showDots'],
+ ] as const).map(([label, field]) => (
+
+ updateRoot(field, event.target.checked)}
+ className="accent-cyan-glow"
+ />
+ {label}
+
+ ))}
+
+
+
+ {slides.map((slide, slideIndex) => {
+ const focalPoint = isRecord(slide.focalPoint) ? slide.focalPoint : {}
+ const focalX = getString(focalPoint.x, '50%')
+ const focalY = getString(focalPoint.y, '50%')
+
+ return (
+
+
+
+
Slide {slideIndex + 1}
+
Desktop image is required. Mobile falls back to desktop.
+
+
+ moveSlide(slideIndex, -1)} disabled={slideIndex === 0} className="rounded-lg p-2 text-slate-500 hover:bg-white/5 hover:text-white disabled:opacity-30" aria-label={`Move slide ${slideIndex + 1} up`}>
+
+
+ moveSlide(slideIndex, 1)} disabled={slideIndex === slides.length - 1} className="rounded-lg p-2 text-slate-500 hover:bg-white/5 hover:text-white disabled:opacity-30" aria-label={`Move slide ${slideIndex + 1} down`}>
+
+
+ removeSlide(slideIndex)} disabled={slides.length === 1} className="rounded-lg p-2 text-slate-500 hover:bg-rose-500/10 hover:text-rose-400 disabled:opacity-30" aria-label={`Delete slide ${slideIndex + 1}`}>
+
+
+
+
+
+
+ {([
+ { field: 'imageUrl', label: 'Desktop image *', icon: Monitor },
+ { field: 'mobileImageUrl', label: 'Mobile image', icon: Smartphone },
+ ] as const).map(({ field, label, icon: DeviceIcon }) => {
+ const imageUrl = getString(slide[field])
+ return (
+
+
+ {imageUrl ? (
+
+ ) : (
+
+ )}
+
+
+ updateSlide(slideIndex, { [field]: event.target.value })}
+ placeholder={`${label} URL`}
+ className="admin-input min-w-0 flex-1"
+ />
+ onBrowseMedia((url) => updateSlide(slideIndex, { [field]: url }))}
+ className="rounded-xl border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2.5 text-cyan-glow hover:bg-cyan-glow/20"
+ aria-label={`Choose ${label.toLowerCase()} from media library`}
+ >
+
+
+
+
+ )
+ })}
+
+
+
+ {([
+ ['title', 'Title'],
+ ['subtitle', 'Subtitle'],
+ ['buttonText', 'Button text'],
+ ['alt', 'Image alt text'],
+ ] as const).map(([field, label]) => (
+
+ {label} ({activeLanguage.toUpperCase()})
+ updateSlide(slideIndex, { [field]: updateLocalizedValue(slide[field], activeLanguage, event.target.value) })}
+ className="admin-input w-full"
+ />
+
+ ))}
+
+ Link URL
+ updateSlide(slideIndex, { linkUrl: event.target.value })}
+ placeholder="/shop or https://..."
+ className="admin-input w-full"
+ />
+
+
+ Focal point X
+ updateSlide(slideIndex, { focalPoint: { ...focalPoint, x: event.target.value } })}
+ placeholder="50%"
+ className="admin-input w-full"
+ />
+
+
+ Focal point Y
+ updateSlide(slideIndex, { focalPoint: { ...focalPoint, y: event.target.value } })}
+ placeholder="50%"
+ className="admin-input w-full"
+ />
+
+
+
+ )
+ })}
+
+
+ {slides.length === 0 && (
+
+ Add at least one slide before saving.
+
+ )}
+
+
+
+ Add Slide
+
+
+ )
+}
diff --git a/apps/admin/src/components/products/productHeroCarouselValidation.ts b/apps/admin/src/components/products/productHeroCarouselValidation.ts
new file mode 100644
index 0000000..971a287
--- /dev/null
+++ b/apps/admin/src/components/products/productHeroCarouselValidation.ts
@@ -0,0 +1,25 @@
+const isRecord = (value: unknown): value is Record => (
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+)
+
+export const validateProductHeroCarouselPayload = (payload: unknown): string[] => {
+ if (!isRecord(payload) || !Array.isArray(payload.slides) || payload.slides.length === 0) {
+ return ['Add at least one slide.']
+ }
+
+ const issues = payload.slides.flatMap((slide, index) => {
+ if (!isRecord(slide) || typeof slide.imageUrl !== 'string' || slide.imageUrl.trim().length === 0) {
+ return [`Slide ${index + 1}: Desktop image is required.`]
+ }
+ return []
+ })
+
+ if (
+ payload.autoplay !== false
+ && (typeof payload.interval !== 'number' || !Number.isFinite(payload.interval) || payload.interval < 1000)
+ ) {
+ issues.push('Autoplay interval must be at least 1000 ms.')
+ }
+
+ return issues
+}
diff --git a/apps/admin/src/pages/PageBuilderPage.tsx b/apps/admin/src/pages/PageBuilderPage.tsx
index 34c849f..f9e0415 100644
--- a/apps/admin/src/pages/PageBuilderPage.tsx
+++ b/apps/admin/src/pages/PageBuilderPage.tsx
@@ -61,6 +61,7 @@ import HeroV2Form from '../components/page-builder/HeroV2Form';
import UspBarForm from '../components/page-builder/UspBarForm';
import CustomBlockForm from '../components/page-builder/CustomBlockForm';
import ProductLayoutBlockForm from '../components/page-builder/ProductLayoutBlockForm';
+import DividerForm from '../components/page-builder/DividerForm';
import AddSectionModal from '../components/page-builder/AddSectionModal';
import FeaturedTilesForm from '../components/page-builder/FeaturedTilesForm';
import HeroVideoForm from '../components/page-builder/HeroVideoForm';
@@ -86,6 +87,46 @@ interface Brand {
name: string;
}
+interface ApiValidationIssue {
+ path?: unknown;
+ message?: unknown;
+}
+
+const getPageBuilderApiError = (data: unknown) => {
+ if (!data || typeof data !== 'object') return 'Unknown error';
+
+ const response = data as {
+ error?: unknown;
+ message?: unknown;
+ validationIssues?: unknown;
+ issues?: unknown;
+ details?: unknown;
+ };
+ const summary = typeof response.error === 'string'
+ ? response.error
+ : typeof response.message === 'string'
+ ? response.message
+ : 'Unknown error';
+ const issueSource = [response.validationIssues, response.issues, response.details]
+ .find(Array.isArray);
+ if (!Array.isArray(issueSource)) return summary;
+
+ const issueMessages = issueSource
+ .filter((issue): issue is ApiValidationIssue => Boolean(issue) && typeof issue === 'object')
+ .map((issue) => {
+ const path = typeof issue.path === 'string'
+ ? issue.path
+ : Array.isArray(issue.path)
+ ? issue.path.join('.')
+ : '';
+ const message = typeof issue.message === 'string' ? issue.message : '';
+ return [path, message].filter(Boolean).join(': ');
+ })
+ .filter(Boolean);
+
+ return issueMessages.length > 0 ? `${summary} — ${issueMessages.join('; ')}` : summary;
+};
+
export default function PageBuilderPage() {
const { brandName } = useStoreSettings();
const { t } = useTranslation();
@@ -946,6 +987,15 @@ export default function PageBuilderPage() {
subtitle: 'We would love to hear from you.'
};
break;
+ case 'DIVIDER':
+ defaultPayload = {
+ width: 'container',
+ style: 'solid',
+ thickness: 1,
+ spacing: 'md',
+ color: '#94a3b8',
+ };
+ break;
case 'CUSTOM':
defaultPayload = {
componentId: 'BakeryCategories',
@@ -1038,8 +1088,9 @@ export default function PageBuilderPage() {
// setEditingSection(null); // Fix: Do not close editing panel on save
if (selectedPageId) fetchPageDetails(selectedPageId);
} else {
- const errData = await res.json();
- alert(t('pageBuilder.errSaveBlock', { error: errData.error || 'Unknown error', defaultValue: `Failed to save: ${errData.error || 'Unknown error'}` }));
+ const errData: unknown = await res.json();
+ const errorMessage = getPageBuilderApiError(errData);
+ alert(t('pageBuilder.errSaveBlock', { error: errorMessage, defaultValue: `Failed to save: ${errorMessage}` }));
}
} catch (err) {
console.error('Save section error:', err);
@@ -1396,6 +1447,7 @@ export default function PageBuilderPage() {
ACCORDION (FAQ Style)
CONTACT INFO (Location/Hours)
CONTACT FORM (Message inputs)
+ DIVIDER (Section separator)
HERO V2 (Split Layout Hero)
USP BAR (Features / Icons)
CUSTOM (Registered component)
@@ -1833,6 +1885,13 @@ export default function PageBuilderPage() {
/>
)}
+ {editingSection.type?.toUpperCase() === 'DIVIDER' && (
+
+ )}
+
{PRODUCT_LAYOUT_BLOCK_TYPES.has(editingSection.type?.toUpperCase() || '') && (
{
+ if (!data || typeof data !== 'object') return fallback
+
+ const response = data as { message?: unknown; issues?: unknown }
+ const message = typeof response.message === 'string' ? response.message : fallback
+ if (!Array.isArray(response.issues)) return message
+
+ const issueMessages = response.issues
+ .filter((issue): issue is ApiValidationIssue => Boolean(issue) && typeof issue === 'object')
+ .map((issue) => {
+ const detail = typeof issue.message === 'string' ? issue.message : ''
+ const path = typeof issue.path === 'string' ? issue.path : ''
+ return [path, detail].filter(Boolean).join(': ')
+ })
+ .filter(Boolean)
+
+ return issueMessages.length > 0 ? `${message} — ${issueMessages.join('; ')}` : message
+}
+
const PRODUCT_DETAIL_SECTION_TYPES = [
'PRODUCT_MAIN',
'HERO',
'HERO_V2',
+ 'HERO_CAROUSEL',
+ 'DIVIDER',
'PRODUCT_DETAILS',
'PRODUCT_SPECS',
'PRODUCT_REVIEWS',
@@ -64,10 +93,18 @@ const PRODUCT_NATIVE_SECTION_TYPES = new Set([
'RELATED_PRODUCTS',
])
+const PRODUCT_STRUCTURED_SECTION_TYPES = new Set([
+ ...PRODUCT_NATIVE_SECTION_TYPES,
+ 'HERO_CAROUSEL',
+ 'DIVIDER',
+])
+
const SECTION_LABELS: Record = {
PRODUCT_MAIN: 'Product main',
HERO: 'Hero',
HERO_V2: 'Hero V2',
+ HERO_CAROUSEL: 'Hero carousel (Wide Promo Slider)',
+ DIVIDER: 'Divider',
PRODUCT_DETAILS: 'Product details',
PRODUCT_SPECS: 'Product specs',
PRODUCT_REVIEWS: 'Product reviews',
@@ -85,6 +122,8 @@ const SECTION_HELP: Record = {
PRODUCT_MAIN: 'Live gallery, purchase panel, price, variants, and add-to-cart controls.',
HERO: 'Full-width Page Builder hero with image, headline, copy, and CTA.',
HERO_V2: 'Advanced Page Builder hero with alignment, ornament, and primary/secondary CTAs.',
+ HERO_CAROUSEL: 'Product-specific wide promotional slider with responsive desktop and mobile artwork.',
+ DIVIDER: 'Configurable horizontal separator between product CMS blocks.',
PRODUCT_DETAILS: 'Long product detail content from the product record.',
PRODUCT_SPECS: 'Product attributes and specification table.',
PRODUCT_REVIEWS: 'Published customer reviews for this product.',
@@ -138,6 +177,27 @@ const createDefaultProductDetailPayload = (type: string) => {
secondaryButtonText: { th: '', en: '' },
secondaryButtonLink: '',
}
+ case 'HERO_CAROUSEL':
+ return {
+ slides: [{
+ imageUrl: '',
+ mobileImageUrl: '',
+ linkUrl: '',
+ alt: { th: '', en: '' },
+ title: { th: '', en: '' },
+ subtitle: { th: '', en: '' },
+ buttonText: { th: '', en: '' },
+ focalPoint: { x: '50%', y: '50%' },
+ }],
+ autoplay: true,
+ interval: 4500,
+ aspectRatio: '21/7',
+ mobileAspectRatio: '16/9',
+ showArrows: true,
+ showDots: true,
+ }
+ case 'DIVIDER':
+ return { width: 'container', style: 'solid', thickness: 1, spacing: 'md', color: '#94a3b8' }
case 'RICH_TEXT':
case 'TEXT':
return { title: { th: '', en: '' }, content: { th: '', en: '' }, textAlign: 'left' }
@@ -177,13 +237,14 @@ export default function ProductCmsPage() {
const [sectionPayload, setSectionPayload] = useState>(createDefaultProductDetailPayload('PRODUCT_DETAILS'))
const [sectionJson, setSectionJson] = useState(JSON.stringify(createDefaultProductDetailPayload('PRODUCT_DETAILS'), null, 2))
const [sectionIsActive, setSectionIsActive] = useState(true)
+ const [sectionSaveError, setSectionSaveError] = useState(null)
const [activeLang, setActiveLang] = useState('th')
const [error, setError] = useState(null)
const [success, setSuccess] = useState(false)
const [categories, setCategories] = useState([])
const [brands, setBrands] = useState([])
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
- const [mediaTargetField, setMediaTargetField] = useState<'imageUrl' | null>(null)
+ const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
const [isProductPickerOpen, setIsProductPickerOpen] = useState(false)
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']
const selectedSection = selectedSectionId === 'new'
@@ -199,6 +260,7 @@ export default function ProductCmsPage() {
setSectionPayload(payload)
setSectionJson(JSON.stringify(payload, null, 2))
setSectionIsActive(true)
+ setSectionSaveError(null)
}
const startNewSection = (nextType: string) => {
@@ -212,6 +274,7 @@ export default function ProductCmsPage() {
setSectionPayload(payload)
setSectionJson(JSON.stringify(payload, null, 2))
setSectionIsActive(section.isActive)
+ setSectionSaveError(null)
}
const fetchDetailSections = async (productId: string | number) => {
@@ -285,11 +348,15 @@ export default function ProductCmsPage() {
syncNativeLocalizedPayload(field, value)
}
- const openImagePicker = (field: 'imageUrl') => {
- setMediaTargetField(field)
+ const openMediaPicker = (onSelect: (url: string) => void) => {
+ setOnSelectMedia(() => onSelect)
setIsMediaModalOpen(true)
}
+ const openImagePicker = (field: 'imageUrl') => {
+ openMediaPicker((url) => updatePayloadValue(field, url))
+ }
+
const getSectionLocalizedValue = (field: string) => {
const value = sectionPayload[field]
if (typeof value === 'string') return value
@@ -322,8 +389,13 @@ export default function ProductCmsPage() {
if (!id) return
setSectionsSaving(true)
setError(null)
+ setSectionSaveError(null)
try {
- const payload = PRODUCT_NATIVE_SECTION_TYPES.has(sectionType) ? sectionPayload : JSON.parse(sectionJson || '{}')
+ const payload = PRODUCT_STRUCTURED_SECTION_TYPES.has(sectionType) ? sectionPayload : JSON.parse(sectionJson || '{}')
+ if (sectionType === 'HERO_CAROUSEL') {
+ const validationIssues = validateProductHeroCarouselPayload(payload)
+ if (validationIssues.length > 0) throw new Error(validationIssues.join(' '))
+ }
const isNewSection = selectedSectionId === 'new'
const response = await fetch(
isNewSection ? `${API_BASE}/products/${id}/detail-sections` : `${API_BASE}/products/detail-sections/${selectedSectionId}`,
@@ -334,13 +406,15 @@ export default function ProductCmsPage() {
}
)
const data = await response.json().catch(() => null)
- if (!response.ok) throw new Error(data?.message || 'Failed to save product detail section')
+ if (!response.ok) throw new Error(getApiErrorMessage(data, 'Failed to save product detail section'))
await fetchDetailSections(id)
if (data?.section) selectDetailSection(data.section)
setSuccess(true)
setTimeout(() => setSuccess(false), 2500)
} catch (err: any) {
- setError(err.message)
+ const message = err instanceof Error ? err.message : 'Failed to save product detail section'
+ setError(message)
+ setSectionSaveError(message)
} finally {
setSectionsSaving(false)
}
@@ -846,6 +920,26 @@ export default function ProductCmsPage() {
These forms follow the Page Builder block design, but stay scoped to this product.
+ {sectionType === 'HERO_CAROUSEL' && (
+ {
+ setSectionPayload(nextPayload)
+ setSectionJson(JSON.stringify(nextPayload, null, 2))
+ }}
+ onBrowseMedia={openMediaPicker}
+ />
+ )}
+ {sectionType === 'DIVIDER' && (
+ {
+ setSectionPayload(nextPayload)
+ setSectionJson(JSON.stringify(nextPayload, null, 2))
+ }}
+ />
+ )}
{sectionType === 'HERO' && (
@@ -1193,7 +1287,7 @@ export default function ProductCmsPage() {
)}
- {!['HERO', 'HERO_V2', 'RICH_TEXT', 'BANNER', 'EDITORIAL_TILE', 'QUOTE_CALLOUT', 'PRODUCT_CAROUSEL'].includes(sectionType) && (
+ {!['HERO', 'HERO_V2', 'HERO_CAROUSEL', 'DIVIDER', 'RICH_TEXT', 'BANNER', 'EDITORIAL_TILE', 'QUOTE_CALLOUT', 'PRODUCT_CAROUSEL'].includes(sectionType) && (