Files
homaroma/apps/website/src/components/ProductSwatchCard.tsx
T
2026-10-06 19:55:05 +07:00

116 lines
4.4 KiB
TypeScript

"use client"
import { 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
const activeVariant = useMemo<Variant | null>(() => {
return colorSwatches[0]?.variant || product.variants?.[0] || null
}, [colorSwatches, product.variants])
// 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 (Display Only) */}
{colorSwatches.length > 0 && (
<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>
</div>
)
}