Zum Inhalt springen
UI Kit
Accent

Zahlungsformular

Zahlungsart wählen und bezahlen, ohne Logos von Kartenmarken. Zahlungsarten, die eigene Felder öffnen, die Bestellung neben der Zahlung mit Gutscheincode, eine Lastschrift mit echter IBAN-Prüfung und Ratenzahlung mit jedem Termin.

Eine von 3 Components in Adresse, Zahlung und Einwilligung

4 Variants

Variants

Zahlungsarten

Karte, Lastschrift, Rechnung und Überweisung als Karten. Die gewählte öffnet ihre eigenen Felder mit weicher Höhe, der Knopf sagt, was passiert, bei der Karte mit Betrag.

Im Studio öffnen

Mit Bestellübersicht

Die Karte neben der Bestellung. Ein Gutscheincode wird zum Chip mit der Ersparnis, die Summe steigt neu ein. Auf Telefon und Tablet faltet sich die Bestellung in eine Leiste mit Summe über der Zahlung.

Im Studio öffnen

Lastschrift

Eine IBAN, die sich in Vierergruppen ordnet, das Land der Bank nennt und einen dünnen Balken bis zur erwarteten Länge füllt. Geprüft mit der echten Prüfsumme, gültig zeichnet sie einen Haken, das Mandat steht in klaren Worten.

Im Studio öffnen

In Raten zahlen

Auf einmal oder in drei oder sechs Raten ohne Aufpreis. Der Plan neben der Wahl zeichnet sich mit jedem Termin und Betrag neu, der Knopf nennt, was heute belastet wird.

Im Studio öffnen

Details

Was Zahlungsformular mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/payment-form.tsx
Bringt mit
utilskitbuttoniconrevealsectionmotionform-flowinputcheckboxcard-input
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 58 eigene Texte

Props

Die Schnittstelle PaymentFormProps aus components/ui/payment-form.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    PaymentFormVariant

    methodssplit-summarydirect-debitinstallments

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • totalPflicht
    number

    Amount to pay

  • currencyoptional
    string

    ISO currency, e.g. EUR or USD

  • methodsoptional
    ('card' | 'debit' | 'invoice' | 'transfer')[]

    Methods to offer in methods

  • linesoptional
    PaymentLine[]

    split-summary: the order lines and shipping

  • shippingoptional
    number
  • promoCodesoptional
    Record<string, number>

    split-summary: codes that work and their discount in percent

  • creditoroptional
    string

    direct-debit: who collects, for the mandate text

  • plansoptional
    number[]

    installments: plans in number of parts, e.g. [1, 3, 6], and the first date

  • startDateoptional
    string
  • submitLabeloptional
    string
  • onSubmitoptional
    (data: Record<string, unknown>) => void

Eingebaute Texte

