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.'])
})
})
@@ -0,0 +1,318 @@
import { describe, expect, test } from "bun:test";
import {
createCustomProductDetailSection,
createProductMainDetailSection,
isProductDetailSectionType,
validateProductDetailSectionPayload,
} from "./productDetailSections";
const validHeroCarouselPayload = {
slides: [
{
imageUrl: "/uploads/products/hero-wide.webp",
mobileImageUrl: "/uploads/products/hero-mobile.webp",
linkUrl: "/products/2",
alt: { th: "ภาพโปรโมชัน", en: "Promotion" },
title: { th: "สินค้าแนะนำ", en: "Featured product" },
subtitle: { th: "ข้อความรอง", en: "Supporting copy" },
buttonText: { th: "ดูสินค้า", en: "View product" },
focalPoint: { x: "50%", y: "45%" },
},
],
autoplay: true,
interval: 4500,
aspectRatio: "21/7",
mobileAspectRatio: "16/9",
showArrows: true,
showDots: true,
};
describe("product detail section contract", () => {
test("allows DIVIDER with the shared strict presentation contract", () => {
expect(isProductDetailSectionType("DIVIDER")).toBe(true);
const result = validateProductDetailSectionPayload("DIVIDER", {
width: "container",
style: "solid",
thickness: 1,
spacing: "md",
});
expect(result.success).toBe(true);
});
test("rejects an out-of-range DIVIDER thickness", () => {
const result = validateProductDetailSectionPayload("DIVIDER", {
width: "full",
style: "dashed",
thickness: 5,
spacing: "lg",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.issues[0]?.path).toEqual(["thickness"]);
}
});
test.each(["#123", "#AbC8", "#12aBcD", "#12aBcD80"])(
"accepts product DIVIDER color %s",
(color) => {
const result = validateProductDetailSectionPayload("DIVIDER", {
width: "container",
style: "dotted",
thickness: 2,
spacing: "lg",
color,
});
expect(result.success).toBe(true);
},
);
test.each(["transparent", "#12", "#12345", "#123456789", "#000; border-image: url(javascript:alert(1))"])(
"rejects unsafe product DIVIDER color %s",
(color) => {
const result = validateProductDetailSectionPayload("DIVIDER", {
width: "container",
style: "dotted",
thickness: 2,
spacing: "lg",
color,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.issues[0]?.path).toEqual(["color"]);
expect(result.issues[0]?.message).toBe(
"Color must be a hex value in #RGB, #RGBA, #RRGGBB, or #RRGGBBAA format",
);
}
},
);
test("allows HERO_CAROUSEL for product detail sections", () => {
expect(isProductDetailSectionType("HERO_CAROUSEL")).toBe(true);
const result = validateProductDetailSectionPayload(
"HERO_CAROUSEL",
validHeroCarouselPayload,
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(validHeroCarouselPayload);
}
});
test("uses the shared HERO_CAROUSEL schema to reject an empty slide list", () => {
const result = validateProductDetailSectionPayload("HERO_CAROUSEL", {
slides: [],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.issues[0]?.path).toEqual(["slides"]);
}
});
test("rejects page-only section types even when they have a page schema", () => {
const result = validateProductDetailSectionPayload("CONTACT_FORM", {
title: { en: "Contact" },
});
expect(result.success).toBe(false);
expect(isProductDetailSectionType("CONTACT_FORM")).toBe(false);
});
});
describe("custom product detail section creation", () => {
test("materializes defaults before adding the first custom section", async () => {
const operations: string[] = [];
const createdRows: Array<Record<string, unknown>> = [];
const tx = {
productDetailSection: {
async count() {
operations.push("count");
return 0;
},
async createMany(args: { data: Array<Record<string, unknown>> }) {
operations.push("createMany");
createdRows.push(...args.data);
return { count: args.data.length };
},
async findFirst(args: { where?: { type?: string } }) {
operations.push("findFirst");
if (args.where?.type) return null;
return { order: 5 };
},
async update() {
throw new Error("update should not be called");
},
async updateMany() {
throw new Error("updateMany should not be called");
},
async create(args: { data: Record<string, unknown> }) {
operations.push("create");
createdRows.push(args.data);
return { id: 99, ...args.data };
},
},
};
const created = await createCustomProductDetailSection(tx, {
productId: 2,
type: "HERO_CAROUSEL",
payload: validHeroCarouselPayload,
isActive: true,
isLocked: false,
});
expect(operations).toEqual(["count", "createMany", "findFirst", "findFirst", "create"]);
expect(createdRows).toHaveLength(6);
expect(createdRows.slice(0, 5).map((row) => row.type)).toEqual([
"PRODUCT_MAIN",
"PRODUCT_DETAILS",
"PRODUCT_SPECS",
"PRODUCT_REVIEWS",
"RELATED_PRODUCTS",
]);
expect(created).toMatchObject({
id: 99,
productId: 2,
type: "HERO_CAROUSEL",
order: 6,
isActive: true,
isLocked: false,
});
});
test("does not duplicate defaults when sections already exist", async () => {
let createManyCalls = 0;
const tx = {
productDetailSection: {
async count() {
return 5;
},
async createMany() {
createManyCalls += 1;
return { count: 5 };
},
async findFirst() {
return { order: 8 };
},
async update() {
throw new Error("update should not be called");
},
async updateMany() {
throw new Error("updateMany should not be called");
},
async create(args: { data: Record<string, unknown> }) {
return args.data;
},
},
};
const created = await createCustomProductDetailSection(tx, {
productId: 2,
type: "HERO_CAROUSEL",
payload: validHeroCarouselPayload,
isActive: true,
isLocked: false,
});
expect(createManyCalls).toBe(0);
expect(created).toMatchObject({ order: 9 });
});
test("updates a matching seeded default instead of creating a duplicate", async () => {
let createCalls = 0;
const tx = {
productDetailSection: {
async count() {
return 0;
},
async createMany() {
return { count: 5 };
},
async findFirst(args: { where?: { type?: string } }) {
if (args.where?.type === "PRODUCT_DETAILS") {
return { id: 12, order: 2, type: "PRODUCT_DETAILS" };
}
return { order: 5 };
},
async update(args: { where: { id: number }; data: Record<string, unknown> }) {
return { id: args.where.id, productId: 2, type: "PRODUCT_DETAILS", order: 2, ...args.data };
},
async updateMany() {
throw new Error("updateMany should not be called");
},
async create() {
createCalls += 1;
return {};
},
},
};
const created = await createCustomProductDetailSection(tx, {
productId: 2,
type: "PRODUCT_DETAILS",
payload: { title: { en: "Details" }, showHeading: true },
isActive: true,
isLocked: false,
});
expect(createCalls).toBe(0);
expect(created).toMatchObject({
id: 12,
productId: 2,
type: "PRODUCT_DETAILS",
order: 2,
payload: { title: { en: "Details" }, showHeading: true },
});
});
});
describe("product main section creation", () => {
test("materializes all defaults and updates the seeded main section", async () => {
let createCalls = 0;
const tx = {
productDetailSection: {
async count() {
return 0;
},
async createMany(args: { data: Array<Record<string, unknown>> }) {
expect(args.data).toHaveLength(5);
return { count: args.data.length };
},
async findFirst() {
return { id: 10, order: 1, type: "PRODUCT_MAIN" };
},
async update(args: { where: { id: number }; data: Record<string, unknown> }) {
return { id: args.where.id, productId: 2, type: "PRODUCT_MAIN", ...args.data };
},
async updateMany() {
throw new Error("updateMany should not be called for an empty product");
},
async create() {
createCalls += 1;
return {};
},
},
};
const payload = { layoutMode: "standard" };
const created = await createProductMainDetailSection(tx, 2, payload);
expect(createCalls).toBe(0);
expect(created).toMatchObject({
id: 10,
productId: 2,
type: "PRODUCT_MAIN",
order: 1,
payload,
isActive: true,
isLocked: true,
});
});
});
@@ -0,0 +1,171 @@
import {
PAGE_SECTION_PAYLOAD_SCHEMAS,
PRODUCT_DETAIL_SECTION_TYPES,
type ProductDetailSectionType,
} from "@homaroma/types";
import type { z } from "zod";
export type ProductDetailSectionValidationResult =
| { success: true; type: ProductDetailSectionType; data: unknown }
| { success: false; issues: z.ZodIssue[] };
export function defaultProductDetailSections(productId: number) {
return [
{ productId, type: "PRODUCT_MAIN", order: 1, payload: { layoutMode: "standard", galleryBehavior: "carousel", purchaseCardSticky: true, showStickyPurchaseBarAfterMain: true, showBreadcrumbs: true, showBrandMeta: true }, isActive: true, isLocked: true },
{ productId, type: "PRODUCT_DETAILS", order: 2, payload: { title: { th: "รายละเอียดสินค้า", en: "Product Details" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_SPECS", order: 3, payload: { title: { th: "ข้อมูลสินค้า", en: "Specifications" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_REVIEWS", order: 4, payload: { title: { th: "รีวิว", en: "Reviews" }, showHeading: true }, isActive: true },
{ productId, type: "RELATED_PRODUCTS", order: 5, payload: { title: { th: "สินค้าที่เกี่ยวข้อง", en: "Related Products" }, showHeading: true, showViewAllLink: true }, isActive: true },
];
}
interface ProductDetailSectionTransaction {
productDetailSection: {
count(args: any): Promise<number>;
createMany(args: any): Promise<unknown>;
findFirst(args: any): Promise<{ id?: number; order: number; type?: string } | null>;
update(args: any): Promise<any>;
updateMany(args: any): Promise<unknown>;
create(args: any): Promise<any>;
};
}
interface CreateCustomProductDetailSectionInput {
productId: number;
type: Exclude<ProductDetailSectionType, "PRODUCT_MAIN">;
payload: unknown;
order?: number;
isActive: boolean;
isLocked: boolean;
}
export async function createCustomProductDetailSection(
tx: ProductDetailSectionTransaction,
input: CreateCustomProductDetailSectionInput,
) {
const existingCount = await tx.productDetailSection.count({
where: { productId: input.productId },
});
if (existingCount === 0) {
await tx.productDetailSection.createMany({
data: defaultProductDetailSections(input.productId),
});
const seededSection = await tx.productDetailSection.findFirst({
where: { productId: input.productId, type: input.type },
});
if (seededSection?.id !== undefined) {
return await tx.productDetailSection.update({
where: { id: seededSection.id },
data: {
payload: input.payload,
isActive: input.isActive,
isLocked: input.isLocked,
...(input.order !== undefined ? { order: input.order } : {}),
},
});
}
}
const lastSection = await tx.productDetailSection.findFirst({
where: { productId: input.productId },
orderBy: { order: "desc" },
});
return await tx.productDetailSection.create({
data: {
productId: input.productId,
type: input.type,
order: input.order ?? (lastSection?.order || 0) + 1,
payload: input.payload,
isActive: input.isActive,
isLocked: input.isLocked,
},
});
}
export async function createProductMainDetailSection(
tx: ProductDetailSectionTransaction,
productId: number,
payload: unknown,
) {
const existingCount = await tx.productDetailSection.count({ where: { productId } });
if (existingCount === 0) {
await tx.productDetailSection.createMany({
data: defaultProductDetailSections(productId),
});
const seededMain = await tx.productDetailSection.findFirst({
where: { productId, type: "PRODUCT_MAIN" },
});
if (seededMain?.id === undefined) {
throw new Error("Seeded PRODUCT_MAIN section was not found");
}
return await tx.productDetailSection.update({
where: { id: seededMain.id },
data: { payload, order: 1, isActive: true, isLocked: true },
});
}
await tx.productDetailSection.updateMany({
where: { productId },
data: { order: { increment: 1 } },
});
return await tx.productDetailSection.create({
data: {
productId,
type: "PRODUCT_MAIN",
order: 1,
payload,
isActive: true,
isLocked: true,
},
});
}
export function isProductDetailSectionType(
type: string,
): type is ProductDetailSectionType {
return (PRODUCT_DETAIL_SECTION_TYPES as readonly string[]).includes(type);
}
export function validateProductDetailSectionPayload(
type: string,
payload: unknown,
): ProductDetailSectionValidationResult {
const normalizedType = type.toUpperCase();
if (!isProductDetailSectionType(normalizedType)) {
return {
success: false,
issues: [
{
code: "custom",
message: `Unknown product detail section type: ${type}`,
path: [],
},
] as z.ZodIssue[],
};
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return {
success: false,
issues: [
{
code: "custom",
message: "Payload must be a JSON object",
path: [],
},
] as z.ZodIssue[],
};
}
const result = PAGE_SECTION_PAYLOAD_SCHEMAS[normalizedType].safeParse(payload);
if (!result.success) {
return { success: false, issues: result.error.issues };
}
return { success: true, type: normalizedType, data: result.data };
}
@@ -2,8 +2,68 @@ import { describe, expect, test } from "bun:test";
import {
mergeSectionTranslations,
transformLocalizedPaths,
validateSectionPayload,
} from "./pages";
describe("Page section payload validation", () => {
test("accepts a DIVIDER payload", () => {
const result = validateSectionPayload("DIVIDER", {
width: "narrow",
style: "dotted",
thickness: 2,
spacing: "xl",
});
expect(result.success).toBe(true);
});
test.each(["#123", "#AbC8", "#12aBcD", "#12aBcD80"])(
"accepts DIVIDER color %s",
(color) => {
const result = validateSectionPayload("DIVIDER", {
width: "container",
style: "solid",
thickness: 1,
spacing: "md",
color,
});
expect(result.success).toBe(true);
},
);
test.each(["red", "#12", "#12345", "#123456789", "#fff; background: url(javascript:alert(1))"])(
"rejects unsafe DIVIDER color %s",
(color) => {
const result = validateSectionPayload("DIVIDER", {
width: "container",
style: "solid",
thickness: 1,
spacing: "md",
color,
});
expect(result.success).toBe(false);
expect(result.issues?.[0]?.path).toEqual(["color"]);
expect(result.issues?.[0]?.message).toBe(
"Color must be a hex value in #RGB, #RGBA, #RRGGBB, or #RRGGBBAA format",
);
},
);
test("rejects an unsupported DIVIDER width", () => {
const result = validateSectionPayload("DIVIDER", {
width: "wide",
style: "solid",
thickness: 1,
spacing: "sm",
});
expect(result.success).toBe(false);
expect(result.issues?.[0]?.path).toEqual(["width"]);
});
});
describe("Page section localization", () => {
const paths = ["title", "items[].title", "items[].note"];
const payload = {
+4
View File
@@ -14,6 +14,7 @@ import {
FeaturedTilesSectionPayloadSchema,
EditorialCarouselSectionPayloadSchema,
HeroCarouselSectionPayloadSchema,
DividerSectionPayloadSchema,
HeroArticleSectionPayloadSchema,
RichTextSectionPayloadSchema,
ArticleHeaderSectionPayloadSchema,
@@ -1335,6 +1336,9 @@ export function validateSectionPayload(
case "HERO_CAROUSEL":
schema = HeroCarouselSectionPayloadSchema;
break;
case "DIVIDER":
schema = DividerSectionPayloadSchema;
break;
case "HERO_ARTICLE":
schema = HeroArticleSectionPayloadSchema;
break;
+24 -47
View File
@@ -1,7 +1,14 @@
import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma";
import { localizeProductAttribute } from "../lib/productAttributes";
import { hydrateSectionPayload, validateSectionPayload } from "./pages";
import {
createCustomProductDetailSection,
createProductMainDetailSection,
defaultProductDetailSections,
validateProductDetailSectionPayload,
} from "../lib/productDetailSections";
import { hydrateSectionPayload } from "./pages";
function summarizeValidationIssues(issues: any[]) {
return issues.map((issue) => ({
@@ -11,16 +18,6 @@ function summarizeValidationIssues(issues: any[]) {
}));
}
function defaultProductDetailSections(productId: number) {
return [
{ productId, type: "PRODUCT_MAIN", order: 1, payload: { layoutMode: "standard", galleryBehavior: "carousel", purchaseCardSticky: true, showStickyPurchaseBarAfterMain: true, showBreadcrumbs: true, showBrandMeta: true }, isActive: true, isLocked: true },
{ productId, type: "PRODUCT_DETAILS", order: 2, payload: { title: { th: "รายละเอียดสินค้า", en: "Product Details" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_SPECS", order: 3, payload: { title: { th: "ข้อมูลสินค้า", en: "Specifications" }, showHeading: true }, isActive: true },
{ productId, type: "PRODUCT_REVIEWS", order: 4, payload: { title: { th: "รีวิว", en: "Reviews" }, showHeading: true }, isActive: true },
{ productId, type: "RELATED_PRODUCTS", order: 5, payload: { title: { th: "สินค้าที่เกี่ยวข้อง", en: "Related Products" }, showHeading: true, showViewAllLink: true }, isActive: true },
];
}
async function assertActiveCategory(categoryId: number, client: any = prisma) {
const category = await client.category.findFirst({
where: { id: categoryId, deletedAt: null },
@@ -508,7 +505,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const payload = body as any;
const type = String(payload.type || "").toUpperCase();
const sectionPayload = payload.payload || {};
const validationResult = validateSectionPayload(type, sectionPayload);
const validationResult = validateProductDetailSectionPayload(type, sectionPayload);
if (!validationResult.success) {
return status(400, {
success: false,
@@ -520,7 +517,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const product = await prisma.product.findUnique({ where: { id }, select: { id: true } });
if (!product) return status(404, { message: "Product not found" });
const isProductMain = type === "PRODUCT_MAIN";
const validatedType = validationResult.type;
const isProductMain = validatedType === "PRODUCT_MAIN";
if (isProductMain) {
const existingMain = await prisma.productDetailSection.count({
where: { productId: id, type: "PRODUCT_MAIN" },
@@ -530,38 +528,16 @@ export const productsRoute = new Elysia({ prefix: "/products" })
}
}
const lastSection = await prisma.productDetailSection.findFirst({
where: { productId: id },
orderBy: { order: "desc" },
});
const section = isProductMain
? (await prisma.$transaction([
prisma.productDetailSection.updateMany({
where: { productId: id },
data: { order: { increment: 1 } },
}),
prisma.productDetailSection.create({
data: {
productId: id,
type,
order: 1,
payload: validationResult.data,
isActive: true,
isLocked: true,
},
}),
]))[1]
: await prisma.productDetailSection.create({
data: {
productId: id,
type,
order: Number.isFinite(Number(payload.order)) ? Number(payload.order) : (lastSection?.order || 0) + 1,
payload: validationResult.data,
isActive: payload.isActive !== false,
isLocked: Boolean(payload.isLocked),
},
});
? await prisma.$transaction((tx: Prisma.TransactionClient) => createProductMainDetailSection(tx, id, validationResult.data))
: await prisma.$transaction((tx: Prisma.TransactionClient) => createCustomProductDetailSection(tx, {
productId: id,
type: validatedType,
payload: validationResult.data,
...(Number.isFinite(Number(payload.order)) ? { order: Number(payload.order) } : {}),
isActive: payload.isActive !== false,
isLocked: Boolean(payload.isLocked),
}));
return { success: true, section };
})
@@ -575,7 +551,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const payload = body as any;
const type = String(payload.type || existing.type).toUpperCase();
const sectionPayload = payload.payload === undefined ? existing.payload : payload.payload;
const validationResult = validateSectionPayload(type, sectionPayload);
const validationResult = validateProductDetailSectionPayload(type, sectionPayload);
if (!validationResult.success) {
return status(400, {
success: false,
@@ -584,8 +560,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
});
}
const validatedType = validationResult.type;
if (existing.isLocked) {
if (payload.type && type !== existing.type) {
if (payload.type && validatedType !== existing.type) {
return status(409, { success: false, message: "Locked product detail sections cannot change type" });
}
if (payload.isActive === false) {
@@ -606,7 +583,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const section = await prisma.productDetailSection.update({
where: { id: sectionId },
data: {
type,
type: validatedType,
payload: validationResult.data,
...(payload.order !== undefined ? { order: Number(payload.order) } : {}),
...(payload.isActive !== undefined ? { isActive: Boolean(payload.isActive) } : {}),
@@ -1,4 +1,4 @@
import type { PageSection, Category, AccordionSectionPayload, ContactInfoSectionPayload, ContactFormSectionPayload, CustomSectionPayload } from '@homaroma/types';
import type { PageSection, Category, AccordionSectionPayload, ContactInfoSectionPayload, ContactFormSectionPayload, CustomSectionPayload, DividerSectionPayload } from '@homaroma/types';
import DynamicHero from './DynamicHero';
import DynamicHeroV2 from './DynamicHeroV2';
import DynamicHeroVideo from './DynamicHeroVideo';
@@ -18,6 +18,7 @@ import DynamicScentNotesBreakdown from './DynamicScentNotesBreakdown';
import DynamicQuoteCallout from './DynamicQuoteCallout';
import DynamicProductCarousel from './DynamicProductCarousel';
import DynamicRichText from './DynamicRichText';
import PageDivider from './PageDivider';
import AccordionSection from './sections/AccordionSection';
import ContactInfoSection from './sections/ContactInfoSection';
import ContactFormSection from './sections/ContactFormSection';
@@ -91,6 +92,8 @@ export default function DynamicPageRenderer({
return <DynamicHeroVideo key={section.id} payload={section.payload} />;
case 'HERO_CAROUSEL':
return <DynamicHeroCarousel key={section.id} payload={section.payload} />;
case 'DIVIDER':
return <PageDivider key={section.id} payload={section.payload as DividerSectionPayload} />;
case 'USP_BAR':
return <DynamicUspBar key={section.id} payload={section.payload} />;
+55
View File
@@ -0,0 +1,55 @@
import type { DividerSectionPayload } from '@homaroma/types'
import type { CSSProperties } from 'react'
interface PageDividerProps {
payload: DividerSectionPayload
}
type PageDividerStyle = CSSProperties & {
'--divider-color'?: string
}
const widthClasses: Record<DividerSectionPayload['width'], string> = {
full: 'w-full',
container: 'mx-auto w-full max-w-7xl px-6',
narrow: 'mx-auto w-full max-w-3xl px-6',
}
const styleClasses: Record<DividerSectionPayload['style'], string> = {
solid: 'border-solid',
dashed: 'border-dashed',
dotted: 'border-dotted',
}
const thicknessClasses: Record<DividerSectionPayload['thickness'], string> = {
1: 'border-t',
2: 'border-t-2',
3: 'border-t-3',
4: 'border-t-4',
}
const spacingClasses: Record<DividerSectionPayload['spacing'], string> = {
none: 'py-0',
sm: 'py-2',
md: 'py-4',
lg: 'py-8',
xl: 'py-12',
}
export default function PageDivider({ payload }: PageDividerProps) {
const dividerColor = payload.color?.trim()
const dividerStyle: PageDividerStyle | undefined = dividerColor
? { '--divider-color': dividerColor }
: undefined
return (
<div className={`w-full ${spacingClasses[payload.spacing]}`}>
<div className={widthClasses[payload.width]}>
<hr
className={`m-0 h-0 w-full ${dividerColor ? '[border-color:var(--divider-color)]' : 'border-pop-black/20'} ${styleClasses[payload.style]} ${thicknessClasses[payload.thickness]}`}
style={dividerStyle}
/>
</div>
</div>
)
}
@@ -0,0 +1,55 @@
import type { DividerSectionPayload } from '@homaroma/types'
import type { CSSProperties } from 'react'
interface ProductDividerProps {
payload: DividerSectionPayload
}
type ProductDividerStyle = CSSProperties & {
'--divider-color'?: string
}
const widthClasses: Record<DividerSectionPayload['width'], string> = {
full: 'w-full',
container: 'mx-auto w-full max-w-7xl px-6',
narrow: 'mx-auto w-full max-w-3xl px-6',
}
const styleClasses: Record<DividerSectionPayload['style'], string> = {
solid: 'border-solid',
dashed: 'border-dashed',
dotted: 'border-dotted',
}
const thicknessClasses: Record<DividerSectionPayload['thickness'], string> = {
1: 'border-t',
2: 'border-t-2',
3: 'border-t-3',
4: 'border-t-4',
}
const spacingClasses: Record<DividerSectionPayload['spacing'], string> = {
none: 'py-0',
sm: 'py-2',
md: 'py-4',
lg: 'py-8',
xl: 'py-12',
}
export default function ProductDivider({ payload }: ProductDividerProps) {
const dividerColor = payload.color?.trim()
const dividerStyle: ProductDividerStyle | undefined = dividerColor
? { '--divider-color': dividerColor }
: undefined
return (
<div className={`w-full ${spacingClasses[payload.spacing]}`}>
<div className={widthClasses[payload.width]}>
<hr
className={`m-0 h-0 w-full ${dividerColor ? '[border-color:var(--divider-color)]' : 'border-pop-black/20'} ${styleClasses[payload.style]} ${thicknessClasses[payload.thickness]}`}
style={dividerStyle}
/>
</div>
</div>
)
}
@@ -0,0 +1,217 @@
import { useEffect, useState, useSyncExternalStore } from 'react'
import { ChevronLeft, ChevronRight, Pause, Play } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { HeroCarouselSectionPayload, TranslatableString } from '@homaroma/types'
interface ProductHeroCarouselProps {
payload: HeroCarouselSectionPayload
lang: string
}
function resolveLocalized(value: TranslatableString | null | undefined, lang: string): string {
if (!value) return ''
if (typeof value === 'string') return value
return value[lang] ?? value[lang.slice(0, 2)] ?? value.th ?? value.en ?? Object.values(value)[0] ?? ''
}
function aspectClasses(
desktop: HeroCarouselSectionPayload['aspectRatio'],
mobile: HeroCarouselSectionPayload['mobileAspectRatio'],
) {
const mobileClass = {
'16/9': 'aspect-[16/9]',
'4/3': 'aspect-[4/3]',
'1/1': 'aspect-square',
'21/9': 'aspect-[21/9]',
auto: 'min-h-[22rem]',
}[mobile ?? '16/9']
const desktopClass = {
'21/6': 'md:aspect-[21/6]',
'21/7': 'md:aspect-[21/7]',
'21/9': 'md:aspect-[21/9]',
'16/5': 'md:aspect-[16/5]',
'16/6': 'md:aspect-[16/6]',
'4/1': 'md:aspect-[4/1]',
'5/1': 'md:aspect-[5/1]',
auto: 'md:aspect-auto md:min-h-[30rem]',
}[desktop ?? '21/7']
return `${mobileClass} ${desktopClass}`
}
function SlideLink({ href, children }: { href: string; children: React.ReactNode }) {
const className = 'inline-flex rounded-full bg-pop-bg px-6 py-3 text-sm font-bold uppercase tracking-widest text-pop-black transition hover:bg-pop-bg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pop-bg focus-visible:ring-offset-2 focus-visible:ring-offset-pop-black'
return href.startsWith('/') ? (
<Link to={href} className={className}>{children}</Link>
) : (
<a href={href} className={className}>{children}</a>
)
}
function SlideOverlayLink({ href, label }: { href: string; label: string }) {
const className = 'absolute inset-0 z-[25] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-pop-bg'
return href.startsWith('/') ? (
<Link to={href} className={className}><span className="sr-only">{label}</span></Link>
) : (
<a href={href} className={className}><span className="sr-only">{label}</span></a>
)
}
function subscribeToReducedMotion(onStoreChange: () => void) {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
mediaQuery.addEventListener('change', onStoreChange)
return () => mediaQuery.removeEventListener('change', onStoreChange)
}
function getReducedMotionSnapshot() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
export default function ProductHeroCarousel({ payload, lang }: ProductHeroCarouselProps) {
const slides = payload.slides ?? []
const [activeIndex, setActiveIndex] = useState(0)
const [isUserPaused, setIsUserPaused] = useState(false)
const [isInteracting, setIsInteracting] = useState(false)
const prefersReducedMotion = useSyncExternalStore(
subscribeToReducedMotion,
getReducedMotionSnapshot,
() => false,
)
const slideCount = slides.length
const currentIndex = slideCount > 0 ? activeIndex % slideCount : 0
const autoplayConfigured = payload.autoplay !== false && slideCount > 1
const autoplay = autoplayConfigured && !prefersReducedMotion
const rotationPaused = isUserPaused || isInteracting
const interval = Math.max(payload.interval ?? 4500, 2000)
const labels = lang.startsWith('th')
? { carousel: 'โปรโมชันสินค้า', previous: 'สไลด์ก่อนหน้า', next: 'สไลด์ถัดไป', goTo: 'ไปยังสไลด์', pause: 'หยุดการเลื่อนสไลด์อัตโนมัติ', resume: 'เล่นสไลด์อัตโนมัติต่อ', open: 'เปิดโปรโมชัน' }
: { carousel: 'Product promotions', previous: 'Previous slide', next: 'Next slide', goTo: 'Go to slide', pause: 'Pause automatic slides', resume: 'Resume automatic slides', open: 'Open promotion' }
useEffect(() => {
if (!autoplay || rotationPaused) return
const timeoutId = window.setTimeout(() => {
setActiveIndex((index) => (index + 1) % slideCount)
}, interval)
return () => window.clearTimeout(timeoutId)
}, [autoplay, currentIndex, interval, rotationPaused, slideCount])
if (slideCount === 0) return null
const move = (direction: -1 | 1) => {
setActiveIndex((index) => (index + direction + slideCount) % slideCount)
}
return (
<section
className={`relative w-full overflow-hidden bg-pop-black/5 ${aspectClasses(payload.aspectRatio, payload.mobileAspectRatio)}`}
aria-roledescription="carousel"
aria-label={labels.carousel}
onMouseEnter={() => setIsInteracting(true)}
onMouseLeave={() => setIsInteracting(false)}
onFocusCapture={() => setIsInteracting(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setIsInteracting(false)
}}
>
<div className="absolute inset-0" aria-live={rotationPaused || prefersReducedMotion ? 'polite' : 'off'}>
{slides.map((slide, index) => {
const isActive = index === currentIndex
const title = resolveLocalized(slide.title, lang)
const subtitle = resolveLocalized(slide.subtitle, lang)
const buttonText = resolveLocalized(slide.buttonText, lang)
const alt = resolveLocalized(slide.alt, lang) || title
const focalPoint = slide.focalPoint
return (
<article
key={`${slide.imageUrl}-${index}`}
className={`absolute inset-0 ${prefersReducedMotion ? '' : 'transition-opacity duration-700'} ${isActive ? 'z-10 opacity-100' : 'pointer-events-none opacity-0'}`}
role="group"
aria-roledescription="slide"
aria-label={`${index + 1} / ${slideCount}`}
aria-hidden={!isActive}
inert={!isActive}
>
<picture>
{slide.mobileImageUrl && <source media="(max-width: 767px)" srcSet={slide.mobileImageUrl} />}
<img
src={slide.imageUrl}
alt={alt}
className="absolute inset-0 h-full w-full object-cover"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
loading={index === 0 ? 'eager' : 'lazy'}
/>
</picture>
{slide.linkUrl && !buttonText && (
<SlideOverlayLink href={slide.linkUrl} label={alt || title || labels.open} />
)}
{(title || subtitle || (buttonText && slide.linkUrl)) && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-pop-black/40 px-6 py-12 text-center text-pop-bg">
<div className="max-w-4xl space-y-4">
{title && <h2 className="text-3xl font-bold leading-tight md:text-5xl">{title}</h2>}
{subtitle && <p className="mx-auto max-w-2xl text-sm leading-relaxed text-pop-bg/90 md:text-lg">{subtitle}</p>}
{buttonText && slide.linkUrl && (
<div className="pointer-events-auto pt-2"><SlideLink href={slide.linkUrl}>{buttonText}</SlideLink></div>
)}
</div>
</div>
)}
</article>
)
})}
</div>
{slideCount > 1 && payload.showArrows !== false && (
<>
<button
type="button"
onClick={() => move(-1)}
className="absolute left-3 top-1/2 z-30 -translate-y-1/2 rounded-full border border-pop-bg/40 bg-pop-black/35 p-2.5 text-pop-bg backdrop-blur-sm transition hover:bg-pop-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pop-bg md:left-6"
aria-label={labels.previous}
>
<ChevronLeft className="h-5 w-5 md:h-6 md:w-6" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => move(1)}
className="absolute right-3 top-1/2 z-30 -translate-y-1/2 rounded-full border border-pop-bg/40 bg-pop-black/35 p-2.5 text-pop-bg backdrop-blur-sm transition hover:bg-pop-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pop-bg md:right-6"
aria-label={labels.next}
>
<ChevronRight className="h-5 w-5 md:h-6 md:w-6" aria-hidden="true" />
</button>
</>
)}
{slideCount > 1 && payload.showDots !== false && (
<div className="absolute bottom-4 left-1/2 z-30 flex -translate-x-1/2 gap-2 rounded-full bg-pop-black/35 px-3 py-2 backdrop-blur-sm">
{slides.map((slide, index) => (
<button
key={`${slide.imageUrl}-dot-${index}`}
type="button"
onClick={() => setActiveIndex(index)}
className={`h-2.5 rounded-full transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pop-bg ${index === currentIndex ? 'w-7 bg-pop-bg' : 'w-2.5 bg-pop-bg/50 hover:bg-pop-bg/80'}`}
aria-label={`${labels.goTo} ${index + 1}`}
aria-current={index === currentIndex ? 'true' : undefined}
/>
))}
</div>
)}
{autoplayConfigured && !prefersReducedMotion && (
<button
type="button"
onClick={() => setIsUserPaused((paused) => !paused)}
className="absolute right-4 top-4 z-30 rounded-full border border-pop-bg/40 bg-pop-black/35 p-2 text-pop-bg backdrop-blur-sm transition hover:bg-pop-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pop-bg"
aria-label={isUserPaused ? labels.resume : labels.pause}
aria-pressed={isUserPaused}
title={isUserPaused ? labels.resume : labels.pause}
>
{isUserPaused ? <Play className="h-4 w-4" aria-hidden="true" /> : <Pause className="h-4 w-4" aria-hidden="true" />}
</button>
)}
</section>
)
}
+16
View File
@@ -6,6 +6,8 @@ import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import ProductReviews from '../components/ProductReviews'
import ProductImageGallery from '../components/ProductImageGallery'
import ProductHeroCarousel from '../components/ProductHeroCarousel'
import ProductDivider from '../components/ProductDivider'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product, type PageSection, type ProductAttribute, type ProductDetailSection, type TranslatableString } from '../types'
import { useCartStore } from '../store/cartStore'
@@ -474,6 +476,20 @@ export default function ProductDetailPage() {
return renderProductNativeSection(section, index);
}
if (section.type.toUpperCase() === 'HERO_CAROUSEL') {
return (
<ProductHeroCarousel
key={section.id}
payload={section.payload}
lang={i18n.language}
/>
);
}
if (section.type.toUpperCase() === 'DIVIDER') {
return <ProductDivider key={section.id} payload={section.payload} />;
}
return (
<DynamicPageRenderer
key={section.id}
@@ -8,6 +8,8 @@ import remarkGfm from 'remark-gfm'
import { getCategoryImage, type PageSection, type Product, type ProductDetailSection } from '@homaroma/types'
import AddToCartSection from '@/components/AddToCartSection'
import ProductGallery from '@/components/ProductGallery'
import ProductHeroCarousel from '@/components/ProductHeroCarousel'
import ProductDivider from '@/components/ProductDivider'
import ProductStickyPurchaseBar from '@/components/ProductStickyPurchaseBar'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
@@ -551,11 +553,16 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }} />
{productLayoutSections.map((section, index) => {
const sectionType = section.type.toUpperCase()
const renderedSection = isProductNativeSection(section)
? renderProductNativeSection(section, index)
: <DynamicPageRenderer key={section.id} sections={[toPageSection(section)]} categories={categories} lang={lang} />
: sectionType === 'HERO_CAROUSEL'
? <ProductHeroCarousel key={section.id} payload={section.payload} lang={lang} />
: sectionType === 'DIVIDER'
? <ProductDivider key={section.id} payload={section.payload} />
: <DynamicPageRenderer key={section.id} sections={[toPageSection(section)]} categories={categories} lang={lang} />
if (section.type.toUpperCase() === 'PRODUCT_MAIN') {
if (sectionType === 'PRODUCT_MAIN') {
const payload = payloadRecord(section)
return (
<div key={section.id} className="contents">
@@ -0,0 +1,55 @@
import type { DividerSectionPayload } from '@homaroma/types'
import type { CSSProperties } from 'react'
interface PageDividerProps {
payload: DividerSectionPayload
}
type PageDividerStyle = CSSProperties & {
'--divider-color'?: string
}
const widthClasses: Record<DividerSectionPayload['width'], string> = {
full: 'w-full',
container: 'mx-auto w-full max-w-7xl px-6',
narrow: 'mx-auto w-full max-w-3xl px-6',
}
const styleClasses: Record<DividerSectionPayload['style'], string> = {
solid: 'border-solid',
dashed: 'border-dashed',
dotted: 'border-dotted',
}
const thicknessClasses: Record<DividerSectionPayload['thickness'], string> = {
1: 'border-t',
2: 'border-t-2',
3: 'border-t-3',
4: 'border-t-4',
}
const spacingClasses: Record<DividerSectionPayload['spacing'], string> = {
none: 'py-0',
sm: 'py-2',
md: 'py-4',
lg: 'py-8',
xl: 'py-12',
}
export default function PageDivider({ payload }: PageDividerProps) {
const dividerColor = payload.color?.trim()
const dividerStyle: PageDividerStyle | undefined = dividerColor
? { '--divider-color': dividerColor }
: undefined
return (
<div className={`w-full ${spacingClasses[payload.spacing]}`}>
<div className={widthClasses[payload.width]}>
<hr
className={`m-0 h-0 w-full ${dividerColor ? '[border-color:var(--divider-color)]' : 'border-line-primary/20'} ${styleClasses[payload.style]} ${thicknessClasses[payload.thickness]}`}
style={dividerStyle}
/>
</div>
</div>
)
}
@@ -0,0 +1,55 @@
import type { DividerSectionPayload } from '@homaroma/types'
import type { CSSProperties } from 'react'
interface ProductDividerProps {
payload: DividerSectionPayload
}
type ProductDividerStyle = CSSProperties & {
'--divider-color'?: string
}
const widthClasses: Record<DividerSectionPayload['width'], string> = {
full: 'w-full',
container: 'mx-auto w-full max-w-7xl px-6',
narrow: 'mx-auto w-full max-w-3xl px-6',
}
const styleClasses: Record<DividerSectionPayload['style'], string> = {
solid: 'border-solid',
dashed: 'border-dashed',
dotted: 'border-dotted',
}
const thicknessClasses: Record<DividerSectionPayload['thickness'], string> = {
1: 'border-t',
2: 'border-t-2',
3: 'border-t-3',
4: 'border-t-4',
}
const spacingClasses: Record<DividerSectionPayload['spacing'], string> = {
none: 'py-0',
sm: 'py-2',
md: 'py-4',
lg: 'py-8',
xl: 'py-12',
}
export default function ProductDivider({ payload }: ProductDividerProps) {
const dividerColor = payload.color?.trim()
const dividerStyle: ProductDividerStyle | undefined = dividerColor
? { '--divider-color': dividerColor }
: undefined
return (
<div className={`w-full ${spacingClasses[payload.spacing]}`}>
<div className={widthClasses[payload.width]}>
<hr
className={`m-0 h-0 w-full ${dividerColor ? '[border-color:var(--divider-color)]' : 'border-line-primary/20'} ${styleClasses[payload.style]} ${thicknessClasses[payload.thickness]}`}
style={dividerStyle}
/>
</div>
</div>
)
}
@@ -0,0 +1,225 @@
'use client'
import { useEffect, useState, useSyncExternalStore } from 'react'
import Image from 'next/image'
import { ChevronLeft, ChevronRight, Pause, Play } from 'lucide-react'
import type { HeroCarouselSectionPayload } from '@homaroma/types'
import LocalizedLink from '@/components/LocalizedLink'
import { resolveLocalized } from '@/lib/localization'
interface ProductHeroCarouselProps {
payload: HeroCarouselSectionPayload
lang: string
}
function aspectClasses(
desktop: HeroCarouselSectionPayload['aspectRatio'],
mobile: HeroCarouselSectionPayload['mobileAspectRatio'],
) {
const mobileClass = {
'16/9': 'aspect-[16/9]',
'4/3': 'aspect-[4/3]',
'1/1': 'aspect-square',
'21/9': 'aspect-[21/9]',
auto: 'min-h-[22rem]',
}[mobile ?? '16/9']
const desktopClass = {
'21/6': 'md:aspect-[21/6]',
'21/7': 'md:aspect-[21/7]',
'21/9': 'md:aspect-[21/9]',
'16/5': 'md:aspect-[16/5]',
'16/6': 'md:aspect-[16/6]',
'4/1': 'md:aspect-[4/1]',
'5/1': 'md:aspect-[5/1]',
auto: 'md:aspect-auto md:min-h-[30rem]',
}[desktop ?? '21/7']
return `${mobileClass} ${desktopClass}`
}
function SlideLink({ href, children }: { href: string; children: React.ReactNode }) {
const className = 'inline-flex rounded-full bg-surface-card px-6 py-3 text-sm font-bold uppercase tracking-widest text-text-main transition hover:bg-surface-card/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-card focus-visible:ring-offset-2 focus-visible:ring-offset-text-main'
return href.startsWith('/') ? (
<LocalizedLink href={href} className={className}>{children}</LocalizedLink>
) : (
<a href={href} className={className}>{children}</a>
)
}
function SlideOverlayLink({ href, label }: { href: string; label: string }) {
const className = 'absolute inset-0 z-[25] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-surface-card'
return href.startsWith('/') ? (
<LocalizedLink href={href} className={className}><span className="sr-only">{label}</span></LocalizedLink>
) : (
<a href={href} className={className}><span className="sr-only">{label}</span></a>
)
}
function subscribeToReducedMotion(onStoreChange: () => void) {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
mediaQuery.addEventListener('change', onStoreChange)
return () => mediaQuery.removeEventListener('change', onStoreChange)
}
function getReducedMotionSnapshot() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
export default function ProductHeroCarousel({ payload, lang }: ProductHeroCarouselProps) {
const slides = payload.slides ?? []
const [activeIndex, setActiveIndex] = useState(0)
const [isUserPaused, setIsUserPaused] = useState(false)
const [isInteracting, setIsInteracting] = useState(false)
const prefersReducedMotion = useSyncExternalStore(
subscribeToReducedMotion,
getReducedMotionSnapshot,
() => false,
)
const slideCount = slides.length
const currentIndex = slideCount > 0 ? activeIndex % slideCount : 0
const autoplayConfigured = payload.autoplay !== false && slideCount > 1
const autoplay = autoplayConfigured && !prefersReducedMotion
const rotationPaused = isUserPaused || isInteracting
const interval = Math.max(payload.interval ?? 4500, 2000)
const labels = lang.startsWith('th')
? { carousel: 'โปรโมชันสินค้า', previous: 'สไลด์ก่อนหน้า', next: 'สไลด์ถัดไป', goTo: 'ไปยังสไลด์', pause: 'หยุดการเลื่อนสไลด์อัตโนมัติ', resume: 'เล่นสไลด์อัตโนมัติต่อ', open: 'เปิดโปรโมชัน' }
: { carousel: 'Product promotions', previous: 'Previous slide', next: 'Next slide', goTo: 'Go to slide', pause: 'Pause automatic slides', resume: 'Resume automatic slides', open: 'Open promotion' }
useEffect(() => {
if (!autoplay || rotationPaused) return
const timeoutId = window.setTimeout(() => {
setActiveIndex((index) => (index + 1) % slideCount)
}, interval)
return () => window.clearTimeout(timeoutId)
}, [autoplay, currentIndex, interval, rotationPaused, slideCount])
if (slideCount === 0) return null
const move = (direction: -1 | 1) => {
setActiveIndex((index) => (index + direction + slideCount) % slideCount)
}
return (
<section
className={`relative w-full overflow-hidden bg-surface-muted ${aspectClasses(payload.aspectRatio, payload.mobileAspectRatio)}`}
aria-roledescription="carousel"
aria-label={labels.carousel}
onMouseEnter={() => setIsInteracting(true)}
onMouseLeave={() => setIsInteracting(false)}
onFocusCapture={() => setIsInteracting(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setIsInteracting(false)
}}
>
<div className="absolute inset-0" aria-live={rotationPaused || prefersReducedMotion ? 'polite' : 'off'}>
{slides.map((slide, index) => {
const isActive = index === currentIndex
const title = resolveLocalized(slide.title, lang)
const subtitle = resolveLocalized(slide.subtitle, lang)
const buttonText = resolveLocalized(slide.buttonText, lang)
const alt = resolveLocalized(slide.alt, lang) || title
const focalPoint = slide.focalPoint
return (
<article
key={`${slide.imageUrl}-${index}`}
className={`absolute inset-0 ${prefersReducedMotion ? '' : 'transition-opacity duration-700'} ${isActive ? 'z-10 opacity-100' : 'pointer-events-none opacity-0'}`}
role="group"
aria-roledescription="slide"
aria-label={`${index + 1} / ${slideCount}`}
aria-hidden={!isActive}
inert={!isActive}
>
{slide.mobileImageUrl && (
<Image
src={slide.mobileImageUrl}
alt={alt}
fill
sizes="100vw"
className="object-cover md:hidden"
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
loading={index === 0 ? 'eager' : 'lazy'}
/>
)}
<Image
src={slide.imageUrl}
alt={alt}
fill
sizes="100vw"
className={`object-cover ${slide.mobileImageUrl ? 'hidden md:block' : ''}`}
style={{ objectPosition: focalPoint ? `${focalPoint.x} ${focalPoint.y}` : undefined }}
loading={index === 0 ? 'eager' : 'lazy'}
/>
{slide.linkUrl && !buttonText && (
<SlideOverlayLink href={slide.linkUrl} label={alt || title || labels.open} />
)}
{(title || subtitle || (buttonText && slide.linkUrl)) && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-text-main/40 px-6 py-12 text-center text-surface-card">
<div className="max-w-4xl space-y-4">
{title && <h2 className="text-3xl font-bold leading-tight md:text-5xl">{title}</h2>}
{subtitle && <p className="mx-auto max-w-2xl text-sm leading-relaxed text-surface-card/90 md:text-lg">{subtitle}</p>}
{buttonText && slide.linkUrl && (
<div className="pointer-events-auto pt-2"><SlideLink href={slide.linkUrl}>{buttonText}</SlideLink></div>
)}
</div>
</div>
)}
</article>
)
})}
</div>
{slideCount > 1 && payload.showArrows !== false && (
<>
<button
type="button"
onClick={() => move(-1)}
className="absolute left-3 top-1/2 z-30 -translate-y-1/2 rounded-full border border-surface-card/40 bg-text-main/35 p-2.5 text-surface-card backdrop-blur-sm transition hover:bg-text-main/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-card md:left-6"
aria-label={labels.previous}
>
<ChevronLeft className="h-5 w-5 md:h-6 md:w-6" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => move(1)}
className="absolute right-3 top-1/2 z-30 -translate-y-1/2 rounded-full border border-surface-card/40 bg-text-main/35 p-2.5 text-surface-card backdrop-blur-sm transition hover:bg-text-main/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-card md:right-6"
aria-label={labels.next}
>
<ChevronRight className="h-5 w-5 md:h-6 md:w-6" aria-hidden="true" />
</button>
</>
)}
{slideCount > 1 && payload.showDots !== false && (
<div className="absolute bottom-4 left-1/2 z-30 flex -translate-x-1/2 gap-2 rounded-full bg-text-main/35 px-3 py-2 backdrop-blur-sm">
{slides.map((slide, index) => (
<button
key={`${slide.imageUrl}-dot-${index}`}
type="button"
onClick={() => setActiveIndex(index)}
className={`h-2.5 rounded-full transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-card ${index === currentIndex ? 'w-7 bg-surface-card' : 'w-2.5 bg-surface-card/50 hover:bg-surface-card/80'}`}
aria-label={`${labels.goTo} ${index + 1}`}
aria-current={index === currentIndex ? 'true' : undefined}
/>
))}
</div>
)}
{autoplayConfigured && !prefersReducedMotion && (
<button
type="button"
onClick={() => setIsUserPaused((paused) => !paused)}
className="absolute right-4 top-4 z-30 rounded-full border border-surface-card/40 bg-text-main/35 p-2 text-surface-card backdrop-blur-sm transition hover:bg-text-main/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-surface-card"
aria-label={isUserPaused ? labels.resume : labels.pause}
aria-pressed={isUserPaused}
title={isUserPaused ? labels.resume : labels.pause}
>
{isUserPaused ? <Play className="h-4 w-4" aria-hidden="true" /> : <Pause className="h-4 w-4" aria-hidden="true" />}
</button>
)}
</section>
)
}
@@ -4,7 +4,7 @@ import Image from 'next/image'
import Link from '../LocalizedLink'
import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight, Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart, Download, FileText } from 'lucide-react'
import { useState, useRef, useEffect, useMemo } from 'react'
import type { Category, PageSection, Product } from '@homaroma/types'
import type { Category, DividerSectionPayload, PageSection, Product } from '@homaroma/types'
import { getCategoryImage, resolveCategoryMediaUrl } from '@homaroma/types'
import ProductCard from '../ProductCard'
import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization'
@@ -14,6 +14,7 @@ import { customRegistry } from '../../config/customRegistry'
import { Swiper, SwiperSlide } from 'swiper/react'
import { Autoplay, Pagination, Navigation } from 'swiper/modules'
import ScrollReveal from '../ScrollReveal'
import PageDivider from '../PageDivider'
import 'swiper/css'
import 'swiper/css/pagination'
import 'swiper/css/navigation'
@@ -2268,6 +2269,8 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
<HeroCarouselSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'DIVIDER':
return <PageDivider key={section.id} payload={payload as DividerSectionPayload} />
case 'HERO_MINIMAL':
return (
<ScrollReveal key={section.id}>
+17 -1
View File
@@ -618,6 +618,18 @@ export const HeroCarouselSectionPayloadSchema = z.object({
});
export type HeroCarouselSectionPayload = z.infer<typeof HeroCarouselSectionPayloadSchema>;
export const DividerSectionPayloadSchema = z.object({
width: z.enum(['full', 'container', 'narrow']),
style: z.enum(['solid', 'dashed', 'dotted']),
thickness: z.number().int().min(1).max(4),
spacing: z.enum(['none', 'sm', 'md', 'lg', 'xl']),
color: z.string().regex(
/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i,
'Color must be a hex value in #RGB, #RGBA, #RRGGBB, or #RRGGBBAA format',
).optional(),
});
export type DividerSectionPayload = z.infer<typeof DividerSectionPayloadSchema>;
export interface CustomerAddress {
id: number
customerId: number
@@ -1014,7 +1026,7 @@ export const UspMinimalSectionPayloadSchema = z.object({
export type UspMinimalSectionPayload = z.infer<typeof UspMinimalSectionPayloadSchema>;
export const PAGE_SECTION_TYPES = [
'HERO', 'HERO_V2', 'HERO_VIDEO', 'HERO_CAROUSEL', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE', 'FEATURED_TILES',
'HERO', 'HERO_V2', 'HERO_VIDEO', 'HERO_CAROUSEL', 'DIVIDER', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE', 'FEATURED_TILES',
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
'HERO_ARTICLE', 'RICH_TEXT', 'ARTICLE_HEADER', 'ARTICLE_MEDIA', 'ARTICLE_GALLERY',
'ARTICLE_FACTS', 'ARTICLE_VIDEO', 'ARTICLE_REFERENCES', 'ARTICLE_TABLE', 'ARTICLE_DOWNLOAD',
@@ -1031,6 +1043,7 @@ export const PAGE_SECTION_LOCALIZED_PATHS: Record<SectionType, readonly string[]
HERO_V2: ['tagline', 'title', 'description', 'primaryButtonText', 'secondaryButtonText'],
HERO_VIDEO: ['tagline', 'title', 'description', 'buttonText', 'secondaryButtonText'],
HERO_CAROUSEL: ['slides[].title', 'slides[].subtitle', 'slides[].buttonText', 'slides[].alt'],
DIVIDER: [],
ANNOUNCEMENT_BAR: ['slides[]'],
NEWSLETTER: ['title', 'description', 'buttonText', 'placeholderText', 'privacyText'],
EDITORIAL_TILE: ['title', 'description', 'buttonText'],
@@ -1077,6 +1090,7 @@ export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
HERO_V2: HeroV2SectionPayloadSchema,
HERO_VIDEO: HeroVideoSectionPayloadSchema,
HERO_CAROUSEL: HeroCarouselSectionPayloadSchema,
DIVIDER: DividerSectionPayloadSchema,
ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema,
NEWSLETTER: NewsletterSectionPayloadSchema,
EDITORIAL_TILE: EditorialTileSectionPayloadSchema,
@@ -1123,6 +1137,8 @@ export const PRODUCT_DETAIL_SECTION_TYPES = [
'HERO',
'HERO_V2',
'HERO_VIDEO',
'HERO_CAROUSEL',
'DIVIDER',
'PRODUCT_DETAILS',
'PRODUCT_SPECS',
'PRODUCT_REVIEWS',