Skip to content
UI Kit
Accent color

Checkout pages for React shops

From one page to step by step, express checkout and the confirmation after.

One of 2 components in Cart and checkout

5 variants

From cart to confirmation

The checkout is the step where shops lose the most buyers, so every field and every surprise counts. These checkout layouts keep the order summary in view, format prices and totals in the language of the page and validate address and payment fields as people go.

One page shows contact, delivery and payment on a single page, the fastest path for small orders. Two columns puts the form beside the summary on desktop and folds the summary into a toggle on phones. Multi-step splits address, shipping and payment for larger orders. Express offers wallet payments first and the form below, Confirmation thanks the buyer and says what happens next.

Show the full price including shipping before the payment step, late costs are the most common reason to abandon. Allow guest checkout. The components handle the interface, payment itself goes through your provider, and the card fields in the kit never send data anywhere on their own.

Variants

One page

Contact, delivery, shipping, payment and summary in one calm column.

Two columns

Form left, summary right. On phones the summary folds out at the top.

Multi-step

Four steps with progress, checks per step and a review with change links.

Express

Neutral express buttons first, email as the calm fallback.

Confirmation

Thank you, order status, what was bought and where it goes.

Details

What Checkout brings along, read from the registry and the source.

Variants
5
Version
1.0.1 · since 1.0.0
File
components/ui/checkout.tsx
Brings along
utilskitbuttoniconrevealproduct-imagecart
npm packages
None
Languages
English and German, 68 texts of its own
Studio
Adjust in code
In collections
Online shop

Props

The CheckoutProps interface from components/ui/checkout.tsx, with the comments from the code.

  • variantoptional
    CheckoutVariant

    one-pagetwo-columnmulti-stepexpressconfirmation

  • itemsrequired
    LineItem[]
  • shippingOptionsoptional
    ShippingOption[]
  • paymentOptionsoptional
    PaymentOption[]
  • expressOptionsoptional
    { label: string; icon: IconName }[]

    express: neutral express buttons, text only

  • valuesoptional
    CheckoutValues

    Prefilled fields, e.g. for a signed-in customer

  • countriesoptional
    string[]
  • initialStepoptional
    number

    multi-step: start on this step (0 to 3)

  • orderoptional
    OrderConfirmation

    confirmation

  • storeNameoptional
    React.ReactNode
  • freeShippingFromoptional
    number
  • vatRateoptional
    number
  • currencyoptional
    string
  • legaloptional
    { terms: string; withdrawal: string; privacy: string }

    Links in the legal line under the order button

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

Built-in texts

