This commit is contained in:
Your Name committed 2026-10-06 19:40:15 +07:00
1 parent ce4502d123
commit acee55a633
14 files changed
+634 -104

No files matched your search

+114 -1
View File
@@ -11,12 +11,15 @@ import { generateProductSlug } from '../lib/slugify'
import { useLang } from '../hooks/useLang'
import { useCartStore } from '../store/cartStore'
import WishlistButton from './WishlistButton'
import ProductSwatchCard from './ProductSwatchCard'
interface ProductCardProps {
product: Product
cardStyle?: 'standard' | 'minimal' | 'swatch' | 'minimal_swatch'
badgeText?: string | null
}
export default function ProductCard({ product }: ProductCardProps) {
export default function ProductCard({ product, cardStyle = 'standard', badgeText }: ProductCardProps) {
const lang = useLang()
const { addToCart } = useCartStore()
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null)
@@ -79,6 +82,116 @@ export default function ProductCard({ product }: ProductCardProps) {
const optionsText = optionsLabels[lang] || optionsLabels.en
const selectOptionsText = selectOptionsLabels[lang] || selectOptionsLabels.en
if (cardStyle === 'swatch' || cardStyle === 'minimal_swatch') {
return <ProductSwatchCard product={product} badgeText={badgeText} />
}
if (cardStyle === 'minimal') {
return (
<div className="group flex flex-col h-full select-none cursor-pointer">
{/* 3:4 Image Frame */}
<div className="relative aspect-[3/4] overflow-hidden rounded-lg bg-surface-page">
{hasMultipleImages ? (
<>
{/* Desktop Hover Effect */}
<Link href={`/products/${productSlug}`} className="hidden md:block absolute inset-0 w-full h-full">
<Image src={images[0]} alt={productName} fill unoptimized className="absolute inset-0 h-full w-full object-cover transition duration-700 group-hover:opacity-0" />
<Image src={images[1]} alt={productName} fill unoptimized className="absolute inset-0 h-full w-full object-cover transition duration-700 opacity-0 group-hover:opacity-100 group-hover:scale-105" />
</Link>
{/* Mobile Native Slider */}
<div className="md:hidden relative w-full h-full">
<div className="flex w-full h-full overflow-x-auto snap-x snap-mandatory hide-scrollbar">
{images.map((img, idx) => (
<Link key={idx} href={`/products/${productSlug}`} className="relative w-full h-full flex-none snap-center snap-always block">
<Image src={img} alt={`${productName} ${idx + 1}`} fill unoptimized className="absolute inset-0 h-full w-full object-cover" />
</Link>
))}
</div>
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1.5 z-10 pointer-events-none">
{images.map((_, idx) => (
<div key={idx} className="w-1.5 h-1.5 rounded-full bg-white/80 shadow-[0_1px_2px_rgba(0,0,0,0.3)]" />
))}
</div>
</div>
</>
) : (
<Link href={`/products/${productSlug}`} className="block h-full w-full">
<Image
src={images[0]}
alt={productName}
fill
unoptimized
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
/>
</Link>
)}
{badgeText && (
<span className="absolute top-2.5 left-2.5 z-20 text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded-full bg-black/70 text-white backdrop-blur-sm">
{badgeText}
</span>
)}
</div>
{/* Content Info (Fashion Minimal / Pomelo Style) */}
<div className="pt-2 pb-1 flex flex-col gap-1">
{/* Row 1: Brand Name + Wishlist */}
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-text-main uppercase tracking-wider truncate">
{product.brand?.name || 'Brand'}
</span>
<div className="shrink-0 -mr-1">
<WishlistButton product={product} />
</div>
</div>
{/* Row 2: Product Name */}
<Link href={`/products/${productSlug}`}>
<h3 className="line-clamp-1 text-xs md:text-sm font-normal text-text-main transition-colors group-hover:text-brand-primary">
{productName}
</h3>
</Link>
{/* Row 3: Price + Status */}
<div className="flex items-center justify-between text-xs pt-0.5">
<span className="font-bold text-text-main md:text-sm">
{formatPrice(displayPrice)}
</span>
{totalStock > 0 ? (
<span className="text-[10px] text-muted-text">
มีสินค้า
</span>
) : (
<span className="text-[10px] text-rose-500 font-semibold">
สินค้าหมด
</span>
)}
</div>
{/* Row 4: Swatch Dots */}
{colorSwatches.length > 0 && (
<div className="flex items-center gap-1.5 pt-0.5 flex-wrap">
{colorSwatches.slice(0, 5).map(({ colorCode, variant, label }) => (
<span
key={variant.id || colorCode}
className="relative h-3 w-3 rounded-full border border-black/15 dark:border-white/20 shadow-sm shrink-0"
style={{ backgroundColor: colorCode }}
title={label || colorCode}
/>
))}
{colorSwatches.length > 5 && (
<span className="text-[10px] font-semibold text-muted-text pl-0.5">
+{colorSwatches.length - 5}
</span>
)}
</div>
)}
</div>
</div>
)
}
return (
<div className="ui-card ui-card-hover group flex h-full cursor-pointer flex-col overflow-hidden p-2">
<div className="relative aspect-square overflow-hidden rounded-xl bg-surface-page">
@@ -91,34 +91,22 @@ export default function ProductSwatchCard({ product, badgeText }: ProductSwatchC
{formatPrice(displayPrice)}
</div>
{/* Color Swatch Dots */}
{/* Color Swatch Dots (Display Only) */}
{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 className="flex items-center gap-1.5 pt-1 flex-wrap">
{colorSwatches.slice(0, 5).map(({ colorCode, variant }) => (
<span
key={colorCode}
className="relative w-3.5 h-3.5 rounded-full border border-black/15 dark:border-white/20 shadow-sm shrink-0"
style={{ backgroundColor: colorCode }}
title={variant.option1 || variant.option2 || colorCode}
/>
))}
{colorSwatches.length > 5 && (
<span className="text-[10px] font-semibold text-neutral-500 dark:text-neutral-400 pl-0.5">
+{colorSwatches.length - 5}
</span>
)}
</div>
)}
</div>
@@ -1132,11 +1132,49 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' })
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' })
const isMinimal = cardStyle === 'minimal_swatch'
const isMinimal = cardStyle === 'minimal' || cardStyle === 'swatch' || cardStyle === 'minimal_swatch'
const containerWidth = typeof payload.containerWidth === 'string' ? payload.containerWidth : 'standard'
const cardSize = typeof payload.cardSize === 'string' ? payload.cardSize : 'medium'
const getContainerWidthClass = (width: string) => {
switch (width) {
case 'full':
return 'w-full max-w-none'
case 'wide':
return 'w-full max-w-[1600px] mx-auto'
case 'standard':
default:
return 'w-full max-w-7xl mx-auto'
}
}
const getCarouselCardWidth = () => {
switch (cardSize) {
case 'compact':
return 'w-[46vw] sm:w-[calc((100%-32px)/3)] md:w-[calc((100%-60px)/4)] lg:w-[calc((100%-100px)/6)] flex-none snap-start'
case 'large':
return 'w-[78vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-20px)/2)] lg:w-[calc((100%-40px)/3)] flex-none snap-start'
case 'medium':
default:
return 'w-[68vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-40px)/3)] lg:w-[calc((100%-60px)/4)] flex-none snap-start'
}
}
const getGridColsClass = () => {
switch (cardSize) {
case 'compact':
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6'
case 'large':
return 'grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3'
case 'medium':
default:
return 'grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
}
}
return (
<section className={`bg-surface-page ${getSectionPaddingClasses(payload)}`}>
<div className="ui-section-shell space-y-8">
<section className={`overflow-hidden bg-surface-page px-4 sm:px-6 md:px-8 ${getSectionPaddingClasses(payload)}`}>
<div className={`${getContainerWidthClass(containerWidth)} 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"}>
@@ -1168,17 +1206,9 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
{products.map((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"
}
className={getCarouselCardWidth()}
>
{isMinimal ? (
<ProductSwatchCard product={product} badgeText={badgeText} />
) : (
<ProductCard product={product} />
)}
<ProductCard product={product} cardStyle={cardStyle as any} badgeText={badgeText} />
</div>
))}
</div>
@@ -1191,14 +1221,10 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
</button>
</div>
) : (
<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 className={getGridColsClass()}>
{products.map((product) => (
<ProductCard key={product.id} product={product} cardStyle={cardStyle as any} badgeText={badgeText} />
))}
</div>
)
) : (
@@ -2595,7 +2621,7 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
case 'PRODUCT_CAROUSEL':
return (
<ScrollReveal key={section.id}>
<ProductsSection payload={{ ...payload, layout: 'carousel' }} lang={lang} />
<ProductsSection payload={{ layout: 'carousel', ...payload }} lang={lang} />
</ScrollReveal>
)
case 'BANNER':