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.'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user