From 4c1ad4c91c40ef2e316654615d2eb38ed6fcd04d Mon Sep 17 00:00:00 2001 From: Abeuty Date: Tue, 5 May 2026 11:08:37 -0600 Subject: [PATCH 1/2] feat(shop): product drawer, color swatches, and UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/shop/ProductCard.tsx | 322 ++++++++---- src/components/shop/ProductDrawer.tsx | 702 ++++++++++++++++++++++++++ src/components/shop/ShopHero.tsx | 4 +- src/components/shop/ShopLayout.tsx | 291 +---------- src/components/shop/ui/Badge.tsx | 2 +- src/components/shop/ui/Button.tsx | 8 +- src/components/shop/ui/Chip.tsx | 70 ++- src/components/shop/ui/Mono.tsx | 8 +- src/components/shop/ui/Select.tsx | 8 +- src/components/shop/ui/Size.tsx | 11 +- src/components/shop/ui/Tab.tsx | 30 +- src/routes/shop.index.tsx | 77 ++- src/routes/shop.tsx | 37 +- src/styles/app.css | 32 ++ src/styles/shop.css | 40 +- src/utils/shopify-format.ts | 2 + src/utils/shopify-queries.ts | 34 +- 17 files changed, 1152 insertions(+), 526 deletions(-) create mode 100644 src/components/shop/ProductDrawer.tsx diff --git a/src/components/shop/ProductCard.tsx b/src/components/shop/ProductCard.tsx index d4f5231d..f4f2ca55 100644 --- a/src/components/shop/ProductCard.tsx +++ b/src/components/shop/ProductCard.tsx @@ -1,139 +1,263 @@ +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 */} -