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. +

+
+ +
+ + + + + + + + +
+ +
+ + 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. +

+
+ +
+ + + + + + + + +
+ +
+ + 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.

+
+ +
+ + + + {([ + ['Autoplay', 'autoplay'], + ['Show arrows', 'showArrows'], + ['Show dots', 'showDots'], + ] as const).map(([label, field]) => ( + + ))} +
+ +
+ {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.

+
+
+ + + +
+
+ +
+ {([ + { 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" + /> + +
+
+ ) + })} +
+ +
+ {([ + ['title', 'Title'], + ['subtitle', 'Subtitle'], + ['buttonText', 'Button text'], + ['alt', 'Image alt text'], + ] as const).map(([field, label]) => ( + + ))} + + + +
+
+ ) + })} +
+ + {slides.length === 0 && ( +
+ Add at least one slide before saving. +
+ )} + + +
+ ) +} 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() { + @@ -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) && (