diff --git a/src/components/shop/ProductCard.tsx b/src/components/shop/ProductCard.tsx index d4f5231d..b9899bd0 100644 --- a/src/components/shop/ProductCard.tsx +++ b/src/components/shop/ProductCard.tsx @@ -1,139 +1,266 @@ +import * as React from 'react' import { Link } from '@tanstack/react-router' import { ProductImage } from './ProductImage' -import { ShopBadge, ShopMono } from './ui' -import { formatMoney } from '~/utils/shopify-format' +import { ShopMono } from './ui' +import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format' import type { ProductListItem } from '~/utils/shopify-queries' +const TWO_WEEKS_MS = 365 * 24 * 60 * 60 * 1000 + +// Kept in sync with ProductDrawer COLOR_HEX — last token wins ("Vintage Black" → black) +const COLOR_MAP: Record = { + black: '#0a0a0a', + white: '#f5f5f0', + cream: '#e4dcc4', + bone: '#e4dcc4', + natural: '#ddd3b8', + vintage: '#e8e0d0', + fog: '#c9c6ba', + sand: '#c8b97a', + ink: '#16130d', + navy: '#1a2e50', + slate: '#2e3339', + olive: '#5a5a3a', + rust: '#b84a27', + red: '#c41d1d', + blue: '#1d4ed8', + sea: '#3a5d66', + green: '#15803d', + gray: '#6b7280', + grey: '#6b7280', + charcoal: '#3a3a3c', + heather: '#8a8a9a', + denim: '#1a4569', + brown: '#6b3a2a', + pink: '#e8749a', + purple: '#7c3aed', + yellow: '#ca8a04', + orange: '#c2410c', + royal: '#4169e1', + kelly: '#4daa59', + aqua: '#00c4d4', + rose: '#c8818a', + dusty: '#c8818a', + coral: '#e8756a', + forest: '#228b22', + teal: '#0d9488', + lavender: '#967bb6', + lilac: '#967bb6', + tan: '#d2b48c', + ivory: '#fffff0', + gold: '#c9a227', + silver: '#a8a9ad', + ash: '#b2bec3', + stone: '#78716c', + moss: '#6b7c55', + sage: '#87a878', + sky: '#0ea5e9', + midnight: '#1e1b4b', + espresso: '#3c1f0f', + // card-specific + mixed: '#ef4c7a', + holo: '#d6e7ff', + polished: '#c5b07a', + blend: '#e8e0d0', +} + +// Last token wins: "Vintage Black" → ["vintage","black"] reversed → "black" wins +function colorHex(name: string): string | undefined { + const tokens = name + .toLowerCase() + .split(/[\s_-]+/) + .reverse() + for (const token of tokens) { + if (COLOR_MAP[token]) return COLOR_MAP[token] + } + return undefined +} + +const NEW_BADGE_GRADIENT = + 'linear-gradient(180deg, rgba(116,220,255,0.99) 8.23%, rgba(255,242,124,0.99) 29.88%, rgba(255,160,92,0.99) 61.46%, rgba(255,95,95,0.99) 89.43%)' + +function NewBadge() { + return ( +
+ + NEW + +
+ ) +} + type ProductCardProps = { product: ProductListItem sizes?: string loading?: 'eager' | 'lazy' + onQuickView?: (handle: string) => void } -/** - * Grid tile for a product. Editorial styling via Tailwind utilities: - * stripe-lined image well, hover quick-view chip, mono caption tag, - * JetBrains Mono price. New/Sale/Low-stock badges derived from tags. - */ export function ProductCard({ product, sizes, loading = 'lazy', + onQuickView, }: ProductCardProps) { const { minVariantPrice, maxVariantPrice } = product.priceRange const compareAt = product.compareAtPriceRange?.minVariantPrice const isRange = minVariantPrice.amount !== maxVariantPrice.amount - const isDiscounted = - compareAt && Number(compareAt.amount) > Number(minVariantPrice.amount) - const isNew = product.tags?.some((t) => t.toLowerCase() === 'new') - const isLowStock = product.tags?.some( - (t) => t.toLowerCase() === 'low-stock' || t.toLowerCase() === 'low stock', - ) + const isNew = product.publishedAt + ? Date.now() - new Date(product.publishedAt).getTime() < TWO_WEEKS_MS + : false - const tag = product.productType || product.tags?.[0] || '' + const colorOption = product.options?.find((o) => /colou?r/i.test(o.name)) + const swatches = colorOption + ? colorOption.values.slice(0, 6).map((v) => ({ + name: v, + hex: colorHex(v), + })) + : [] - return ( - (null) + + // Preload all color-variant images on mount so hover swaps are instant + React.useEffect(() => { + if (!colorOption) return + const seen = new Set() + for (const v of product.variants.nodes) { + if (!v.image?.url || seen.has(v.image.url)) continue + seen.add(v.image.url) + const img = new Image() + img.src = shopifyImageUrl(v.image.url, { width: 600, format: 'webp' }) + } + }, [colorOption, product.variants.nodes]) + + const activeImage = React.useMemo(() => { + if (!hoveredColor || !colorOption) return product.featuredImage + const variant = product.variants.nodes.find((v) => + v.selectedOptions.some( + (o) => /colou?r/i.test(o.name) && o.value === hoveredColor, + ), + ) + return variant?.image ?? product.featuredImage + }, [hoveredColor, colorOption, product]) + + const cardBody = ( +
-
- {/* Diagonal stripe texture */} -