Pakketkaart: knop naar de volledige tracking, een beeldvlak met lopende-band-arcering en het pakket erop, bestemming met vlag, de status groot, pakketnummer met datum en een QR-code (slot). Alles komt bij het laden één voor één omhoog. Componeert button.
Nederland
Onderweg naar je
Pakketnummer:
4902 9880 1508 1012 9411
Amsterdam · 14-09-2026
De band loopt; de knop bovenin meldt onTrack.
blocks/commerce/package-tracker.tsx
/**
* PackageTracker — een pakketkaart: bovenin een knop naar de volledige
* tracking, een beeldvlak met een lopende band-arcering en het pakket
* (slot) erop, daaronder bestemming met vlag, de status groot, het
* pakketnummer met datum en een QR-code (slot: beeld of element). Alles komt
* bij het laden één voor één omhoog.
*
* Geoogst uit 21st.dev Tracker Card (ravikatiyar162): qrcode.react →
* `qrCode`-slot (het project levert een beeld of element — geen dependency),
* lucide CheckCircle2/QrCode → inline paden, text-green-500 → --success,
* hsl(var(--muted)/…) → color-mix op --muted, spring 100/15 → smooth-token
* met stagger, onClick → onTrack via button (FC-0003), Engelse teksten →
* labels. De lopende band beweegt ambient (background-position, token-
* veelvoud), uit onder reduced motion.
*/
"use client";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { Button } from "../../primitives/ui/button";
export interface PackageTrackerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
status: string;
packageNumber: string;
destination: string;
flag?: React.ReactNode;
date: string;
/** QR-code: een <img> of element; zonder: een placeholder-icoon. */
qrCode?: React.ReactNode;
/** Het pakket (beeld) op de lopende band. */
image?: React.ReactNode;
onTrack?: () => void;
labels?: { track?: string; number?: string };
}
export function PackageTracker({ status, packageNumber, destination, flag, date, qrCode, image, onTrack, labels, className, ...rest }: PackageTrackerProps) {
const t = { track: "Volledige tracking", number: "Pakketnummer", ...labels };
const reduced = useReducedMotion() ?? false;
const veer = useSpringTransition("smooth", { mass: "content" });
let stap = 0;
const entree = () => {
const i = stap++;
return { initial: reduced ? false : { opacity: 0, y: 20 }, animate: { opacity: 1, y: 0 }, transition: { ...veer, delay: reduced ? 0 : i * 0.1 } } as const;
};
return (
<div {...rest} className={["fp-pakket", className].filter(Boolean).join(" ")}>
<motion.div className="fp-pakket__top" {...entree()}>
<Button variant="ghost" onPress={onTrack} className="fp-pakket__knop">
<svg aria-hidden="true" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="fp-pakket__vinkje"><circle cx="12" cy="12" r="9" /><path d="M8.5 12.5l2.5 2.5 4.5-5" /></svg>
{t.track}
</Button>
</motion.div>
<motion.div className="fp-pakket__band" {...entree()}>
<span className="fp-pakket__arcering" aria-hidden="true" />
<div className="fp-pakket__beeld">{image}</div>
</motion.div>
<div className="fp-pakket__details">
<motion.div className="fp-pakket__bestemming" {...entree()}>
{flag && <span className="fp-pakket__vlag" aria-hidden="true">{flag}</span>}
<span>{destination}</span>
</motion.div>
<motion.h2 className="fp-pakket__status" {...entree()}>{status}</motion.h2>
<div className="fp-pakket__voet">
<motion.div className="fp-pakket__nummer" {...entree()}>
<p className="fp-pakket__label">{t.number}:</p>
<p className="fp-pakket__code">{packageNumber}</p>
<p className="fp-pakket__label">{date}</p>
</motion.div>
<motion.div className="fp-pakket__qr" {...entree()}>
{qrCode ?? (
<span className="fp-pakket__qr-leeg" aria-hidden="true">
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" strokeWidth={1.8}><path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h3v3h-3zM17 17h3v3h-3zM14 20h3" /></svg>
</span>
)}
</motion.div>
</div>
</div>
</div>
);
}
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); }