Skip to content

Commit 3a4a24b

Browse files
Abeutyclaudeautofix-ci[bot]
authored
feat(shop): product drawer, color swatches, and UI polish (#891)
* feat(shop): product drawer, color swatches, and UI polish Introduces a full-featured product quick-view drawer and a suite of shop UI improvements built toward the 2026 design system. **ProductDrawer** (new component) - Slide-in drawer with resizable width (default 520px, drag to resize, double-click to reset), solid exit animation via `displayHandle` latch - Hero image with blurred transparent background (40% opacity, 20px backdrop-blur); solid cream content area below - Vertical scrollable thumbnail strip showing all Shopify images; selecting a color updates the hero immediately via wildcard variant match - Wrapping color/size option pills (individual `flex-wrap` pills, not joined strips); standardized to `text-shop-sm` (12px) across all selector states so pills never shift width on selection - Minimal close button (no box) at top-left of drawer **ProductCard** - Color swatches now use the same hex map and last-token-wins resolution as the drawer ("Vintage Black" → black, not vintage) - Swatch circles preload all variant images on mount via `new Image()` so hover-to-preview is instant (in-place `src` swap on cached URLs) - Hover over a swatch previews that color's variant image; mouse-leave the swatch row reverts to the featured image **Shop UI tokens / CSS** - Added `@theme inline` block to `app.css` so Tailwind generates shop color utilities (`bg-shop-bg`, `border-shop-line`, etc.) — fixes tokens silently resolving to transparent when shop.css was loaded via `?url` - Filter tabs and sort select match Figma pill spec: rounded-xl, DM Mono, border-weight active state; tab counts hidden per spec - Chip.tsx and Size.tsx tokenized from hardcoded dark hex values to shop surface/line tokens Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: apply automated fixes --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 63d81b8 commit 3a4a24b

17 files changed

Lines changed: 1198 additions & 526 deletions
Lines changed: 226 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,266 @@
1+
import * as React from 'react'
12
import { Link } from '@tanstack/react-router'
23
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'
56
import type { ProductListItem } from '~/utils/shopify-queries'
67

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+
795
type ProductCardProps = {
896
product: ProductListItem
997
sizes?: string
1098
loading?: 'eager' | 'lazy'
99+
onQuickView?: (handle: string) => void
11100
}
12101

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-
*/
18102
export function ProductCard({
19103
product,
20104
sizes,
21105
loading = 'lazy',
106+
onQuickView,
22107
}: ProductCardProps) {
23108
const { minVariantPrice, maxVariantPrice } = product.priceRange
24109
const compareAt = product.compareAtPriceRange?.minVariantPrice
25110
const isRange = minVariantPrice.amount !== maxVariantPrice.amount
26-
const isDiscounted =
27-
compareAt && Number(compareAt.amount) > Number(minVariantPrice.amount)
28111

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
33115

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+
: []
35123

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
40150
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
44156
"
45157
>
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">
58160
<ProductImage
59-
image={product.featuredImage}
161+
image={activeImage}
60162
alt={product.title}
61163
width={600}
62164
sizes={sizes}
63165
loading={loading}
64-
className="relative z-[1]"
166+
className="w-full h-full object-cover"
65167
/>
168+
{isNew ? <NewBadge /> : null}
169+
</div>
66170

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>
74186
) : 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)}
89196
>
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>
91235
</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>
114237
</div>
238+
</div>
239+
)
115240

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}
136253
</div>
254+
)
255+
}
256+
257+
return (
258+
<Link
259+
to="/shop/products/$handle"
260+
params={{ handle: product.handle }}
261+
className="block"
262+
>
263+
{cardBody}
137264
</Link>
138265
)
139266
}

0 commit comments

Comments
 (0)