'use client' import { useEffect, useId, useRef, useState } from 'react' import { cn } from '../../lib/cn' import { formatPrice, plural } from '../../lib/format' import { Badge } from '../Badge' import { Button } from '../Button' import { CheckIcon, StarIcon } from '../internal/icons' import { Surface } from '../Surface' import { Text } from '../Text' import { VisuallyHidden } from '../VisuallyHidden' export interface ProductCardImage { src: string /** What the photo shows. */ alt: string } export interface ProductCardSwatch { value: string /** Colour name, announced and shown on hover — "Sage". */ label: string /** Any CSS colour for the chip. It is product content, not theme. */ color: string } export type ProductCardBadge = 'sale' | 'new' export interface ProductCardProps { title: string /** Product page. Makes the title a link. */ href?: string image: ProductCardImage /** A second photo shown while the card is hovered — the product worn, or from behind. */ hoverImage?: ProductCardImage /** Corner label. sale uses the accent fill; new the neutral one. */ badge?: ProductCardBadge price: number /** The earlier price. Shown struck through when higher than `price`. */ compareAtPrice?: number /** Currency symbol prefixed to prices. */ currency?: string /** Average rating and review count. */ rating?: { value: number; count: number } /** Colour options, as a radio group. */ swatches?: ProductCardSwatch[] /** Controlled selected swatch value. */ swatch?: string /** Initial swatch when uncontrolled. Defaults to the first. */ defaultSwatch?: string /** Called when a swatch is chosen. */ onSwatchChange?: (value: string) => void /** Add to cart. Return a promise to show the loading state until it settles. */ onAddToCart?: (swatch: string | undefined) => void | Promise /** Controlled wishlist state. */ wishlisted?: boolean /** Initial wishlist state when uncontrolled. */ defaultWishlisted?: boolean /** Called when the heart is toggled. */ onWishlistChange?: (wishlisted: boolean) => void /** Heading level for the title. */ headingLevel?: 'h2' | 'h3' | 'h4' /** Merged last, so it wins. */ className?: string } const HeartIcon = ({ filled }: { filled: boolean }) => ( ) /** * A product in a grid: enough to decide whether to open it, and enough to buy * it without doing so. * * The add-to-cart button runs through three states — ready, adding, added — in * place, and the result is also announced, because a cart count changing in a * header far away is not feedback anyone notices. Swatches are native radios, * so arrow keys choose a colour, and the wishlist heart is a toggle button with * a pressed state rather than an icon that silently changes fill. * * The hover photo is a crossfade the reader causes, and it switches instantly * under reduced motion. */ export function ProductCard({ title, href, image, hoverImage, badge, price, compareAtPrice, currency = '$', rating, swatches, swatch: controlledSwatch, defaultSwatch, onSwatchChange, onAddToCart, wishlisted: controlledWish, defaultWishlisted = false, onWishlistChange, headingLevel: Heading = 'h3', className, }: ProductCardProps) { const name = useId() const [swatchState, setSwatchState] = useState(defaultSwatch ?? swatches?.[0]?.value) const [wishState, setWishState] = useState(defaultWishlisted) const [cart, setCart] = useState<'idle' | 'adding' | 'added'>('idle') const reset = useRef(0) useEffect(() => () => window.clearTimeout(reset.current), []) const swatch = controlledSwatch ?? swatchState const wishlisted = controlledWish ?? wishState const onSale = compareAtPrice !== undefined && compareAtPrice > price const swatchLabel = swatches?.find((entry) => entry.value === swatch)?.label const add = async () => { if (!onAddToCart || cart === 'adding') return window.clearTimeout(reset.current) setCart('adding') try { await onAddToCart(swatch) setCart('added') reset.current = window.setTimeout(() => setCart('idle'), 2000) } catch { setCart('idle') } } return (
{image.alt} {hoverImage && ( )} {badge && ( {badge === 'sale' ? 'Sale' : 'New'} )}
{href ? ( {title} ) : ( title )} {rating && ( Rated {rating.value.toFixed(1)} out of 5 from {plural(rating.count, 'review')} )}

{onSale && Now } {formatPrice(price, currency)} {onSale && ( was {formatPrice(compareAtPrice as number, currency)} )}

{swatches && swatches.length > 0 && (
{swatches.map((entry) => ( ))}
)} {onAddToCart && ( )} {cart === 'added' ? `${title} added to cart` : ''}
) }