Checkout writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 68 texts
checkoutEnglish CheckoutGerman Kasse
secureEnglish Secure checkoutGerman Sichere Kasse
contactEnglish ContactGerman Kontakt
emailEnglish EmailGerman E-Mail
emailHintEnglish For your order confirmation and tracking link.German Für deine Bestellbestätigung und den Link zur Sendung.
newsletterEnglish Email me about new pieces and restocksGerman Schreib mir bei neuen Stücken und Nachschub
deliveryEnglish Delivery addressGerman Lieferadresse
firstNameEnglish First nameGerman Vorname
lastNameEnglish Last nameGerman Nachname
streetEnglish Street and numberGerman Straße und Hausnummer
addressExtraEnglish Apartment, floor (optional)German Adresszusatz (optional)
zipEnglish PostcodeGerman PLZ
cityEnglish CityGerman Ort
countryEnglish CountryGerman Land
phoneEnglish Phone (optional)German Telefon (optional)
phoneHintEnglish Only for the courier, if they cannot find you.German Nur für den Paketdienst, falls er dich nicht findet.
shippingMethodEnglish ShippingGerman Versand
paymentEnglish PaymentGerman Zahlung
paymentHintEnglish All payments are encrypted.German Alle Zahlungen sind verschlüsselt.
cardNumberEnglish Card numberGerman Kartennummer
expiryEnglish Expiry (MM/YY)German Gültig bis (MM/JJ)
cvcEnglish Security codeGerman Prüfnummer
cardNameEnglish Name on cardGerman Name auf der Karte
billingSameEnglish Billing address is the same as deliveryGerman Rechnungsadresse entspricht der Lieferadresse
summaryEnglish Order summaryGerman Bestellübersicht
showSummaryEnglish Show order summaryGerman Bestellübersicht zeigen
hideSummaryEnglish Hide order summaryGerman Bestellübersicht ausblenden
placeOrderEnglish Place orderGerman Zahlungspflichtig bestellen
placingEnglish Placing your orderGerman Bestellung wird gesendet
placedEnglish Order placedGerman Bestellung aufgegeben
legalEnglish By placing your order you agree to our {terms}. Read about your {withdrawal} and how we handle your data in our {privacy}.German Mit deiner Bestellung akzeptierst du unsere {terms}. Hinweise zum {withdrawal} und zum Umgang mit deinen Daten findest du in der {privacy}.
termsEnglish termsGerman AGB
withdrawalEnglish right of withdrawalGerman Widerrufsrecht
privacyEnglish privacy policyGerman Datenschutzerklärung
requiredEnglish Please fill in this field.German Bitte fülle dieses Feld aus.
invalidEmailEnglish This email address looks incomplete, e.g. name@example.com.German Diese E-Mail-Adresse wirkt unvollständig, zum Beispiel name@beispiel.de.
fixErrorsEnglish Some fields need your attention.German Ein paar Felder brauchen noch deine Aufmerksamkeit.
freeEnglish FreeGerman Kostenlos
continueEnglish ContinueGerman Weiter
backEnglish BackGerman Zurück
changeEnglish ChangeGerman Ändern
reviewEnglish ReviewGerman Prüfen
informationEnglish InformationGerman Angaben
stepEnglish StepGerman Schritt
ofEnglish ofGerman von
reviewTitleEnglish Check and place your orderGerman Prüfen und bestellen
expressEnglish Express checkoutGerman Express-Kasse
expressHintEnglish One tap, your saved address and payment.German Ein Tipp, gespeicherte Adresse und Zahlung.
orEmailEnglish or continue with emailGerman oder mit E-Mail weiter
haveAccountEnglish Have an account?German Schon ein Konto?
signInEnglish Sign inGerman Anmelden
continuePaymentEnglish Continue to deliveryGerman Weiter zur Lieferung
thanksEnglish Thank you, {name}!German Danke, {name}!
confirmedEnglish Order {number} is confirmed.German Deine Bestellung {number} ist bestätigt.
sentToEnglish We sent the confirmation to {email}.German Die Bestätigung ist unterwegs an {email}.
arrivesEnglish ArrivesGerman Ankunft
trackEnglish Track orderGerman Sendung verfolgen
keepShoppingEnglish Continue shoppingGerman Weiter einkaufen
yourOrderEnglish Your orderGerman Deine Bestellung
detailsEnglish DetailsGerman Details
deliveredToEnglish Delivery addressGerman Lieferadresse
paidWithEnglish PaymentGerman Zahlung
shippedWithEnglish ShippingGerman Versand
accountEnglish Save your details for next timeGerman Daten fürs nächste Mal speichern
accountTextEnglish Create an account with one click and track this order without the email.German Leg mit einem Klick ein Konto an und verfolge diese Bestellung ohne E-Mail.
createAccountEnglish Create accountGerman Konto anlegen
printEnglish PrintGerman Drucken
qtyEnglish QtyGerman Menge

Accessibility

Read from the source of the component.

  • Sets aria-current, aria-describedby, aria-disabled, aria-expanded, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
  • Uses the role alert.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 14 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,109 lines
components/ui/checkout.tsx
'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} />;
}

Frequent questions

How do I install Checkout?

With aiw add @ai-website/checkout. The command puts components/ui/checkout.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, product-image and cart. It needs no further npm packages.

Which variants does Checkout have?

5 variants, One page, Two columns, Multi-step, Express and Confirmation. You pick one with the variant prop, for example variant="two-column".

Can I translate the texts of Checkout?

Yes. Checkout carries 68 texts of its own in English and German, such as “Checkout”, “Secure checkout” and “Contact”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Checkout?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Checkout with aiw_components and install it with aiw_add @ai-website/checkout. Name the variant in your prompt, for example variant="one-page", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard