A custom product card
ProductCard takes a name, a preformatted price, an image and two flags, and
nothing from shared/. That is on purpose: the theme editor renders one next to
the colour pickers with no catalogue behind it. A replacement should keep the same
props so ProductList and the theme editor keep working, and should read every
colour from a --beluga-* token so it keeps matching after a merchant changes the
palette.
This version adds a second image on hover. The list only passes one image today,
so it takes an optional hoverImage and ProductList gains one line to pass
product.images[1].
src/components/product/ProductCard.tsx
Section titled “src/components/product/ProductCard.tsx”import { Link } from "react-router-dom";import type { Image } from "@shared/schema";import { ProductImage } from "@/components/ui/ProductImage";import styles from "./ProductCard.module.css";
interface ProductCardProps { href: string; name: string; /** Preformatted — callers own currency and range formatting. */ price: string | null; soldOut?: boolean; onSale?: boolean; image?: Image | null; /** Shown on hover when present. Falls back to the first image. */ hoverImage?: Image | null; sizes?: string | undefined; collection?: string | undefined;}
export function ProductCard({ href, name, price, soldOut = false, onSale = false, image = null, hoverImage = null, sizes, collection,}: ProductCardProps) { return ( <Link to={href} state={collection ? { collection } : null} className={styles.card}> <div className={styles.frame}> <ProductImage image={image} ratio={1} {...(sizes ? { sizes } : {})} /> {hoverImage && ( <div className={styles.hover} aria-hidden="true"> <ProductImage image={hoverImage} ratio={1} {...(sizes ? { sizes } : {})} /> </div> )} {soldOut ? ( <span className={styles.badge}>Sold out</span> ) : onSale ? ( <span className={`${styles.badge} ${styles.sale}`}>Sale</span> ) : null} </div> <div className={styles.meta}> <span className={styles.name}>{name}</span> {price && <span className={styles.price}>{price}</span>} </div> </Link> );}src/components/product/ProductCard.module.css
Section titled “src/components/product/ProductCard.module.css”.card { display: block; color: var(--beluga-ink); text-decoration: none;}.frame { position: relative; overflow: hidden; border: 1px solid var(--beluga-line); border-radius: var(--beluga-radius); background: var(--beluga-surface);}.hover { position: absolute; inset: 0; opacity: 0; transition: opacity 200ms ease;}.card:hover .hover,.card:focus-visible .hover { opacity: 1;}.badge { position: absolute; top: 0.5rem; left: 0.5rem; padding: 0.2rem 0.5rem; font-size: 0.75rem; letter-spacing: 0.04em; text-transform: uppercase; background: var(--beluga-primary); color: var(--beluga-on-primary); border-radius: var(--beluga-radius);}.sale { background: var(--beluga-accent); color: var(--beluga-on-accent);}.meta { display: flex; justify-content: space-between; gap: 1rem; padding: 0.6rem 0.1rem 0;}.price { color: var(--beluga-muted);}@media (prefers-reduced-motion: reduce) { .hover { transition: none; }}The one line in ProductList.tsx
Section titled “The one line in ProductList.tsx”image={product.images[0] ?? null}hoverImage={product.images[1] ?? null}What this is careful about
Section titled “What this is careful about”- Price and sold-out state still come from the list, which derives them from
the catalogue on every render via
formatPriceRangeandisSoldOut. The card never caches either. That is the cart’s identifier-only rule applied one layer up: a cached price is the value a stale tab shows. - Every colour is a token. Change the palette in Settings → Look and the badge, border and text follow.
ProductImageis kept, because it is what builds thesrcsetfrom the recorded widths using the naming rule inshared/images.ts. A plain<img>would download the 1600 px original for a 300 px card.- Hover is ignored on touch by nature, and the second image is
aria-hiddenso a screen reader is not told about the same product twice.