|
| 1 | +import * as React from 'react' |
1 | 2 | import { Link } from '@tanstack/react-router' |
2 | 3 | import { ProductImage } from './ProductImage' |
3 | | -import { ShopBadge, ShopMono } from './ui' |
4 | | -import { formatMoney } from '~/utils/shopify-format' |
| 4 | +import { ShopMono } from './ui' |
| 5 | +import { formatMoney, shopifyImageUrl } from '~/utils/shopify-format' |
5 | 6 | import type { ProductListItem } from '~/utils/shopify-queries' |
6 | 7 |
|
| 8 | +const TWO_WEEKS_MS = 365 * 24 * 60 * 60 * 1000 |
| 9 | + |
| 10 | +// Kept in sync with ProductDrawer COLOR_HEX — last token wins ("Vintage Black" → black) |
| 11 | +const COLOR_MAP: Record<string, string> = { |
| 12 | + black: '#0a0a0a', |
| 13 | + white: '#f5f5f0', |
| 14 | + cream: '#e4dcc4', |
| 15 | + bone: '#e4dcc4', |
| 16 | + natural: '#ddd3b8', |
| 17 | + vintage: '#e8e0d0', |
| 18 | + fog: '#c9c6ba', |
| 19 | + sand: '#c8b97a', |
| 20 | + ink: '#16130d', |
| 21 | + navy: '#1a2e50', |
| 22 | + slate: '#2e3339', |
| 23 | + olive: '#5a5a3a', |
| 24 | + rust: '#b84a27', |
| 25 | + red: '#c41d1d', |
| 26 | + blue: '#1d4ed8', |
| 27 | + sea: '#3a5d66', |
| 28 | + green: '#15803d', |
| 29 | + gray: '#6b7280', |
| 30 | + grey: '#6b7280', |
| 31 | + charcoal: '#3a3a3c', |
| 32 | + heather: '#8a8a9a', |
| 33 | + denim: '#1a4569', |
| 34 | + brown: '#6b3a2a', |
| 35 | + pink: '#e8749a', |
| 36 | + purple: '#7c3aed', |
| 37 | + yellow: '#ca8a04', |
| 38 | + orange: '#c2410c', |
| 39 | + royal: '#4169e1', |
| 40 | + kelly: '#4daa59', |
| 41 | + aqua: '#00c4d4', |
| 42 | + rose: '#c8818a', |
| 43 | + dusty: '#c8818a', |
| 44 | + coral: '#e8756a', |
| 45 | + forest: '#228b22', |
| 46 | + teal: '#0d9488', |
| 47 | + lavender: '#967bb6', |
| 48 | + lilac: '#967bb6', |
| 49 | + tan: '#d2b48c', |
| 50 | + ivory: '#fffff0', |
| 51 | + gold: '#c9a227', |
| 52 | + silver: '#a8a9ad', |
| 53 | + ash: '#b2bec3', |
| 54 | + stone: '#78716c', |
| 55 | + moss: '#6b7c55', |
| 56 | + sage: '#87a878', |
| 57 | + sky: '#0ea5e9', |
| 58 | + midnight: '#1e1b4b', |
| 59 | + espresso: '#3c1f0f', |
| 60 | + // card-specific |
| 61 | + mixed: '#ef4c7a', |
| 62 | + holo: '#d6e7ff', |
| 63 | + polished: '#c5b07a', |
| 64 | + blend: '#e8e0d0', |
| 65 | +} |
| 66 | + |
| 67 | +// Last token wins: "Vintage Black" → ["vintage","black"] reversed → "black" wins |
| 68 | +function colorHex(name: string): string | undefined { |
| 69 | + const tokens = name |
| 70 | + .toLowerCase() |
| 71 | + .split(/[\s_-]+/) |
| 72 | + .reverse() |
| 73 | + for (const token of tokens) { |
| 74 | + if (COLOR_MAP[token]) return COLOR_MAP[token] |
| 75 | + } |
| 76 | + return undefined |
| 77 | +} |
| 78 | + |
| 79 | +const NEW_BADGE_GRADIENT = |
| 80 | + '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%)' |
| 81 | + |
| 82 | +function NewBadge() { |
| 83 | + return ( |
| 84 | + <div |
| 85 | + className="absolute bottom-3 left-3 z-[2] h-5 px-2.5 rounded-full flex items-center" |
| 86 | + style={{ background: NEW_BADGE_GRADIENT }} |
| 87 | + > |
| 88 | + <span className="font-shop-mono font-medium text-shop-xs text-black leading-none tracking-wide"> |
| 89 | + NEW |
| 90 | + </span> |
| 91 | + </div> |
| 92 | + ) |
| 93 | +} |
| 94 | + |
7 | 95 | type ProductCardProps = { |
8 | 96 | product: ProductListItem |
9 | 97 | sizes?: string |
10 | 98 | loading?: 'eager' | 'lazy' |
| 99 | + onQuickView?: (handle: string) => void |
11 | 100 | } |
12 | 101 |
|
13 | | -/** |
14 | | - * Grid tile for a product. Editorial styling via Tailwind utilities: |
15 | | - * stripe-lined image well, hover quick-view chip, mono caption tag, |
16 | | - * JetBrains Mono price. New/Sale/Low-stock badges derived from tags. |
17 | | - */ |
18 | 102 | export function ProductCard({ |
19 | 103 | product, |
20 | 104 | sizes, |
21 | 105 | loading = 'lazy', |
| 106 | + onQuickView, |
22 | 107 | }: ProductCardProps) { |
23 | 108 | const { minVariantPrice, maxVariantPrice } = product.priceRange |
24 | 109 | const compareAt = product.compareAtPriceRange?.minVariantPrice |
25 | 110 | const isRange = minVariantPrice.amount !== maxVariantPrice.amount |
26 | | - const isDiscounted = |
27 | | - compareAt && Number(compareAt.amount) > Number(minVariantPrice.amount) |
28 | 111 |
|
29 | | - const isNew = product.tags?.some((t) => t.toLowerCase() === 'new') |
30 | | - const isLowStock = product.tags?.some( |
31 | | - (t) => t.toLowerCase() === 'low-stock' || t.toLowerCase() === 'low stock', |
32 | | - ) |
| 112 | + const isNew = product.publishedAt |
| 113 | + ? Date.now() - new Date(product.publishedAt).getTime() < TWO_WEEKS_MS |
| 114 | + : false |
33 | 115 |
|
34 | | - const tag = product.productType || product.tags?.[0] || '' |
| 116 | + const colorOption = product.options?.find((o) => /colou?r/i.test(o.name)) |
| 117 | + const swatches = colorOption |
| 118 | + ? colorOption.values.slice(0, 6).map((v) => ({ |
| 119 | + name: v, |
| 120 | + hex: colorHex(v), |
| 121 | + })) |
| 122 | + : [] |
35 | 123 |
|
36 | | - return ( |
37 | | - <Link |
38 | | - to="/shop/products/$handle" |
39 | | - params={{ handle: product.handle }} |
| 124 | + const [hoveredColor, setHoveredColor] = React.useState<string | null>(null) |
| 125 | + |
| 126 | + // Preload all color-variant images on mount so hover swaps are instant |
| 127 | + React.useEffect(() => { |
| 128 | + if (!colorOption) return |
| 129 | + const seen = new Set<string>() |
| 130 | + for (const v of product.variants.nodes) { |
| 131 | + if (!v.image?.url || seen.has(v.image.url)) continue |
| 132 | + seen.add(v.image.url) |
| 133 | + const img = new Image() |
| 134 | + img.src = shopifyImageUrl(v.image.url, { width: 600, format: 'webp' }) |
| 135 | + } |
| 136 | + }, [colorOption, product.variants.nodes]) |
| 137 | + |
| 138 | + const activeImage = React.useMemo(() => { |
| 139 | + if (!hoveredColor || !colorOption) return product.featuredImage |
| 140 | + const variant = product.variants.nodes.find((v) => |
| 141 | + v.selectedOptions.some( |
| 142 | + (o) => /colou?r/i.test(o.name) && o.value === hoveredColor, |
| 143 | + ), |
| 144 | + ) |
| 145 | + return variant?.image ?? product.featuredImage |
| 146 | + }, [hoveredColor, colorOption, product]) |
| 147 | + |
| 148 | + const cardBody = ( |
| 149 | + <div |
40 | 150 | className=" |
41 | | - group flex flex-col gap-2.5 rounded-xl border border-shop-line bg-shop-panel p-2.5 |
42 | | - transition-[border-color,transform] duration-200 |
43 | | - hover:border-shop-line-2 hover:-translate-y-0.5 |
| 151 | + group flex flex-col min-w-[340px] max-w-[400px] w-full rounded-xl |
| 152 | + border border-transparent bg-transparent |
| 153 | + hover:bg-shop-bg-2 hover:border-shop-line-2 |
| 154 | + transition-[border-color,background-color] duration-200 |
| 155 | + px-[22px] pt-7 pb-5 |
44 | 156 | " |
45 | 157 | > |
46 | | - <div className="relative aspect-square rounded-lg overflow-hidden bg-shop-panel-2"> |
47 | | - {/* Diagonal stripe texture */} |
48 | | - <div |
49 | | - aria-hidden="true" |
50 | | - className="absolute inset-0 [background:repeating-linear-gradient(45deg,transparent_0_12px,color-mix(in_srgb,var(--shop-text)_2%,transparent)_12px_13px)]" |
51 | | - /> |
52 | | - {/* Soft accent glow */} |
53 | | - <div |
54 | | - aria-hidden="true" |
55 | | - className="absolute inset-0 bg-[radial-gradient(60%_50%_at_50%_50%,color-mix(in_srgb,var(--shop-accent)_8%,transparent),transparent_70%)]" |
56 | | - /> |
57 | | - {/* Image */} |
| 158 | + {/* Image */} |
| 159 | + <div className="relative aspect-square rounded-lg overflow-hidden"> |
58 | 160 | <ProductImage |
59 | | - image={product.featuredImage} |
| 161 | + image={activeImage} |
60 | 162 | alt={product.title} |
61 | 163 | width={600} |
62 | 164 | sizes={sizes} |
63 | 165 | loading={loading} |
64 | | - className="relative z-[1]" |
| 166 | + className="w-full h-full object-cover" |
65 | 167 | /> |
| 168 | + {isNew ? <NewBadge /> : null} |
| 169 | + </div> |
66 | 170 |
|
67 | | - {/* Badges */} |
68 | | - {isNew || isLowStock || isDiscounted ? ( |
69 | | - <div className="absolute top-2.5 left-2.5 flex gap-1 z-[2]"> |
70 | | - {isNew ? <ShopBadge variant="new">New</ShopBadge> : null} |
71 | | - {isDiscounted ? <ShopBadge variant="sale">Sale</ShopBadge> : null} |
72 | | - {isLowStock ? ( |
73 | | - <ShopBadge variant="sale">Low stock</ShopBadge> |
| 171 | + {/* Meta */} |
| 172 | + <div className="flex flex-col gap-2.5 pt-4"> |
| 173 | + {/* Title + price */} |
| 174 | + <div className="flex justify-between items-baseline gap-3"> |
| 175 | + <span className="text-shop-title font-semibold font-shop-display leading-tight text-shop-text truncate"> |
| 176 | + {product.title} |
| 177 | + </span> |
| 178 | + <ShopMono className="text-shop-price text-shop-text whitespace-nowrap shrink-0 font-light"> |
| 179 | + {isRange ? 'From ' : ''} |
| 180 | + {formatMoney(minVariantPrice.amount, minVariantPrice.currencyCode)} |
| 181 | + {compareAt && |
| 182 | + Number(compareAt.amount) > Number(minVariantPrice.amount) ? ( |
| 183 | + <span className="ml-1.5 text-shop-ui text-shop-muted line-through"> |
| 184 | + {formatMoney(compareAt.amount, compareAt.currencyCode)} |
| 185 | + </span> |
74 | 186 | ) : null} |
75 | | - </div> |
76 | | - ) : null} |
77 | | - |
78 | | - {/* Category tag (hidden on hover, replaced by quick-view chip) */} |
79 | | - {tag ? ( |
80 | | - <span |
81 | | - className=" |
82 | | - absolute bottom-2.5 right-2.5 z-[1] |
83 | | - font-shop-mono text-[9.5px] tracking-[0.1em] uppercase |
84 | | - text-shop-muted bg-shop-bg-2/80 backdrop-blur |
85 | | - border border-shop-line-2 rounded px-1.5 py-[3px] |
86 | | - max-w-[calc(100%-1.25rem)] overflow-hidden text-ellipsis whitespace-nowrap |
87 | | - transition-opacity group-hover:opacity-0 |
88 | | - " |
| 187 | + </ShopMono> |
| 188 | + </div> |
| 189 | + |
| 190 | + {/* Swatches + Quick View row */} |
| 191 | + <div className="flex items-center justify-between"> |
| 192 | + {/* Round color swatches — hover to preview color */} |
| 193 | + <div |
| 194 | + className="flex gap-[10px]" |
| 195 | + onMouseLeave={() => setHoveredColor(null)} |
89 | 196 | > |
90 | | - {tag} |
| 197 | + {swatches.map((s) => ( |
| 198 | + <button |
| 199 | + key={s.name} |
| 200 | + type="button" |
| 201 | + title={s.name} |
| 202 | + onMouseEnter={() => setHoveredColor(s.name)} |
| 203 | + onClick={(e) => { |
| 204 | + e.stopPropagation() |
| 205 | + e.preventDefault() |
| 206 | + }} |
| 207 | + className={`w-4 h-4 rounded-full shrink-0 cursor-pointer border transition-[box-shadow,border-color,transform] duration-150 ${ |
| 208 | + hoveredColor === s.name |
| 209 | + ? 'border-white/70 ring-2 ring-white/30 scale-125' |
| 210 | + : 'border-white/15 hover:scale-110' |
| 211 | + }`} |
| 212 | + style={ |
| 213 | + s.hex |
| 214 | + ? { background: s.hex } |
| 215 | + : { |
| 216 | + background: |
| 217 | + 'conic-gradient(from 30deg,#ef4c7a,#f4c74a,#22c993,#36d3f3,#4b9bff,#ef4c7a)', |
| 218 | + } |
| 219 | + } |
| 220 | + /> |
| 221 | + ))} |
| 222 | + </div> |
| 223 | + |
| 224 | + {/* Quick View — hover only */} |
| 225 | + <span className="opacity-0 group-hover:opacity-100 transition-opacity text-shop-xs text-shop-text-2 inline-flex items-center gap-1"> |
| 226 | + Quick View |
| 227 | + <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"> |
| 228 | + <path |
| 229 | + d="M2 5h6M6 2l3 3-3 3" |
| 230 | + stroke="currentColor" |
| 231 | + strokeWidth="1.6" |
| 232 | + fill="none" |
| 233 | + /> |
| 234 | + </svg> |
91 | 235 | </span> |
92 | | - ) : null} |
93 | | - |
94 | | - {/* Quick view chip */} |
95 | | - <span |
96 | | - className=" |
97 | | - absolute bottom-2.5 right-2.5 z-[2] |
98 | | - inline-flex items-center gap-1 px-2 py-[5px] rounded-md |
99 | | - bg-shop-accent text-shop-accent-ink text-[11px] font-semibold |
100 | | - opacity-0 translate-y-1 pointer-events-none |
101 | | - transition-all group-hover:opacity-100 group-hover:translate-y-0 |
102 | | - " |
103 | | - > |
104 | | - Quick view |
105 | | - <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"> |
106 | | - <path |
107 | | - d="M2 5h6M6 2l3 3-3 3" |
108 | | - stroke="currentColor" |
109 | | - strokeWidth="1.6" |
110 | | - fill="none" |
111 | | - /> |
112 | | - </svg> |
113 | | - </span> |
| 236 | + </div> |
114 | 237 | </div> |
| 238 | + </div> |
| 239 | + ) |
115 | 240 |
|
116 | | - <div className="flex justify-between items-baseline gap-2.5 px-1 pb-1.5"> |
117 | | - <div className="min-w-0 flex-1"> |
118 | | - <div className="text-[13.5px] font-semibold tracking-[-0.005em] leading-tight text-shop-text truncate"> |
119 | | - {product.title} |
120 | | - </div> |
121 | | - {tag ? ( |
122 | | - <ShopMono className="block text-[11.5px] text-shop-muted tracking-[0.02em] mt-0.5 truncate"> |
123 | | - {tag} |
124 | | - </ShopMono> |
125 | | - ) : null} |
126 | | - </div> |
127 | | - <ShopMono className="text-[12.5px] text-shop-text whitespace-nowrap"> |
128 | | - {isRange ? 'From ' : ''} |
129 | | - {formatMoney(minVariantPrice.amount, minVariantPrice.currencyCode)} |
130 | | - {isDiscounted ? ( |
131 | | - <span className="ml-1.5 text-[11px] text-shop-muted line-through"> |
132 | | - {formatMoney(compareAt.amount, compareAt.currencyCode)} |
133 | | - </span> |
134 | | - ) : null} |
135 | | - </ShopMono> |
| 241 | + if (onQuickView) { |
| 242 | + return ( |
| 243 | + <div |
| 244 | + role="button" |
| 245 | + tabIndex={0} |
| 246 | + onClick={() => onQuickView(product.handle)} |
| 247 | + onKeyDown={(e) => { |
| 248 | + if (e.key === 'Enter' || e.key === ' ') onQuickView(product.handle) |
| 249 | + }} |
| 250 | + className="text-left block cursor-pointer" |
| 251 | + > |
| 252 | + {cardBody} |
136 | 253 | </div> |
| 254 | + ) |
| 255 | + } |
| 256 | + |
| 257 | + return ( |
| 258 | + <Link |
| 259 | + to="/shop/products/$handle" |
| 260 | + params={{ handle: product.handle }} |
| 261 | + className="block" |
| 262 | + > |
| 263 | + {cardBody} |
137 | 264 | </Link> |
138 | 265 | ) |
139 | 266 | } |
0 commit comments