Diese Texte schreibt Zahlungsformular selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 58 Texte anzeigen
methodEnglisch How would you like to pay?Deutsch Wie möchtest du bezahlen?
cardEnglisch CardDeutsch Karte
cardTextEnglisch Credit or debit card, charged when you orderDeutsch Kredit- oder Debitkarte, belastet bei der Bestellung
debitEnglisch Direct debitDeutsch Lastschrift
debitTextEnglisch From your bank account, about two days after orderingDeutsch Vom Bankkonto, etwa zwei Tage nach der Bestellung
invoiceEnglisch InvoiceDeutsch Rechnung
invoiceTextEnglisch Pay within 14 days, for businessesDeutsch Zahlung innerhalb von 14 Tagen, für Firmen
transferEnglisch Bank transferDeutsch Überweisung
transferTextEnglisch We send the details, the order ships when the money arrivesDeutsch Wir schicken die Daten, die Bestellung geht raus, sobald das Geld da ist
cardNeededEnglisch Please complete the card details, so the payment goes through.Deutsch Bitte vervollständige die Kartendaten, damit die Zahlung durchgeht.
holderEnglisch Account holderDeutsch Kontoinhaber
holderNeededEnglisch Please add the account holder, it must match the bank account.Deutsch Bitte gib den Kontoinhaber an, er muss zum Konto passen.
ibanEnglisch IBANDeutsch IBAN
ibanHintEnglisch Starts with two letters, for example DEDeutsch Beginnt mit zwei Buchstaben, zum Beispiel DE
ibanNeededEnglisch This IBAN does not add up. Please check it against your bank card or app, one digit is often enough.Deutsch Diese IBAN geht nicht auf. Bitte prüf sie mit deiner Bankkarte oder App, oft ist es nur eine Ziffer.
mandateEnglisch I authorise {c} to collect payments from my account by direct debit and instruct my bank to pay them. I can ask for a refund within eight weeks of the debit.Deutsch Ich ermächtige {c}, Zahlungen von meinem Konto per Lastschrift einzuziehen, und weise meine Bank an, sie einzulösen. Ich kann innerhalb von acht Wochen nach der Belastung die Erstattung verlangen.
mandateNeededEnglisch Please agree to the mandate, without it we cannot collect the amount.Deutsch Bitte stimme dem Mandat zu, ohne es können wir den Betrag nicht einziehen.
companyEnglisch CompanyDeutsch Firma
mandateAgreeEnglisch I agree to the direct debit mandateDeutsch Ich erteile das Lastschriftmandat
setupEnglisch Set up direct debitDeutsch Lastschrift einrichten
secureDebitEnglisch Encrypted. Your account details are used only for this mandate.Deutsch Verschlüsselt. Deine Kontodaten nutzen wir nur für dieses Mandat.
mandateShortEnglisch Direct debit mandateDeutsch Lastschriftmandat
companyNeededEnglisch Please add the company, the invoice is made out to it.Deutsch Bitte gib die Firma an, die Rechnung wird auf sie ausgestellt.
vatEnglisch VAT numberDeutsch USt-IdNr.
transferNoteEnglisch You get our bank details and a reference by email. The order is reserved for seven days.Deutsch Bankdaten und Verwendungszweck kommen per E-Mail. Die Bestellung bleibt sieben Tage reserviert.
payEnglisch Pay {a}Deutsch {a} bezahlen
orderEnglisch Place orderDeutsch Bestellung abschicken
secureEnglisch Encrypted. We never see your full card number.Deutsch Verschlüsselt. Deine volle Kartennummer sehen wir nie.
summaryEnglisch Your orderDeutsch Deine Bestellung
showSummaryEnglisch Show orderDeutsch Bestellung zeigen
hideSummaryEnglisch Hide orderDeutsch Bestellung ausblenden
subtotalEnglisch SubtotalDeutsch Zwischensumme
shippingEnglisch ShippingDeutsch Versand
freeEnglisch FreeDeutsch Kostenlos
discountEnglisch Discount {c}Deutsch Rabatt {c}
totalEnglisch TotalDeutsch Gesamt
promoEnglisch Promo codeDeutsch Gutscheincode
applyEnglisch ApplyDeutsch Einlösen
promoBadEnglisch This code does not work. Check the spelling, codes do not care about upper or lower case.Deutsch Dieser Code funktioniert nicht. Prüf die Schreibweise, Groß- und Kleinschreibung ist egal.
promoOkEnglisch {c} applied, you save {a}Deutsch {c} eingelöst, du sparst {a}
removeEnglisch Remove codeDeutsch Code entfernen
countryEnglisch Bank in {c}Deutsch Bank in {c}
planTitleEnglisch How would you like to pay?Deutsch Wie möchtest du zahlen?
nowEnglisch All at onceDeutsch Auf einmal
partsEnglisch In {n} partsDeutsch In {n} Raten
noFeeEnglisch No extra costDeutsch Ohne Aufpreis
nowTextEnglisch One payment todayDeutsch Eine Zahlung heute
monthlyEnglisch a monthDeutsch im Monat
scheduleEnglisch Your planDeutsch Dein Zahlplan
todayEnglisch TodayDeutsch Heute
paidTitleEnglisch Payment receivedDeutsch Zahlung erhalten
paidTextEnglisch A receipt is on its way to your inbox.Deutsch Die Quittung ist unterwegs in dein Postfach.
debitTitleEnglisch Mandate set upDeutsch Mandat eingerichtet
debitDoneEnglisch We collect {a} in about two days. You get a notice before every debit.Deutsch Wir ziehen {a} in etwa zwei Tagen ein. Vor jeder Abbuchung bekommst du eine Nachricht.
invoiceTitleEnglisch Order placed on invoiceDeutsch Auf Rechnung bestellt
invoiceDoneTextEnglisch The invoice comes with the delivery. Please pay within 14 days.Deutsch Die Rechnung kommt mit der Lieferung. Bitte zahl innerhalb von 14 Tagen.
transferTitleEnglisch Order reservedDeutsch Bestellung reserviert
transferDoneTextEnglisch The bank details are on their way by email.Deutsch Die Bankdaten sind per E-Mail unterwegs.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Zahlungsformular reagiert selbst auf die Tasten Enter.
  • Setzt aria-expanded und aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 759 Zeilen
components/ui/payment-form.tsx
'use client';

import { useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { CardInput, type CardValue } from '@/components/ui/card-input';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { DoneMark, ErrorSummary, FlowHead, Unfold, summaryOf } from '@/components/ui/form-flow';

/*
 * Choosing how to pay and paying, without card brand logos: the kit names
 * methods by what they are. Card fields come from card-input, the account
 * number is checked with the real IBAN checksum. Nothing here talks to a
 * payment provider, `onSubmit` hands the choice to yours.
 *
 *   methods        card, direct debit, invoice or bank transfer, each opens its own fields
 *   split-summary  the order beside the payment, a promo code, the summary folds on phones
 *   direct-debit   an IBAN that groups itself as you type, checked, with the mandate in plain words
 *   installments   pay now or in parts, the plan shows every date and amount
 */

export type PaymentFormVariant = 'methods' | 'split-summary' | 'direct-debit' | 'installments';

export interface PaymentLine {
  title: string;
  detail?: string;
  /** Whole currency units, decimals allowed */
  amount: number;
  quantity?: number;
}

export interface PaymentFormProps {
  variant?: PaymentFormVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Amount to pay */
  total: number;
  /** ISO currency, e.g. EUR or USD */
  currency?: string;
  /** Methods to offer in methods */
  methods?: ('card' | 'debit' | 'invoice' | 'transfer')[];
  /** split-summary: the order lines and shipping */
  lines?: PaymentLine[];
  shipping?: number;
  /** split-summary: codes that work and their discount in percent */
  promoCodes?: Record<string, number>;
  /** direct-debit: who collects, for the mandate text */
  creditor?: string;
  /** installments: plans in number of parts, e.g. [1, 3, 6], and the first date */
  plans?: number[];
  startDate?: string;
  submitLabel?: string;
  onSubmit?: (data: Record<string, unknown>) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    method: 'How would you like to pay?',
    card: 'Card',
    cardText: 'Credit or debit card, charged when you order',
    debit: 'Direct debit',
    debitText: 'From your bank account, about two days after ordering',
    invoice: 'Invoice',
    invoiceText: 'Pay within 14 days, for businesses',
    transfer: 'Bank transfer',
    transferText: 'We send the details, the order ships when the money arrives',
    cardNeeded: 'Please complete the card details, so the payment goes through.',
    holder: 'Account holder',
    holderNeeded: 'Please add the account holder, it must match the bank account.',
    iban: 'IBAN',
    ibanHint: 'Starts with two letters, for example DE',
    ibanNeeded: 'This IBAN does not add up. Please check it against your bank card or app, one digit is often enough.',
    mandate: 'I authorise {c} to collect payments from my account by direct debit and instruct my bank to pay them. I can ask for a refund within eight weeks of the debit.',
    mandateNeeded: 'Please agree to the mandate, without it we cannot collect the amount.',
    company: 'Company',
    mandateAgree: 'I agree to the direct debit mandate',
    setup: 'Set up direct debit',
    secureDebit: 'Encrypted. Your account details are used only for this mandate.',
    mandateShort: 'Direct debit mandate',
    companyNeeded: 'Please add the company, the invoice is made out to it.',
    vat: 'VAT number',
    transferNote: 'You get our bank details and a reference by email. The order is reserved for seven days.',
    pay: 'Pay {a}',
    order: 'Place order',
    secure: 'Encrypted. We never see your full card number.',
    summary: 'Your order',
    showSummary: 'Show order',
    hideSummary: 'Hide order',
    subtotal: 'Subtotal',
    shipping: 'Shipping',
    free: 'Free',
    discount: 'Discount {c}',
    total: 'Total',
    promo: 'Promo code',
    apply: 'Apply',
    promoBad: 'This code does not work. Check the spelling, codes do not care about upper or lower case.',
    promoOk: '{c} applied, you save {a}',
    remove: 'Remove code',
    country: 'Bank in {c}',
    planTitle: 'How would you like to pay?',
    now: 'All at once',
    parts: 'In {n} parts',
    noFee: 'No extra cost',
    nowText: 'One payment today',
    monthly: 'a month',
    schedule: 'Your plan',
    today: 'Today',
    paidTitle: 'Payment received',
    paidText: 'A receipt is on its way to your inbox.',
    debitTitle: 'Mandate set up',
    debitDone: 'We collect {a} in about two days. You get a notice before every debit.',
    invoiceTitle: 'Order placed on invoice',
    invoiceDoneText: 'The invoice comes with the delivery. Please pay within 14 days.',
    transferTitle: 'Order reserved',
    transferDoneText: 'The bank details are on their way by email.',
  },
  de: {
    method: 'Wie möchtest du bezahlen?',
    card: 'Karte',
    cardText: 'Kredit- oder Debitkarte, belastet bei der Bestellung',
    debit: 'Lastschrift',
    debitText: 'Vom Bankkonto, etwa zwei Tage nach der Bestellung',
    invoice: 'Rechnung',
    invoiceText: 'Zahlung innerhalb von 14 Tagen, für Firmen',
    transfer: 'Überweisung',
    transferText: 'Wir schicken die Daten, die Bestellung geht raus, sobald das Geld da ist',
    cardNeeded: 'Bitte vervollständige die Kartendaten, damit die Zahlung durchgeht.',
    holder: 'Kontoinhaber',
    holderNeeded: 'Bitte gib den Kontoinhaber an, er muss zum Konto passen.',
    iban: 'IBAN',
    ibanHint: 'Beginnt mit zwei Buchstaben, zum Beispiel DE',
    ibanNeeded: 'Diese IBAN geht nicht auf. Bitte prüf sie mit deiner Bankkarte oder App, oft ist es nur eine Ziffer.',
    mandate: 'Ich ermächtige {c}, Zahlungen von meinem Konto per Lastschrift einzuziehen, und weise meine Bank an, sie einzulösen. Ich kann innerhalb von acht Wochen nach der Belastung die Erstattung verlangen.',
    mandateNeeded: 'Bitte stimme dem Mandat zu, ohne es können wir den Betrag nicht einziehen.',
    company: 'Firma',
    mandateAgree: 'Ich erteile das Lastschriftmandat',
    setup: 'Lastschrift einrichten',
    secureDebit: 'Verschlüsselt. Deine Kontodaten nutzen wir nur für dieses Mandat.',
    mandateShort: 'Lastschriftmandat',
    companyNeeded: 'Bitte gib die Firma an, die Rechnung wird auf sie ausgestellt.',
    vat: 'USt-IdNr.',
    transferNote: 'Bankdaten und Verwendungszweck kommen per E-Mail. Die Bestellung bleibt sieben Tage reserviert.',
    pay: '{a} bezahlen',
    order: 'Bestellung abschicken',
    secure: 'Verschlüsselt. Deine volle Kartennummer sehen wir nie.',
    summary: 'Deine Bestellung',
    showSummary: 'Bestellung zeigen',
    hideSummary: 'Bestellung ausblenden',
    subtotal: 'Zwischensumme',
    shipping: 'Versand',
    free: 'Kostenlos',
    discount: 'Rabatt {c}',
    total: 'Gesamt',
    promo: 'Gutscheincode',
    apply: 'Einlösen',
    promoBad: 'Dieser Code funktioniert nicht. Prüf die Schreibweise, Groß- und Kleinschreibung ist egal.',
    promoOk: '{c} eingelöst, du sparst {a}',
    remove: 'Code entfernen',
    country: 'Bank in {c}',
    planTitle: 'Wie möchtest du zahlen?',
    now: 'Auf einmal',
    parts: 'In {n} Raten',
    noFee: 'Ohne Aufpreis',
    nowText: 'Eine Zahlung heute',
    monthly: 'im Monat',
    schedule: 'Dein Zahlplan',
    today: 'Heute',
    paidTitle: 'Zahlung erhalten',
    paidText: 'Die Quittung ist unterwegs in dein Postfach.',
    debitTitle: 'Mandat eingerichtet',
    debitDone: 'Wir ziehen {a} in etwa zwei Tagen ein. Vor jeder Abbuchung bekommst du eine Nachricht.',
    invoiceTitle: 'Auf Rechnung bestellt',
    invoiceDoneText: 'Die Rechnung kommt mit der Lieferung. Bitte zahl innerhalb von 14 Tagen.',
    transferTitle: 'Bestellung reserviert',
    transferDoneText: 'Die Bankdaten sind per E-Mail unterwegs.',
  },
};

