x
This commit is contained in:
1 parent
46968782b6
commit
08045eabff
18 files changed
+943
-104
No files matched your search
@@ -33,6 +33,15 @@ export default function ProductCarouselForm({
|
||||
}: ProductCarouselFormProps) {
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editingSection?.payload && !editingSection.payload.type) {
|
||||
setEditingSection(prev => prev ? {
|
||||
...prev,
|
||||
payload: { ...(prev.payload || {}), type: 'featured' }
|
||||
} : null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateFlag = (field: 'showPrice' | 'showAddToCart', value: boolean) => {
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
@@ -104,6 +113,61 @@ export default function ProductCarouselForm({
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Card Layout Style</label>
|
||||
<select
|
||||
value={editingSection.payload?.cardStyle || 'standard'}
|
||||
onChange={(e) =>
|
||||
setEditingSection(prev => prev ? {
|
||||
...prev,
|
||||
payload: { ...(prev.payload || {}), cardStyle: e.target.value }
|
||||
} : null)
|
||||
}
|
||||
className="admin-input w-full bg-slate-900 border-white/10"
|
||||
>
|
||||
<option value="standard">Standard (Retail with Add to Cart)</option>
|
||||
<option value="minimal_swatch">Minimal Swatch (Editorial with Color Dots)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">View All Link (URL)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.viewAllLink || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection(prev => prev ? {
|
||||
...prev,
|
||||
payload: { ...(prev.payload || {}), viewAllLink: e.target.value }
|
||||
} : null)
|
||||
}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. /shop or /category/1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">View All Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'viewAllText')}
|
||||
onChange={(e) => updateLocalizedValue('viewAllText', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. View All / ดูทั้งหมด"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Badge Text (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getLocalizedValue(editingSection.payload, 'badgeText')}
|
||||
onChange={(e) => updateLocalizedValue('badgeText', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. NEW / แนะนำ"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Palette, X, Pipette, Check } from 'lucide-react'
|
||||
|
||||
interface ColorSwatchPickerProps {
|
||||
value?: string | null
|
||||
onChange: (color: string) => void
|
||||
onRemove?: () => void
|
||||
buttonLabel?: string
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
const PRESET_COLORS = [
|
||||
{ name: 'Black', hex: '#18181B' },
|
||||
{ name: 'White', hex: '#FFFFFF' },
|
||||
{ name: 'Charcoal', hex: '#374151' },
|
||||
{ name: 'Slate', hex: '#64748B' },
|
||||
{ name: 'Red', hex: '#EF4444' },
|
||||
{ name: 'Crimson', hex: '#991B1B' },
|
||||
{ name: 'Orange', hex: '#F97316' },
|
||||
{ name: 'Amber', hex: '#F59E0B' },
|
||||
{ name: 'Emerald', hex: '#10B981' },
|
||||
{ name: 'Sky', hex: '#0EA5E9' },
|
||||
{ name: 'Blue', hex: '#2563EB' },
|
||||
{ name: 'Indigo', hex: '#6366F1' },
|
||||
{ name: 'Violet', hex: '#8B5CF6' },
|
||||
{ name: 'Rose', hex: '#F43F5E' },
|
||||
{ name: 'Rose Gold', hex: '#E0A99E' },
|
||||
{ name: 'Gold', hex: '#D4AF37' },
|
||||
]
|
||||
|
||||
export default function ColorSwatchPicker({
|
||||
value,
|
||||
onChange,
|
||||
onRemove,
|
||||
buttonLabel = 'Color',
|
||||
size = 'md',
|
||||
}: ColorSwatchPickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [tempHex, setTempHex] = useState(value || '#18181B')
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const nativeColorInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (value) setTempHex(value)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleSelectPreset = (hex: string) => {
|
||||
setTempHex(hex)
|
||||
onChange(hex)
|
||||
}
|
||||
|
||||
const handleApplyHex = (hex: string) => {
|
||||
let clean = hex.trim()
|
||||
if (clean && !clean.startsWith('#')) {
|
||||
clean = `#${clean}`
|
||||
}
|
||||
setTempHex(clean)
|
||||
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(clean)) {
|
||||
onChange(clean)
|
||||
}
|
||||
}
|
||||
|
||||
const isLightColor = (hex?: string | null) => {
|
||||
if (!hex) return false
|
||||
const color = hex.replace('#', '')
|
||||
if (color.length !== 6) return false
|
||||
const r = parseInt(color.substring(0, 2), 16)
|
||||
const g = parseInt(color.substring(2, 4), 16)
|
||||
const b = parseInt(color.substring(4, 6), 16)
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000
|
||||
return brightness > 180
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={popoverRef}>
|
||||
{value ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`group relative flex items-center gap-2 rounded-xl border border-white/20 bg-night-950 hover:border-cyan-glow/50 transition-all ${
|
||||
size === 'sm' ? 'p-1 text-xs' : 'px-2 py-1.5 text-xs'
|
||||
}`}
|
||||
title={`Color: ${value}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg border border-white/20 shrink-0 shadow-inner flex items-center justify-center ${
|
||||
size === 'sm' ? 'w-6 h-6' : 'w-7 h-7'
|
||||
}`}
|
||||
style={{ backgroundColor: value }}
|
||||
>
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
isLightColor(value) ? 'bg-black/60' : 'bg-white/70'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-mono text-[11px] text-slate-300 group-hover:text-cyan-glow">
|
||||
{value.toUpperCase()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="p-1 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||
title="Remove color"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`shrink-0 flex items-center gap-1.5 rounded-xl border border-dashed border-white/20 hover:border-cyan-glow/50 text-slate-400 hover:text-cyan-glow bg-white/[0.02] font-medium transition-all ${
|
||||
size === 'sm' ? 'px-2 py-1 text-[11px]' : 'px-2.5 py-1.5 text-xs'
|
||||
}`}
|
||||
title="Pick color"
|
||||
>
|
||||
<Palette className={size === 'sm' ? 'w-3 h-3 text-cyan-400/80' : 'w-3.5 h-3.5 text-cyan-400/80'} />
|
||||
<span className="whitespace-nowrap">{buttonLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Popover */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-2 left-0 w-64 p-3 rounded-2xl bg-night-900 border border-white/15 shadow-2xl backdrop-blur-xl space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-2">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-300 flex items-center gap-1.5">
|
||||
<Palette className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
Select Color
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 text-slate-500 hover:text-white rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Preset Palette */}
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 font-semibold mb-1.5 uppercase tracking-wider">Presets</div>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{PRESET_COLORS.map((preset) => {
|
||||
const isSelected = value?.toUpperCase() === preset.hex.toUpperCase()
|
||||
return (
|
||||
<button
|
||||
key={preset.hex}
|
||||
type="button"
|
||||
onClick={() => handleSelectPreset(preset.hex)}
|
||||
title={`${preset.name} (${preset.hex})`}
|
||||
className={`w-6 h-6 rounded-lg border flex items-center justify-center transition-all hover:scale-110 ${
|
||||
isSelected
|
||||
? 'border-cyan-glow ring-2 ring-cyan-glow/40'
|
||||
: 'border-white/20 hover:border-white/50'
|
||||
}`}
|
||||
style={{ backgroundColor: preset.hex }}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check
|
||||
className={`w-3 h-3 ${isLightColor(preset.hex) ? 'text-black' : 'text-white'}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Color Input & Hex */}
|
||||
<div className="pt-2 border-t border-white/10 space-y-2">
|
||||
<div className="text-[10px] text-slate-500 font-semibold uppercase tracking-wider">Custom Shade</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={nativeColorInputRef}
|
||||
type="color"
|
||||
value={tempHex.startsWith('#') && tempHex.length === 7 ? tempHex : '#18181B'}
|
||||
onChange={(e) => {
|
||||
setTempHex(e.target.value)
|
||||
onChange(e.target.value)
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nativeColorInputRef.current?.click()}
|
||||
className="w-9 h-9 rounded-xl border border-white/20 shrink-0 flex items-center justify-center shadow-inner hover:scale-105 transition-all"
|
||||
style={{ backgroundColor: tempHex }}
|
||||
title="Click to open color spectrum picker"
|
||||
>
|
||||
<Pipette
|
||||
className={`w-4 h-4 ${isLightColor(tempHex) ? 'text-black' : 'text-white'}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={tempHex}
|
||||
onChange={(e) => handleApplyHex(e.target.value)}
|
||||
placeholder="#000000"
|
||||
maxLength={7}
|
||||
className="w-full px-3 py-1.5 bg-night-950 border border-white/15 focus:border-cyan-glow/50 rounded-xl outline-none text-xs font-mono text-white transition-all shadow-inner uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="pt-2 border-t border-white/10 flex items-center justify-between">
|
||||
{value && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onRemove()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="text-[11px] text-rose-400 hover:text-rose-300 hover:underline"
|
||||
>
|
||||
Clear Color
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="ml-auto px-3 py-1 bg-cyan-glow/10 hover:bg-cyan-glow/20 border border-cyan-glow/30 text-cyan-glow rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -930,10 +930,11 @@ export default function PageBuilderPage() {
|
||||
defaultPayload = {
|
||||
title: 'Featured Collection',
|
||||
description: 'Hand-picked products for this campaign.',
|
||||
collectionId: '',
|
||||
type: 'featured',
|
||||
limit: 6,
|
||||
showPrice: true,
|
||||
showAddToCart: true,
|
||||
cardStyle: 'standard',
|
||||
};
|
||||
break;
|
||||
case 'PRODUCT_MAIN':
|
||||
|
||||
@@ -28,6 +28,7 @@ import MDEditor from '@uiw/react-md-editor'
|
||||
import MediaLibraryModal from '../components/MediaLibraryModal'
|
||||
import ContentLanguageTabs from '../components/ContentLanguageTabs'
|
||||
import ProductTranslationModal from '../components/products/ProductTranslationModal'
|
||||
import ColorSwatchPicker from '../components/products/ColorSwatchPicker'
|
||||
|
||||
type EditableAttribute = {
|
||||
id?: number
|
||||
@@ -129,7 +130,7 @@ export default function ProductEditPage() {
|
||||
const [name, setName] = useState<Record<string, string>>({})
|
||||
const [description, setDescription] = useState<Record<string, string>>({})
|
||||
const [details, setDetails] = useState<Record<string, string>>({})
|
||||
const [options, setOptions] = useState<{name: Record<string, string>, values: (Record<string, string> & { imageUrl?: string })[]}[]>([])
|
||||
const [options, setOptions] = useState<{name: Record<string, string>, values: (Record<string, string> & { imageUrl?: string; colorCode?: string })[]}[]>([])
|
||||
const [variants, setVariants] = useState<Partial<Variant>[]>([])
|
||||
const [images, setImages] = useState<EditableProductImage[]>([])
|
||||
const [titleGraphicUrl, setTitleGraphicUrl] = useState<string>('')
|
||||
@@ -259,7 +260,7 @@ export default function ProductEditPage() {
|
||||
setVariants(updated)
|
||||
}
|
||||
|
||||
const regenerateVariants = (newOptions: {name: Record<string, string>, values: (Record<string, string> & { imageUrl?: string })[]}[]) => {
|
||||
const regenerateVariants = (newOptions: {name: Record<string, string>, values: (Record<string, string> & { imageUrl?: string; colorCode?: string })[]}[]) => {
|
||||
setVariants(prevVariants => {
|
||||
const validOptions = newOptions.filter(o => o.name && (o.name['th'] || o.name['en']) && o.values.length > 0)
|
||||
if (validOptions.length === 0) {
|
||||
@@ -284,13 +285,15 @@ export default function ProductEditPage() {
|
||||
const existing = prevVariants.find(v => v.option1 === option1 && v.option2 === option2 && v.option3 === option3)
|
||||
const opt1Val = newOptions[0]?.values.find(v => (v['th'] || v['en'] || Object.values(v)[0]) === option1)
|
||||
const swatchUrl = opt1Val?.imageUrl || null
|
||||
const swatchColor = opt1Val?.colorCode || null
|
||||
if (existing) {
|
||||
return {
|
||||
...existing,
|
||||
imageUrl: existing.imageUrl || swatchUrl || null,
|
||||
colorCode: existing.colorCode || swatchColor || null,
|
||||
}
|
||||
}
|
||||
return { option1, option2, option3, price: 0, stock: 0, sku: '', barcode: '', imageUrl: swatchUrl }
|
||||
return { option1, option2, option3, price: 0, stock: 0, sku: '', barcode: '', imageUrl: swatchUrl, colorCode: swatchColor }
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -414,6 +417,7 @@ export default function ProductEditPage() {
|
||||
sku: v.sku || '',
|
||||
barcode: v.barcode || '',
|
||||
imageUrl: v.imageUrl || null,
|
||||
colorCode: v.colorCode || null,
|
||||
})),
|
||||
images: images.filter(img => img.url).map((img, idx) => ({
|
||||
...(img.id ? { id: img.id } : {}),
|
||||
@@ -1165,6 +1169,41 @@ export default function ProductEditPage() {
|
||||
<span className="text-[11px] whitespace-nowrap">รูป 1:1</span>
|
||||
</button>
|
||||
)}
|
||||
<ColorSwatchPicker
|
||||
value={val.colorCode}
|
||||
size="sm"
|
||||
onChange={(color) => {
|
||||
const newOpts = [...options]
|
||||
newOpts[idx].values[vIdx] = { ...newOpts[idx].values[vIdx], colorCode: color }
|
||||
setOptions(newOpts)
|
||||
const valName = val[activeLang] || val['th'] || val['en'] || ''
|
||||
if (valName) {
|
||||
setVariants(prev => prev.map(variant => {
|
||||
const optMatch = idx === 0 ? variant.option1 : idx === 1 ? variant.option2 : variant.option3
|
||||
if (optMatch === valName) {
|
||||
return { ...variant, colorCode: color }
|
||||
}
|
||||
return variant
|
||||
}))
|
||||
}
|
||||
}}
|
||||
onRemove={() => {
|
||||
const newOpts = [...options]
|
||||
const { colorCode: _, ...rest } = newOpts[idx].values[vIdx]
|
||||
newOpts[idx].values[vIdx] = rest
|
||||
setOptions(newOpts)
|
||||
const valName = val[activeLang] || val['th'] || val['en'] || ''
|
||||
if (valName) {
|
||||
setVariants(prev => prev.map(variant => {
|
||||
const optMatch = idx === 0 ? variant.option1 : idx === 1 ? variant.option2 : variant.option3
|
||||
if (optMatch === valName) {
|
||||
return { ...variant, colorCode: null }
|
||||
}
|
||||
return variant
|
||||
}))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newOpts = [...options]
|
||||
@@ -1231,7 +1270,7 @@ export default function ProductEditPage() {
|
||||
)) : (
|
||||
<th className="py-3 px-4 font-bold">Variant</th>
|
||||
)}
|
||||
<th className="py-3 px-4 font-bold">Swatch (1:1)</th>
|
||||
<th className="py-3 px-4 font-bold">Swatch (Image / Color)</th>
|
||||
<th className="py-3 px-4 font-bold">Price (THB)</th>
|
||||
<th className="py-3 px-4 font-bold">Stock</th>
|
||||
<th className="py-3 px-4 font-bold">SKU</th>
|
||||
@@ -1251,38 +1290,50 @@ export default function ProductEditPage() {
|
||||
<td className="py-3 px-4 text-sm font-medium text-white">{v.option1 || 'Default'}</td>
|
||||
)}
|
||||
<td className="py-3 px-4">
|
||||
{v.imageUrl ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-10 h-10 rounded-lg overflow-hidden border border-white/15 bg-black/60 shrink-0">
|
||||
<img src={v.imageUrl} alt="Variant swatch" className="w-full h-full object-cover" />
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 1:1 Image */}
|
||||
{v.imageUrl ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative w-8 h-8 rounded-lg overflow-hidden border border-white/15 bg-black/60 shrink-0">
|
||||
<img src={v.imageUrl} alt="Variant swatch" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openMediaModal((url) => handleUpdateVariant(idx, 'imageUrl', url))}
|
||||
className="text-[10px] text-cyan-glow hover:underline text-left leading-none"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateVariant(idx, 'imageUrl', '')}
|
||||
className="text-[10px] text-rose-400 hover:underline text-left leading-none"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openMediaModal((url) => handleUpdateVariant(idx, 'imageUrl', url))}
|
||||
className="text-[10px] text-cyan-glow hover:underline text-left"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateVariant(idx, 'imageUrl', '')}
|
||||
className="text-[10px] text-rose-400 hover:underline text-left"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openMediaModal((url) => handleUpdateVariant(idx, 'imageUrl', url))}
|
||||
className="px-2.5 py-1.5 rounded-lg border border-dashed border-white/15 hover:border-cyan-glow/40 bg-white/5 hover:bg-white/10 text-[11px] text-slate-400 hover:text-cyan-glow flex items-center gap-1.5 transition-all"
|
||||
>
|
||||
<ImagePlus className="w-3.5 h-3.5" />
|
||||
<span>1:1 Image</span>
|
||||
</button>
|
||||
)}
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openMediaModal((url) => handleUpdateVariant(idx, 'imageUrl', url))}
|
||||
className="px-2 py-1 rounded-lg border border-dashed border-white/15 hover:border-cyan-glow/40 bg-white/5 hover:bg-white/10 text-[11px] text-slate-400 hover:text-cyan-glow flex items-center gap-1 transition-all"
|
||||
title="Attach 1:1 image"
|
||||
>
|
||||
<ImagePlus className="w-3.5 h-3.5" />
|
||||
<span>1:1</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Color Swatch */}
|
||||
<ColorSwatchPicker
|
||||
value={v.colorCode}
|
||||
size="sm"
|
||||
onChange={(color) => handleUpdateVariant(idx, 'colorCode', color)}
|
||||
onRemove={() => handleUpdateVariant(idx, 'colorCode', null)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProductVariant" ADD COLUMN "colorCode" TEXT;
|
||||
@@ -140,6 +140,7 @@ model ProductVariant {
|
||||
option2 String @default("") // Maps to Product.options[1]
|
||||
option3 String @default("") // Maps to Product.options[2]
|
||||
imageUrl String?
|
||||
colorCode String?
|
||||
|
||||
productId Int
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
|
||||
@@ -558,25 +558,31 @@ async function resolveProductsForSection(payload, lang) {
|
||||
},
|
||||
};
|
||||
if (payload.type === "featured") {
|
||||
const whereClause = { isDeleted: false };
|
||||
if (payload.categoryId && Number(payload.categoryId) > 0) {
|
||||
whereClause.categoryId = Number(payload.categoryId);
|
||||
}
|
||||
products = await prisma.product.findMany({
|
||||
where: { categoryId: Number(payload.categoryId) || 1 },
|
||||
where: whereClause,
|
||||
include: productInclude,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
else if ((payload.type === "scent" || payload.type === "brand") &&
|
||||
(payload.scentId || payload.brandId)) {
|
||||
products = await prisma.product.findMany({
|
||||
where: { brandId: Number(payload.brandId || payload.scentId) },
|
||||
where: { brandId: Number(payload.brandId || payload.scentId), isDeleted: false },
|
||||
include: productInclude,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
else if (payload.type === "custom" && Array.isArray(payload.productIds)) {
|
||||
const ids = payload.productIds.map(Number);
|
||||
const ids = payload.productIds.map(Number).filter(id => !isNaN(id) && id > 0);
|
||||
if (ids.length > 0) {
|
||||
products = await prisma.product.findMany({
|
||||
where: { id: { in: ids } },
|
||||
where: { id: { in: ids }, isDeleted: false },
|
||||
include: productInclude,
|
||||
});
|
||||
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
||||
@@ -584,8 +590,10 @@ async function resolveProductsForSection(payload, lang) {
|
||||
}
|
||||
else {
|
||||
products = await prisma.product.findMany({
|
||||
where: { isDeleted: false },
|
||||
include: productInclude,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
return products.map((p) => {
|
||||
@@ -619,8 +627,7 @@ export async function hydrateSectionPayload(section, lang) {
|
||||
return section;
|
||||
const populatedPayload = { ...payload };
|
||||
if (section.type === "PRODUCTS" ||
|
||||
(section.type === "PRODUCT_CAROUSEL" &&
|
||||
!Array.isArray(populatedPayload.products))) {
|
||||
section.type === "PRODUCT_CAROUSEL") {
|
||||
populatedPayload.products = await resolveProductsForSection(payload, lang);
|
||||
}
|
||||
else if (section.type === "CATEGORIES") {
|
||||
|
||||
@@ -1473,10 +1473,15 @@ async function resolveProductsForSection(payload: any, lang: string) {
|
||||
};
|
||||
|
||||
if (payload.type === "featured") {
|
||||
const whereClause: any = { isDeleted: false };
|
||||
if (payload.categoryId && Number(payload.categoryId) > 0) {
|
||||
whereClause.categoryId = Number(payload.categoryId);
|
||||
}
|
||||
products = await prisma.product.findMany({
|
||||
where: { categoryId: Number(payload.categoryId) || 1, isDeleted: false },
|
||||
where: whereClause,
|
||||
include: productInclude,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" as const },
|
||||
});
|
||||
} else if (
|
||||
(payload.type === "scent" || payload.type === "brand") &&
|
||||
@@ -1486,9 +1491,10 @@ async function resolveProductsForSection(payload: any, lang: string) {
|
||||
where: { brandId: Number(payload.brandId || payload.scentId), isDeleted: false },
|
||||
include: productInclude,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" as const },
|
||||
});
|
||||
} else if (payload.type === "custom" && Array.isArray(payload.productIds)) {
|
||||
const ids = payload.productIds.map(Number);
|
||||
const ids = payload.productIds.map(Number).filter((id: number) => !isNaN(id) && id > 0);
|
||||
if (ids.length > 0) {
|
||||
products = await prisma.product.findMany({
|
||||
where: { id: { in: ids }, isDeleted: false },
|
||||
@@ -1501,6 +1507,7 @@ async function resolveProductsForSection(payload: any, lang: string) {
|
||||
where: { isDeleted: false },
|
||||
include: productInclude,
|
||||
take: limit,
|
||||
orderBy: { createdAt: "desc" as const },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1538,8 +1545,7 @@ export async function hydrateSectionPayload(section: any, lang: string) {
|
||||
|
||||
if (
|
||||
section.type === "PRODUCTS" ||
|
||||
(section.type === "PRODUCT_CAROUSEL" &&
|
||||
!Array.isArray(populatedPayload.products))
|
||||
section.type === "PRODUCT_CAROUSEL"
|
||||
) {
|
||||
populatedPayload.products = await resolveProductsForSection(payload, lang);
|
||||
} else if (section.type === "CATEGORIES") {
|
||||
|
||||
@@ -151,6 +151,7 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
|
||||
option3: v.option3 || "",
|
||||
price: v.price != null ? Number(v.price) : 0,
|
||||
imageUrl: v.imageUrl || null,
|
||||
colorCode: v.colorCode || null,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -715,6 +716,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
option3: v.option3 || "",
|
||||
price: v.price || 0,
|
||||
imageUrl: v.imageUrl !== undefined ? (v.imageUrl || null) : undefined,
|
||||
colorCode: v.colorCode !== undefined ? (v.colorCode || null) : undefined,
|
||||
bomItems: v.bomItems ? {
|
||||
deleteMany: {},
|
||||
create: v.bomItems.map((b: any) => ({
|
||||
@@ -737,6 +739,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
option3: v.option3 || "",
|
||||
price: v.price || 0,
|
||||
imageUrl: v.imageUrl || null,
|
||||
colorCode: v.colorCode || null,
|
||||
bomItems: v.bomItems && v.bomItems.length > 0 ? {
|
||||
create: v.bomItems.map((b: any) => ({
|
||||
rawMaterialId: b.rawMaterialId,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import ProductCard from './ProductCard';
|
||||
import ProductSwatchCard from './ProductSwatchCard';
|
||||
import { useRef } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
import { getSectionPaddingClasses } from '../utils/spacing';
|
||||
|
||||
@@ -16,6 +18,11 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
|
||||
const { products = [] } = p;
|
||||
const title = getLocalizedField(p.title, lang);
|
||||
const description = getLocalizedField(p.description, lang);
|
||||
const cardStyle = p.cardStyle || 'standard';
|
||||
const isMinimal = cardStyle === 'minimal_swatch';
|
||||
const viewAllLink = p.viewAllLink || '/products';
|
||||
const viewAllText = getLocalizedField(p.viewAllText, lang) || 'View All';
|
||||
const badgeText = getLocalizedField(p.badgeText, lang);
|
||||
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' });
|
||||
@@ -23,12 +30,25 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
|
||||
|
||||
return (
|
||||
<section className={`bg-pop-bg ${getSectionPaddingClasses(p)}`}>
|
||||
<div className="ui-section-inner">
|
||||
{(title || description) && (
|
||||
<div className="text-center mb-12">
|
||||
{title && <h2 className="text-3xl md:text-5xl font-serif text-pop-black mb-4">{title}</h2>}
|
||||
{description && <p className="text-lg text-pop-black/70 max-w-2xl mx-auto">{description}</p>}
|
||||
<div className="ui-section-inner space-y-6">
|
||||
{isMinimal ? (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between border-b border-black/10 pb-5">
|
||||
<div className="flex flex-col md:flex-row md:items-baseline md:gap-4">
|
||||
{title && <h2 className="text-2xl md:text-4xl font-serif text-pop-black tracking-tight">{title}</h2>}
|
||||
{description && <p className="text-xs md:text-sm text-pop-black/60 font-normal mt-1 md:mt-0">{description}</p>}
|
||||
</div>
|
||||
<Link to={viewAllLink} className="inline-flex items-center gap-1.5 text-xs font-semibold text-pop-black hover:text-brand-primary transition-colors hover:underline self-start md:self-auto">
|
||||
<span>{viewAllText}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
(title || description) && (
|
||||
<div className="text-center mb-12">
|
||||
{title && <h2 className="text-3xl md:text-5xl font-serif text-pop-black mb-4">{title}</h2>}
|
||||
{description && <p className="text-lg text-pop-black/70 max-w-2xl mx-auto">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{products.length === 0 ? (
|
||||
@@ -39,24 +59,35 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
|
||||
<div className="relative -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<button
|
||||
onClick={scrollLeft}
|
||||
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-10 w-10 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -left-5"
|
||||
className="hidden md:flex bg-white rounded-full shadow-md hover:text-brand-primary h-9 w-9 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -left-4 border border-black/10"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div ref={carouselRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-4 pb-12">
|
||||
{products.map((p: any) => (
|
||||
<div key={p.id} className="w-[85vw] sm:w-[300px] flex-none snap-start">
|
||||
<ProductCard product={p} enableQuickShop={true} />
|
||||
<div ref={carouselRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-4 md:gap-5 pb-6">
|
||||
{products.map((prod: any) => (
|
||||
<div
|
||||
key={prod.id}
|
||||
className={
|
||||
isMinimal
|
||||
? "w-[42vw] sm:w-[200px] md:w-[220px] lg:w-[240px] flex-none snap-start"
|
||||
: "w-[85vw] sm:w-[300px] flex-none snap-start"
|
||||
}
|
||||
>
|
||||
{isMinimal ? (
|
||||
<ProductSwatchCard product={prod} badgeText={badgeText} />
|
||||
) : (
|
||||
<ProductCard product={prod} enableQuickShop={true} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={scrollRight}
|
||||
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-10 w-10 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -right-5"
|
||||
className="hidden md:flex bg-white rounded-full shadow-md hover:text-brand-primary h-9 w-9 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -right-4 border border-black/10"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import type { Product, TranslatableString } from '../types';
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { Product, Variant, TranslatableString } from '../types';
|
||||
import { getCategoryImage, formatPrice } from '../types';
|
||||
import { Heart, ShoppingBag, Layers } from 'lucide-react';
|
||||
import { useWishlistStore } from '../store/wishlistStore';
|
||||
@@ -43,7 +43,24 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
||||
const { addToCart } = useCartStore();
|
||||
const { i18n, t } = useTranslation();
|
||||
const isWished = isInWishlist(product.id);
|
||||
const [selectedVariant, _setSelectedVariant] = useState(product.variants?.[0] || null);
|
||||
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null);
|
||||
|
||||
// Extract unique color swatches from variants
|
||||
const colorSwatches = useMemo(() => {
|
||||
const swatches: Array<{ colorCode: string; variant: Variant; label: string }> = [];
|
||||
const seenColors = new Set<string>();
|
||||
|
||||
if (Array.isArray(product.variants)) {
|
||||
for (const v of product.variants) {
|
||||
if (v.colorCode && !seenColors.has(v.colorCode.toLowerCase())) {
|
||||
seenColors.add(v.colorCode.toLowerCase());
|
||||
const label = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ');
|
||||
swatches.push({ colorCode: v.colorCode, variant: v, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
return swatches;
|
||||
}, [product.variants]);
|
||||
const displayName = resolveStr(product.name, i18n.language) || (product.brand ? product.brand.name : '');
|
||||
const displayCategoryName = categoryNameOverride || resolveStr(product.category.name, i18n.language);
|
||||
const fallbackImage = getCategoryImage(displayCategoryName);
|
||||
@@ -131,14 +148,50 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{enableQuickShop && product.variants && product.variants.length > 1 && (
|
||||
<div className="mb-1 mt-2 flex items-center">
|
||||
{colorSwatches.length > 0 || (enableQuickShop && product.variants && product.variants.length > 1) ? (
|
||||
colorSwatches.length > 0 ? (
|
||||
<div className="mb-1 mt-2 flex items-center gap-1.5 flex-wrap" onClick={(e) => e.stopPropagation()}>
|
||||
{colorSwatches.slice(0, 5).map(({ colorCode, variant, label }) => {
|
||||
const isSelected = selectedVariant?.id === variant.id || selectedVariant?.colorCode === colorCode;
|
||||
return (
|
||||
<button
|
||||
key={variant.id || colorCode}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedVariant(variant);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (variant.imageUrl) {
|
||||
setSelectedVariant(variant);
|
||||
}
|
||||
}}
|
||||
className={`relative h-4 w-4 rounded-full border border-black/15 transition-all duration-200 hover:scale-125 focus:outline-none ${
|
||||
isSelected
|
||||
? 'ring-2 ring-pop-black ring-offset-2 scale-110 shadow-sm'
|
||||
: 'opacity-85 hover:opacity-100'
|
||||
}`}
|
||||
style={{ backgroundColor: colorCode }}
|
||||
title={label || colorCode}
|
||||
aria-label={label || `Color ${colorCode}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{colorSwatches.length > 5 && (
|
||||
<span className="text-[10px] font-semibold text-pop-black/60 pl-0.5">
|
||||
+{colorSwatches.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-1 mt-2 flex items-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-pop-black/5 px-2 py-1 text-[10px] font-semibold text-pop-black/70 border border-pop-black/10 shadow-sm transition-colors group-hover:bg-pop-black/10">
|
||||
<Layers className="h-3 w-3 opacity-70" />
|
||||
{product.variants.length} {t('product.variantsAvailable', 'Options')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)) : null}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Product, Variant, TranslatableString } from '../types';
|
||||
import { formatPrice } from '../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function resolveStr(v: TranslatableString | null | undefined, lang: string): string {
|
||||
if (!v) return ''
|
||||
if (typeof v === 'string') return v
|
||||
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? ''
|
||||
}
|
||||
|
||||
interface ProductSwatchCardProps {
|
||||
product: Product;
|
||||
badgeText?: string | null;
|
||||
}
|
||||
|
||||
export default function ProductSwatchCard({ product, badgeText }: ProductSwatchCardProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const displayName = resolveStr(product.name, i18n.language) || (product.brand ? product.brand.name : 'Product');
|
||||
|
||||
// Find all unique color swatches from variants
|
||||
const colorSwatches = useMemo(() => {
|
||||
const swatches: { colorCode: string; variant: Variant }[] = [];
|
||||
const seenColors = new Set<string>();
|
||||
|
||||
if (Array.isArray(product.variants)) {
|
||||
for (const v of product.variants) {
|
||||
if (v.colorCode && !seenColors.has(v.colorCode.toLowerCase())) {
|
||||
seenColors.add(v.colorCode.toLowerCase());
|
||||
swatches.push({ colorCode: v.colorCode, variant: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
return swatches;
|
||||
}, [product.variants]);
|
||||
|
||||
// Active selected variant
|
||||
const [activeVariant, setActiveVariant] = useState<Variant | null>(() => {
|
||||
return colorSwatches[0]?.variant || product.variants?.[0] || null;
|
||||
});
|
||||
|
||||
// Display Image: variant image if available, else first product image
|
||||
const displayImageUrl = useMemo(() => {
|
||||
if (activeVariant?.imageUrl) return activeVariant.imageUrl;
|
||||
if (product.images && product.images.length > 0 && product.images[0].url) {
|
||||
return product.images[0].url;
|
||||
}
|
||||
return product.category?.imageUrl || '/assets/hero.png';
|
||||
}, [activeVariant, product.images, product.category]);
|
||||
|
||||
const displayPrice = activeVariant?.price ?? product.variants?.[0]?.price ?? 0;
|
||||
|
||||
return (
|
||||
<div className="group flex flex-col h-full select-none">
|
||||
{/* 1:1 Image Frame */}
|
||||
<Link
|
||||
to={`/product/${product.id}`}
|
||||
className="relative aspect-square w-full rounded-2xl overflow-hidden bg-[#F6F5F2] border border-black/[0.04] transition-all duration-300 group-hover:shadow-md block"
|
||||
>
|
||||
{/* Badge (e.g. NEW) */}
|
||||
{badgeText && (
|
||||
<div className="absolute top-2.5 right-2.5 z-10">
|
||||
<span className="inline-block px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-widest bg-[#485645] text-[#F0F3EE] shadow-sm">
|
||||
{badgeText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Image */}
|
||||
<div className="relative w-full h-full p-3 flex items-center justify-center">
|
||||
<img
|
||||
src={displayImageUrl}
|
||||
alt={displayName}
|
||||
className="w-full h-full object-contain p-2 transition-transform duration-500 ease-out group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Meta (Title, Price, Swatches) */}
|
||||
<div className="pt-2.5 pb-1 flex flex-col gap-1">
|
||||
{/* Product Title */}
|
||||
<Link to={`/product/${product.id}`}>
|
||||
<h3 className="text-xs md:text-sm font-medium text-pop-black line-clamp-1 hover:underline transition-colors">
|
||||
{displayName}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{/* Price */}
|
||||
<div className="text-xs md:text-sm font-bold text-pop-black">
|
||||
{formatPrice(displayPrice)}
|
||||
</div>
|
||||
|
||||
{/* Color Swatch Dots */}
|
||||
{colorSwatches.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
{colorSwatches.map(({ colorCode, variant }) => {
|
||||
const isActive = activeVariant?.colorCode === colorCode;
|
||||
return (
|
||||
<button
|
||||
key={colorCode}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setActiveVariant(variant);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (variant.imageUrl) {
|
||||
setActiveVariant(variant);
|
||||
}
|
||||
}}
|
||||
className={`relative w-3.5 h-3.5 rounded-full border border-black/15 transition-all duration-200 hover:scale-125 focus:outline-none ${
|
||||
isActive ? 'ring-2 ring-neutral-700 ring-offset-1 scale-110' : 'opacity-85 hover:opacity-100'
|
||||
}`}
|
||||
style={{ backgroundColor: colorCode }}
|
||||
title={variant.option1 || variant.option2 || colorCode}
|
||||
aria-label={`Color ${colorCode}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -304,7 +304,15 @@ export default function ProductDetailPage() {
|
||||
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
|
||||
<span className="inline-flex items-center gap-1.5 justify-center">
|
||||
{v.colorCode && (
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-full border border-black/20 shadow-xs shrink-0"
|
||||
style={{ backgroundColor: v.colorCode }}
|
||||
/>
|
||||
)}
|
||||
<span>{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}</span>
|
||||
</span>
|
||||
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-spa-light' : 'text-spa-stone/60'}`}>
|
||||
{formatPrice(v.price)}
|
||||
</span>
|
||||
|
||||
@@ -255,26 +255,31 @@ export default function AddToCartSection({
|
||||
const productName = resolveLocalized(product.name, lang) || product.brand.name
|
||||
const description = resolveLocalized(product.description, lang)
|
||||
|
||||
const getOptionImage = (optIndex: number, val: string) => {
|
||||
// 1. Check if option itself has an imageUrl on value
|
||||
const getOptionSwatch = (optIndex: number, val: string) => {
|
||||
// 1. Check if option itself has an imageUrl or colorCode on value
|
||||
const option = product.options?.[optIndex];
|
||||
if (option && Array.isArray(option.values)) {
|
||||
const matchVal = option.values.find((v: any) => {
|
||||
const base = typeof v === 'string' ? v : (v?.th || v?.en || Object.values(v)[0]);
|
||||
return base === val;
|
||||
});
|
||||
if (matchVal && typeof matchVal === 'object' && (matchVal as any).imageUrl) {
|
||||
return (matchVal as any).imageUrl as string;
|
||||
if (matchVal && typeof matchVal === 'object') {
|
||||
const img = (matchVal as any).imageUrl as string | undefined;
|
||||
const col = (matchVal as any).colorCode as string | undefined;
|
||||
if (img || col) return { imageUrl: img || null, colorCode: col || null };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to matching variant imageUrl
|
||||
if (!product.variants) return null;
|
||||
// 2. Fallback to matching variant imageUrl or colorCode
|
||||
if (!product.variants) return { imageUrl: null, colorCode: null };
|
||||
const match = product.variants.find(v => {
|
||||
const vVal = optIndex === 0 ? v.option1 : optIndex === 1 ? v.option2 : v.option3;
|
||||
return vVal === val && Boolean(v.imageUrl);
|
||||
return vVal === val && (Boolean(v.imageUrl) || Boolean(v.colorCode));
|
||||
});
|
||||
return match?.imageUrl || null;
|
||||
return {
|
||||
imageUrl: match?.imageUrl || null,
|
||||
colorCode: match?.colorCode || null,
|
||||
};
|
||||
};
|
||||
|
||||
const isOptionAvailable = (optionIndex: number, value: string) => {
|
||||
@@ -414,7 +419,10 @@ export default function AddToCartSection({
|
||||
{product.options.map((option, optIndex) => {
|
||||
const optNameBase = getBaseString(option.name);
|
||||
const optNameDisplay = resolveLocalized(option.name, lang);
|
||||
const hasAnySwatch = option.values.some(v => Boolean(getOptionImage(optIndex, getBaseString(v))));
|
||||
const hasAnySwatch = option.values.some(v => {
|
||||
const sw = getOptionSwatch(optIndex, getBaseString(v));
|
||||
return Boolean(sw.imageUrl || sw.colorCode);
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={optNameBase} className="space-y-3">
|
||||
@@ -428,7 +436,7 @@ export default function AddToCartSection({
|
||||
const displayVal = resolveLocalized(val, lang);
|
||||
const isSelected = selectedOptions[optIndex] === baseVal;
|
||||
const isAvailable = isOptionAvailable(optIndex, baseVal);
|
||||
const swatchImg = getOptionImage(optIndex, baseVal);
|
||||
const swatch = getOptionSwatch(optIndex, baseVal);
|
||||
|
||||
if (hasAnySwatch) {
|
||||
return (
|
||||
@@ -445,14 +453,19 @@ export default function AddToCartSection({
|
||||
}`}
|
||||
>
|
||||
<div className="relative w-14 h-14 md:w-16 md:h-16 rounded-lg overflow-hidden bg-surface-page border border-line-primary/10 flex items-center justify-center">
|
||||
{swatchImg ? (
|
||||
{swatch.imageUrl ? (
|
||||
<Image
|
||||
src={swatchImg}
|
||||
src={swatch.imageUrl}
|
||||
alt={displayVal}
|
||||
fill
|
||||
sizes="64px"
|
||||
className="object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : swatch.colorCode ? (
|
||||
<div
|
||||
className="w-full h-full rounded-md shadow-inner transition-transform group-hover:scale-105"
|
||||
style={{ backgroundColor: swatch.colorCode }}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-text-main">{displayVal}</span>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from './LocalizedLink'
|
||||
import { ShoppingBag, Layers } from 'lucide-react'
|
||||
import type { Product } from '@homaroma/types'
|
||||
import type { Product, Variant } from '@homaroma/types'
|
||||
import { getCategoryImage, formatPrice } from '@homaroma/types'
|
||||
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
|
||||
import { generateProductSlug } from '../lib/slugify'
|
||||
@@ -20,11 +20,28 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
const lang = useLang()
|
||||
const { addToCart } = useCartStore()
|
||||
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null)
|
||||
|
||||
// Extract unique color swatches from variants
|
||||
const colorSwatches = useMemo(() => {
|
||||
const swatches: Array<{ colorCode: string; variant: Variant; label: string }> = []
|
||||
const seenColors = new Set<string>()
|
||||
|
||||
if (Array.isArray(product.variants)) {
|
||||
for (const v of product.variants) {
|
||||
if (v.colorCode && !seenColors.has(v.colorCode.toLowerCase())) {
|
||||
seenColors.add(v.colorCode.toLowerCase())
|
||||
const label = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ')
|
||||
swatches.push({ colorCode: v.colorCode, variant: v, label })
|
||||
}
|
||||
}
|
||||
}
|
||||
return swatches
|
||||
}, [product.variants])
|
||||
|
||||
const categoryName = resolveCategoryName(product.category, lang)
|
||||
const productName = resolveLocalized(product.name, lang) || product.brand.name
|
||||
const productSlug = generateProductSlug(productName, product.id)
|
||||
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
const imageUrl = selectedVariant?.imageUrl || product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
const images = (product.images?.length ?? 0) > 0
|
||||
? product.images!.slice(0, 3).map(img => img.url)
|
||||
: [imageUrl];
|
||||
@@ -125,14 +142,50 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product.variants && product.variants.length > 1 && (
|
||||
<div className="mb-1 mt-2 flex items-center">
|
||||
{colorSwatches.length > 0 || (product.variants && product.variants.length > 1) ? (
|
||||
colorSwatches.length > 0 ? (
|
||||
<div className="mb-1 mt-2 flex items-center gap-1.5 flex-wrap">
|
||||
{colorSwatches.slice(0, 5).map(({ colorCode, variant, label }) => {
|
||||
const isSelected = selectedVariant?.id === variant.id || selectedVariant?.colorCode === colorCode
|
||||
return (
|
||||
<button
|
||||
key={variant.id || colorCode}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSelectedVariant(variant)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (variant.imageUrl) {
|
||||
setSelectedVariant(variant)
|
||||
}
|
||||
}}
|
||||
className={`relative h-4 w-4 rounded-full border border-black/15 dark:border-white/20 transition-all duration-200 hover:scale-125 focus:outline-none ${
|
||||
isSelected
|
||||
? 'ring-2 ring-text-main ring-offset-2 ring-offset-surface-card scale-110 shadow-sm'
|
||||
: 'opacity-85 hover:opacity-100'
|
||||
}`}
|
||||
style={{ backgroundColor: colorCode }}
|
||||
title={label || colorCode}
|
||||
aria-label={label || `Color ${colorCode}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{colorSwatches.length > 5 && (
|
||||
<span className="text-[10px] font-semibold text-muted-text pl-0.5">
|
||||
+{colorSwatches.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-1 mt-2 flex items-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-surface-page px-2 py-1 text-[10px] font-semibold text-muted-text border border-line-primary/20 shadow-sm transition-colors group-hover:bg-surface-page/80">
|
||||
<Layers className="h-3 w-3 opacity-70" />
|
||||
{product.variants.length} {optionsText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)) : null}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from './LocalizedLink'
|
||||
import type { Product, Variant } from '@homaroma/types'
|
||||
import { formatPrice } from '@homaroma/types'
|
||||
import { resolveLocalized } from '../lib/localization'
|
||||
import { generateProductSlug } from '../lib/slugify'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
|
||||
interface ProductSwatchCardProps {
|
||||
product: Product
|
||||
badgeText?: string | null
|
||||
}
|
||||
|
||||
export default function ProductSwatchCard({ product, badgeText }: ProductSwatchCardProps) {
|
||||
const lang = useLang()
|
||||
const productName = resolveLocalized(product.name, lang) || product.brand?.name || 'Product'
|
||||
const productSlug = generateProductSlug(productName, product.id)
|
||||
|
||||
// Find all unique color swatches from variants
|
||||
const colorSwatches = useMemo(() => {
|
||||
const swatches: { colorCode: string; variant: Variant }[] = []
|
||||
const seenColors = new Set<string>()
|
||||
|
||||
if (Array.isArray(product.variants)) {
|
||||
for (const v of product.variants) {
|
||||
if (v.colorCode && !seenColors.has(v.colorCode.toLowerCase())) {
|
||||
seenColors.add(v.colorCode.toLowerCase())
|
||||
swatches.push({ colorCode: v.colorCode, variant: v })
|
||||
}
|
||||
}
|
||||
}
|
||||
return swatches
|
||||
}, [product.variants])
|
||||
|
||||
// Active selected variant (for image and price swap when clicking swatch)
|
||||
const [activeVariant, setActiveVariant] = useState<Variant | null>(() => {
|
||||
return colorSwatches[0]?.variant || product.variants?.[0] || null
|
||||
})
|
||||
|
||||
// Display Image: variant image if available, else first product image
|
||||
const displayImageUrl = useMemo(() => {
|
||||
if (activeVariant?.imageUrl) return activeVariant.imageUrl
|
||||
if (product.images && product.images.length > 0 && product.images[0].url) {
|
||||
return product.images[0].url
|
||||
}
|
||||
return product.category?.imageUrl || '/assets/hero.png'
|
||||
}, [activeVariant, product.images, product.category])
|
||||
|
||||
const displayPrice = activeVariant?.price ?? product.variants?.[0]?.price ?? 0
|
||||
|
||||
return (
|
||||
<div className="group flex flex-col h-full select-none">
|
||||
{/* 1:1 Image Frame */}
|
||||
<Link href={`/products/${productSlug}`} className="relative aspect-square w-full rounded-2xl overflow-hidden bg-[#F6F5F2] dark:bg-neutral-800/80 border border-black/[0.04] dark:border-white/[0.05] transition-all duration-300 group-hover:shadow-md block">
|
||||
{/* Badge (e.g. NEW) */}
|
||||
{badgeText && (
|
||||
<div className="absolute top-2.5 right-2.5 z-10">
|
||||
<span className="inline-block px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-widest bg-[#485645] text-[#F0F3EE] shadow-sm">
|
||||
{badgeText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Image */}
|
||||
<div className="relative w-full h-full p-3 flex items-center justify-center">
|
||||
<Image
|
||||
src={displayImageUrl}
|
||||
alt={productName}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 25vw, 20vw"
|
||||
className="object-contain p-2 transition-transform duration-500 ease-out group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Meta (Title, Price, Swatches) */}
|
||||
<div className="pt-2.5 pb-1 flex flex-col gap-1">
|
||||
{/* Product Title */}
|
||||
<Link href={`/products/${productSlug}`}>
|
||||
<h3 className="text-xs md:text-sm font-medium text-neutral-800 dark:text-neutral-200 line-clamp-1 hover:underline transition-colors">
|
||||
{productName}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{/* Price */}
|
||||
<div className="text-xs md:text-sm font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{formatPrice(displayPrice)}
|
||||
</div>
|
||||
|
||||
{/* Color Swatch Dots */}
|
||||
{colorSwatches.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
{colorSwatches.map(({ colorCode, variant }) => {
|
||||
const isActive = activeVariant?.colorCode === colorCode
|
||||
return (
|
||||
<button
|
||||
key={colorCode}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setActiveVariant(variant)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (variant.imageUrl) {
|
||||
setActiveVariant(variant)
|
||||
}
|
||||
}}
|
||||
className={`relative w-3.5 h-3.5 rounded-full border border-black/15 dark:border-white/20 transition-all duration-200 hover:scale-125 focus:outline-none ${
|
||||
isActive ? 'ring-2 ring-neutral-700 dark:ring-neutral-300 ring-offset-1 scale-110' : 'opacity-85 hover:opacity-100'
|
||||
}`}
|
||||
style={{ backgroundColor: colorCode }}
|
||||
title={variant.option1 || variant.option2 || colorCode}
|
||||
aria-label={`Color ${colorCode}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
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 { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight, Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart, Download, FileText, ArrowRight } from 'lucide-react'
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import type { Category, DividerSectionPayload, PageSection, Product } from '@homaroma/types'
|
||||
import { getCategoryImage, resolveCategoryMediaUrl } from '@homaroma/types'
|
||||
import ProductCard from '../ProductCard'
|
||||
import ProductSwatchCard from '../ProductSwatchCard'
|
||||
import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization'
|
||||
import { useLang } from '../../hooks/useLang'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -1121,52 +1122,83 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
|
||||
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Featured Picks'
|
||||
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
|
||||
const layout = typeof payload.layout === 'string' ? payload.layout : 'grid'
|
||||
const cardStyle = typeof payload.cardStyle === 'string' ? payload.cardStyle : 'standard'
|
||||
const viewAllLink = typeof payload.viewAllLink === 'string' && payload.viewAllLink.trim() ? payload.viewAllLink.trim() : '/shop'
|
||||
const viewAllText = localizedFromRecord(payload, 'viewAllText', resolvedLang) || t('dynamic.viewAll', { lng: resolvedLang }) || 'View All'
|
||||
const badgeText = localizedFromRecord(payload, 'badgeText', resolvedLang)
|
||||
const products = getProducts(payload)
|
||||
|
||||
const carouselRef = useRef<HTMLDivElement>(null)
|
||||
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' })
|
||||
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' })
|
||||
|
||||
const isMinimal = cardStyle === 'minimal_swatch'
|
||||
|
||||
return (
|
||||
<section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
|
||||
<div className="ui-section-shell space-y-10">
|
||||
<div className="flex flex-col gap-4 border-b border-line-primary/20 pb-6 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h2 className="ui-section-title">{title}</h2>
|
||||
{subtitle && <p className="mt-2 ui-section-subtitle">{subtitle}</p>}
|
||||
<div className="ui-section-shell space-y-8">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between border-b border-line-primary/10 pb-5">
|
||||
<div className="flex flex-col md:flex-row md:items-baseline md:gap-4">
|
||||
<h2 className={isMinimal ? "text-2xl md:text-3xl font-serif text-text-main tracking-tight" : "ui-section-title"}>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className={isMinimal ? "text-xs md:text-sm text-muted-text font-normal mt-1 md:mt-0" : "mt-2 ui-section-subtitle"}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/shop" className="ui-section-link">
|
||||
{t('dynamic.viewAll', { lng: resolvedLang })}
|
||||
<Link href={viewAllLink} className="inline-flex items-center gap-1.5 text-xs font-semibold text-text-main hover:text-brand-primary transition-colors underline-offset-4 hover:underline self-start md:self-auto">
|
||||
<span>{viewAllText}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{products.length ? (
|
||||
layout === 'carousel' ? (
|
||||
<div className="relative -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<button
|
||||
onClick={scrollLeft}
|
||||
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-10 w-10 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -left-5"
|
||||
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-9 w-9 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -left-4 border border-line-primary/15"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div ref={carouselRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-4 pb-4">
|
||||
<div ref={carouselRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-4 md:gap-5 pb-4">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="w-[85vw] sm:w-[300px] flex-none snap-start">
|
||||
<ProductCard product={product} />
|
||||
<div
|
||||
key={product.id}
|
||||
className={
|
||||
isMinimal
|
||||
? "w-[42vw] sm:w-[200px] md:w-[220px] lg:w-[240px] flex-none snap-start"
|
||||
: "w-[85vw] sm:w-[300px] flex-none snap-start"
|
||||
}
|
||||
>
|
||||
{isMinimal ? (
|
||||
<ProductSwatchCard product={product} badgeText={badgeText} />
|
||||
) : (
|
||||
<ProductCard product={product} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={scrollRight}
|
||||
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-10 w-10 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -right-5"
|
||||
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-9 w-9 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -right-4 border border-line-primary/15"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
{products.map((product) => <ProductCard key={product.id} product={product} />)}
|
||||
<div className={isMinimal ? "grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6" : "grid grid-cols-1 gap-8 md:grid-cols-3"}>
|
||||
{products.map((product) =>
|
||||
isMinimal ? (
|
||||
<ProductSwatchCard key={product.id} product={product} badgeText={badgeText} />
|
||||
) : (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface Variant {
|
||||
option2: string
|
||||
option3: string
|
||||
imageUrl?: string | null
|
||||
colorCode?: string | null
|
||||
bomItems?: BomItem[]
|
||||
}
|
||||
|
||||
@@ -1026,7 +1027,7 @@ export type QuoteCalloutSectionPayload = z.infer<typeof QuoteCalloutSectionPaylo
|
||||
export const ProductCarouselSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema.optional(),
|
||||
description: LocalizedStringSchema.optional(),
|
||||
type: z.enum(['featured', 'brand', 'custom']),
|
||||
type: z.enum(['featured', 'brand', 'custom']).optional().default('featured'),
|
||||
categoryId: z.number().optional(),
|
||||
brandId: z.number().optional(),
|
||||
productIds: z.array(z.number()).optional(),
|
||||
@@ -1034,6 +1035,11 @@ export const ProductCarouselSectionPayloadSchema = z.object({
|
||||
showPrice: z.boolean().optional(),
|
||||
showAddToCart: z.boolean().optional(),
|
||||
products: z.array(z.any()).optional(),
|
||||
cardStyle: z.enum(['standard', 'minimal_swatch']).optional().default('standard'),
|
||||
viewAllText: LocalizedStringSchema.optional(),
|
||||
viewAllLink: z.string().optional(),
|
||||
badgeText: LocalizedStringSchema.optional(),
|
||||
collectionId: z.any().optional(),
|
||||
}).strict();
|
||||
export type ProductCarouselSectionPayload = z.infer<typeof ProductCarouselSectionPayloadSchema>;
|
||||
|
||||
@@ -1280,7 +1286,7 @@ export const PAGE_SECTION_LOCALIZED_PATHS: Record<SectionType, readonly string[]
|
||||
ARTICLE_CTA: ['title', 'description', 'primaryText', 'secondaryText'],
|
||||
SCENT_NOTES_BREAKDOWN: ['title', 'description', 'topNotes', 'heartNotes', 'middleNotes', 'baseNotes'],
|
||||
QUOTE_CALLOUT: ['quote', 'author', 'role'],
|
||||
PRODUCT_CAROUSEL: ['title', 'description'],
|
||||
PRODUCT_CAROUSEL: ['title', 'description', 'viewAllText', 'badgeText'],
|
||||
ACCORDION: ['title', 'categories[].name', 'categories[].questions[].q', 'categories[].questions[].a'],
|
||||
CONTACT_INFO: ['title', 'address', 'hours'],
|
||||
CONTACT_FORM: ['title', 'subtitle'],
|
||||
|
||||
Reference in new issue
Block a user