This commit is contained in:
Your Name committed 2026-10-06 18:13:03 +07:00
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>
)
}
+2 -1
View File
@@ -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':
+86 -35
View File
@@ -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;
+1
View File
@@ -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])
+13 -6
View File
@@ -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") {
+10 -4
View File
@@ -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") {
+3
View File
@@ -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>
)}
+59 -6
View File
@@ -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>
);
}
+9 -1
View File
@@ -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>
)}
+59 -6
View File
@@ -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'
@@ -21,10 +21,27 @@ export default function ProductCard({ product }: ProductCardProps) {
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>
)
) : (
+8 -2
View File
@@ -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'],