← alle entries

product-card

FC-0079Kaartenproduct-cardblockv1.0.0

Eén product in een winkel: beeld dat licht inzoomt op hover, badge, hartje voor de verlanglijst, sterren, prijs met korting (Intl, valuta van het project) en een knop die zelf zijn feedback toont — bezig, toegevoegd, mislukt. Receptuur: async-button + usePress.

zelfde component — andere tokens
Sale

Loopschoen Atlas

4.5
€ 129,00€ 179,00-28%
Nieuw

Eau de parfum Nacht

5
€ 89,00
blocks/commerce/product-card.tsx
/**
 * ProductCard — één product in een winkel: beeld dat licht inzoomt op hover,
 * badge, hartje voor de verlanglijst, sterren, prijs met eventuele korting,
 * en een knop die zelf zijn feedback toont (bezig → toegevoegd → rust).
 *
 * Receptuur: de knop is async-button (FC-0017) — het project levert
 * `onAddToCart` als (async) actie; het hartje loopt via usePress. Prijzen
 * worden met Intl opgemaakt (valuta + locale van het project). Het beeld
 * komt als `image` (url) of als slot (`media`) voor next/image.
 *
 * Beweging: binnenkomen bij in beeld (whileInView, scroll: trigger) met het
 * default-token; de zoom is één CSS-transition op transform (smooth-token);
 * de badge popt met het pop-token. Onder reduced motion staat alles direct.
 */

"use client";

import { useEffect, useState } from "react";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { AsyncButton } from "../async-button/async-button";
import { usePress } from "../../primitives/ui/press";
import { useSpringTransition } from "../../primitives/motion";

export interface ProductCardLabels {
  addToCart: string;
  adding: string;
  added: string;
  failed: string;
  wishlistAdd: string;
  wishlistRemove: string;
  /** {n} = de score */
  rating: string;
}

export interface ProductCardProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
  title: string;
  price: number;
  originalPrice?: number;
  /** ISO-valutacode voor Intl (standaard EUR) en locale (standaard nl-NL). */
  currency?: string;
  locale?: string;
  /** 0–5, halve sterren toegestaan. */
  rating?: number;
  badge?: string;
  /** Kleur van de badge: primair, succes (nieuw) of gevaar (sale). */
  badgeTone?: "primary" | "success" | "danger";
  image?: string;
  /** Eigen beeld-element (bv. next/image) in plaats van `image`. */
  media?: React.ReactNode;
  wishlisted?: boolean;
  onWishlist?: (wishlisted: boolean) => void;
  /** Toevoegen aan de winkelwagen; mag een promise geven — de knop toont bezig/gelukt/mislukt. */
  onAddToCart?: () => unknown;
  labels?: Partial<ProductCardLabels>;
}

const STANDAARD: ProductCardLabels = {
  addToCart: "In winkelwagen",
  adding: "Toevoegen…",
  added: "Toegevoegd",
  failed: "Niet gelukt",
  wishlistAdd: "Bewaar {title}",
  wishlistRemove: "Verwijder {title} van je verlanglijst",
  rating: "{n} van 5 sterren",
};

const STER = "M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z";

function Ster({ vulling, id }: { vulling: 0 | 0.5 | 1; id: string }) {
  return (
    <svg aria-hidden="true" viewBox="0 0 24 24" className="fp-product__ster" data-vulling={vulling}>
      {vulling === 0.5 && (
        <defs>
          <linearGradient id={id}>
            <stop offset="50%" stopColor="currentColor" />
            <stop offset="50%" stopColor="transparent" />
          </linearGradient>
        </defs>
      )}
      <path d={STER} fill={vulling === 1 ? "currentColor" : vulling === 0.5 ? `url(#${id})` : "none"} stroke="currentColor" strokeWidth={1.5} strokeLinejoin="round" />
    </svg>
  );
}

function Sterren({ score, label, id }: { score: number; label: string; id: string }) {
  const vol = Math.floor(score);
  const rest = score - vol;
  const half = rest >= 0.25 && rest < 0.75;
  const omhoog = rest >= 0.75;
  return (
    <div className="fp-product__sterren" aria-label={label.replace("{n}", String(score))}>
      {Array.from({ length: 5 }, (_, i) => {
        const vulling: 0 | 0.5 | 1 = i < vol || (omhoog && i === vol) ? 1 : half && i === vol ? 0.5 : 0;
        return <Ster key={i} vulling={vulling} id={`${id}-ster-${i}`} />;
      })}
      <span className="fp-product__score">{score}</span>
    </div>
  );
}

export function ProductCard({
  title,
  price,
  originalPrice,
  currency = "EUR",
  locale = "nl-NL",
  rating,
  badge,
  badgeTone = "primary",
  image,
  media,
  wishlisted: wishlistedProp,
  onWishlist,
  onAddToCart,
  labels: eigen,
  className,
  ...rest
}: ProductCardProps) {
  const L = { ...STANDAARD, ...eigen };
  const reduced = useReducedMotion() ?? false;
  const [hoverApparaat, setHoverApparaat] = useState(false);
  const [eigenHart, setEigenHart] = useState(false);
  const hart = wishlistedProp ?? eigenHart;
  const binnen = useSpringTransition("default");
  const pop = useSpringTransition("pop");
  const id = `p-${title.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;

  useEffect(() => {
    const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
    const lees = () => setHoverApparaat(mq.matches);
    lees();
    mq.addEventListener("change", lees);
    return () => mq.removeEventListener("change", lees);
  }, []);

  const { pressed, pressProps } = usePress({
    onPress: () => {
      const volgende = !hart;
      if (wishlistedProp === undefined) setEigenHart(volgende);
      onWishlist?.(volgende);
    },
  });

  const geld = new Intl.NumberFormat(locale, { style: "currency", currency });
  const korting = originalPrice !== undefined && originalPrice > price ? Math.round(((originalPrice - price) / originalPrice) * 100) : 0;

  return (
    <motion.article
      {...(rest as React.ComponentProps<typeof motion.article>)}
      className={["fp-product", hoverApparaat ? "fp-product--hover" : "", className].filter(Boolean).join(" ")}
      aria-label={`${title} — ${geld.format(price)}`}
      initial={reduced ? false : { opacity: 0, y: 20, scale: 0.97 }}
      whileInView={{ opacity: 1, y: 0, scale: 1 }}
      viewport={{ once: true, margin: "-50px" }}
      transition={binnen}
    >
      <div className="fp-product__beeld">
        {media ?? (image ? /* eslint-disable-next-line @next/next/no-img-element -- copy-in entry, of gebruik `media` */ <img src={image} alt="" draggable={false} /> : null)}
        <div className="fp-product__sluier" aria-hidden="true" />
        {badge && (
          <motion.span
            className="fp-product__badge"
            data-toon={badgeTone}
            role="status"
            initial={reduced ? false : { opacity: 0, scale: 0.6 }}
            animate={{ opacity: 1, scale: 1 }}
            transition={{ ...pop, delay: reduced ? 0 : 0.25 }}
          >
            {badge}
          </motion.span>
        )}
        <button
          {...pressProps}
          type="button"
          className="fp-product__hart"
          data-pressed={pressed ? "" : undefined}
          data-aan={hart ? "" : undefined}
          aria-pressed={hart}
          aria-label={(hart ? L.wishlistRemove : L.wishlistAdd).replace("{title}", title)}
        >
          <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill={hart ? "currentColor" : "none"} stroke="currentColor" strokeWidth={hart ? 0 : 2} strokeLinecap="round" strokeLinejoin="round">
            <path d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
          </svg>
        </button>
      </div>

      <div className="fp-product__inhoud">
        <h3 className="fp-product__titel">{title}</h3>
        {rating !== undefined && <Sterren score={rating} label={L.rating} id={id} />}
        <div className="fp-product__prijs">
          <span className="fp-product__bedrag">{geld.format(price)}</span>
          {korting > 0 && (
            <>
              <s className="fp-product__oud">{geld.format(originalPrice!)}</s>
              <span className="fp-product__korting">-{korting}%</span>
            </>
          )}
        </div>
        <div className="fp-product__actie">
          <AsyncButton onAction={() => onAddToCart?.()} bezigLabel={L.adding} geluktLabel={L.added} misluktLabel={L.failed} className="fp-product__knop">
            {L.addToCart}
          </AsyncButton>
        </div>
      </div>
    </motion.article>
  );
}
blocks/commerce/commerce.css
/**
 * Commerce-blocks: product-card, payment-methods.
 * Importeren in de app-root (de registry-site doet dat in app/globals.css).
 */

/* ── ProductCard ─────────────────────────────────────────────────────────── */

.fp-product {
  position: relative; display: flex; flex-direction: column; width: 100%; overflow: hidden;
  background: var(--card); color: var(--card-foreground, var(--foreground));
  border: 1px solid var(--border); border-radius: calc(var(--radius) * 2);
  box-shadow: 0 1px 2px var(--shadow-overlay);
  transition: box-shadow var(--motion-smooth-duration) var(--motion-smooth);
}
.fp-product--hover:hover { box-shadow: 0 16px 32px -16px var(--shadow-overlay); }
.fp-product__beeld { position: relative; aspect-ratio: 1 / 1; overflow: hidden; background: var(--muted); }
.fp-product__beeld > :first-child:not(.fp-product__sluier) {
  display: block; width: 100%; height: 100%; object-fit: cover;
  transition: transform var(--motion-smooth-duration) var(--motion-smooth);
}
.fp-product--hover:hover .fp-product__beeld > :first-child:not(.fp-product__sluier) { transform: scale(1.05); }
.fp-product__sluier {
  position: absolute; inset: 0; pointer-events: none; opacity: 0;
  background: linear-gradient(to top, color-mix(in srgb, var(--foreground) 10%, transparent), transparent 40%);
  transition: opacity var(--motion-default-duration) var(--motion-default);
}
.fp-product--hover:hover .fp-product__sluier { opacity: 1; }
.fp-product__badge {
  position: absolute; top: 0.75rem; left: 0.75rem; padding: 0.25rem 0.625rem; border-radius: 999px;
  font-size: 0.75rem; font-weight: 600; box-shadow: 0 1px 2px var(--shadow-overlay);
  background: var(--primary); color: var(--primary-foreground);
}
.fp-product__badge[data-toon="success"] { background: var(--success); color: var(--background); }
.fp-product__badge[data-toon="danger"] { background: var(--danger); color: var(--background); }
.fp-product__hart {
  position: absolute; top: 0.75rem; right: 0.75rem; display: flex; align-items: center; justify-content: center; width: 2rem; height: 2rem;
  border: 0; border-radius: 50%; cursor: pointer; color: var(--foreground);
  background: color-mix(in srgb, var(--background) 80%, transparent); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
  transition: opacity var(--motion-instant-duration) var(--motion-instant), transform var(--motion-instant-duration) var(--motion-instant), color var(--motion-instant-duration) var(--motion-instant);
}
.fp-product--hover .fp-product__hart { opacity: 0; }
.fp-product--hover:hover .fp-product__hart, .fp-product--hover .fp-product__hart:focus-visible, .fp-product--hover .fp-product__hart[data-aan] { opacity: 1; }
.fp-product__hart[data-pressed] { transform: scale(0.85); }
.fp-product__hart[data-aan] { color: var(--danger); }
.fp-product__hart:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-product__inhoud { display: flex; flex: 1; flex-direction: column; gap: 0.5rem; padding: 1rem; }
.fp-product__titel { margin: 0; font: inherit; font-weight: 600; font-size: 0.875rem; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-product__sterren { display: flex; align-items: center; gap: 0.125rem; color: var(--warning); }
.fp-product__ster { width: 0.875rem; height: 0.875rem; }
.fp-product__ster[data-vulling="0"] { color: color-mix(in srgb, var(--muted-foreground) 30%, transparent); }
.fp-product__score { margin-left: 0.25rem; font-size: 0.75rem; font-weight: 500; color: var(--muted-foreground); }
.fp-product__prijs { display: flex; align-items: baseline; gap: 0.5rem; }
.fp-product__bedrag { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.01em; }
.fp-product__oud { font-size: 0.875rem; color: var(--muted-foreground); }
.fp-product__korting { padding: 0.125rem 0.375rem; border-radius: calc(var(--radius) * 0.5); font-size: 0.75rem; font-weight: 600; color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, transparent); }
.fp-product__actie { margin-top: auto; padding-top: 0.5rem; }
.fp-product__knop { width: 100%; }

/* ── PaymentMethods ──────────────────────────────────────────────────────── */

.fp-pay {
  display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 28rem; padding: 1.25rem; box-sizing: border-box;
  background: var(--card); color: var(--card-foreground, var(--foreground)); border: 1px solid var(--border); border-radius: calc(var(--radius) * 1.5);
}
.fp-pay__titel { margin: 0; font: inherit; font-weight: 600; font-size: 1rem; }
.fp-pay__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.fp-pay__rij { position: relative; display: flex; align-items: center; gap: 0.5rem; }
.fp-pay__kies {
  display: flex; flex: 1; align-items: center; gap: 0.75rem; min-width: 0; padding: 0.75rem; cursor: pointer;
  border: 1px solid var(--border); border-radius: var(--radius); outline: none;
  transition: border-color var(--motion-instant-duration) var(--motion-instant), background-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-pay__kies:hover { background: color-mix(in srgb, var(--muted) 50%, transparent); }
.fp-pay__kies:focus-visible { box-shadow: 0 0 0 2px var(--ring); }
.fp-pay__rij[data-gekozen] .fp-pay__kies { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 5%, transparent); }
.fp-pay__merk { display: inline-flex; flex-shrink: 0; color: var(--muted-foreground); }
.fp-pay__tekst { display: flex; flex: 1; flex-direction: column; min-width: 0; }
.fp-pay__naam { font-size: 0.875rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-pay__verloopt { font-size: 0.75rem; color: var(--muted-foreground); }
.fp-pay__badge::before { content: var(--fp-pay-badge, "Standaard"); display: inline-block; padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.75rem; font-weight: 500; background: var(--muted); color: var(--foreground); }
.fp-pay__weg {
  display: flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; flex-shrink: 0; border: 0; border-radius: var(--radius); background: none; cursor: pointer; color: var(--muted-foreground);
  transition: color var(--motion-instant-duration) var(--motion-instant), background-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-pay__weg:hover, .fp-pay__weg[data-pressed] { color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, transparent); }
.fp-pay__weg:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-pay__leeg-icoon { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; border-radius: var(--radius); background: var(--muted); color: var(--foreground); }
.fp-pay__voet { display: flex; }
.fp-pay__toevoegen { width: 100%; justify-content: center; gap: 0.5rem; }

/* ── PackageTracker ─────────────────────────────────────────────────────── */

.fp-pakket { width: 100%; max-width: 24rem; overflow: hidden; border-radius: calc(var(--radius) * 3); border: 1px solid var(--border); background: var(--card); color: var(--card-foreground, var(--foreground)); box-shadow: var(--shadow-overlay); }
.fp-pakket__top { padding: 1rem; }
.fp-pakket__knop { width: 100%; justify-content: center; }
.fp-pakket__vinkje { color: var(--success); margin-right: 0.5rem; }
/* De lopende band: een schuine arcering die ambient naar rechts loopt (token-veelvoud), uit onder reduced motion. */
.fp-pakket__band { position: relative; display: flex; align-items: center; justify-content: center; height: 12rem; overflow: hidden; background: color-mix(in srgb, var(--muted) 60%, transparent); }
.fp-pakket__arcering {
  position: absolute; inset: 0; pointer-events: none;
  background-image: repeating-linear-gradient(45deg, transparent 0 25px, color-mix(in srgb, var(--muted-foreground) 14%, transparent) 25px 50px), repeating-linear-gradient(-45deg, transparent 0 25px, color-mix(in srgb, var(--muted-foreground) 10%, transparent) 25px 50px);
  background-size: 71px 71px;
  animation: fp-pakket-band calc(var(--motion-smooth-duration) * 6) linear infinite;
}
@keyframes fp-pakket-band { to { background-position: 71px 0; } }
@media (prefers-reduced-motion: reduce) { .fp-pakket__arcering { animation: none; } }
.fp-pakket__beeld { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; max-height: 100%; }
.fp-pakket__beeld > * { max-height: 10rem; }
.fp-pakket__details { padding: 1.5rem; }
.fp-pakket__bestemming { display: flex; align-items: center; gap: 0.5rem; font: inherit; font-size: 0.875rem; font-weight: 500; color: var(--muted-foreground); }
.fp-pakket__vlag { display: inline-flex; width: 1.5rem; height: 1rem; overflow: hidden; border-radius: calc(var(--radius) * 0.25); }
.fp-pakket__vlag > * { width: 100%; height: 100%; }
.fp-pakket__status { margin: 0.5rem 0 0; font: inherit; font-size: 1.875rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
.fp-pakket__voet { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-top: 1.5rem; }
.fp-pakket__nummer { display: grid; gap: 0.25rem; min-width: 0; }
.fp-pakket__label { margin: 0; font: inherit; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-pakket__code { margin: 0; font: inherit; font-size: 0.875rem; font-variant-numeric: tabular-nums; letter-spacing: 0.04em; overflow-wrap: anywhere; }
.fp-pakket__qr { flex: 0 0 auto; padding: 0.25rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--background); }
.fp-pakket__qr > * { display: block; width: 4rem; height: 4rem; }
.fp-pakket__qr-leeg { display: flex; align-items: center; justify-content: center; width: 4rem; height: 4rem; background: var(--muted); color: var(--muted-foreground); }