← alle entries

payment-methods

FC-0080Account & toegangpayment-methodsblockv1.0.0

De bewaarde betaalmethoden van een klant: kaarten met merk, laatste vier cijfers en vervaldatum, één als standaard, per kaart verwijderen, onderaan toevoegen, en een lege staat als er niets is. Receptuur: empty-state + button + usePress; de betaalprovider is van het project.

zelfde component — andere tokens

Betaalmethoden

Verwijder alles en de lege staat verschijnt; ‘toevoegen’ zet de demo-kaarten terug.

blocks/commerce/payment-methods.tsx
/**
 * PaymentMethods — de bewaarde betaalmethoden van een klant: een lijst
 * kaarten (merk, laatste vier cijfers, vervaldatum), waarvan er één de
 * standaard is; per kaart een verwijderknop, onderaan een toevoegknop, en
 * een lege staat als er niets is.
 *
 * Receptuur: de lege staat is empty-state (FC-0011, compact), knoppen zijn
 * button (FC-0003), de rijen lopen via usePress. Wat er gebeurt bij kiezen,
 * verwijderen of toevoegen bepaalt het project (V6): deze entry kent geen
 * betaalprovider. Een merklogo komt via `renderBrand` of blijft een
 * generiek kaartje.
 */

"use client";

import { useState } from "react";
import type * as React from "react";
import { Button } from "../../primitives/ui/button";
import { usePress } from "../../primitives/ui/press";
import { EmptyState } from "../empty-state/empty-state";

export interface PaymentMethod {
  id: string;
  /** Merknaam zoals het project die toont (Visa, Mastercard, iDEAL…). */
  brand: string;
  last4?: string;
  /** Vervaldatum, vrij formaat ("08 / 27"). */
  expires?: string;
}

export interface PaymentMethodsLabels {
  title: string;
  defaultBadge: string;
  /** {brand} en {last4} */
  remove: string;
  add: string;
  emptyTitle: string;
  emptyDescription: string;
  /** {brand} eindigend op {last4} */
  card: string;
  /** {date} */
  expires: string;
}

export interface PaymentMethodsProps extends Omit<React.HTMLAttributes<HTMLElement>, "onSelect" | "title"> {
  methods: PaymentMethod[];
  selectedId?: string | null;
  onSelect?: (id: string) => void;
  onRemove?: (id: string) => void;
  onAdd?: () => void;
  /** Merkicoon per methode; zonder: een generiek kaartje. */
  renderBrand?: (method: PaymentMethod) => React.ReactNode;
  labels?: Partial<PaymentMethodsLabels>;
}

const STANDAARD: PaymentMethodsLabels = {
  title: "Betaalmethoden",
  defaultBadge: "Standaard",
  remove: "Verwijder {brand} eindigend op {last4}",
  add: "Betaalmethode toevoegen",
  emptyTitle: "Nog geen betaalmethode",
  emptyDescription: "Voeg een kaart toe om je abonnement te starten.",
  card: "{brand} eindigend op {last4}",
  expires: "Verloopt {date}",
};

function KaartIcoon() {
  return (
    <svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
      <rect x="2.5" y="5.5" width="19" height="13" rx="2" />
      <path d="M2.5 10h19" />
      <path d="M6 14.5h4" />
    </svg>
  );
}

function Rij({ method, gekozen, label, verwijderLabel, verloopt, brand, onKies, onVerwijder }: {
  method: PaymentMethod;
  gekozen: boolean;
  label: string;
  verwijderLabel: string;
  verloopt?: string;
  brand: React.ReactNode;
  onKies: () => void;
  onVerwijder?: () => void;
}) {
  const kies = usePress({ onPress: onKies });
  const weg = usePress({ onPress: () => onVerwijder?.() });
  return (
    <li className="fp-pay__rij" data-gekozen={gekozen ? "" : undefined}>
      <div {...kies.pressProps} role="radio" aria-checked={gekozen} tabIndex={0} className="fp-pay__kies" data-pressed={kies.pressed ? "" : undefined}>
        <span className="fp-pay__merk" aria-hidden="true">{brand}</span>
        <span className="fp-pay__tekst">
          <span className="fp-pay__naam">{label}</span>
          {verloopt && <span className="fp-pay__verloopt">{verloopt}</span>}
        </span>
        {gekozen && <span className="fp-pay__badge" />}
      </div>
      {onVerwijder && (
        <button {...weg.pressProps} type="button" className="fp-pay__weg" data-pressed={weg.pressed ? "" : undefined} aria-label={verwijderLabel}>
          <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
            <path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 002 2h6a2 2 0 002-2l1-12M9 7V4h6v3" />
          </svg>
        </button>
      )}
    </li>
  );
}

export function PaymentMethods({ methods, selectedId: selectedProp, onSelect, onRemove, onAdd, renderBrand, labels: eigen, className, ...rest }: PaymentMethodsProps) {
  const L = { ...STANDAARD, ...eigen };
  const [eigenKeuze, setEigenKeuze] = useState<string | null>(methods[0]?.id ?? null);
  const gekozenId = selectedProp === undefined ? eigenKeuze : selectedProp;
  const kies = (id: string) => {
    if (selectedProp === undefined) setEigenKeuze(id);
    onSelect?.(id);
  };
  const vul = (s: string, m: PaymentMethod) => s.replace("{brand}", m.brand).replace("{last4}", m.last4 ?? "");

  return (
    <section {...rest} className={["fp-pay", className].filter(Boolean).join(" ")}>
      <h3 className="fp-pay__titel">{L.title}</h3>
      {methods.length === 0 ? (
        <EmptyState variant="compact" visual={<span className="fp-pay__leeg-icoon"><KaartIcoon /></span>} title={L.emptyTitle} description={L.emptyDescription} />
      ) : (
        <ul className="fp-pay__lijst" role="radiogroup" aria-label={L.title} style={{ ["--fp-pay-badge" as string]: `"${L.defaultBadge.replace(/"/g, "'")}"` } as React.CSSProperties}>
          {methods.map((m) => (
            <Rij
              key={m.id}
              method={m}
              gekozen={m.id === gekozenId}
              label={m.last4 ? vul(L.card, m) : m.brand}
              verwijderLabel={vul(L.remove, m)}
              verloopt={m.expires ? L.expires.replace("{date}", m.expires) : undefined}
              brand={renderBrand ? renderBrand(m) : <KaartIcoon />}
              onKies={() => kies(m.id)}
              onVerwijder={onRemove ? () => onRemove(m.id) : undefined}
            />
          ))}
        </ul>
      )}
      {onAdd && (
        <div className="fp-pay__voet">
          <Button variant="ghost" className="fp-pay__toevoegen" onPress={onAdd}>
            <svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
              <path d="M12 5v14M5 12h14" />
            </svg>
            {L.add}
          </Button>
        </div>
      )}
    </section>
  );
}
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); }