-
-
Notifications
You must be signed in to change notification settings - Fork 339
feat(shop): product drawer, color swatches, and UI polish #891
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string> = { | ||
| 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 | ||
| } | ||
|
Comment on lines
+10
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Duplicated The comment on line 10 claims this is kept in sync with Consider extracting both the map and the resolver into a small shared module (e.g. 🤖 Prompt for AI Agents |
||
|
|
||
| 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 ( | ||
| <div | ||
| className="absolute bottom-3 left-3 z-[2] h-5 px-2.5 rounded-full flex items-center" | ||
| style={{ background: NEW_BADGE_GRADIENT }} | ||
| > | ||
| <span className="font-shop-mono font-medium text-shop-xs text-black leading-none tracking-wide"> | ||
| NEW | ||
| </span> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| 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 ( | ||
| <Link | ||
| to="/shop/products/$handle" | ||
| params={{ handle: product.handle }} | ||
| const [hoveredColor, setHoveredColor] = React.useState<string | null>(null) | ||
|
|
||
| // Preload all color-variant images on mount so hover swaps are instant | ||
| React.useEffect(() => { | ||
| if (!colorOption) return | ||
| const seen = new Set<string>() | ||
| 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 = ( | ||
| <div | ||
| className=" | ||
| group flex flex-col gap-2.5 rounded-xl border border-shop-line bg-shop-panel p-2.5 | ||
| transition-[border-color,transform] duration-200 | ||
| hover:border-shop-line-2 hover:-translate-y-0.5 | ||
| group flex flex-col min-w-[340px] max-w-[400px] w-full rounded-xl | ||
| border border-transparent bg-transparent | ||
| hover:bg-shop-bg-2 hover:border-shop-line-2 | ||
| transition-[border-color,background-color] duration-200 | ||
| px-[22px] pt-7 pb-5 | ||
| " | ||
| > | ||
| <div className="relative aspect-square rounded-lg overflow-hidden bg-shop-panel-2"> | ||
| {/* Diagonal stripe texture */} | ||
| <div | ||
| aria-hidden="true" | ||
| className="absolute inset-0 [background:repeating-linear-gradient(45deg,transparent_0_12px,color-mix(in_srgb,var(--shop-text)_2%,transparent)_12px_13px)]" | ||
| /> | ||
| {/* Soft accent glow */} | ||
| <div | ||
| aria-hidden="true" | ||
| className="absolute inset-0 bg-[radial-gradient(60%_50%_at_50%_50%,color-mix(in_srgb,var(--shop-accent)_8%,transparent),transparent_70%)]" | ||
| /> | ||
| {/* Image */} | ||
| {/* Image */} | ||
| <div className="relative aspect-square rounded-lg overflow-hidden"> | ||
| <ProductImage | ||
| image={product.featuredImage} | ||
| image={activeImage} | ||
| alt={product.title} | ||
| width={600} | ||
| sizes={sizes} | ||
| loading={loading} | ||
| className="relative z-[1]" | ||
| className="w-full h-full object-cover" | ||
| /> | ||
| {isNew ? <NewBadge /> : null} | ||
| </div> | ||
|
|
||
| {/* Badges */} | ||
| {isNew || isLowStock || isDiscounted ? ( | ||
| <div className="absolute top-2.5 left-2.5 flex gap-1 z-[2]"> | ||
| {isNew ? <ShopBadge variant="new">New</ShopBadge> : null} | ||
| {isDiscounted ? <ShopBadge variant="sale">Sale</ShopBadge> : null} | ||
| {isLowStock ? ( | ||
| <ShopBadge variant="sale">Low stock</ShopBadge> | ||
| {/* Meta */} | ||
| <div className="flex flex-col gap-2.5 pt-4"> | ||
| {/* Title + price */} | ||
| <div className="flex justify-between items-baseline gap-3"> | ||
| <span className="text-shop-title font-semibold font-shop-display leading-tight text-shop-text truncate"> | ||
| {product.title} | ||
| </span> | ||
| <ShopMono className="text-shop-price text-shop-text whitespace-nowrap shrink-0 font-light"> | ||
| {isRange ? 'From ' : ''} | ||
| {formatMoney(minVariantPrice.amount, minVariantPrice.currencyCode)} | ||
| {compareAt && | ||
| Number(compareAt.amount) > Number(minVariantPrice.amount) ? ( | ||
| <span className="ml-1.5 text-shop-ui text-shop-muted line-through"> | ||
| {formatMoney(compareAt.amount, compareAt.currencyCode)} | ||
| </span> | ||
| ) : null} | ||
| </div> | ||
| ) : null} | ||
|
|
||
| {/* Category tag (hidden on hover, replaced by quick-view chip) */} | ||
| {tag ? ( | ||
| <span | ||
| className=" | ||
| absolute bottom-2.5 right-2.5 z-[1] | ||
| font-shop-mono text-[9.5px] tracking-[0.1em] uppercase | ||
| text-shop-muted bg-shop-bg-2/80 backdrop-blur | ||
| border border-shop-line-2 rounded px-1.5 py-[3px] | ||
| max-w-[calc(100%-1.25rem)] overflow-hidden text-ellipsis whitespace-nowrap | ||
| transition-opacity group-hover:opacity-0 | ||
| " | ||
| </ShopMono> | ||
| </div> | ||
|
|
||
| {/* Swatches + Quick View row */} | ||
| <div className="flex items-center justify-between"> | ||
| {/* Round color swatches — hover to preview color */} | ||
| <div | ||
| className="flex gap-[10px]" | ||
| onMouseLeave={() => setHoveredColor(null)} | ||
| > | ||
| {tag} | ||
| {swatches.map((s) => ( | ||
| <button | ||
| key={s.name} | ||
| type="button" | ||
| title={s.name} | ||
| onMouseEnter={() => setHoveredColor(s.name)} | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| e.preventDefault() | ||
| }} | ||
| className={`w-4 h-4 rounded-full shrink-0 cursor-pointer border transition-[box-shadow,border-color,transform] duration-150 ${ | ||
| hoveredColor === s.name | ||
| ? 'border-white/70 ring-2 ring-white/30 scale-125' | ||
| : 'border-white/15 hover:scale-110' | ||
| }`} | ||
| style={ | ||
| s.hex | ||
| ? { background: s.hex } | ||
| : { | ||
| background: | ||
| 'conic-gradient(from 30deg,#ef4c7a,#f4c74a,#22c993,#36d3f3,#4b9bff,#ef4c7a)', | ||
| } | ||
| } | ||
| /> | ||
| ))} | ||
| </div> | ||
|
|
||
| {/* Quick View — hover only */} | ||
| <span className="opacity-0 group-hover:opacity-100 transition-opacity text-shop-xs text-shop-text-2 inline-flex items-center gap-1"> | ||
| Quick View | ||
| <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"> | ||
| <path | ||
| d="M2 5h6M6 2l3 3-3 3" | ||
| stroke="currentColor" | ||
| strokeWidth="1.6" | ||
| fill="none" | ||
| /> | ||
| </svg> | ||
| </span> | ||
| ) : null} | ||
|
|
||
| {/* Quick view chip */} | ||
| <span | ||
| className=" | ||
| absolute bottom-2.5 right-2.5 z-[2] | ||
| inline-flex items-center gap-1 px-2 py-[5px] rounded-md | ||
| bg-shop-accent text-shop-accent-ink text-[11px] font-semibold | ||
| opacity-0 translate-y-1 pointer-events-none | ||
| transition-all group-hover:opacity-100 group-hover:translate-y-0 | ||
| " | ||
| > | ||
| Quick view | ||
| <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"> | ||
| <path | ||
| d="M2 5h6M6 2l3 3-3 3" | ||
| stroke="currentColor" | ||
| strokeWidth="1.6" | ||
| fill="none" | ||
| /> | ||
| </svg> | ||
| </span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ) | ||
|
|
||
| <div className="flex justify-between items-baseline gap-2.5 px-1 pb-1.5"> | ||
| <div className="min-w-0 flex-1"> | ||
| <div className="text-[13.5px] font-semibold tracking-[-0.005em] leading-tight text-shop-text truncate"> | ||
| {product.title} | ||
| </div> | ||
| {tag ? ( | ||
| <ShopMono className="block text-[11.5px] text-shop-muted tracking-[0.02em] mt-0.5 truncate"> | ||
| {tag} | ||
| </ShopMono> | ||
| ) : null} | ||
| </div> | ||
| <ShopMono className="text-[12.5px] text-shop-text whitespace-nowrap"> | ||
| {isRange ? 'From ' : ''} | ||
| {formatMoney(minVariantPrice.amount, minVariantPrice.currencyCode)} | ||
| {isDiscounted ? ( | ||
| <span className="ml-1.5 text-[11px] text-shop-muted line-through"> | ||
| {formatMoney(compareAt.amount, compareAt.currencyCode)} | ||
| </span> | ||
| ) : null} | ||
| </ShopMono> | ||
| if (onQuickView) { | ||
| return ( | ||
| <div | ||
| role="button" | ||
| tabIndex={0} | ||
| onClick={() => onQuickView(product.handle)} | ||
| onKeyDown={(e) => { | ||
| if (e.key === 'Enter' || e.key === ' ') onQuickView(product.handle) | ||
| }} | ||
| className="text-left block cursor-pointer" | ||
| > | ||
| {cardBody} | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| return ( | ||
| <Link | ||
| to="/shop/products/$handle" | ||
| params={{ handle: product.handle }} | ||
| className="block" | ||
| > | ||
| {cardBody} | ||
| </Link> | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical:
TWO_WEEKS_MSis actually one year365 * 24 * 60 * 60 * 1000is 365 days — not 14 days. The "NEW" badge will be applied to every product published within the last year. The constant should multiply 14 days instead.🐛 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents