diff --git a/apps/admin/src/components/page-builder/ProductCarouselForm.tsx b/apps/admin/src/components/page-builder/ProductCarouselForm.tsx index e039f7e..6319186 100644 --- a/apps/admin/src/components/page-builder/ProductCarouselForm.tsx +++ b/apps/admin/src/components/page-builder/ProductCarouselForm.tsx @@ -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" /> + +
+ + +
+ +
+ + + setEditingSection(prev => prev ? { + ...prev, + payload: { ...(prev.payload || {}), viewAllLink: e.target.value } + } : null) + } + className="admin-input w-full" + placeholder="e.g. /shop or /category/1" + /> +
+ +
+ + updateLocalizedValue('viewAllText', e.target.value)} + className="admin-input w-full" + placeholder="e.g. View All / ดูทั้งหมด" + /> +
+ +
+ + updateLocalizedValue('badgeText', e.target.value)} + className="admin-input w-full" + placeholder="e.g. NEW / แนะนำ" + /> +
diff --git a/apps/admin/src/components/products/ColorSwatchPicker.tsx b/apps/admin/src/components/products/ColorSwatchPicker.tsx new file mode 100644 index 0000000..2cf3fa8 --- /dev/null +++ b/apps/admin/src/components/products/ColorSwatchPicker.tsx @@ -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(null) + const nativeColorInputRef = useRef(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 ( +
+ {value ? ( +
+ + + {onRemove && ( + + )} +
+ ) : ( + + )} + + {/* Popover */} + {isOpen && ( +
+
+ + + Select Color + + +
+ + {/* Quick Preset Palette */} +
+
Presets
+
+ {PRESET_COLORS.map((preset) => { + const isSelected = value?.toUpperCase() === preset.hex.toUpperCase() + return ( + + ) + })} +
+
+ + {/* Custom Color Input & Hex */} +
+
Custom Shade
+
+ { + setTempHex(e.target.value) + onChange(e.target.value) + }} + className="sr-only" + /> + + +
+ 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" + /> +
+
+
+ + {/* Footer Actions */} +
+ {value && onRemove && ( + + )} + +
+
+ )} +
+ ) +} diff --git a/apps/admin/src/pages/PageBuilderPage.tsx b/apps/admin/src/pages/PageBuilderPage.tsx index 131c7d4..edbccfd 100644 --- a/apps/admin/src/pages/PageBuilderPage.tsx +++ b/apps/admin/src/pages/PageBuilderPage.tsx @@ -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': diff --git a/apps/admin/src/pages/ProductEditPage.tsx b/apps/admin/src/pages/ProductEditPage.tsx index 72b5fa7..b4531e8 100644 --- a/apps/admin/src/pages/ProductEditPage.tsx +++ b/apps/admin/src/pages/ProductEditPage.tsx @@ -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>({}) const [description, setDescription] = useState>({}) const [details, setDetails] = useState>({}) - const [options, setOptions] = useState<{name: Record, values: (Record & { imageUrl?: string })[]}[]>([]) + const [options, setOptions] = useState<{name: Record, values: (Record & { imageUrl?: string; colorCode?: string })[]}[]>([]) const [variants, setVariants] = useState[]>([]) const [images, setImages] = useState([]) const [titleGraphicUrl, setTitleGraphicUrl] = useState('') @@ -259,7 +260,7 @@ export default function ProductEditPage() { setVariants(updated) } - const regenerateVariants = (newOptions: {name: Record, values: (Record & { imageUrl?: string })[]}[]) => { + const regenerateVariants = (newOptions: {name: Record, values: (Record & { 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() { รูป 1:1 )} + { + 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 + })) + } + }} + /> + +
-
- - -
- - ) : ( - - )} + ) : ( + + )} + + {/* Color Swatch */} + handleUpdateVariant(idx, 'colorCode', color)} + onRemove={() => handleUpdateVariant(idx, 'colorCode', null)} + /> + 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") { diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts index 21550a6..569cff1 100644 --- a/apps/backend/src/routes/pages.ts +++ b/apps/backend/src/routes/pages.ts @@ -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") { diff --git a/apps/backend/src/routes/products.ts b/apps/backend/src/routes/products.ts index 4cb0816..937221c 100644 --- a/apps/backend/src/routes/products.ts +++ b/apps/backend/src/routes/products.ts @@ -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, diff --git a/apps/spa/src/components/DynamicProductCarousel.tsx b/apps/spa/src/components/DynamicProductCarousel.tsx index bc7fd1a..9d2d7ab 100644 --- a/apps/spa/src/components/DynamicProductCarousel.tsx +++ b/apps/spa/src/components/DynamicProductCarousel.tsx @@ -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(null); const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' }); @@ -23,12 +30,25 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous return (
-
- {(title || description) && ( -
- {title &&

{title}

} - {description &&

{description}

} +
+ {isMinimal ? ( +
+
+ {title &&

{title}

} + {description &&

{description}

} +
+ + {viewAllText} + +
+ ) : ( + (title || description) && ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ ) )} {products.length === 0 ? ( @@ -39,24 +59,35 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
-
- {products.map((p: any) => ( -
- +
+ {products.map((prod: any) => ( +
+ {isMinimal ? ( + + ) : ( + + )}
))}
)} diff --git a/apps/spa/src/components/ProductCard.tsx b/apps/spa/src/components/ProductCard.tsx index edee9a3..6847b85 100644 --- a/apps/spa/src/components/ProductCard.tsx +++ b/apps/spa/src/components/ProductCard.tsx @@ -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(); + + 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

- {enableQuickShop && product.variants && product.variants.length > 1 && ( -
+ {colorSwatches.length > 0 || (enableQuickShop && product.variants && product.variants.length > 1) ? ( + colorSwatches.length > 0 ? ( +
e.stopPropagation()}> + {colorSwatches.slice(0, 5).map(({ colorCode, variant, label }) => { + const isSelected = selectedVariant?.id === variant.id || selectedVariant?.colorCode === colorCode; + return ( +
+ ) : ( +
{product.variants.length} {t('product.variantsAvailable', 'Options')}
- )} + )) : null}
diff --git a/apps/spa/src/components/ProductSwatchCard.tsx b/apps/spa/src/components/ProductSwatchCard.tsx new file mode 100644 index 0000000..5acf3f5 --- /dev/null +++ b/apps/spa/src/components/ProductSwatchCard.tsx @@ -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(); + + 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(() => { + 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 ( +
+ {/* 1:1 Image Frame */} + + {/* Badge (e.g. NEW) */} + {badgeText && ( +
+ + {badgeText} + +
+ )} + + {/* Product Image */} +
+ {displayName} +
+ + + {/* Meta (Title, Price, Swatches) */} +
+ {/* Product Title */} + +

+ {displayName} +

+ + + {/* Price */} +
+ {formatPrice(displayPrice)} +
+ + {/* Color Swatch Dots */} + {colorSwatches.length > 0 && ( +
+ {colorSwatches.map(({ colorCode, variant }) => { + const isActive = activeVariant?.colorCode === colorCode; + return ( +
+ )} +
+
+ ); +} diff --git a/apps/spa/src/pages/ProductDetailPage.tsx b/apps/spa/src/pages/ProductDetailPage.tsx index c4063b7..13d649c 100644 --- a/apps/spa/src/pages/ProductDetailPage.tsx +++ b/apps/spa/src/pages/ProductDetailPage.tsx @@ -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(' / ')} + + {v.colorCode && ( + + )} + {[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')} + {formatPrice(v.price)} diff --git a/apps/website/src/components/AddToCartSection.tsx b/apps/website/src/components/AddToCartSection.tsx index 4631bb8..b5ee3fd 100644 --- a/apps/website/src/components/AddToCartSection.tsx +++ b/apps/website/src/components/AddToCartSection.tsx @@ -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 (
@@ -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({ }`} >
- {swatchImg ? ( + {swatch.imageUrl ? ( {displayVal} + ) : swatch.colorCode ? ( +
) : ( {displayVal} )} diff --git a/apps/website/src/components/ProductCard.tsx b/apps/website/src/components/ProductCard.tsx index b65a00e..e214261 100644 --- a/apps/website/src/components/ProductCard.tsx +++ b/apps/website/src/components/ProductCard.tsx @@ -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() + + 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) { )}
- {product.variants && product.variants.length > 1 && ( -
+ {colorSwatches.length > 0 || (product.variants && product.variants.length > 1) ? ( + colorSwatches.length > 0 ? ( +
+ {colorSwatches.slice(0, 5).map(({ colorCode, variant, label }) => { + const isSelected = selectedVariant?.id === variant.id || selectedVariant?.colorCode === colorCode + return ( +
+ ) : ( +
{product.variants.length} {optionsText}
- )} + )) : null}
diff --git a/apps/website/src/components/ProductSwatchCard.tsx b/apps/website/src/components/ProductSwatchCard.tsx new file mode 100644 index 0000000..bb4e331 --- /dev/null +++ b/apps/website/src/components/ProductSwatchCard.tsx @@ -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() + + 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(() => { + 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 ( +
+ {/* 1:1 Image Frame */} + + {/* Badge (e.g. NEW) */} + {badgeText && ( +
+ + {badgeText} + +
+ )} + + {/* Product Image */} +
+ {productName} +
+ + + {/* Meta (Title, Price, Swatches) */} +
+ {/* Product Title */} + +

+ {productName} +

+ + + {/* Price */} +
+ {formatPrice(displayPrice)} +
+ + {/* Color Swatch Dots */} + {colorSwatches.length > 0 && ( +
+ {colorSwatches.map(({ colorCode, variant }) => { + const isActive = activeVariant?.colorCode === colorCode + return ( +
+ )} +
+
+ ) +} diff --git a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx index 60328cd..c5d20b5 100644 --- a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx +++ b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx @@ -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, 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(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 (
-
-
-
-

{title}

- {subtitle &&

{subtitle}

} +
+
+
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )}
- - {t('dynamic.viewAll', { lng: resolvedLang })} + + {viewAllText} +
+ {products.length ? ( layout === 'carousel' ? (
-
+
{products.map((product) => ( -
- +
+ {isMinimal ? ( + + ) : ( + + )}
))}
) : ( -
- {products.map((product) => )} +
+ {products.map((product) => + isMinimal ? ( + + ) : ( + + ) + )}
) ) : ( diff --git a/packages/types/index.ts b/packages/types/index.ts index 24af3be..d259037 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -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; @@ -1280,7 +1286,7 @@ export const PAGE_SECTION_LOCALIZED_PATHS: Record