Loopschoen Atlas
4.5
€ 129,00€ 179,00-28%
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.
/**
* 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>
);
}
/**
* 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); }