Skip to content

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].

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; }
}
image={product.images[0] ?? null}
hoverImage={product.images[1] ?? null}
  • Price and sold-out state still come from the list, which derives them from the catalogue on every render via formatPriceRange and isSoldOut. 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.
  • ProductImage is kept, because it is what builds the srcset from the recorded widths using the naming rule in shared/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-hidden so a screen reader is not told about the same product twice.