This commit is contained in:
Your Name committed 2026-10-01 15:42:52 +07:00
1 parent 2d39edbd57
commit 78b722e120
24 files changed
+2130 -65

No files matched your search

@@ -706,7 +706,20 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
</div>
)
},
// 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: () => (
<div className="flex min-h-14 items-center rounded-xl border border-white/5 bg-slate-900/40 px-4">
<div className="w-full border-t border-slate-500" />
</div>
)
},
// 31. CUSTOM
{
type: 'CUSTOM',
category: 'custom',
@@ -0,0 +1,161 @@
import { type PageSection } from '@homaroma/types'
interface DividerFormProps {
editingSection: Partial<PageSection>
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 (
<div className="space-y-6">
<div className="rounded-2xl border border-cyan-glow/10 bg-cyan-glow/[0.03] p-4">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">Page Section Divider</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">
Add a visual separator between Page CMS blocks. Spacing is applied above and below the line.
</p>
</div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Width</span>
<select
value={width}
onChange={(event) => updatePayloadValue('width', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{WIDTH_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Line style</span>
<select
value={style}
onChange={(event) => updatePayloadValue('style', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{STYLE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Thickness</span>
<select
value={thickness}
onChange={(event) => updatePayloadValue('thickness', Number(event.target.value))}
className="admin-input w-full bg-slate-900 border-white/10"
>
{[1, 2, 3, 4].map((value) => <option key={value} value={value}>{value}px</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Vertical spacing</span>
<select
value={getString(payload.spacing, 'md')}
onChange={(event) => updatePayloadValue('spacing', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{SPACING_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div className="space-y-2 md:col-span-2">
<label
htmlFor="page-divider-color-text"
className="block text-[10px] font-bold uppercase tracking-widest text-slate-500"
>
Line color
</label>
<div className="flex items-center gap-3">
<label htmlFor="page-divider-color-picker" className="sr-only">Choose divider color</label>
<input
id="page-divider-color-picker"
type="color"
value={pickerColor}
onChange={(event) => updatePayloadValue('color', event.target.value)}
className="h-11 w-14 cursor-pointer rounded-lg border border-white/10 bg-slate-900 p-1"
/>
<input
id="page-divider-color-text"
type="text"
value={color}
onChange={(event) => 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"
/>
</div>
<p
id="page-divider-color-help"
className={`text-xs ${isColorValid ? 'text-slate-500' : 'text-rose-400'}`}
>
{isColorValid ? 'Accepted formats: #RGB, #RGBA, #RRGGBB, or #RRGGBBAA.' : 'Enter a valid hex color. The preview keeps a safe fallback until the value is complete.'}
</p>
</div>
</div>
<div className="rounded-2xl border border-white/5 bg-night-950 p-6" aria-label="Divider preview">
<div
className={`mx-auto border-slate-500 ${width === 'full' ? 'w-full' : width === 'narrow' ? 'w-1/2' : 'w-4/5'}`}
style={{ borderTopColor: previewColor, borderTopStyle: style, borderTopWidth: thickness }}
/>
</div>
</div>
)
}
@@ -0,0 +1,159 @@
interface ProductDividerBlockFormProps {
payload: Record<string, unknown>
onChange: (payload: Record<string, 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 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 (
<div className="space-y-6">
<div className="rounded-2xl border border-cyan-glow/10 bg-cyan-glow/[0.03] p-4">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">Product Section Divider</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">
Add a visual separator between blocks on this product detail page. This form is scoped to Product CMS.
</p>
</div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Width</span>
<select
value={width}
onChange={(event) => updatePayload('width', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{WIDTH_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Line style</span>
<select
value={style}
onChange={(event) => updatePayload('style', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{STYLE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Thickness</span>
<select
value={thickness}
onChange={(event) => updatePayload('thickness', Number(event.target.value))}
className="admin-input w-full bg-slate-900 border-white/10"
>
{[1, 2, 3, 4].map((value) => <option key={value} value={value}>{value}px</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Vertical spacing</span>
<select
value={getString(payload.spacing, 'md')}
onChange={(event) => updatePayload('spacing', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{SPACING_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div className="space-y-2 md:col-span-2">
<label
htmlFor="product-divider-color-text"
className="block text-[10px] font-bold uppercase tracking-widest text-slate-500"
>
Line color
</label>
<div className="flex items-center gap-3">
<label htmlFor="product-divider-color-picker" className="sr-only">Choose divider color</label>
<input
id="product-divider-color-picker"
type="color"
value={pickerColor}
onChange={(event) => updatePayload('color', event.target.value)}
className="h-11 w-14 cursor-pointer rounded-lg border border-white/10 bg-slate-900 p-1"
/>
<input
id="product-divider-color-text"
type="text"
value={color}
onChange={(event) => 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"
/>
</div>
<p
id="product-divider-color-help"
className={`text-xs ${isColorValid ? 'text-slate-500' : 'text-rose-400'}`}
>
{isColorValid ? 'Accepted formats: #RGB, #RGBA, #RRGGBB, or #RRGGBBAA.' : 'Enter a valid hex color. The preview keeps a safe fallback until the value is complete.'}
</p>
</div>
</div>
<div className="rounded-2xl border border-white/5 bg-night-950 p-6" aria-label="Divider preview">
<div
className={`mx-auto border-slate-500 ${width === 'full' ? 'w-full' : width === 'narrow' ? 'w-1/2' : 'w-4/5'}`}
style={{ borderTopColor: previewColor, borderTopStyle: style, borderTopWidth: thickness }}
/>
</div>
</div>
)
}
@@ -0,0 +1,277 @@
import {
ChevronDown,
ChevronUp,
ImagePlus,
Monitor,
Plus,
Smartphone,
Trash2,
} from 'lucide-react'
type ProductHeroCarouselBlockFormProps = {
payload: Record<string, unknown>
activeLanguage: string
onChange: (payload: Record<string, unknown>) => void
onBrowseMedia: (onSelect: (url: string) => void) => void
}
type SlidePayload = Record<string, unknown>
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<string, unknown> =>
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<string, unknown>): 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 (
<div className="space-y-6">
<div className="rounded-2xl border border-cyan-glow/10 bg-cyan-glow/[0.03] p-4">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">Product Wide Promo Slider</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">This form is scoped to Product CMS and does not share UI code with Page CMS.</p>
</div>
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-white/5 bg-night-950 p-4 md:grid-cols-2 xl:grid-cols-4">
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Desktop ratio</span>
<select
value={getString(payload.aspectRatio, '21/7')}
onChange={(event) => updateRoot('aspectRatio', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{DESKTOP_ASPECT_RATIOS.map((ratio) => <option key={ratio} value={ratio}>{ratio}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Mobile ratio</span>
<select
value={getString(payload.mobileAspectRatio, '16/9')}
onChange={(event) => updateRoot('mobileAspectRatio', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{MOBILE_ASPECT_RATIOS.map((ratio) => <option key={ratio} value={ratio}>{ratio}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Interval (ms)</span>
<input
type="number"
min={1000}
step={500}
value={typeof payload.interval === 'number' ? payload.interval : 4500}
disabled={payload.autoplay === false}
onChange={(event) => updateRoot('interval', Number(event.target.value))}
className="admin-input w-full disabled:opacity-40"
/>
</label>
{([
['Autoplay', 'autoplay'],
['Show arrows', 'showArrows'],
['Show dots', 'showDots'],
] as const).map(([label, field]) => (
<label key={field} className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/5 px-4 py-3">
<input
type="checkbox"
checked={payload[field] !== false}
onChange={(event) => updateRoot(field, event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">{label}</span>
</label>
))}
</div>
<div className="space-y-4">
{slides.map((slide, slideIndex) => {
const focalPoint = isRecord(slide.focalPoint) ? slide.focalPoint : {}
const focalX = getString(focalPoint.x, '50%')
const focalY = getString(focalPoint.y, '50%')
return (
<article key={slideIndex} className="space-y-5 rounded-2xl border border-white/5 bg-white/[0.02] p-4 md:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="font-bold text-white">Slide {slideIndex + 1}</h4>
<p className="mt-1 text-xs text-slate-500">Desktop image is required. Mobile falls back to desktop.</p>
</div>
<div className="flex items-center gap-1">
<button type="button" onClick={() => 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`}>
<ChevronUp className="h-4 w-4" />
</button>
<button type="button" onClick={() => 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`}>
<ChevronDown className="h-4 w-4" />
</button>
<button type="button" onClick={() => 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}`}>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{([
{ 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 (
<div key={field} className="space-y-3">
<div className="flex aspect-[16/7] items-center justify-center overflow-hidden rounded-xl border border-white/5 bg-night-950">
{imageUrl ? (
<img src={imageUrl} alt="" className="h-full w-full object-cover" style={{ objectPosition: `${focalX} ${focalY}` }} />
) : (
<DeviceIcon className="h-8 w-8 text-slate-700" />
)}
</div>
<div className="flex gap-2">
<input
type="url"
required={field === 'imageUrl'}
aria-required={field === 'imageUrl'}
value={imageUrl}
onChange={(event) => updateSlide(slideIndex, { [field]: event.target.value })}
placeholder={`${label} URL`}
className="admin-input min-w-0 flex-1"
/>
<button
type="button"
onClick={() => 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`}
>
<ImagePlus className="h-4 w-4" />
</button>
</div>
</div>
)
})}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{([
['title', 'Title'],
['subtitle', 'Subtitle'],
['buttonText', 'Button text'],
['alt', 'Image alt text'],
] as const).map(([field, label]) => (
<label key={field} className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">{label} ({activeLanguage.toUpperCase()})</span>
<input
type="text"
value={getLocalizedValue(slide[field], activeLanguage)}
onChange={(event) => updateSlide(slideIndex, { [field]: updateLocalizedValue(slide[field], activeLanguage, event.target.value) })}
className="admin-input w-full"
/>
</label>
))}
<label className="space-y-2 md:col-span-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Link URL</span>
<input
type="text"
value={getString(slide.linkUrl)}
onChange={(event) => updateSlide(slideIndex, { linkUrl: event.target.value })}
placeholder="/shop or https://..."
className="admin-input w-full"
/>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Focal point X</span>
<input
type="text"
value={focalX}
onChange={(event) => updateSlide(slideIndex, { focalPoint: { ...focalPoint, x: event.target.value } })}
placeholder="50%"
className="admin-input w-full"
/>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Focal point Y</span>
<input
type="text"
value={focalY}
onChange={(event) => updateSlide(slideIndex, { focalPoint: { ...focalPoint, y: event.target.value } })}
placeholder="50%"
className="admin-input w-full"
/>
</label>
</div>
</article>
)
})}
</div>
{slides.length === 0 && (
<div className="rounded-2xl border-2 border-dashed border-white/5 py-10 text-center text-sm text-slate-600">
Add at least one slide before saving.
</div>
)}
<button
type="button"
onClick={addSlide}
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-bold text-slate-300 hover:bg-white/10"
>
<Plus className="h-4 w-4" />
Add Slide
</button>
</div>
)
}
@@ -0,0 +1,25 @@
const isRecord = (value: unknown): value is Record<string, unknown> => (
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
}
+61 -2
View File
@@ -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() {
<option value="ACCORDION">ACCORDION (FAQ Style)</option>
<option value="CONTACT_INFO">CONTACT INFO (Location/Hours)</option>
<option value="CONTACT_FORM">CONTACT FORM (Message inputs)</option>
<option value="DIVIDER">DIVIDER (Section separator)</option>
<option value="HERO_V2">HERO V2 (Split Layout Hero)</option>
<option value="USP_BAR">USP BAR (Features / Icons)</option>
<option value="CUSTOM">CUSTOM (Registered component)</option>
@@ -1833,6 +1885,13 @@ export default function PageBuilderPage() {
/>
)}
{editingSection.type?.toUpperCase() === 'DIVIDER' && (
<DividerForm
editingSection={editingSection}
updatePayloadValue={updatePayloadValue}
/>
)}
{PRODUCT_LAYOUT_BLOCK_TYPES.has(editingSection.type?.toUpperCase() || '') && (
<ProductLayoutBlockForm
editingSection={editingSection}
+110 -10
View File
@@ -32,6 +32,9 @@ import { API_BASE, type Category, type Product, type ProductDetailSection } from
import MediaLibraryModal from '../components/MediaLibraryModal'
import FocalPointPicker from '../components/page-builder/FocalPointPicker'
import ProductPickerModal from '../components/page-builder/ProductPickerModal'
import ProductHeroCarouselBlockForm from '../components/products/ProductHeroCarouselBlockForm'
import ProductDividerBlockForm from '../components/products/ProductDividerBlockForm'
import { validateProductHeroCarouselPayload } from '../components/products/productHeroCarouselValidation'
import { getAdminDisplayString } from '../utils/localization'
interface Brand {
@@ -39,10 +42,36 @@ interface Brand {
name: string
}
interface ApiValidationIssue {
path?: string
message?: string
}
const getApiErrorMessage = (data: unknown, fallback: string) => {
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<string, string> = {
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<string, string> = {
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<Record<string, unknown>>(createDefaultProductDetailPayload('PRODUCT_DETAILS'))
const [sectionJson, setSectionJson] = useState<string>(JSON.stringify(createDefaultProductDetailPayload('PRODUCT_DETAILS'), null, 2))
const [sectionIsActive, setSectionIsActive] = useState(true)
const [sectionSaveError, setSectionSaveError] = useState<string | null>(null)
const [activeLang, setActiveLang] = useState('th')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [categories, setCategories] = useState<Category[]>([])
const [brands, setBrands] = useState<Brand[]>([])
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() {
</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">These forms follow the Page Builder block design, but stay scoped to this product.</p>
</div>
{sectionType === 'HERO_CAROUSEL' && (
<ProductHeroCarouselBlockForm
payload={sectionPayload}
activeLanguage={activeLang}
onChange={(nextPayload) => {
setSectionPayload(nextPayload)
setSectionJson(JSON.stringify(nextPayload, null, 2))
}}
onBrowseMedia={openMediaPicker}
/>
)}
{sectionType === 'DIVIDER' && (
<ProductDividerBlockForm
payload={sectionPayload}
onChange={(nextPayload) => {
setSectionPayload(nextPayload)
setSectionJson(JSON.stringify(nextPayload, null, 2))
}}
/>
)}
{sectionType === 'HERO' && (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<div className="md:col-span-2 space-y-2">
@@ -1193,7 +1287,7 @@ export default function ProductCmsPage() {
</div>
)}
{!['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) && (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Payload JSON</label>
<textarea
@@ -1207,6 +1301,12 @@ export default function ProductCmsPage() {
)}
<div className="flex flex-col-reverse gap-3 border-t border-white/5 pt-5 sm:flex-row sm:justify-end">
{sectionSaveError && (
<div className="flex items-start gap-2 rounded-xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-400 sm:mr-auto sm:max-w-xl" role="alert">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>{sectionSaveError}</p>
</div>
)}
{PRODUCT_NATIVE_SECTION_TYPES.has(sectionType) && (
<button
type="button"
@@ -1253,12 +1353,12 @@ export default function ProductCmsPage() {
isOpen={isMediaModalOpen}
onClose={() => {
setIsMediaModalOpen(false)
setMediaTargetField(null)
setOnSelectMedia(null)
}}
onSelect={(url) => {
if (mediaTargetField) updatePayloadValue(mediaTargetField, url)
if (onSelectMedia) onSelectMedia(url)
setIsMediaModalOpen(false)
setMediaTargetField(null)
setOnSelectMedia(null)
}}
/>
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test'
import { validateProductHeroCarouselPayload } from '../src/components/products/productHeroCarouselValidation'
describe('validateProductHeroCarouselPayload', () => {
test('accepts a carousel with a desktop image and valid interval', () => {
expect(validateProductHeroCarouselPayload({
slides: [{ imageUrl: 'https://cdn.example.com/desktop.webp' }],
autoplay: true,
interval: 4500,
})).toEqual([])
})
test('requires at least one slide', () => {
expect(validateProductHeroCarouselPayload({ slides: [] })).toEqual(['Add at least one slide.'])
})
test('identifies every slide with a missing desktop image', () => {
expect(validateProductHeroCarouselPayload({
slides: [{ imageUrl: ' ' }, { mobileImageUrl: 'https://cdn.example.com/mobile.webp' }],
autoplay: false,
})).toEqual([
'Slide 1: Desktop image is required.',
'Slide 2: Desktop image is required.',
])
})
test('rejects an invalid autoplay interval', () => {
expect(validateProductHeroCarouselPayload({
slides: [{ imageUrl: 'https://cdn.example.com/desktop.webp' }],
autoplay: true,
interval: 500,
})).toEqual(['Autoplay interval must be at least 1000 ms.'])
})
})