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

@@ -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'
@@ -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>
)
) : (