x
This commit is contained in:
1 parent
46968782b6
commit
08045eabff
18 files changed
+943
-104
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user