Checkout Seiten für React-Shops
Von einer Seite bis Schritt für Schritt, Express-Kasse und die Bestätigung danach.
Eine von 2 Components in Cart und Checkout
Vom Warenkorb bis zur Bestätigung
Im Checkout verlieren Shops die meisten Käufer, jedes Feld und jede Überraschung zählt. Diese Checkout-Layouts halten die Bestellübersicht im Blick, formatieren Preise und Summen in der Sprache der Seite und prüfen Adress- und Zahlungsfelder schon beim Ausfüllen.
„Eine Seite“ zeigt Kontakt, Lieferung und Zahlung auf einer Seite, der schnellste Weg für kleine Bestellungen. „Zweispaltig“ stellt am Desktop das Formular neben die Übersicht und faltet die Übersicht am Handy in einen Umschalter. „In Schritten“ teilt Adresse, Versand und Zahlung für größere Bestellungen. Express bietet zuerst Wallet-Zahlungen und darunter das Formular, die Bestätigung dankt und sagt, was als Nächstes passiert.
Zeig den vollen Preis samt Versand vor dem Zahlungsschritt, späte Kosten sind der häufigste Abbruchgrund. Erlaube die Bestellung als Gast. Die Components liefern die Oberfläche, die Zahlung selbst läuft über deinen Anbieter, und die Kartenfelder des Kits senden von sich aus nirgendwohin.
Variants
Details
Was Kasse mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Cart und Checkout
- Bereich
- E-Commerce
- Variants
- 5
- Version
- 1.0.1 · seit 1.0.0
- Datei
- components/ui/checkout.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 68 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- Online-Shop
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CheckoutProps aus components/ui/checkout.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalCheckoutVariantone-pagetwo-columnmulti-stepexpressconfirmation
itemsPflichtLineItem[]shippingOptionsoptionalShippingOption[]paymentOptionsoptionalPaymentOption[]expressOptionsoptional{ label: string; icon: IconName }[]express: neutral express buttons, text only
valuesoptionalCheckoutValuesPrefilled fields, e.g. for a signed-in customer
countriesoptionalstring[]initialStepoptionalnumbermulti-step: start on this step (0 to 3)
orderoptionalOrderConfirmationconfirmation
storeNameoptionalReact.ReactNodefreeShippingFromoptionalnumbervatRateoptionalnumbercurrencyoptionalstringlegaloptional{ terms: string; withdrawal: string; privacy: string }Links in the legal line under the order button
onSubmitoptional(data: Record<string, string>) => void
Eingebaute Texte
Diese Texte schreibt Kasse selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 68 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| checkout | Englisch Checkout | Deutsch Kasse |
| secure | Englisch Secure checkout | Deutsch Sichere Kasse |
| contact | Englisch Contact | Deutsch Kontakt |
| Englisch Email | Deutsch E-Mail | |
| emailHint | Englisch For your order confirmation and tracking link. | Deutsch Für deine Bestellbestätigung und den Link zur Sendung. |
| newsletter | Englisch Email me about new pieces and restocks | Deutsch Schreib mir bei neuen Stücken und Nachschub |
| delivery | Englisch Delivery address | Deutsch Lieferadresse |
| firstName | Englisch First name | Deutsch Vorname |
| lastName | Englisch Last name | Deutsch Nachname |
| street | Englisch Street and number | Deutsch Straße und Hausnummer |
| addressExtra | Englisch Apartment, floor (optional) | Deutsch Adresszusatz (optional) |
| zip | Englisch Postcode | Deutsch PLZ |
| city | Englisch City | Deutsch Ort |
| country | Englisch Country | Deutsch Land |
| phone | Englisch Phone (optional) | Deutsch Telefon (optional) |
| phoneHint | Englisch Only for the courier, if they cannot find you. | Deutsch Nur für den Paketdienst, falls er dich nicht findet. |
| shippingMethod | Englisch Shipping | Deutsch Versand |
| payment | Englisch Payment | Deutsch Zahlung |
| paymentHint | Englisch All payments are encrypted. | Deutsch Alle Zahlungen sind verschlüsselt. |
| cardNumber | Englisch Card number | Deutsch Kartennummer |
| expiry | Englisch Expiry (MM/YY) | Deutsch Gültig bis (MM/JJ) |
| cvc | Englisch Security code | Deutsch Prüfnummer |
| cardName | Englisch Name on card | Deutsch Name auf der Karte |
| billingSame | Englisch Billing address is the same as delivery | Deutsch Rechnungsadresse entspricht der Lieferadresse |
| summary | Englisch Order summary | Deutsch Bestellübersicht |
| showSummary | Englisch Show order summary | Deutsch Bestellübersicht zeigen |
| hideSummary | Englisch Hide order summary | Deutsch Bestellübersicht ausblenden |
| placeOrder | Englisch Place order | Deutsch Zahlungspflichtig bestellen |
| placing | Englisch Placing your order | Deutsch Bestellung wird gesendet |
| placed | Englisch Order placed | Deutsch Bestellung aufgegeben |
| legal | Englisch By placing your order you agree to our {terms}. Read about your {withdrawal} and how we handle your data in our {privacy}. | Deutsch Mit deiner Bestellung akzeptierst du unsere {terms}. Hinweise zum {withdrawal} und zum Umgang mit deinen Daten findest du in der {privacy}. |
| terms | Englisch terms | Deutsch AGB |
| withdrawal | Englisch right of withdrawal | Deutsch Widerrufsrecht |
| privacy | Englisch privacy policy | Deutsch Datenschutzerklärung |
| required | Englisch Please fill in this field. | Deutsch Bitte fülle dieses Feld aus. |
| invalidEmail | Englisch This email address looks incomplete, e.g. name@example.com. | Deutsch Diese E-Mail-Adresse wirkt unvollständig, zum Beispiel name@beispiel.de. |
| fixErrors | Englisch Some fields need your attention. | Deutsch Ein paar Felder brauchen noch deine Aufmerksamkeit. |
| free | Englisch Free | Deutsch Kostenlos |
| continue | Englisch Continue | Deutsch Weiter |
| back | Englisch Back | Deutsch Zurück |
| change | Englisch Change | Deutsch Ändern |
| review | Englisch Review | Deutsch Prüfen |
| information | Englisch Information | Deutsch Angaben |
| step | Englisch Step | Deutsch Schritt |
| of | Englisch of | Deutsch von |
| reviewTitle | Englisch Check and place your order | Deutsch Prüfen und bestellen |
| express | Englisch Express checkout | Deutsch Express-Kasse |
| expressHint | Englisch One tap, your saved address and payment. | Deutsch Ein Tipp, gespeicherte Adresse und Zahlung. |
| orEmail | Englisch or continue with email | Deutsch oder mit E-Mail weiter |
| haveAccount | Englisch Have an account? | Deutsch Schon ein Konto? |
| signIn | Englisch Sign in | Deutsch Anmelden |
| continuePayment | Englisch Continue to delivery | Deutsch Weiter zur Lieferung |
| thanks | Englisch Thank you, {name}! | Deutsch Danke, {name}! |
| confirmed | Englisch Order {number} is confirmed. | Deutsch Deine Bestellung {number} ist bestätigt. |
| sentTo | Englisch We sent the confirmation to {email}. | Deutsch Die Bestätigung ist unterwegs an {email}. |
| arrives | Englisch Arrives | Deutsch Ankunft |
| track | Englisch Track order | Deutsch Sendung verfolgen |
| keepShopping | Englisch Continue shopping | Deutsch Weiter einkaufen |
| yourOrder | Englisch Your order | Deutsch Deine Bestellung |
| details | Englisch Details | Deutsch Details |
| deliveredTo | Englisch Delivery address | Deutsch Lieferadresse |
| paidWith | Englisch Payment | Deutsch Zahlung |
| shippedWith | Englisch Shipping | Deutsch Versand |
| account | Englisch Save your details for next time | Deutsch Daten fürs nächste Mal speichern |
| accountText | Englisch Create an account with one click and track this order without the email. | Deutsch Leg mit einem Klick ein Konto an und verfolge diese Bestellung ohne E-Mail. |
| createAccount | Englisch Create account | Deutsch Konto anlegen |
| Englisch Print | Deutsch Drucken | |
| qty | Englisch Qty | Deutsch Menge |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-current, aria-describedby, aria-disabled, aria-expanded, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle alert.
- 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.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 14 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.109 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { ProductMedia, ShopMotion, Swap, TweenPrice, formatPrice, vatOf, type LineItem } from '@/components/ui/product-image';
import { CART_TEXTS, SummaryLines, computeTotals } from '@/components/ui/cart';
export type CheckoutVariant = 'one-page' | 'two-column' | 'multi-step' | 'express' | 'confirmation';
export interface ShippingOption {
id: string;
label: string;
/** e.g. "Thu, 23 Oct" or "2 to 3 working days" */
note: string;
price: number;
}
export interface PaymentOption {
id: string;
label: string;
note?: string;
icon: IconName;
/** card shows card fields */
kind?: 'card' | 'other';
}
export interface CheckoutValues {
email?: string;
firstName?: string;
lastName?: string;
street?: string;
zip?: string;
city?: string;
country?: string;
}
export interface OrderConfirmation {
number: string;
firstName: string;
email: string;
/** Delivery estimate, already formatted */
delivery: string;
address: string[];
payment: string;
shipping: string;
/** Status steps, the first `done` of them are complete */
steps: { label: string; date?: string }[];
done: number;
}
export interface CheckoutProps {
variant?: CheckoutVariant;
items: LineItem[];
shippingOptions?: ShippingOption[];
paymentOptions?: PaymentOption[];
/** express: neutral express buttons, text only */
expressOptions?: { label: string; icon: IconName }[];
/** Prefilled fields, e.g. for a signed-in customer */
values?: CheckoutValues;
countries?: string[];
/** multi-step: start on this step (0 to 3) */
initialStep?: number;
/** confirmation */
order?: OrderConfirmation;
storeName?: React.ReactNode;
freeShippingFrom?: number;
vatRate?: number;
currency?: string;
/** Links in the legal line under the order button */
legal?: { terms: string; withdrawal: string; privacy: string };
onSubmit?: (data: Record<string, string>) => void;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
checkout: 'Checkout',
secure: 'Secure checkout',
contact: 'Contact',
email: 'Email',
emailHint: 'For your order confirmation and tracking link.',
newsletter: 'Email me about new pieces and restocks',
delivery: 'Delivery address',
firstName: 'First name',
lastName: 'Last name',
street: 'Street and number',
addressExtra: 'Apartment, floor (optional)',
zip: 'Postcode',
city: 'City',
country: 'Country',
phone: 'Phone (optional)',
phoneHint: 'Only for the courier, if they cannot find you.',
shippingMethod: 'Shipping',
payment: 'Payment',
paymentHint: 'All payments are encrypted.',
cardNumber: 'Card number',
expiry: 'Expiry (MM/YY)',
cvc: 'Security code',
cardName: 'Name on card',
billingSame: 'Billing address is the same as delivery',
summary: 'Order summary',
showSummary: 'Show order summary',
hideSummary: 'Hide order summary',
placeOrder: 'Place order',
placing: 'Placing your order',
placed: 'Order placed',
legal: 'By placing your order you agree to our {terms}. Read about your {withdrawal} and how we handle your data in our {privacy}.',
terms: 'terms',
withdrawal: 'right of withdrawal',
privacy: 'privacy policy',
required: 'Please fill in this field.',
invalidEmail: 'This email address looks incomplete, e.g. name@example.com.',
fixErrors: 'Some fields need your attention.',
free: 'Free',
continue: 'Continue',
back: 'Back',
change: 'Change',
review: 'Review',
information: 'Information',
step: 'Step',
of: 'of',
reviewTitle: 'Check and place your order',
express: 'Express checkout',
expressHint: 'One tap, your saved address and payment.',
orEmail: 'or continue with email',
haveAccount: 'Have an account?',
signIn: 'Sign in',
continuePayment: 'Continue to delivery',
thanks: 'Thank you, {name}!',
confirmed: 'Order {number} is confirmed.',
sentTo: 'We sent the confirmation to {email}.',
arrives: 'Arrives',
track: 'Track order',
keepShopping: 'Continue shopping',
yourOrder: 'Your order',
details: 'Details',
deliveredTo: 'Delivery address',
paidWith: 'Payment',
shippedWith: 'Shipping',
account: 'Save your details for next time',
accountText: 'Create an account with one click and track this order without the email.',
createAccount: 'Create account',
print: 'Print',
qty: 'Qty',
},
de: {
checkout: 'Kasse',
secure: 'Sichere Kasse',
contact: 'Kontakt',
email: 'E-Mail',
emailHint: 'Für deine Bestellbestätigung und den Link zur Sendung.',
newsletter: 'Schreib mir bei neuen Stücken und Nachschub',
delivery: 'Lieferadresse',
firstName: 'Vorname',
lastName: 'Nachname',
street: 'Straße und Hausnummer',
addressExtra: 'Adresszusatz (optional)',
zip: 'PLZ',
city: 'Ort',
country: 'Land',
phone: 'Telefon (optional)',
phoneHint: 'Nur für den Paketdienst, falls er dich nicht findet.',
shippingMethod: 'Versand',
payment: 'Zahlung',
paymentHint: 'Alle Zahlungen sind verschlüsselt.',
cardNumber: 'Kartennummer',
expiry: 'Gültig bis (MM/JJ)',
cvc: 'Prüfnummer',
cardName: 'Name auf der Karte',
billingSame: 'Rechnungsadresse entspricht der Lieferadresse',
summary: 'Bestellübersicht',
showSummary: 'Bestellübersicht zeigen',
hideSummary: 'Bestellübersicht ausblenden',
placeOrder: 'Zahlungspflichtig bestellen',
placing: 'Bestellung wird gesendet',
placed: 'Bestellung aufgegeben',
legal: 'Mit deiner Bestellung akzeptierst du unsere {terms}. Hinweise zum {withdrawal} und zum Umgang mit deinen Daten findest du in der {privacy}.',
terms: 'AGB',
withdrawal: 'Widerrufsrecht',
privacy: 'Datenschutzerklärung',
required: 'Bitte fülle dieses Feld aus.',
invalidEmail: 'Diese E-Mail-Adresse wirkt unvollständig, zum Beispiel name@beispiel.de.',
fixErrors: 'Ein paar Felder brauchen noch deine Aufmerksamkeit.',
free: 'Kostenlos',
continue: 'Weiter',
back: 'Zurück',
change: 'Ändern',
review: 'Prüfen',
information: 'Angaben',
step: 'Schritt',
of: 'von',
reviewTitle: 'Prüfen und bestellen',
express: 'Express-Kasse',
expressHint: 'Ein Tipp, gespeicherte Adresse und Zahlung.',
orEmail: 'oder mit E-Mail weiter',
haveAccount: 'Schon ein Konto?',
signIn: 'Anmelden',
continuePayment: 'Weiter zur Lieferung',
thanks: 'Danke, {name}!',
confirmed: 'Deine Bestellung {number} ist bestätigt.',
sentTo: 'Die Bestätigung ist unterwegs an {email}.',
arrives: 'Ankunft',
track: 'Sendung verfolgen',
keepShopping: 'Weiter einkaufen',
yourOrder: 'Deine Bestellung',
details: 'Details',
deliveredTo: 'Lieferadresse',
paidWith: 'Zahlung',
shippedWith: 'Versand',
account: 'Daten fürs nächste Mal speichern',
accountText: 'Leg mit einem Klick ein Konto an und verfolge diese Bestellung ohne E-Mail.',
createAccount: 'Konto anlegen',
print: 'Drucken',
qty: 'Menge',
},
};
type T = (typeof TEXTS)['en'];
const fill = (s: string, map: Record<string, React.ReactNode>) => {
const parts = s.split(/(\{\w+\})/);
return parts.map((p, i) => {
const m = p.match(/^\{(\w+)\}$/);
return m ? <span key={i}>{map[m[1]]}</span> : p;
});
};
const fillText = (s: string, map: Record<string, string>) => s.replace(/\{(\w+)\}/g, (_, k) => map[k] ?? '');
/* ------------------------------------------------------------------ */
/* Form parts */
/* ------------------------------------------------------------------ */
const inputClass =
'h-12 w-full rounded-lg border border-input bg-background px-3.5 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-[color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:ring-[color-mix(in_oklch,var(--destructive)_16%,transparent)]';
function Field({ label, name, type = 'text', autoComplete, hint, error, defaultValue, className, inputMode, placeholder, onChange, value, required = true }: {
label: string;
name: string;
type?: string;
autoComplete?: string;
hint?: string;
error?: string;
defaultValue?: string;
className?: string;
inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
placeholder?: string;
onChange?: (v: string) => void;
value?: string;
required?: boolean;
}) {
const id = useId();
return (
<div className={className}>
<ShopMotion />
<label htmlFor={id} className="mb-1.5 block text-[13px] font-medium text-foreground">
{label}
</label>
<input
id={id}
name={name}
type={type}
autoComplete={autoComplete}
inputMode={inputMode}
placeholder={placeholder}
required={required}
defaultValue={value === undefined ? defaultValue : undefined}
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
aria-invalid={!!error}
aria-describedby={hint || error ? `${id}-d` : undefined}
className={cn(inputClass, 'aria-[invalid=true]:animate-[kitshop-shake_0.42s_var(--ease-kit)]')}
/>
{(error || hint) && (
<p key={error ? 'e' : 'h'} id={`${id}-d`} className={cn('mt-1.5 flex items-start gap-1.5 text-[12px]', error ? 'animate-[kitshop-in_0.35s_var(--ease-kit)] text-destructive' : 'text-muted-foreground')}>
{error && <Icon name="alert-circle" size={13} className="mt-px" />}
{error ?? hint}
</p>
)}
</div>
);
}
function SelectField({ label, name, options, defaultValue, autoComplete }: { label: string; name: string; options: string[]; defaultValue?: string; autoComplete?: string }) {
const id = useId();
return (
<div>
<label htmlFor={id} className="mb-1.5 block text-[13px] font-medium text-foreground">
{label}
</label>
<div className="relative">
<select id={id} name={name} defaultValue={defaultValue ?? options[0]} autoComplete={autoComplete} className={cn(inputClass, 'appearance-none pr-10')}>
{options.map((o) => (
<option key={o}>{o}</option>
))}
</select>
<Icon name="chevrons-up-down" size={15} className="pointer-events-none absolute right-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
);
}
function Check({ label, name, defaultChecked }: { label: string; name: string; defaultChecked?: boolean }) {
return (
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-[14px] text-foreground">
<input type="checkbox" name={name} defaultChecked={defaultChecked} className="peer sr-only" />
<span aria-hidden className="grid size-5 shrink-0 place-items-center rounded-[6px] border border-border-strong bg-background text-transparent transition-colors peer-checked:border-primary peer-checked:bg-primary peer-checked:text-primary-foreground peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)]">
<Icon name="check" size={13} strokeWidth={2.4} />
</span>
{label}
</label>
);
}
/** Radio options as stacked rows in one frame. The picked row can unfold extra fields. */
function RadioCards<O extends { id: string; label: string; note?: string }>({ name, options, value, onChange, right, icon, legend, expand }: {
name: string;
options: O[];
value: string;
onChange: (id: string) => void;
right?: (o: O) => React.ReactNode;
icon?: (o: O) => IconName | undefined;
legend: string;
expand?: (o: O) => React.ReactNode;
}) {
// One frame glides to the picked row and follows its height when it unfolds.
// Until it is measured, the picked row draws its own frame.
const box = useRef<HTMLDivElement>(null);
const index = options.findIndex((o) => o.id === value);
const [mark, setMark] = useState<{ y: number; h: number; ready: boolean } | null>(null);
useEffect(() => {
const el = box.current;
if (!el) return;
const update = () => {
const row = el.querySelectorAll<HTMLElement>(':scope > [data-row]')[index];
if (!row) return setMark(null);
setMark((m) => ({ y: row.offsetTop, h: row.offsetHeight, ready: !!m }));
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [index]);
return (
<fieldset className="min-w-0">
<legend className="sr-only">{legend}</legend>
<ShopMotion />
<div ref={box} className="relative overflow-hidden rounded-xl border border-border-strong">
{options.map((o, n) => {
const on = value === o.id;
const ic = icon?.(o);
const more = on ? expand?.(o) : null;
return (
<div key={o.id} data-row className={cn('relative transition-colors duration-300', n > 0 && 'border-t border-border', on && 'bg-[color-mix(in_oklab,var(--primary)_5%,var(--background))]')}>
<label className={cn('relative flex min-h-16 cursor-pointer items-center gap-3.5 px-4 py-3.5 transition-colors has-[:focus-visible]:z-10 has-[:focus-visible]:outline-2 has-[:focus-visible]:-outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]', !on && 'hover:bg-surface')}>
<input type="radio" name={name} value={o.id} checked={on} onChange={() => onChange(o.id)} className="sr-only" />
<span aria-hidden className={cn('grid size-[18px] shrink-0 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-border-strong bg-background')}>
<span className={cn('size-1.5 rounded-full bg-primary-foreground transition-transform', on ? 'scale-100' : 'scale-0')} />
</span>
{ic && <Icon name={ic} size={18} className="text-muted-foreground" />}
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-medium text-foreground">{o.label}</span>
{o.note && <span className="block text-[13px] text-muted-foreground">{o.note}</span>}
</span>
{right?.(o)}
</label>
{more && <div className="kit-rise px-4 pb-4 [animation-duration:0.4s]">{more}</div>}
{on && !mark && <span aria-hidden className={cn('pointer-events-none absolute inset-x-0 bottom-0 z-10 border-[1.5px] border-primary', n > 0 ? '-top-px' : 'top-0 rounded-t-[11px]', n === options.length - 1 && 'rounded-b-[11px]')} />}
</div>
);
})}
{mark && (
<span
aria-hidden
className={cn('pointer-events-none absolute inset-x-0 top-0 z-10 rounded-[11px] border-[1.5px] border-primary', mark.ready && 'transition-[transform,height] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')}
style={{ transform: `translateY(${mark.y}px)`, height: mark.h }}
/>
)}
</div>
</fieldset>
);
}
function CardFields({ t }: { t: T }) {
const [num, setNum] = useState('');
const [exp, setExp] = useState('');
return (
<div className="grid grid-cols-2 gap-3 border-t border-border pt-4">
<Field
label={t.cardNumber}
name="cc-number"
autoComplete="cc-number"
inputMode="numeric"
placeholder="1234 5678 9012 3456"
className="col-span-2"
value={num}
onChange={(v) => setNum(v.replace(/\D/g, '').slice(0, 16).replace(/(\d{4})(?=\d)/g, '$1 '))}
/>
<Field label={t.expiry} name="cc-exp" autoComplete="cc-exp" inputMode="numeric" placeholder="08/29" value={exp} onChange={(v) => { const d = v.replace(/\D/g, '').slice(0, 4); setExp(d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d); }} />
<Field label={t.cvc} name="cc-csc" autoComplete="cc-csc" inputMode="numeric" placeholder="123" />
<Field label={t.cardName} name="cc-name" autoComplete="cc-name" className="col-span-2" />
</div>
);
}
function Group({ n, title, children, aside }: { n?: number; title: string; children: React.ReactNode; aside?: React.ReactNode }) {
return (
<section className="py-8 first:pt-0">
{/* Scroll: each part of the form rises in as it is reached, the divider line stays */}
<Reveal>
<div className="mb-5 flex items-baseline justify-between gap-4">
<h2 className="flex items-baseline gap-3 text-[18px] font-semibold tracking-[-0.015em] text-foreground">
{n !== undefined && <span className="text-[12px] font-medium tabular-nums text-subtle-foreground">0{n}</span>}
{title}
</h2>
{aside}
</div>
{children}
</Reveal>
</section>
);
}
/* ------------------------------------------------------------------ */
/* Shared checkout state and sections */
/* ------------------------------------------------------------------ */
function useCheckout(props: CheckoutProps) {
const [ship, setShip] = useState(props.shippingOptions?.[0]?.id ?? '');
const [pay, setPay] = useState(props.paymentOptions?.[0]?.id ?? '');
const shipping = props.shippingOptions?.find((s) => s.id === ship);
const base = computeTotals(props.items, {});
const first = props.shippingOptions?.[0];
// The standard option becomes free above the threshold, faster options keep their price
const shipCost = !shipping ? 0 : shipping.id === first?.id && props.freeShippingFrom !== undefined && base.subtotal >= props.freeShippingFrom ? 0 : shipping.price;
const total = round(base.subtotal + shipCost);
return { ship, setShip, pay, setPay, shipping, totals: { ...base, shipping: shipCost, total, vat: vatOf(total, props.vatRate ?? 19) } };
}
type Errors = Record<string, string>;
function validate(form: HTMLFormElement, t: T): Errors {
const errors: Errors = {};
for (const el of Array.from(form.elements) as HTMLInputElement[]) {
if (!el.name || !el.required) continue;
if (!el.value.trim()) errors[el.name] = t.required;
else if (el.type === 'email' && !/^\S+@\S+\.\S+$/.test(el.value)) errors[el.name] = t.invalidEmail;
}
return errors;
}
function ContactFields({ t, v, e }: { t: T; v?: CheckoutValues; e: Errors }) {
return (
<div className="space-y-2">
<Field label={t.email} name="email" type="email" autoComplete="email" hint={t.emailHint} defaultValue={v?.email} error={e.email} />
<Check label={t.newsletter} name="newsletter" />
</div>
);
}
function AddressFields({ t, v, e, countries }: { t: T; v?: CheckoutValues; e: Errors; countries: string[] }) {
return (
<div className="grid grid-cols-2 gap-x-3 gap-y-4">
<Field label={t.firstName} name="firstName" autoComplete="given-name" defaultValue={v?.firstName} error={e.firstName} />
<Field label={t.lastName} name="lastName" autoComplete="family-name" defaultValue={v?.lastName} error={e.lastName} />
<Field label={t.street} name="street" autoComplete="address-line1" defaultValue={v?.street} error={e.street} className="col-span-2" />
<Field label={t.addressExtra} name="extra" autoComplete="address-line2" required={false} className="col-span-2" />
<Field label={t.zip} name="zip" autoComplete="postal-code" inputMode="numeric" defaultValue={v?.zip} error={e.zip} />
<Field label={t.city} name="city" autoComplete="address-level2" defaultValue={v?.city} error={e.city} />
<div className="col-span-2">
<SelectField label={t.country} name="country" options={countries} defaultValue={v?.country} autoComplete="country-name" />
</div>
<Field label={t.phone} name="phone" type="tel" autoComplete="tel" hint={t.phoneHint} required={false} className="col-span-2" />
</div>
);
}
function ShippingFields({ t, props, c }: { t: T; props: CheckoutProps; c: ReturnType<typeof useCheckout> }) {
const locale = useLocale();
if (!props.shippingOptions?.length) return null;
const free = (o: ShippingOption) => o.id === props.shippingOptions![0].id && props.freeShippingFrom !== undefined && c.totals.subtotal >= props.freeShippingFrom;
return (
<RadioCards
legend={t.shippingMethod}
name="shipping"
options={props.shippingOptions}
value={c.ship}
onChange={c.setShip}
right={(o) => <span className={cn('text-[14px] font-medium tabular-nums', free(o) || o.price === 0 ? 'text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))] dark:text-success' : 'text-foreground')}>{free(o) || o.price === 0 ? t.free : formatPrice(locale, o.price, props.currency)}</span>}
/>
);
}
function PaymentFields({ t, props, c }: { t: T; props: CheckoutProps; c: ReturnType<typeof useCheckout> }) {
if (!props.paymentOptions?.length) return null;
return (
<div>
<RadioCards legend={t.payment} name="payment" options={props.paymentOptions} value={c.pay} onChange={c.setPay} icon={(o) => o.icon} expand={(o) => (o.kind === 'card' ? <CardFields t={t} /> : null)} />
<div className="mt-3">
<Check label={t.billingSame} name="billingSame" defaultChecked />
</div>
</div>
);
}
function Legal({ t, legal }: { t: T; legal?: CheckoutProps['legal'] }) {
const a = (href: string | undefined, label: string) => (
<a href={href ?? '#'} className="underline decoration-border-strong underline-offset-2 hover:text-foreground">
{label}
</a>
);
return <p className="text-pretty text-[12px] leading-relaxed text-muted-foreground">{fill(t.legal, { terms: a(legal?.terms, t.terms), withdrawal: a(legal?.withdrawal, t.withdrawal), privacy: a(legal?.privacy, t.privacy) })}</p>;
}
/** The order button. It counts to a new total, shows a spinner while sending and a check when done. */
function PlaceOrder({ t, total, currency, className, status = 'idle' }: { t: T; total: number; currency?: string; className?: string; status?: Status }) {
return (
<Button type="submit" size="lg" aria-disabled={status !== 'idle' || undefined} className={cn('w-full overflow-hidden', status === 'done' && 'bg-success text-background hover:bg-success', className)}>
<Swap value={status} className="min-w-0 gap-2">
{status === 'sending' ? (
<span aria-hidden className="size-[17px] shrink-0 animate-kit-spin rounded-full border-2 border-current border-r-transparent [--kit-duration:0.8s] motion-reduce:animate-none" />
) : (
<Icon name={status === 'done' ? 'check' : 'lock'} size={17} className="shrink-0" />
)}
<span className="truncate">
{status === 'sending' ? t.placing : status === 'done' ? t.placed : (
<>
{t.placeOrder} · <TweenPrice value={total} currency={currency} />
</>
)}
</span>
</Swap>
</Button>
);
}
function Summary({ props, totals, t, compact }: { props: CheckoutProps; totals: ReturnType<typeof useCheckout>['totals']; t: T; compact?: boolean }) {
const ct = useTexts(CART_TEXTS);
return (
<div>
<Stagger as="ul" step={80} className={cn('divide-y divide-border', compact && '-my-1')}>
{props.items.map((i) => (
<li key={i.id} className="flex items-center gap-3.5 py-3">
<div className="relative shrink-0">
<ProductMedia className="size-16 rounded-lg border border-border">{i.image}</ProductMedia>
<span className="absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full bg-foreground text-[11px] font-semibold tabular-nums text-background" aria-label={`${t.qty} ${i.quantity}`}>
{i.quantity}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[14px] font-medium text-foreground">{i.name}</p>
{i.variant && <p className="truncate text-[12px] text-muted-foreground">{i.variant}</p>}
</div>
<p className="text-[14px] tabular-nums text-foreground">
<SummaryPrice value={i.price * i.quantity} currency={props.currency} />
</p>
</li>
))}
</Stagger>
<div className="mt-4 border-t border-border pt-4">
<SummaryLines totals={totals} currency={props.currency} t={ct} vatRate={props.vatRate} />
</div>
</div>
);
}
function SummaryPrice({ value, currency }: { value: number; currency?: string }) {
const locale = useLocale();
return <>{formatPrice(locale, value, currency)}</>;
}
function Head({ props, t, className }: { props: CheckoutProps; t: T; className?: string }) {
return (
<div className={cn('flex items-center justify-between gap-4', className)}>
<span className="text-[18px] font-semibold tracking-[-0.02em] text-foreground">{props.storeName}</span>
<span className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground">
<Icon name="lock" size={14} />
{t.secure}
</span>
</div>
);
}
function ErrorBanner({ show, t, attempt = 0 }: { show: boolean; t: T; attempt?: number }) {
if (!show) return null;
return (
<p key={attempt} role="alert" className="mb-6 flex animate-[kitshop-in_0.4s_var(--ease-kit)] items-center gap-2.5 rounded-lg border border-[color-mix(in_oklch,var(--destructive)_35%,transparent)] bg-[color-mix(in_oklch,var(--destructive)_7%,transparent)] px-4 py-3 text-[14px] text-foreground">
<Icon name="alert-circle" size={17} className="text-destructive" />
{t.fixErrors}
</p>
);
}
type Status = 'idle' | 'sending' | 'done';
function useSubmit(props: CheckoutProps, t: T) {
const [errors, setErrors] = useState<Errors>({});
const [attempt, setAttempt] = useState(0);
const [status, setStatus] = useState<Status>('idle');
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (status !== 'idle') return;
const errs = validate(e.currentTarget, t);
setErrors(errs);
setAttempt((a) => a + 1);
if (Object.keys(errs).length) {
const form = e.currentTarget;
requestAnimationFrame(() => (form.querySelector('[aria-invalid="true"]') as HTMLElement | null)?.focus());
return;
}
const data = Object.fromEntries(new FormData(e.currentTarget).entries()) as Record<string, string>;
// A short sending state, then the confirmation on the button
setStatus('sending');
timer.current = setTimeout(() => {
setStatus('done');
props.onSubmit?.(data);
}, 1100);
};
return { errors, onSubmit, attempt, status };
}
const COUNTRIES = { en: ['Germany', 'Austria', 'Switzerland', 'Netherlands', 'France'], de: ['Deutschland', 'Österreich', 'Schweiz', 'Niederlande', 'Frankreich'] };
/* ------------------------------------------------------------------ */
/* Variants */
/* ------------------------------------------------------------------ */
function OnePage(props: CheckoutProps & { t: T }) {
const { t } = props;
const locale = useLocale();
const c = useCheckout(props);
const { errors, onSubmit, attempt, status } = useSubmit(props, t);
const countries = props.countries ?? COUNTRIES[locale];
return (
<section className={cn('bg-background pb-20 pt-8 sm:bg-surface sm:pt-12 lg:pb-28', props.className)}>
<div className="mx-auto w-full max-w-[40rem] px-6">
<Head props={props} t={t} className="mb-8" />
<form noValidate onSubmit={onSubmit} className="sm:rounded-2xl sm:border sm:border-border sm:bg-card sm:px-8 sm:py-8 sm:shadow-[var(--highlight),var(--shadow-md)]">
<h1 className="mb-8 text-[28px] font-semibold tracking-[-0.025em] text-foreground">{t.checkout}</h1>
<ErrorBanner show={Object.keys(errors).length > 0} t={t} attempt={attempt} />
<div className="divide-y divide-border">
<Group n={1} title={t.contact}>
<ContactFields t={t} v={props.values} e={errors} />
</Group>
<Group n={2} title={t.delivery}>
<AddressFields t={t} v={props.values} e={errors} countries={countries} />
</Group>
<Group n={3} title={t.shippingMethod}>
<ShippingFields t={t} props={props} c={c} />
</Group>
<Group n={4} title={t.payment} aside={<span className="text-[12px] text-muted-foreground max-sm:hidden">{t.paymentHint}</span>}>
<PaymentFields t={t} props={props} c={c} />
</Group>
<Group title={t.summary}>
<Summary props={props} totals={c.totals} t={t} />
</Group>
</div>
<PlaceOrder t={t} total={c.totals.total} currency={props.currency} className="mt-2" status={status} />
<div className="mt-4">
<Legal t={t} legal={props.legal} />
</div>
</form>
</div>
</section>
);
}
function TwoColumn(props: CheckoutProps & { t: T }) {
const { t } = props;
const locale = useLocale();
const c = useCheckout(props);
const { errors, onSubmit, attempt, status } = useSubmit(props, t);
const [open, setOpen] = useState(false);
const countries = props.countries ?? COUNTRIES[locale];
return (
<section className={cn('relative', props.className)}>
{/* the summary side runs to the right edge on desktop */}
<div aria-hidden className="absolute inset-y-0 right-0 hidden w-[calc(50%-1rem)] border-l border-border bg-surface lg:block xl:w-[calc(50%-2.5rem)]" />
<div className="kit-container relative">
{/* Phone and tablet: the summary folds out at the top */}
<div className="-mx-6 border-b border-border bg-surface lg:hidden">
<button type="button" aria-expanded={open} onClick={() => setOpen(!open)} className="flex min-h-14 w-full items-center justify-between gap-4 px-6 text-[14px]">
<span className="flex items-center gap-2 font-medium text-foreground">
<Icon name="bag" size={17} />
{open ? t.hideSummary : t.showSummary}
<Icon name="chevron-down" size={15} className={cn('text-muted-foreground transition-transform motion-reduce:transition-none', open && 'rotate-180')} />
</span>
<TweenPrice value={c.totals.total} currency={props.currency} className="text-[16px] font-semibold text-foreground" />
</button>
<div className={cn('grid transition-[grid-template-rows] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className="overflow-hidden">
<div className="mx-auto max-w-[38rem] px-6 pb-6" inert={!open}>
<Summary props={props} totals={c.totals} t={t} />
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2">
<form noValidate onSubmit={onSubmit} className="w-full py-10 max-lg:mx-auto max-lg:max-w-[38rem] lg:py-14 lg:pr-14 xl:pr-20">
<Head props={props} t={t} className="mb-10" />
<ErrorBanner show={Object.keys(errors).length > 0} t={t} attempt={attempt} />
<div className="divide-y divide-border">
<Group title={t.contact} aside={<a href="#" className="text-[13px] text-muted-foreground underline decoration-border-strong underline-offset-4 hover:text-foreground">{t.signIn}</a>}>
<ContactFields t={t} v={props.values} e={errors} />
</Group>
<Group title={t.delivery}>
<AddressFields t={t} v={props.values} e={errors} countries={countries} />
</Group>
<Group title={t.shippingMethod}>
<ShippingFields t={t} props={props} c={c} />
</Group>
<Group title={t.payment} aside={<span className="text-[12px] text-muted-foreground max-sm:hidden">{t.paymentHint}</span>}>
<PaymentFields t={t} props={props} c={c} />
</Group>
</div>
<PlaceOrder t={t} total={c.totals.total} currency={props.currency} className="mt-2" status={status} />
<div className="mt-4">
<Legal t={t} legal={props.legal} />
</div>
</form>
<aside className="max-lg:hidden lg:py-14 lg:pl-14 xl:pl-20" aria-label={t.summary}>
<div className="sticky top-10">
<h2 className="mb-4 text-[16px] font-semibold text-foreground">{t.summary}</h2>
<Summary props={props} totals={c.totals} t={t} />
</div>
</aside>
</div>
</div>
</section>
);
}
function MultiStep(props: CheckoutProps & { t: T }) {
const { t } = props;
const locale = useLocale();
const c = useCheckout(props);
const [step, setStepRaw] = useState(props.initialStep ?? 0);
// Forward slides in from the right, back from the left
const [dir, setDir] = useState<0 | 1 | -1>(0);
const setStep = (n: number) => {
setDir(n > step ? 1 : -1);
setStepRaw(n);
};
const [errors, setErrors] = useState<Errors>({});
const [data, setData] = useState<Record<string, string>>({ ...(props.values as Record<string, string>) });
const [summaryOpen, setSummaryOpen] = useState(false);
const countries = props.countries ?? COUNTRIES[locale];
const steps = [t.information, t.shippingMethod, t.payment, t.review];
const fp = (n: number) => formatPrice(locale, n, props.currency);
const next = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const errs = validate(e.currentTarget, t);
setErrors(errs);
if (Object.keys(errs).length) return;
// Read the fields now, the event target is gone once React runs the updater
const fields = Object.fromEntries(new FormData(e.currentTarget).entries()) as Record<string, string>;
setData((d) => ({ ...d, ...fields }));
if (step < 3) setStep(step + 1);
else props.onSubmit?.(data);
};
const row = (label: string, value: React.ReactNode, to: number) => (
<div className="flex items-start gap-4 py-4">
<dt className="w-24 shrink-0 text-[13px] text-muted-foreground sm:w-32">{label}</dt>
<dd className="min-w-0 flex-1 text-[14px] text-foreground">{value}</dd>
<button type="button" onClick={() => setStep(to)} className="-my-2 h-9 shrink-0 rounded-md px-2 text-[13px] font-medium text-foreground underline decoration-border-strong underline-offset-4 hover:bg-accent">
{t.change}
</button>
</div>
);
const shipping = props.shippingOptions?.find((s) => s.id === c.ship);
const payment = props.paymentOptions?.find((p) => p.id === c.pay);
return (
<section className={cn('pb-20 pt-8 sm:pt-12 lg:pb-28', props.className)}>
<div className="kit-container">
<Head props={props} t={t} className="mb-10" />
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-14">
<div className="min-w-0">
<nav aria-label={t.checkout} className="mb-10">
<ol className="grid grid-cols-4 gap-2">
{steps.map((s, n) => {
const done = n < step;
const on = n === step;
return (
<li key={s}>
<button type="button" disabled={n > step} onClick={() => setStep(n)} aria-current={on ? 'step' : undefined} className="group w-full text-left disabled:cursor-default">
<span className="block h-1 overflow-hidden rounded-full bg-track">
<span className={cn('block h-full origin-left rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', done ? 'scale-x-100' : on ? 'scale-x-50' : 'scale-x-0')} style={{ transitionDelay: done ? '0ms' : '160ms' }} />
</span>
<span className={cn('mt-2.5 flex items-center gap-1.5 text-[12px] sm:text-[13px]', on ? 'font-medium text-foreground' : done ? 'text-foreground' : 'text-subtle-foreground')}>
{done && <Icon name="check" size={13} strokeWidth={2.2} className="animate-[kitshop-pop_0.4s_var(--ease-kit)] text-primary max-sm:hidden" />}
<span className="truncate">{s}</span>
</span>
</button>
</li>
);
})}
</ol>
<p className="sr-only" aria-live="polite">
{t.step} {step + 1} {t.of} 4, {steps[step]}
</p>
</nav>
<form noValidate onSubmit={next} key={step} className={cn(dir === 1 && 'animate-[kitshop-next_0.5s_var(--ease-kit)]', dir === -1 && 'animate-[kitshop-prev_0.5s_var(--ease-kit)]')}>
<h1 className="mb-6 text-[26px] font-semibold tracking-[-0.025em] text-foreground">{step === 3 ? t.reviewTitle : steps[step]}</h1>
{step === 0 && (
<div className="space-y-8">
<ContactFields t={t} v={data} e={errors} />
<div>
<h2 className="mb-4 text-[16px] font-semibold text-foreground">{t.delivery}</h2>
<AddressFields t={t} v={data} e={errors} countries={countries} />
</div>
</div>
)}
{step === 1 && <ShippingFields t={t} props={props} c={c} />}
{step === 2 && <PaymentFields t={t} props={props} c={c} />}
{step === 3 && (
<div>
<dl className="divide-y divide-border rounded-xl border border-border px-4 sm:px-5">
{row(t.contact, data.email, 0)}
{row(t.delivery, [data.firstName, data.lastName].filter(Boolean).join(' ') + (data.street ? `, ${data.street}, ${data.zip} ${data.city}` : ''), 0)}
{row(t.shippingMethod, shipping && `${shipping.label} · ${c.totals.shipping ? fp(c.totals.shipping) : t.free}`, 1)}
{row(t.payment, payment?.label, 2)}
</dl>
<div className="mt-6 lg:hidden">
<Summary props={props} totals={c.totals} t={t} />
</div>
</div>
)}
<div className="mt-8 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
{step > 0 ? (
<Button variant="ghost" iconLeft="arrow-left" onClick={() => setStep(step - 1)}>
{t.back}
</Button>
) : (
<span />
)}
{step < 3 ? (
<Button type="submit" size="lg" icon="arrow-right">
{t.continue}
</Button>
) : (
<PlaceOrder t={t} total={c.totals.total} currency={props.currency} className="sm:w-auto" />
)}
</div>
{step === 3 && (
<div className="mt-4 sm:text-right">
<Legal t={t} legal={props.legal} />
</div>
)}
</form>
</div>
<aside aria-label={t.summary} className="lg:sticky lg:top-8 lg:self-start">
<div className="rounded-2xl border border-border bg-surface p-5 sm:p-6">
<button type="button" aria-expanded={summaryOpen} onClick={() => setSummaryOpen(!summaryOpen)} className="flex w-full items-center justify-between gap-4 text-left lg:pointer-events-none">
<h2 className="text-[16px] font-semibold text-foreground">{t.summary}</h2>
<span className="flex items-center gap-2 text-[15px] font-semibold tabular-nums text-foreground lg:hidden">
<TweenPrice value={c.totals.total} currency={props.currency} />
<Icon name="chevron-down" size={15} className={cn('text-muted-foreground transition-transform', summaryOpen && 'rotate-180')} />
</span>
</button>
<div className={cn('mt-4 lg:block', !summaryOpen && 'max-lg:hidden')}>
<Summary props={props} totals={c.totals} t={t} compact />
</div>
</div>
</aside>
</div>
</div>
</section>
);
}
function Express(props: CheckoutProps & { t: T }) {
const { t } = props;
const locale = useLocale();
const c = useCheckout(props);
const { errors, onSubmit, status } = useSubmit(props, t);
const [open, setOpen] = useState(false);
const count = props.items.reduce((a, i) => a + i.quantity, 0);
return (
<section className={cn('kit-glow kit-dots pb-20 pt-10 sm:pt-16 lg:pb-28', props.className)}>
<div className="mx-auto w-full max-w-[28rem] px-6">
<Head props={props} t={t} className="mb-8" />
<div className="kit-rise rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-7">
{/* Hover or open: the stacked thumbnails fan out */}
<button type="button" aria-expanded={open} onClick={() => setOpen(!open)} className="group/stack flex w-full items-center gap-3 rounded-xl bg-surface-2 p-3 text-left transition-colors hover:bg-muted">
<div className="flex -space-x-3">
{props.items.slice(0, 3).map((i, n) => (
<ProductMedia
key={i.id}
className={cn('size-10 rounded-lg border-2 border-card transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'translate-x-[var(--fan)]' : 'group-hover/stack:translate-x-[var(--fan)]')}
style={{ '--fan': `${n * 6}px`, zIndex: 3 - n } as React.CSSProperties}
>
{i.image}
</ProductMedia>
))}
</div>
<span className="min-w-0 flex-1 text-[13px] text-muted-foreground">
{count} {locale === 'de' ? 'Artikel' : count === 1 ? 'item' : 'items'}
</span>
<TweenPrice value={c.totals.total} currency={props.currency} className="text-[16px] font-semibold text-foreground" />
<Icon name="chevron-down" size={15} className={cn('text-muted-foreground transition-transform duration-300 motion-reduce:transition-none', open && 'rotate-180')} />
</button>
<div className={cn('grid transition-[grid-template-rows] duration-300 motion-reduce:transition-none', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className="overflow-hidden" inert={!open}>
<div className="pt-4">
<Summary props={props} totals={c.totals} t={t} />
</div>
</div>
</div>
<h1 className="mt-7 text-[22px] font-semibold tracking-[-0.02em] text-foreground">{t.express}</h1>
<p className="mt-1 text-[14px] text-muted-foreground">{t.expressHint}</p>
<div className="mt-5 grid gap-2.5">
{(props.expressOptions ?? []).map((o, n) => (
<Reveal key={o.label} delay={n * 90} className="grid">
<button
type="button"
className={cn(
'group flex h-12 items-center justify-center gap-2.5 rounded-lg text-[15px] font-semibold tracking-[-0.01em] transition-[background-color,transform] active:scale-[0.99]',
n === 0 ? 'kit-shine bg-foreground text-background hover:bg-[color-mix(in_oklch,var(--foreground)_88%,var(--background))]' : 'border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] hover:bg-accent',
)}
>
<Icon name={o.icon} size={18} />
{o.label}
</button>
</Reveal>
))}
</div>
<div className="my-7 flex items-center gap-3 text-[12px] text-muted-foreground">
<span className="h-px flex-1 bg-border" />
{t.orEmail}
<span className="h-px flex-1 bg-border" />
</div>
<form noValidate onSubmit={onSubmit}>
<Field label={t.email} name="email" type="email" autoComplete="email" defaultValue={props.values?.email} error={errors.email} hint={t.emailHint} />
<Button type="submit" size="lg" aria-disabled={status !== 'idle' || undefined} className="mt-4 w-full overflow-hidden">
<Swap value={status} className="gap-2">
{t.continuePayment}
{status === 'idle' ? (
<Icon name="arrow-right" size={17} />
) : status === 'sending' ? (
<span aria-hidden className="size-[17px] animate-kit-spin rounded-full border-2 border-current border-r-transparent [--kit-duration:0.8s] motion-reduce:animate-none" />
) : (
<Icon name="check" size={17} />
)}
</Swap>
</Button>
</form>
<p className="mt-5 text-center text-[13px] text-muted-foreground">
{t.haveAccount}{' '}
<a href="#" className="font-medium text-foreground underline decoration-border-strong underline-offset-4">
{t.signIn}
</a>
</p>
</div>
<p className="mt-6 flex items-center justify-center gap-1.5 text-[12px] text-muted-foreground">
<Icon name="shield" size={14} />
{t.paymentHint}
</p>
</div>
</section>
);
}
function Confirmation(props: CheckoutProps & { t: T }) {
const { t, order } = props;
const c = useCheckout(props);
// The tracking line fills up to the current step once the card is in view
const [track, trackIn] = useInView<HTMLDivElement>('0px 0px -15% 0px');
if (!order) return null;
const pct = Math.round(((order.done - 0.5) / (order.steps.length - 1)) * 100);
const share = Math.max(0, Math.min(100, pct)) / 100;
return (
<section className={cn('kit-glow pb-20 pt-12 sm:pt-16 lg:pb-28', props.className)}>
<div className="kit-container">
<div className="mx-auto max-w-[44rem] text-center">
<div className="kit-rise relative mx-auto grid size-16 place-items-center">
<span className="absolute inset-0 animate-ping rounded-full bg-[color-mix(in_oklch,var(--success)_25%,transparent)] [animation-duration:2.4s] [animation-iteration-count:2] motion-reduce:hidden" aria-hidden />
<span className="relative grid size-16 place-items-center rounded-full bg-success text-background shadow-[var(--shadow-md)]">
{/* The check draws itself after the circle has risen */}
<ShopMotion />
<svg viewBox="0 0 24 24" width={30} height={30} fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m5 12.5 4.5 4.5L19 7.5" strokeDasharray="24" className="animate-[kitshop-draw_0.6s_var(--ease-kit)_0.35s_both] [--kitshop-len:24]" />
</svg>
</span>
</div>
<h1 className="kit-rise mt-7 text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{fillText(t.thanks, { name: order.firstName })}
</h1>
<p className="kit-rise mx-auto mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '140ms' } as React.CSSProperties}>
{fill(t.confirmed, { number: <strong className="font-semibold text-foreground">{order.number}</strong> })} {fillText(t.sentTo, { email: order.email })}
</p>
</div>
<Reveal className="mx-auto mt-12 max-w-[56rem]">
<div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-md)] sm:p-8">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-[13px] text-muted-foreground">{t.arrives}</p>
<p className="text-[18px] font-semibold tracking-[-0.015em] text-foreground">{order.delivery}</p>
</div>
<div ref={track} className="relative mt-6">
{/* Desktop: across. Phone: down the left edge, from the first dot to the last. */}
<div className="absolute left-[calc(12.5%)] right-[calc(12.5%)] top-[11px] h-0.5 rounded-full bg-track max-sm:hidden" aria-hidden>
<div className="h-full origin-left rounded-full bg-success transition-transform duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${trackIn ? share : 0})` }} />
</div>
<div className="absolute bottom-5 left-[11px] top-5 w-0.5 rounded-full bg-track sm:hidden" aria-hidden>
<div className="h-full origin-top rounded-full bg-success transition-transform duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleY(${trackIn ? share : 0})` }} />
</div>
<ol className="relative grid grid-cols-4 gap-2 max-sm:grid-cols-1 max-sm:gap-0">
{order.steps.map((s, n) => {
const done = n < order.done;
const now = n === order.done - 1;
// Each dot turns green when the line reaches it
const lit = done && trackIn;
return (
<li key={s.label} className="flex items-center gap-3 text-center sm:flex-col sm:gap-2.5 max-sm:py-2 max-sm:text-left">
<span
className={cn('relative grid size-6 shrink-0 place-items-center rounded-full border-2 transition-[background-color,border-color,color,transform] duration-300 motion-reduce:transition-none', lit ? 'border-success bg-success text-background' : 'border-border-strong bg-card text-transparent', done && !trackIn && 'scale-90')}
style={{ transitionDelay: trackIn ? `${n * 300}ms` : '0ms' }}
>
{done && <Icon name="check" size={12} strokeWidth={2.8} />}
{now && <span className="absolute -inset-1.5 animate-kit-pulse rounded-full border border-success motion-reduce:animate-none" aria-hidden />}
</span>
<span>
<span className={cn('block text-[13px] font-medium', done ? 'text-foreground' : 'text-muted-foreground')}>{s.label}</span>
{s.date && <span className="block text-[12px] text-muted-foreground">{s.date}</span>}
</span>
</li>
);
})}
</ol>
</div>
</div>
</Reveal>
<div className="mx-auto mt-6 grid max-w-[56rem] gap-6 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
<Reveal delay={60} className="h-full">
<div className="h-full rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-6">
<h2 className="mb-2 text-[16px] font-semibold text-foreground">{t.yourOrder}</h2>
<Summary props={props} totals={c.totals} t={t} />
</div>
</Reveal>
<Reveal delay={120} className="h-full">
<div className="flex h-full flex-col gap-6">
<dl className="divide-y divide-border rounded-2xl border border-border bg-card px-5 shadow-[var(--highlight),var(--shadow-sm)] sm:px-6">
{[
[t.deliveredTo, order.address.map((a) => <span key={a} className="block">{a}</span>), 'map-pin'],
[t.shippedWith, order.shipping, 'truck'],
[t.paidWith, order.payment, 'credit-card'],
].map(([k, v, icon]) => (
<div key={k as string} className="flex gap-3.5 py-4">
<Icon name={icon as IconName} size={17} className="-mt-px shrink-0 text-muted-foreground" />
<div>
<dt className="text-[12px] text-muted-foreground">{k}</dt>
<dd className="mt-0.5 text-[14px] text-foreground">{v}</dd>
</div>
</div>
))}
</dl>
<div className="flex flex-1 flex-col items-start rounded-2xl border border-dashed border-border-strong p-5">
<h3 className="text-[14px] font-semibold text-foreground">{t.account}</h3>
<p className="mt-1 text-pretty text-[13px] text-muted-foreground">{t.accountText}</p>
<Button variant="outline" size="sm" className="mt-4 h-10" iconLeft="user-plus">
{t.createAccount}
</Button>
</div>
</div>
</Reveal>
</div>
<div className="mx-auto mt-10 flex max-w-[56rem] flex-col justify-center gap-3 sm:flex-row">
<Button href="#" size="lg" iconLeft="truck">
{t.track}
</Button>
<Button href="#" size="lg" variant="outline" icon="arrow-right">
{t.keepShopping}
</Button>
</div>
</div>
</section>
);
}
/**
* Checkout: everything on one page, two columns with a summary, step by
* step, express with neutral wallet buttons, and the confirmation after.
*/
export function Checkout(props: CheckoutProps) {
const t = useTexts(TEXTS, props.labels);
const { variant = 'two-column' } = props;
if (variant === 'one-page') return <OnePage {...props} t={t} />;
if (variant === 'multi-step') return <MultiStep {...props} t={t} />;
if (variant === 'express') return <Express {...props} t={t} />;
if (variant === 'confirmation') return <Confirmation {...props} t={t} />;
return <TwoColumn {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Kasse?
Mit aiw add @ai-website/checkout. Der Befehl legt die Datei components/ui/checkout.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, product-image und cart. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Kasse?
5 Varianten, Eine Seite, Zweispaltig, In Schritten, Express und Bestätigung. Du wählst sie mit der Prop variant, zum Beispiel variant="two-column".
Kann ich die Texte von Kasse übersetzen?
Ja. Kasse bringt 68 eigene Texte auf Deutsch und Englisch mit, etwa „Kasse“, „Sichere Kasse“ und „Kontakt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Kasse einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kasse mit aiw_components und installieren sie mit aiw_add @ai-website/checkout. Nenn im Prompt die Variante, etwa variant="one-page", dann schreibt der Assistent deine Inhalte direkt in die Props.