x
This commit is contained in:
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,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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user