type Texts = (typeof TEXTS)['en'];

function useMoney(currency = 'EUR') {
  const locale = useLocale();
  const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency });
  return (n: number) => nf.format(round(n, 2));
}

/* ------------------------------------------------------------------ */
/* IBAN                                                                */
/* ------------------------------------------------------------------ */

const IBAN_LENGTH: Record<string, number> = { DE: 22, AT: 20, CH: 21, NL: 18, FR: 27, BE: 16, IT: 27, ES: 24, LU: 20, GB: 22 };
const IBAN_COUNTRY: Record<string, { en: string; de: string }> = {
  DE: { en: 'Germany', de: 'Deutschland' },
  AT: { en: 'Austria', de: 'Österreich' },
  CH: { en: 'Switzerland', de: 'Schweiz' },
  NL: { en: 'Netherlands', de: 'Niederlande' },
  FR: { en: 'France', de: 'Frankreich' },
  BE: { en: 'Belgium', de: 'Belgien' },
  IT: { en: 'Italy', de: 'Italien' },
  ES: { en: 'Spain', de: 'Spanien' },
  LU: { en: 'Luxembourg', de: 'Luxemburg' },
  GB: { en: 'United Kingdom', de: 'Vereinigtes Königreich' },
};

const ibanClean = (v: string) => v.replace(/\s+/g, '').toUpperCase();
const ibanGroup = (v: string) => ibanClean(v).replace(/(.{4})/g, '$1 ').trim();

/** The ISO 13616 check: move the first four characters to the end, letters to numbers, mod 97 must be 1 */
export function ibanValid(v: string) {
  const s = ibanClean(v);
  if (!/^[A-Z]{2}\d{2}[A-Z0-9]{8,30}$/.test(s)) return false;
  const len = IBAN_LENGTH[s.slice(0, 2)];
  if (len && s.length !== len) return false;
  const moved = s.slice(4) + s.slice(0, 4);
  let rest = 0;
  for (const ch of moved) {
    const n = ch >= 'A' ? String(ch.charCodeAt(0) - 55) : ch;
    for (const d of n) rest = (rest * 10 + Number(d)) % 97;
  }
  return rest === 1;
}

function IbanField({ id, t, value, set, error }: { id: string; t: Texts; value: string; set: (v: string) => void; error?: string }) {
  const locale = useLocale();
  const clean = ibanClean(value);
  const country = IBAN_COUNTRY[clean.slice(0, 2)];
  const len = IBAN_LENGTH[clean.slice(0, 2)];
  const ok = ibanValid(value);
  return (
    <div>
      <Input
        id={id}
        label={t.iban}
        required
        autoComplete="off"
        spellCheck={false}
        inputMode="text"
        placeholder="DE89 3704 0044 0532 0130 00"
        hint={error ? undefined : country ? t.country.replace('{c}', country[locale]) : t.ibanHint}
        value={value}
        onChange={(e) => set(ibanGroup(e.target.value).slice(0, 42))}
        success={ok}
        error={error}
        size="lg"
        inputClassName="tabular-nums max-sm:text-[15px] sm:tracking-[0.04em]"
      />
      {/* A thin bar fills to the length this country expects */}
      {len && !ok && (
        <div aria-hidden className="mt-2 h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]">
          <div className="h-full origin-left rounded-full bg-primary transition-transform duration-300" style={{ transform: `scaleX(${round(Math.min(1, clean.length / len), 3)})` }} />
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Shared                                                              */
/* ------------------------------------------------------------------ */

function Paid({ title, text }: { title: string; text: string }) {
  return (
    <div role="status" className="kit-rise flex flex-col items-center py-6 text-center">
      <DoneMark size={60} />
      <p className="mt-5 text-[21px] font-semibold tracking-[-0.02em] text-foreground">{title}</p>
      <p className="mt-2 max-w-[26rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{text}</p>
    </div>
  );
}

function Secure({ t, debit }: { t: Texts; debit?: boolean }) {
  return (
    <p className="text-balance text-center text-[12.5px] leading-snug text-muted-foreground">
      <Icon name="lock" size={13} className="-mt-0.5 me-1.5 inline-block align-middle" />
      {debit ? t.secureDebit : t.secure}
    </p>
  );
}

/* ------------------------------------------------------------------ */
/* methods                                                             */
/* ------------------------------------------------------------------ */

type Method = 'card' | 'debit' | 'invoice' | 'transfer';
const METHOD_ICON: Record<Method, IconName> = { card: 'credit-card', debit: 'landmark', invoice: 'receipt', transfer: 'banknote' };

function Methods({ p, t }: { p: PaymentFormProps; t: Texts }) {
  const id = useId();
  const money = useMoney(p.currency);
  const methods = p.methods ?? ['card', 'debit', 'invoice', 'transfer'];
  const [method, setMethod] = useState<Method>(methods[0]);
  const [card, setCard] = useState<CardValue | null>(null);
  const [holder, setHolder] = useState('');
  const [iban, setIban] = useState('');
  const [mandate, setMandate] = useState(false);
  const [company, setCompany] = useState('');
  const [vat, setVat] = useState('');
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [done, setDone] = useState(false);
  const summaryRef = useRef<HTMLDivElement>(null);
  const clear = (k: string) => errors[k] && setErrors((e) => Object.fromEntries(Object.entries(e).filter(([x]) => x !== k)));
  const labels: Record<Method, [string, string]> = { card: [t.card, t.cardText], debit: [t.debit, t.debitText], invoice: [t.invoice, t.invoiceText], transfer: [t.transfer, t.transferText] };
  const summary = summaryOf(errors, [
    ['card', `${id}-card`, t.card],
    ['holder', `${id}-holder`, t.holder],
    ['iban', `${id}-iban`, t.iban],
    ['mandate', `${id}-mandate`, t.mandateShort],
    ['company', `${id}-company`, t.company],
  ]);

  if (done) {
    const [title, text] = method === 'card' ? [t.paidTitle, t.paidText] : method === 'debit' ? [t.debitTitle, t.debitDone.replace('{a}', money(p.total))] : method === 'invoice' ? [t.invoiceTitle, t.invoiceDoneText] : [t.transferTitle, t.transferDoneText];
    return (
      <div className="kit-card mx-auto max-w-[40rem] p-6 sm:p-10">
        <Paid title={title} text={text} />
      </div>
    );
  }
  return (
    <Reveal effect="rise" className="mx-auto max-w-[40rem]">
      <form
        noValidate
        className="kit-card grid gap-5 p-6 sm:p-8"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (method === 'card' && !card?.complete) found.card = t.cardNeeded;
          if (method === 'debit') {
            if (!holder.trim()) found.holder = t.holderNeeded;
            if (!ibanValid(iban)) found.iban = t.ibanNeeded;
            if (!mandate) found.mandate = t.mandateNeeded;
          }
          if (method === 'invoice' && !company.trim()) found.company = t.companyNeeded;
          setErrors(found);
          if (Object.keys(found).length) {
            requestAnimationFrame(() => summaryRef.current?.focus());
            return;
          }
          p.onSubmit?.({ method, card, holder, iban: ibanClean(iban), company, vat });
          setDone(true);
        }}
      >
        <ErrorSummary ref={summaryRef} errors={summary} />
        <fieldset className="min-w-0">
          <legend className="mb-4 text-[15px] font-semibold text-foreground">{t.method}</legend>
          {/* Each method is a card that opens its own fields with a soft height */}
          <Stagger step={70} as="div" className="grid gap-2.5">
            {methods.map((m) => {
              const on = method === m;
              return (
                <div key={m} className={cn('overflow-hidden rounded-xl border transition-[border-color,box-shadow,background-color] duration-300', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-card shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_45%,var(--border))]' : 'border-border bg-card hover:border-border-strong')}>
                  <label className="flex cursor-pointer items-center gap-3.5 p-4 has-[:focus-visible]:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)]">
                    <input type="radio" name={`${id}-m`} checked={on} onChange={() => (setMethod(m), setErrors({}))} className="sr-only" />
                    <span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
                      <span className={cn('size-2 rounded-full bg-primary-foreground transition-[scale] duration-300 ease-[cubic-bezier(0.34,1.7,0.64,1)]', on ? 'scale-100' : 'scale-0')} />
                    </span>
                    <span className="min-w-0 flex-1">
                      <span className="block text-[15px] font-medium text-foreground">{labels[m][0]}</span>
                      <span className="block text-[13px] leading-snug text-muted-foreground">{labels[m][1]}</span>
                    </span>
                    <Icon name={METHOD_ICON[m]} size={20} className={cn('shrink-0 transition-colors', on ? 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-muted-foreground')} />
                  </label>
                  <Unfold open={on}>
                    <div className="border-t border-border bg-surface p-4 sm:p-5">
                      {m === 'card' && (
                        <div id={`${id}-card`} tabIndex={-1} className="outline-none">
                          <CardInput variant="stacked" withName onValueChange={(v) => (setCard(v), clear('card'))} error={errors.card} />
                        </div>
                      )}
                      {m === 'debit' && (
                        <div className="grid gap-4">
                          <Input id={`${id}-holder`} label={t.holder} required autoComplete="name" value={holder} onChange={(e) => (setHolder(e.target.value), clear('holder'))} error={errors.holder} />
                          <IbanField id={`${id}-iban`} t={t} value={iban} set={(v) => (setIban(v), clear('iban'))} error={errors.iban} />
                          <Checkbox id={`${id}-mandate`} label={t.mandateAgree} description={t.mandate.replace('{c}', p.creditor ?? '')} checked={mandate} onChange={(e) => (setMandate(e.target.checked), clear('mandate'))} error={errors.mandate} />
                        </div>
                      )}
                      {m === 'invoice' && (
                        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
                          <Input id={`${id}-company`} label={t.company} required autoComplete="organization" value={company} onChange={(e) => (setCompany(e.target.value), clear('company'))} error={errors.company} />
                          <Input label={t.vat} optional value={vat} onChange={(e) => setVat(e.target.value)} />
                        </div>
                      )}
                      {m === 'transfer' && <p className="text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{t.transferNote}</p>}
                    </div>
                  </Unfold>
                </div>
              );
            })}
          </Stagger>
        </fieldset>
        <Button type="submit" size="lg" icon={method === 'card' ? 'lock' : 'arrow-right'} className="w-full">
          {p.submitLabel ?? (method === 'card' ? t.pay.replace('{a}', money(p.total)) : t.order)}
        </Button>
        <Secure t={t} debit={method !== 'card'} />
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* split-summary                                                       */
/* ------------------------------------------------------------------ */

function SplitSummary({ p, t }: { p: PaymentFormProps; t: Texts }) {
  const id = useId();
  const money = useMoney(p.currency);
  const lines = p.lines ?? [];
  const [card, setCard] = useState<CardValue | null>(null);
  const [error, setError] = useState('');
  const [code, setCode] = useState('');
  const [applied, setApplied] = useState<string | null>(null);
  const [codeError, setCodeError] = useState('');
  const [open, setOpen] = useState(false);
  const [done, setDone] = useState(false);
  const subtotal = lines.reduce((s, l) => s + l.amount * (l.quantity ?? 1), 0);
  const percent = applied ? (p.promoCodes?.[applied] ?? 0) : 0;
  const discount = round((subtotal * percent) / 100, 2);
  const total = subtotal - discount + (p.shipping ?? 0);
  const tryCode = () => {
    const key = Object.keys(p.promoCodes ?? {}).find((k) => k.toLowerCase() === code.trim().toLowerCase());
    if (!key) {
      setCodeError(t.promoBad);
      return;
    }
    setApplied(key);
    setCodeError('');
    setCode('');
  };
  const summary = (
    <div>
      <ul className="grid gap-3">
        {lines.map((l) => (
          <li key={l.title} className="flex items-start gap-3">
            <span className="relative grid size-12 shrink-0 place-items-center rounded-lg border border-border bg-surface-2 text-muted-foreground">
              <Icon name="package" size={18} />
              {(l.quantity ?? 1) > 1 && <span className="absolute -end-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-foreground text-[11px] font-semibold text-background">{l.quantity}</span>}
            </span>
            <span className="min-w-0 flex-1">
              <span className="block text-[14px] font-medium text-foreground">{l.title}</span>
              {l.detail && <span className="block text-[12.5px] text-muted-foreground">{l.detail}</span>}
            </span>
            <span className="text-[14px] tabular-nums text-foreground">{money(l.amount * (l.quantity ?? 1))}</span>
          </li>
        ))}
      </ul>
      {/* Promo code: applied codes become a chip with a way to remove */}
      <div className="mt-5 border-t border-border pt-5">
        {applied ? (
          <p className="kit-rise flex items-center justify-between gap-3 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] px-3 py-2 text-[13.5px] text-success-foreground">
            <span className="flex items-center gap-2">
              <Icon name="tag" size={15} />
              {t.promoOk.replace('{c}', applied).replace('{a}', money(discount))}
            </span>
            <button type="button" aria-label={t.remove} onClick={() => setApplied(null)} className="grid size-9 max-sm:size-11 place-items-center rounded-md text-muted-foreground hover:text-foreground focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
              <Icon name="x" size={15} />
            </button>
          </p>
        ) : (
          <div className="flex items-start gap-2">
            <Input
              aria-label={t.promo}
              placeholder={t.promo}
              value={code}
              onChange={(e) => (setCode(e.target.value), setCodeError(''))}
              onKeyDown={(e) => {
                if (e.key === 'Enter') {
                  e.preventDefault();
                  tryCode();
                }
              }}
              error={codeError || undefined}
              className="min-w-0 flex-1"
            />
            <Button type="button" variant="outline" onClick={tryCode}>
              {t.apply}
            </Button>
          </div>
        )}
      </div>
      <dl className="mt-5 grid gap-2 border-t border-border pt-5 text-[14px]">
        <div className="flex justify-between">
          <dt className="text-muted-foreground">{t.subtotal}</dt>
          <dd className="tabular-nums text-foreground">{money(subtotal)}</dd>
        </div>
        {discount > 0 && (
          <div className="kit-rise flex justify-between">
            <dt className="text-muted-foreground">{t.discount.replace('{c}', applied ?? '')}</dt>
            <dd className="tabular-nums text-success-foreground">−{money(discount).replace('-', '')}</dd>
          </div>
        )}
        <div className="flex justify-between">
          <dt className="text-muted-foreground">{t.shipping}</dt>
          <dd className="tabular-nums text-foreground">{p.shipping ? money(p.shipping) : t.free}</dd>
        </div>
        <div className="mt-2 flex items-baseline justify-between border-t border-border pt-3">
          <dt className="text-[15px] font-semibold text-foreground">{t.total}</dt>
          <dd key={total} className="text-[20px] font-semibold tabular-nums tracking-[-0.02em] text-foreground motion-safe:animate-[kit-rise_0.4s_var(--ease-kit)]">
            {money(total)}
          </dd>
        </div>
      </dl>
    </div>
  );
  if (done)
    return (
      <div className="kit-card mx-auto max-w-[40rem] p-6 sm:p-10">
        <Paid title={t.paidTitle} text={t.paidText} />
      </div>
    );
  return (
    <div className="mx-auto grid max-w-[62rem] grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start lg:gap-10">
      {/* Phones and tablets: the order folds into a bar with the total */}
      <div className="kit-card overflow-hidden lg:hidden">
        <button type="button" aria-expanded={open} onClick={() => setOpen((o) => !o)} className="flex min-h-14 w-full items-center gap-3 px-5 text-start focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
          <Icon name="bag" size={18} className="text-muted-foreground" />
          <span className="flex-1 text-[14.5px] font-medium text-foreground">{open ? t.hideSummary : t.showSummary}</span>
          <span className="text-[15px] font-semibold tabular-nums text-foreground">{money(total)}</span>
          <Icon name="chevron-down" size={17} className={cn('text-muted-foreground transition-transform duration-300', open && 'rotate-180')} />
        </button>
        <Unfold open={open}>
          <div className="border-t border-border p-5">{summary}</div>
        </Unfold>
      </div>
      <Reveal effect="rise" className="min-w-0 lg:order-none">
        <form
          noValidate
          className="kit-card grid gap-5 p-6 sm:p-8"
          onSubmit={(e) => {
            e.preventDefault();
            if (!card?.complete) {
              setError(t.cardNeeded);
              document.getElementById(`${id}-card`)?.querySelector<HTMLElement>('input')?.focus();
              return;
            }
            p.onSubmit?.({ card, code: applied, total });
            setDone(true);
          }}
        >
          <p className="flex items-center gap-2.5 text-[15px] font-semibold text-foreground">
            <Icon name="credit-card" size={18} className="text-muted-foreground" />
            {t.card}
          </p>
          <div id={`${id}-card`}>
            <CardInput variant="preview" withName onValueChange={(v) => (setCard(v), setError(''))} error={error || undefined} />
          </div>
          <Button type="submit" size="lg" icon="lock" magnetic className="w-full">
            {p.submitLabel ?? t.pay.replace('{a}', money(total))}
          </Button>
          <Secure t={t} />
        </form>
      </Reveal>
      <Reveal effect="rise" delay={120} className="hidden lg:sticky lg:top-24 lg:block">
        <aside aria-label={t.summary} className="kit-card p-6">
          <p className="mb-5 text-[15px] font-semibold text-foreground">{t.summary}</p>
          {summary}
        </aside>
      </Reveal>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* direct-debit                                                        */
/* ------------------------------------------------------------------ */

function DirectDebit({ p, t }: { p: PaymentFormProps; t: Texts }) {
  const id = useId();
  const money = useMoney(p.currency);
  const [holder, setHolder] = useState('');
  const [iban, setIban] = useState('');
  const [mandate, setMandate] = useState(false);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [done, setDone] = useState(false);
  const summaryRef = useRef<HTMLDivElement>(null);
  const clear = (k: string) => errors[k] && setErrors((e) => Object.fromEntries(Object.entries(e).filter(([x]) => x !== k)));
  const summary = summaryOf(errors, [
    ['holder', `${id}-holder`, t.holder],
    ['iban', `${id}-iban`, t.iban],
    ['mandate', `${id}-mandate`, t.mandateShort],
  ]);
  if (done)
    return (
      <div className="kit-card mx-auto max-w-[38rem] p-6 sm:p-10">
        <Paid title={t.debitTitle} text={t.debitDone.replace('{a}', money(p.total))} />
      </div>
    );
  return (
    <Reveal effect="rise" className="mx-auto max-w-[38rem]">
      <form
        noValidate
        className="kit-card overflow-hidden"
        onSubmit={(e) => {
          e.preventDefault();
          const found: Record<string, string> = {};
          if (!holder.trim()) found.holder = t.holderNeeded;
          if (!ibanValid(iban)) found.iban = t.ibanNeeded;
          if (!mandate) found.mandate = t.mandateNeeded;
          setErrors(found);
          if (Object.keys(found).length) {
            requestAnimationFrame(() => summaryRef.current?.focus());
            return;
          }
          p.onSubmit?.({ holder, iban: ibanClean(iban) });
          setDone(true);
        }}
      >
        <div className="flex items-center justify-between gap-4 border-b border-border bg-surface px-6 py-4 sm:px-8">
          <span className="flex items-center gap-2.5 text-[14px] text-muted-foreground">
            <Icon name="landmark" size={17} />
            {t.debit}
          </span>
          <span className="text-[17px] font-semibold tabular-nums text-foreground">{money(p.total)}</span>
        </div>
        <div className="grid gap-5 p-6 sm:p-8">
          <ErrorSummary ref={summaryRef} errors={summary} />
          <Input id={`${id}-holder`} label={t.holder} required autoComplete="name" value={holder} onChange={(e) => (setHolder(e.target.value), clear('holder'))} error={errors.holder} />
          <IbanField id={`${id}-iban`} t={t} value={iban} set={(v) => (setIban(v), clear('iban'))} error={errors.iban} />
          {/* The mandate in plain words, in a quiet box */}
          <div className={cn('rounded-xl border p-4 transition-colors', errors.mandate ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-border bg-surface')}>
            <Checkbox id={`${id}-mandate`} label={t.mandateAgree} description={t.mandate.replace('{c}', p.creditor ?? '')} checked={mandate} onChange={(e) => (setMandate(e.target.checked), clear('mandate'))} error={errors.mandate} />
          </div>
          <Button type="submit" size="lg" icon="check" className="w-full">
            {p.submitLabel ?? t.setup}
          </Button>
          <Secure t={t} debit />
        </div>
      </form>
    </Reveal>
  );
}

/* ------------------------------------------------------------------ */
/* installments                                                        */
/* ------------------------------------------------------------------ */

function Installments({ p, t }: { p: PaymentFormProps; t: Texts }) {
  const id = useId();
  const locale = useLocale();
  const money = useMoney(p.currency);
  const plans = p.plans ?? [1, 3, 6];
  const [plan, setPlan] = useState(plans[0]);
  const [card, setCard] = useState<CardValue | null>(null);
  const [error, setError] = useState('');
  const [done, setDone] = useState(false);
  const start = p.startDate ?? '2026-10-21';
  const part = round(p.total / plan, 2);
  const dates = Array.from({ length: plan }, (_, i) => {
    const [y, m, d] = start.split('-').map(Number);
    const date = new Date(Date.UTC(y, m - 1 + i, d));
    return i === 0 ? t.today : date.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
  });
  if (done)
    return (
      <div className="kit-card mx-auto max-w-[40rem] p-6 sm:p-10">
        <Paid title={t.paidTitle} text={t.paidText} />
      </div>
    );
  return (
    <div className="mx-auto grid max-w-[60rem] grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-8">
      <Reveal effect="rise" className="kit-card p-6 sm:p-8">
        <fieldset className="min-w-0">
          <legend className="mb-4 text-[15px] font-semibold text-foreground">{t.planTitle}</legend>
          <Stagger step={80} as="div" className="grid gap-2.5">
            {plans.map((n) => {
              const on = plan === n;
              return (
                <label key={n} className={cn('flex cursor-pointer items-center gap-4 rounded-xl border bg-card p-4 transition-[border-color,box-shadow] duration-300 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)]', on ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_50%,var(--border))]' : 'border-border hover:border-border-strong')}>
                  <input type="radio" name={`${id}-plan`} checked={on} onChange={() => setPlan(n)} className="sr-only" />
                  <span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-[color-mix(in_oklab,var(--foreground)_38%,var(--border-strong))]')}>
                    <span className={cn('size-2 rounded-full bg-primary-foreground transition-[scale] duration-300', on ? 'scale-100' : 'scale-0')} />
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="block text-[15px] font-medium text-foreground">{n === 1 ? t.now : t.parts.replace('{n}', String(n))}</span>
                    <span className="block text-[13px] text-muted-foreground">{n === 1 ? t.nowText : t.noFee}</span>
                  </span>
                  <span className="shrink-0 text-end tabular-nums">
                    <span className="block text-[15px] font-semibold text-foreground">{money(n === 1 ? p.total : round(p.total / n, 2))}</span>
                    {n > 1 && <span className="block text-[12px] text-muted-foreground">{t.monthly}</span>}
                  </span>
                </label>
              );
            })}
          </Stagger>
        </fieldset>
        {/* The plan as a rail of dates, it redraws for every choice */}
        <div className="mt-7 border-t border-border pt-6">
          <p className="text-[13px] font-medium text-muted-foreground">{t.schedule}</p>
          <ol key={plan} className="relative mt-4 grid gap-0 ps-6">
            {plan > 1 && <span aria-hidden className="absolute bottom-3 start-[7px] top-3 w-px bg-border" />}
            {dates.map((d, i) => (
              <li key={d} className="kit-rise relative flex items-baseline justify-between gap-3 py-1.5 text-[14px]" style={{ '--kit-delay': `${i * 60}ms` } as React.CSSProperties}>
                <span aria-hidden className={cn('absolute -start-6 top-[11px] size-[15px] rounded-full border-2', i === 0 ? 'border-primary bg-primary' : 'border-border-strong bg-card')} />
                <span className={i === 0 ? 'font-medium text-foreground' : 'text-muted-foreground'}>{d}</span>
                <span className="tabular-nums text-foreground">{money(part)}</span>
              </li>
            ))}
          </ol>
        </div>
      </Reveal>
      <Reveal effect="rise" delay={100}>
        <form
          noValidate
          className="kit-card grid gap-5 p-6 sm:p-8"
          onSubmit={(e) => {
            e.preventDefault();
            if (!card?.complete) {
              setError(t.cardNeeded);
              const form = e.currentTarget;
              requestAnimationFrame(() => form.querySelector<HTMLElement>('input[autocomplete="cc-number"]')?.focus());
              return;
            }
            p.onSubmit?.({ plan, card });
            setDone(true);
          }}
        >
          <p className="flex items-center gap-2.5 text-[15px] font-semibold text-foreground">
            <Icon name="credit-card" size={18} className="text-muted-foreground" />
            {t.card}
          </p>
          <CardInput variant="stacked" withName onValueChange={(v) => (setCard(v), setError(''))} error={error || undefined} />
          <Button type="submit" size="lg" icon="lock" className="w-full">
            {p.submitLabel ?? t.pay.replace('{a}', money(part))}
          </Button>
          <Secure t={t} />
        </form>
      </Reveal>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Section                                                             */
/* ------------------------------------------------------------------ */

/**
 * Choosing how to pay and paying, without card brand logos.
 *   <PaymentForm variant="methods" total={89} currency="EUR" creditor="Northwind GmbH" onSubmit={(data) => …} />
 */
export function PaymentForm(props: PaymentFormProps) {
  const { variant = 'methods', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  return (
    <Section className={className}>
      <FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
      {variant === 'methods' && <Methods p={props} t={t} />}
      {variant === 'split-summary' && <SplitSummary p={props} t={t} />}
      {variant === 'direct-debit' && <DirectDebit p={props} t={t} />}
      {variant === 'installments' && <Installments p={props} t={t} />}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Zahlungsformular?

Mit aiw add @ai-website/payment-form. Der Befehl legt die Datei components/ui/payment-form.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, motion, form-flow, input, checkbox und card-input. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Zahlungsformular?

4 Varianten, Zahlungsarten, Mit Bestellübersicht, Lastschrift und In Raten zahlen. Du wählst sie mit der Prop variant, zum Beispiel variant="split-summary".

Kann ich die Texte von Zahlungsformular übersetzen?

Ja. Zahlungsformular bringt 58 eigene Texte auf Deutsch und Englisch mit, etwa „Wie möchtest du bezahlen?“, „Karte“ und „Kredit- oder Debitkarte, belastet bei der Bestellung“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Zahlungsformular einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Zahlungsformular mit aiw_components und installieren sie mit aiw_add @ai-website/payment-form. Nenn im Prompt die Variante, etwa variant="methods", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Zahlungsformular ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden PaymentForm-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert