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
Variants
Details
Was Zahlungsformular mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Adresse, Zahlung und Einwilligung
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle PaymentFormProps aus components/ui/payment-form.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalPaymentFormVariantmethodssplit-summarydirect-debitinstallments
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodetotalPflichtnumberAmount to pay
currencyoptionalstringISO currency, e.g. EUR or USD
methodsoptional('card' | 'debit' | 'invoice' | 'transfer')[]Methods to offer in methods
linesoptionalPaymentLine[]split-summary: the order lines and shipping
shippingoptionalnumberpromoCodesoptionalRecord<string, number>split-summary: codes that work and their discount in percent
creditoroptionalstringdirect-debit: who collects, for the mandate text
plansoptionalnumber[]installments: plans in number of parts, e.g. [1, 3, 6], and the first date
startDateoptionalstringsubmitLabeloptionalstringonSubmitoptional(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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| method | Englisch How would you like to pay? | Deutsch Wie möchtest du bezahlen? |
| card | Englisch Card | Deutsch Karte |
| cardText | Englisch Credit or debit card, charged when you order | Deutsch Kredit- oder Debitkarte, belastet bei der Bestellung |
| debit | Englisch Direct debit | Deutsch Lastschrift |
| debitText | Englisch From your bank account, about two days after ordering | Deutsch Vom Bankkonto, etwa zwei Tage nach der Bestellung |
| invoice | Englisch Invoice | Deutsch Rechnung |
| invoiceText | Englisch Pay within 14 days, for businesses | Deutsch Zahlung innerhalb von 14 Tagen, für Firmen |
| transfer | Englisch Bank transfer | Deutsch Überweisung |
| transferText | Englisch We send the details, the order ships when the money arrives | Deutsch Wir schicken die Daten, die Bestellung geht raus, sobald das Geld da ist |
| cardNeeded | Englisch Please complete the card details, so the payment goes through. | Deutsch Bitte vervollständige die Kartendaten, damit die Zahlung durchgeht. |
| holder | Englisch Account holder | Deutsch Kontoinhaber |
| holderNeeded | Englisch Please add the account holder, it must match the bank account. | Deutsch Bitte gib den Kontoinhaber an, er muss zum Konto passen. |
| iban | Englisch IBAN | Deutsch IBAN |
| ibanHint | Englisch Starts with two letters, for example DE | Deutsch Beginnt mit zwei Buchstaben, zum Beispiel DE |
| ibanNeeded | Englisch 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. |
| mandate | Englisch 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. |
| mandateNeeded | Englisch 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. |
| company | Englisch Company | Deutsch Firma |
| mandateAgree | Englisch I agree to the direct debit mandate | Deutsch Ich erteile das Lastschriftmandat |
| setup | Englisch Set up direct debit | Deutsch Lastschrift einrichten |
| secureDebit | Englisch Encrypted. Your account details are used only for this mandate. | Deutsch Verschlüsselt. Deine Kontodaten nutzen wir nur für dieses Mandat. |
| mandateShort | Englisch Direct debit mandate | Deutsch Lastschriftmandat |
| companyNeeded | Englisch Please add the company, the invoice is made out to it. | Deutsch Bitte gib die Firma an, die Rechnung wird auf sie ausgestellt. |
| vat | Englisch VAT number | Deutsch USt-IdNr. |
| transferNote | Englisch 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. |
| pay | Englisch Pay {a} | Deutsch {a} bezahlen |
| order | Englisch Place order | Deutsch Bestellung abschicken |
| secure | Englisch Encrypted. We never see your full card number. | Deutsch Verschlüsselt. Deine volle Kartennummer sehen wir nie. |
| summary | Englisch Your order | Deutsch Deine Bestellung |
| showSummary | Englisch Show order | Deutsch Bestellung zeigen |
| hideSummary | Englisch Hide order | Deutsch Bestellung ausblenden |
| subtotal | Englisch Subtotal | Deutsch Zwischensumme |
| shipping | Englisch Shipping | Deutsch Versand |
| free | Englisch Free | Deutsch Kostenlos |
| discount | Englisch Discount {c} | Deutsch Rabatt {c} |
| total | Englisch Total | Deutsch Gesamt |
| promo | Englisch Promo code | Deutsch Gutscheincode |
| apply | Englisch Apply | Deutsch Einlösen |
| promoBad | Englisch 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. |
| promoOk | Englisch {c} applied, you save {a} | Deutsch {c} eingelöst, du sparst {a} |
| remove | Englisch Remove code | Deutsch Code entfernen |
| country | Englisch Bank in {c} | Deutsch Bank in {c} |
| planTitle | Englisch How would you like to pay? | Deutsch Wie möchtest du zahlen? |
| now | Englisch All at once | Deutsch Auf einmal |
| parts | Englisch In {n} parts | Deutsch In {n} Raten |
| noFee | Englisch No extra cost | Deutsch Ohne Aufpreis |
| nowText | Englisch One payment today | Deutsch Eine Zahlung heute |
| monthly | Englisch a month | Deutsch im Monat |
| schedule | Englisch Your plan | Deutsch Dein Zahlplan |
| today | Englisch Today | Deutsch Heute |
| paidTitle | Englisch Payment received | Deutsch Zahlung erhalten |
| paidText | Englisch A receipt is on its way to your inbox. | Deutsch Die Quittung ist unterwegs in dein Postfach. |
| debitTitle | Englisch Mandate set up | Deutsch Mandat eingerichtet |
| debitDone | Englisch 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. |
| invoiceTitle | Englisch Order placed on invoice | Deutsch Auf Rechnung bestellt |
| invoiceDoneText | Englisch The invoice comes with the delivery. Please pay within 14 days. | Deutsch Die Rechnung kommt mit der Lieferung. Bitte zahl innerhalb von 14 Tagen. |
| transferTitle | Englisch Order reserved | Deutsch Bestellung reserviert |
| transferDoneText | Englisch 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
'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.