Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
325 changes: 226 additions & 99 deletions src/components/shop/ProductCard.tsx
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical: TWO_WEEKS_MS is actually one year

365 * 24 * 60 * 60 * 1000 is 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
-const TWO_WEEKS_MS = 365 * 24 * 60 * 60 * 1000
+const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const TWO_WEEKS_MS = 365 * 24 * 60 * 60 * 1000
const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/shop/ProductCard.tsx` at line 8, TWO_WEEKS_MS is miscalculated
as one year; change its value to represent 14 days by using 14 * 24 * 60 * 60 *
1000 (or compute from a DAY_MS constant) in ProductCard (symbol: TWO_WEEKS_MS)
so the "NEW" badge logic uses a two-week window rather than 365 days.


// 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Duplicated COLOR_MAP with ProductDrawer.COLOR_HEX is already drifting

The comment on line 10 claims this is kept in sync with ProductDrawer.COLOR_HEX, but the two maps already disagree: this card map has mixed/holo/polished/blend (card-specific), and the drawer map adds maroon. The colorHex resolver here also lower-cases and tokenizes differently (no full-string lookup before token reverse) than resolveColorHex in the drawer. Future fixes will silently drift further.

Consider extracting both the map and the resolver into a small shared module (e.g. src/components/shop/colorTokens.ts) and importing it from both ProductCard.tsx and ProductDrawer.tsx.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/shop/ProductCard.tsx` around lines 10 - 77, ProductCard.tsx
duplicates COLOR_MAP and a different resolver (colorHex) from
ProductDrawer.COLOR_HEX/resolveColorHex causing drift; extract a single shared
module (e.g. src/components/shop/colorTokens.ts) that exports the canonical
COLOR_MAP and a single resolver function (e.g. resolveColorHex) that does a
full-string lookup then tokenized reverse lookup, include all tokens used by
both card and drawer (add card-only keys like mixed/holo/polished/blend and
drawer-only keys like maroon) and update ProductCard.tsx to import
COLOR_MAP/resolveColorHex (replace colorHex) and ProductDrawer.tsx to import the
same exports so both components use the identical map and lookup behavior.


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"

Check warning on line 244 in src/components/shop/ProductCard.tsx

View workflow job for this annotation

GitHub Actions / PR

eslint-plugin-jsx-a11y(prefer-tag-over-role)

Prefer `button` over `role` attribute `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>
)
}
Loading
Loading