Zum Inhalt springen
UI Kit
Accent

Gutscheinfeld für den Checkout

Rabattcodes und Gutscheinkarten an der Kasse. Codes werden geprüft, Fehler sagen, was hilft, und die Übersicht zählt mit.

4 VariantsEine von 4 Components in Cart und Checkout

Codes, die klar greifen

Das Gutscheinfeld ist ein kleiner Moment mit großer Wirkung, Käufer wollen sehen, dass der Rabatt ankommt. Dieses prüft jeden Code, erklärt jeden Fehler mit dem, was hilft, und rechnet den Rabatt sofort in die Übersicht.

„Feld mit Knopf“ setzt ein Feld mit Knopf in die Bestellübersicht, die Prüfung dreht sich kurz, ein Fehler erklärt sich selbst und ein angewendeter Code wird zum Chip. „Mehrere Codes“ öffnet das Feld über einen leisen Link, kombinierbare Codes stapeln sich als Chips und Gratisversand färbt die Versandzeile grün. Die „Gutschein-Wallet“ zeigt die Codes angemeldeter Kunden als gelochte Tickets, ein Klick wendet einen an, ein gesperrtes zeigt einen Balken bis zum Mindestbestellwert. Die „Gutscheinkarte“ füllt eine gezeichnete Karte beim Tippen von Nummer und PIN, prüft das Guthaben und nutzt einen gewählten Betrag, der Rest bleibt auf der Karte.

Leg das Feld hinter einen leisen Link, wenn Codes die Ausnahme sind. Schreib Fehler als Hilfe, „Code am 31. Mai abgelaufen, hier sind unsere aktuellen Angebote“. Zeig die Ersparnis in der Summe und neben dem Code.

Variants

Feld mit KnopfNeu in 1.2

Ein Feld mit Knopf in der Bestellübersicht. Die Prüfung dreht kurz, ein Fehler schüttelt die Zeile und erklärt sich, ein eingelöster Code wird zum Chip und der Rabatt zählt in die Summe.

Mehrere CodesNeu in 1.2

Ein leises „Hast du einen Rabattcode?“, das das Feld öffnet, das Plus dreht sich zum Kreuz. Kombinierbare Codes stapeln sich als Chips, Gratisversand färbt die Versandzeile grün.

Gutschein-WalletNeu in 1.2

Die Codes einer angemeldeten Kundin als gestanzte Tickets. Wählen löst sofort ein, ein gesperrtes Ticket zeigt einen Balken bis zum Mindestwert, darunter passt noch ein anderer Code.

GutscheinkarteNeu in 1.2

Nummer und PIN füllen beim Tippen eine gezeichnete Karte. Die Guthabenprüfung benennt jedes falsche Feld, ein Regler wählt den Betrag und der Rest bleibt auf der Karte.

Details

Was Gutscheinfeld mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/coupon-field.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 54 eigene Texte
Studio
Im Code anpassen
In Sammlungen
Shop-Checkout

Props

Die Schnittstelle CouponFieldProps aus components/ui/coupon-field.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    CouponFieldVariant

    inlinecollapsiblewalletgift-card

  • codesoptional
    Coupon[]

    Codes the shop knows

  • giftCardsoptional
    GiftCard[]

    Gift cards for the gift-card variant

  • defaultAppliedoptional
    string[]

    Codes applied on load

  • maxCodesoptional
    number

    collapsible: most codes at once

  • subtotaloptional
    number

    Summary: subtotal of the cart and shipping before codes

  • shippingoptional
    number
  • summaryoptional
    boolean

    Shows the summary next to or under the field

  • linesoptional
    { name: string; variant?: string; price: number; quantity?: number; image?: React.ReactNode }[]

    inline and collapsible: the cart lines above the summary

  • defaultOpenoptional
    boolean

    Opens the collapsible field on load

  • todayoptional
    string

    "Today" for expiry checks, ISO date. Default the real today.

  • onCheckoptional
    (code: string) => Promise<Coupon | CouponError> | Coupon | CouponError

    Your own check, e.g. a request to the server. Return the coupon or an error.

  • onChangeoptional
    (applied: Coupon[], total: number) => void
  • currencyoptional
    string

Eingebaute Texte

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

Alle 54 Texte anzeigen
labelEnglisch Discount codeDeutsch Rabattcode
placeholderEnglisch Enter codeDeutsch Code eingeben
applyEnglisch ApplyDeutsch Einlösen
checkingEnglisch CheckingDeutsch Wird geprüft
removeEnglisch Remove code {code}Deutsch Code {code} entfernen
haveEnglisch Have a discount code?Deutsch Hast du einen Rabattcode?
addAnotherEnglisch Add another codeDeutsch Weiteren Code hinzufügen
appliedEnglisch {code} applied, you save {amount}Deutsch {code} eingelöst, du sparst {amount}
appliedShortEnglisch appliedDeutsch eingelöst
freeShippingAppliedEnglisch {code} applied, shipping is on usDeutsch {code} eingelöst, der Versand geht auf uns
eEmptyEnglisch Enter your code to apply it.Deutsch Gib deinen Code ein, um ihn einzulösen.
eFormatEnglisch Codes use letters and numbers only, 3 to 20 characters.Deutsch Codes bestehen aus Buchstaben und Ziffern, 3 bis 20 Zeichen.
eUnknownEnglisch This code is not in our system. Check the spelling, codes are often printed in capitals.Deutsch Diesen Code kennen wir nicht. Prüf die Schreibweise, Codes stehen meist in Großbuchstaben.
eExpiredEnglisch This code was valid until {date}. Your other codes still work.Deutsch Dieser Code galt bis {date}. Deine anderen Codes gelten weiter.
eMinEnglisch This code works from {amount}. Add {left} more to use it.Deutsch Dieser Code gilt ab {amount}. Noch {left} mehr, dann greift er.
eTwiceEnglisch This code is already applied.Deutsch Dieser Code ist schon eingelöst.
eSingleEnglisch This code works on its own. Remove the other code to use it.Deutsch Dieser Code gilt allein. Entferne den anderen Code, um ihn zu nutzen.
eMaxEnglisch Up to {n} codes per order.Deutsch Bis zu {n} Codes pro Bestellung.
summaryEnglisch Order summaryDeutsch Bestellübersicht
showSummaryEnglisch Show summaryDeutsch Übersicht zeigen
hideSummaryEnglisch Hide summaryDeutsch Übersicht ausblenden
subtotalEnglisch SubtotalDeutsch Zwischensumme
discountEnglisch DiscountDeutsch Rabatt
shippingEnglisch ShippingDeutsch Versand
freeEnglisch FreeDeutsch Kostenlos
totalEnglisch TotalDeutsch Gesamt
walletTitleEnglisch Your codesDeutsch Deine Codes
walletHintEnglisch Pick one, it applies right away.Deutsch Wähl einen aus, er gilt sofort.
useEnglisch Use this codeDeutsch Diesen Code nutzen
inUseEnglisch In useDeutsch Aktiv
unlockAtEnglisch {left} more to unlockDeutsch Noch {left} bis zur Freischaltung
validUntilEnglisch Valid until {date}Deutsch Gültig bis {date}
otherEnglisch Another codeDeutsch Anderer Code
offEnglisch offDeutsch Rabatt
freeShippingEnglisch Free shippingDeutsch Gratisversand
gcTitleEnglisch Pay with a gift cardDeutsch Mit Gutscheinkarte zahlen
gcNumberEnglisch Card numberDeutsch Kartennummer
gcPinEnglisch PINDeutsch PIN
gcShowPinEnglisch Show PINDeutsch PIN zeigen
gcHidePinEnglisch Hide PINDeutsch PIN verbergen
gcCheckEnglisch Check balanceDeutsch Guthaben prüfen
gcBalanceEnglisch Balance {amount}Deutsch Guthaben {amount}
gcUseEnglisch Amount to useDeutsch Betrag einlösen
gcApplyEnglisch Use {amount}Deutsch {amount} einlösen
gcAppliedEnglisch Gift card ending {last}Deutsch Gutscheinkarte mit Endung {last}
gcLeftEnglisch {amount} stays on the cardDeutsch {amount} bleiben auf der Karte
gcENumberEnglisch A gift card number has 16 digits, you find it on the back.Deutsch Eine Kartennummer hat 16 Ziffern, du findest sie auf der Rückseite.
gcEPinEnglisch The PIN has 4 digits and sits under the scratch field.Deutsch Die PIN hat 4 Ziffern und steht unter dem Rubbelfeld.
gcEUnknownEnglisch Number and PIN do not match a card. Check both and try again.Deutsch Nummer und PIN passen zu keiner Karte. Prüf beides und versuch es noch einmal.
gcEEmptyEnglisch This card has no balance left.Deutsch Auf dieser Karte ist kein Guthaben mehr.
gcEAmountEnglisch Pick an amount between {min} and {max}.Deutsch Wähl einen Betrag zwischen {min} und {max}.
giftCardEnglisch Gift cardDeutsch Gutscheinkarte
gcRemoveEnglisch Remove gift cardDeutsch Gutscheinkarte entfernen
toPayEnglisch To payDeutsch Zu zahlen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

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

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 5 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 867 Zeilen
components/ui/coupon-field.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { useShake } from '@/components/ui/input';
import { ProductMedia, ShopMotion, TweenPrice, formatPrice } from '@/components/ui/product-image';

/*
 * Discount codes and gift cards at checkout, with a live summary.
 *
 *   inline       one field and a button, an applied code turns into a chip
 *   collapsible  "Have a code?" opens the field, several codes stack as chips
 *   wallet       the codes of the customer as tickets to pick, locked ones say what is missing
 *   gift-card    number and PIN, balance check and the amount to use, drawn on a card
 *
 * The check is a function you pass (`onCheck`), so the server decides. Without
 * it the block checks against `codes`, which is enough for a demo or a static shop.
 */

export type CouponFieldVariant = 'inline' | 'collapsible' | 'wallet' | 'gift-card';

export interface Coupon {
  code: string;
  /** percent off the subtotal, a fixed amount off, or free shipping */
  kind: 'percent' | 'amount' | 'shipping';
  /** Percent or amount, ignored for shipping */
  value?: number;
  /** Short line on the ticket, e.g. "Welcome to the club" */
  title?: string;
  /** Valid from this subtotal on */
  minOrder?: number;
  /** ISO date until which the code works */
  validUntil?: string;
  /** Combines with other codes (collapsible) */
  stackable?: boolean;
  /** wallet: shown as a ticket */
  inWallet?: boolean;
}

export interface GiftCard {
  /** 16 digits, spaces are ignored */
  number: string;
  pin: string;
  balance: number;
}

export type CouponError = 'empty' | 'format' | 'unknown' | 'expired' | 'min' | 'twice' | 'single' | 'max';

export interface CouponFieldProps {
  variant?: CouponFieldVariant;
  /** Codes the shop knows */
  codes?: Coupon[];
  /** Gift cards for the gift-card variant */
  giftCards?: GiftCard[];
  /** Codes applied on load */
  defaultApplied?: string[];
  /** collapsible: most codes at once */
  maxCodes?: number;
  /** Summary: subtotal of the cart and shipping before codes */
  subtotal?: number;
  shipping?: number;
  /** Shows the summary next to or under the field */
  summary?: boolean;
  /** inline and collapsible: the cart lines above the summary */
  lines?: { name: string; variant?: string; price: number; quantity?: number; image?: React.ReactNode }[];
  /** Opens the collapsible field on load */
  defaultOpen?: boolean;
  /** "Today" for expiry checks, ISO date. Default the real today. */
  today?: string;
  /** Your own check, e.g. a request to the server. Return the coupon or an error. */
  onCheck?: (code: string) => Promise<Coupon | CouponError> | Coupon | CouponError;
  onChange?: (applied: Coupon[], total: number) => void;
  currency?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    label: 'Discount code',
    placeholder: 'Enter code',
    apply: 'Apply',
    checking: 'Checking',
    remove: 'Remove code {code}',
    have: 'Have a discount code?',
    addAnother: 'Add another code',
    applied: '{code} applied, you save {amount}',
    appliedShort: 'applied',
    freeShippingApplied: '{code} applied, shipping is on us',
    eEmpty: 'Enter your code to apply it.',
    eFormat: 'Codes use letters and numbers only, 3 to 20 characters.',
    eUnknown: 'This code is not in our system. Check the spelling, codes are often printed in capitals.',
    eExpired: 'This code was valid until {date}. Your other codes still work.',
    eMin: 'This code works from {amount}. Add {left} more to use it.',
    eTwice: 'This code is already applied.',
    eSingle: 'This code works on its own. Remove the other code to use it.',
    eMax: 'Up to {n} codes per order.',
    summary: 'Order summary',
    showSummary: 'Show summary',
    hideSummary: 'Hide summary',
    subtotal: 'Subtotal',
    discount: 'Discount',
    shipping: 'Shipping',
    free: 'Free',
    total: 'Total',
    walletTitle: 'Your codes',
    walletHint: 'Pick one, it applies right away.',
    use: 'Use this code',
    inUse: 'In use',
    unlockAt: '{left} more to unlock',
    validUntil: 'Valid until {date}',
    other: 'Another code',
    off: 'off',
    freeShipping: 'Free shipping',
    gcTitle: 'Pay with a gift card',
    gcNumber: 'Card number',
    gcPin: 'PIN',
    gcShowPin: 'Show PIN',
    gcHidePin: 'Hide PIN',
    gcCheck: 'Check balance',
    gcBalance: 'Balance {amount}',
    gcUse: 'Amount to use',
    gcApply: 'Use {amount}',
    gcApplied: 'Gift card ending {last}',
    gcLeft: '{amount} stays on the card',
    gcENumber: 'A gift card number has 16 digits, you find it on the back.',
    gcEPin: 'The PIN has 4 digits and sits under the scratch field.',
    gcEUnknown: 'Number and PIN do not match a card. Check both and try again.',
    gcEEmpty: 'This card has no balance left.',
    gcEAmount: 'Pick an amount between {min} and {max}.',
    giftCard: 'Gift card',
    gcRemove: 'Remove gift card',
    toPay: 'To pay',
  },
  de: {
    label: 'Rabattcode',
    placeholder: 'Code eingeben',
    apply: 'Einlösen',
    checking: 'Wird geprüft',
    remove: 'Code {code} entfernen',
    have: 'Hast du einen Rabattcode?',
    addAnother: 'Weiteren Code hinzufügen',
    applied: '{code} eingelöst, du sparst {amount}',
    appliedShort: 'eingelöst',
    freeShippingApplied: '{code} eingelöst, der Versand geht auf uns',
    eEmpty: 'Gib deinen Code ein, um ihn einzulösen.',
    eFormat: 'Codes bestehen aus Buchstaben und Ziffern, 3 bis 20 Zeichen.',
    eUnknown: 'Diesen Code kennen wir nicht. Prüf die Schreibweise, Codes stehen meist in Großbuchstaben.',
    eExpired: 'Dieser Code galt bis {date}. Deine anderen Codes gelten weiter.',
    eMin: 'Dieser Code gilt ab {amount}. Noch {left} mehr, dann greift er.',
    eTwice: 'Dieser Code ist schon eingelöst.',
    eSingle: 'Dieser Code gilt allein. Entferne den anderen Code, um ihn zu nutzen.',
    eMax: 'Bis zu {n} Codes pro Bestellung.',
    summary: 'Bestellübersicht',
    showSummary: 'Übersicht zeigen',
    hideSummary: 'Übersicht ausblenden',
    subtotal: 'Zwischensumme',
    discount: 'Rabatt',
    shipping: 'Versand',
    free: 'Kostenlos',
    total: 'Gesamt',
    walletTitle: 'Deine Codes',
    walletHint: 'Wähl einen aus, er gilt sofort.',
    use: 'Diesen Code nutzen',
    inUse: 'Aktiv',
    unlockAt: 'Noch {left} bis zur Freischaltung',
    validUntil: 'Gültig bis {date}',
    other: 'Anderer Code',
    off: 'Rabatt',
    freeShipping: 'Gratisversand',
    gcTitle: 'Mit Gutscheinkarte zahlen',
    gcNumber: 'Kartennummer',
    gcPin: 'PIN',
    gcShowPin: 'PIN zeigen',
    gcHidePin: 'PIN verbergen',
    gcCheck: 'Guthaben prüfen',
    gcBalance: 'Guthaben {amount}',
    gcUse: 'Betrag einlösen',
    gcApply: '{amount} einlösen',
    gcApplied: 'Gutscheinkarte mit Endung {last}',
    gcLeft: '{amount} bleiben auf der Karte',
    gcENumber: 'Eine Kartennummer hat 16 Ziffern, du findest sie auf der Rückseite.',
    gcEPin: 'Die PIN hat 4 Ziffern und steht unter dem Rubbelfeld.',
    gcEUnknown: 'Nummer und PIN passen zu keiner Karte. Prüf beides und versuch es noch einmal.',
    gcEEmpty: 'Auf dieser Karte ist kein Guthaben mehr.',
    gcEAmount: 'Wähl einen Betrag zwischen {min} und {max}.',
    giftCard: 'Gutscheinkarte',
    gcRemove: 'Gutscheinkarte entfernen',
    toPay: 'Zu zahlen',
  },
};

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

const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const currencyOf = (locale: Locale, currency?: string) => currency ?? (locale === 'de' ? 'EUR' : 'USD');
const norm = (s: string) => s.trim().toUpperCase().replace(/\s+/g, '');
const dateOf = (locale: Locale, iso: string) => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(iso + 'T00:00:00Z'));
const todayIso = () => new Date().toISOString().slice(0, 10);

/** What a coupon takes off a subtotal */
function saving(c: Coupon, subtotal: number, shipping: number) {
  if (c.kind === 'shipping') return shipping;
  if (c.kind === 'percent') return round((subtotal * (c.value ?? 0)) / 100);
  return Math.min(subtotal, c.value ?? 0);
}

function totalsOf(applied: Coupon[], subtotal: number, shipping: number, giftCard = 0) {
  const free = applied.some((c) => c.kind === 'shipping');
  const discount = round(Math.min(subtotal, applied.filter((c) => c.kind !== 'shipping').reduce((a, c) => a + saving(c, subtotal, shipping), 0)));
  const ship = free ? 0 : shipping;
  const total = round(Math.max(0, subtotal - discount + ship - giftCard));
  return { discount, shipping: ship, total, before: round(subtotal - discount + ship) };
}

/** The shop's check against the list, or the one passed in */
function useCheck(props: CouponFieldProps, subtotal: number) {
  const today = props.today ?? todayIso();
  return async (raw: string, applied: Coupon[], max: number): Promise<Coupon | CouponError> => {
    const code = norm(raw);
    if (!code) return 'empty';
    if (!/^[A-Z0-9-]{3,20}$/.test(code)) return 'format';
    if (applied.some((c) => c.code.toUpperCase() === code)) return 'twice';
    let found: Coupon | CouponError | undefined;
    if (props.onCheck) found = await props.onCheck(code);
    else {
      // A short pause, as a real check takes a moment
      await new Promise((r) => setTimeout(r, 450));
      found = props.codes?.find((c) => c.code.toUpperCase() === code) ?? 'unknown';
    }
    if (typeof found === 'string') return found;
    if (found.validUntil && found.validUntil < today) return 'expired';
    if (found.minOrder && subtotal < found.minOrder) return 'min';
    if (applied.length >= max) return 'max';
    if (applied.length && (!found.stackable || applied.some((c) => !c.stackable))) return 'single';
    return found;
  };
}

function errorText(e: CouponError, t: Texts, ctx: { locale: Locale; coupon?: Coupon; subtotal: number; currency: string; max: number }) {
  const fp = (n: number) => formatPrice(ctx.locale, n, ctx.currency);
  switch (e) {
    case 'empty': return t.eEmpty;
    case 'format': return t.eFormat;
    case 'expired': return fill(t.eExpired, { date: ctx.coupon?.validUntil ? dateOf(ctx.locale, ctx.coupon.validUntil) : '' });
    case 'min': return fill(t.eMin, { amount: fp(ctx.coupon?.minOrder ?? 0), left: fp(round((ctx.coupon?.minOrder ?? 0) - ctx.subtotal)) });
    case 'twice': return t.eTwice;
    case 'single': return t.eSingle;
    case 'max': return fill(t.eMax, { n: ctx.max });
    default: return t.eUnknown;
  }
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

const inputClass =
  'h-11 w-0 min-w-0 flex-1 rounded-lg border border-input bg-background px-3.5 text-base uppercase tracking-[0.06em] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:normal-case placeholder:tracking-normal placeholder:text-subtle-foreground hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] sm:text-[14px]';

/** Field, button and message. Calls onApply with the coupon once the check says yes. */
function CodeForm({ applied, max, onApply, t, subtotal, currency, props, autoFocus, compact }: { applied: Coupon[]; max: number; onApply: (c: Coupon) => void; t: Texts; subtotal: number; currency: string; props: CouponFieldProps; autoFocus?: boolean; compact?: boolean }) {
  const locale = useLocale();
  const id = useId();
  const [code, setCode] = useState('');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<{ e: CouponError; coupon?: Coupon } | null>(null);
  const row = useRef<HTMLDivElement>(null);
  const input = useRef<HTMLInputElement>(null);
  const check = useCheck(props, subtotal);
  useShake(row, !!error);
  useEffect(() => {
    if (autoFocus) input.current?.focus();
  }, [autoFocus]);
  const submit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (busy) return;
    setBusy(true);
    const r = await check(code, applied, max);
    setBusy(false);
    if (typeof r === 'string') {
      const coupon = props.codes?.find((c) => c.code.toUpperCase() === norm(code));
      setError({ e: r, coupon });
      input.current?.focus();
      return;
    }
    setError(null);
    setCode('');
    onApply(r);
  };
  return (
    <form onSubmit={submit} noValidate>
      <label htmlFor={id} className={cn('mb-1.5 block text-[13px] font-medium text-foreground', compact && 'sr-only')}>
        {t.label}
      </label>
      <div ref={row} className="flex gap-2">
        <div className="relative flex min-w-0 flex-1">
          <Icon name="tag" size={16} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
          <input
            ref={input}
            id={id}
            value={code}
            onChange={(e) => { setCode(e.target.value); if (error) setError(null); }}
            placeholder={t.placeholder}
            autoComplete="off"
            autoCapitalize="characters"
            spellCheck={false}
            aria-invalid={!!error || undefined}
            aria-describedby={`${id}-msg`}
            className={cn(inputClass, 'pl-10')}
          />
        </div>
        <Button type="submit" variant="outline" disabled={busy} aria-busy={busy || undefined} className="min-w-[6.5rem] max-sm:min-w-0">
          {busy ? (
            <>
              <Icon name="loader" size={16} className="animate-kit-spin motion-reduce:animate-none" />
              <span className="max-sm:sr-only">{t.checking}</span>
            </>
          ) : (
            t.apply
          )}
        </Button>
      </div>
      <p id={`${id}-msg`} aria-live="polite" className={cn('text-[12px] leading-[1.45] text-destructive', error ? 'mt-2 animate-[kitshop-in_0.35s_var(--ease-kit)]' : 'sr-only')}>
        {error && errorText(error.e, t, { locale, coupon: error.coupon, subtotal, currency, max })}
      </p>
    </form>
  );
}

/** An applied code: code, what it saves, remove */
function Chip({ c, subtotal, shipping, currency, onRemove, t }: { c: Coupon; subtotal: number; shipping: number; currency: string; onRemove: () => void; t: Texts }) {
  const locale = useLocale();
  return (
    <li className="flex animate-[kitshop-pop_0.4s_var(--ease-kit)] items-center gap-2.5 rounded-lg border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] py-1 pl-3 pr-1">
      <Icon name={c.kind === 'shipping' ? 'truck' : 'tag'} size={15} className="shrink-0 text-success" />
      <span className="min-w-0 flex-1 text-[13px]">
        <span className="font-semibold uppercase tracking-[0.06em] text-foreground">{c.code}</span>{' '}
        <span className="text-success-foreground">{c.kind === 'shipping' ? t.freeShipping : `− ${formatPrice(locale, saving(c, subtotal, shipping), currency)}`}</span>
      </span>
      <button type="button" onClick={onRemove} aria-label={fill(t.remove, { code: c.code })} className="grid size-9 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:size-11">
        <Icon name="x" size={15} />
      </button>
    </li>
  );
}

function Lines({ subtotal, totals, t, currency, giftCard, giftLabel }: { subtotal: number; totals: ReturnType<typeof totalsOf>; t: Texts; currency: string; giftCard?: number; giftLabel?: string }) {
  const locale = useLocale();
  return (
    <dl className="space-y-2.5 text-[14px]">
      <div className="flex justify-between gap-4">
        <dt className="text-muted-foreground">{t.subtotal}</dt>
        <dd className="tabular-nums text-foreground">{formatPrice(locale, subtotal, currency)}</dd>
      </div>
      {totals.discount > 0 && (
        <div className="flex animate-[kitshop-in_0.4s_var(--ease-kit)] justify-between gap-4">
          <dt className="text-muted-foreground">{t.discount}</dt>
          <dd className="text-success-foreground">
            − <TweenPrice value={totals.discount} currency={currency} />
          </dd>
        </div>
      )}
      <div className="flex justify-between gap-4">
        <dt className="text-muted-foreground">{t.shipping}</dt>
        <dd className={cn('tabular-nums', totals.shipping ? 'text-foreground' : 'font-medium text-success-foreground')}>{totals.shipping ? formatPrice(locale, totals.shipping, currency) : t.free}</dd>
      </div>
      {!!giftCard && (
        <div className="flex animate-[kitshop-in_0.4s_var(--ease-kit)] justify-between gap-4">
          <dt className="min-w-0 truncate text-muted-foreground">{giftLabel ?? t.giftCard}</dt>
          <dd className="text-success-foreground">
            − <TweenPrice value={giftCard} currency={currency} />
          </dd>
        </div>
      )}
      <div className="flex items-baseline justify-between gap-4 border-t border-border pt-3.5">
        <dt className="text-[15px] font-semibold text-foreground">{giftCard ? t.toPay : t.total}</dt>
        <dd>
          <TweenPrice value={totals.total} currency={currency} className="text-[20px] font-semibold tracking-[-0.02em] text-foreground" />
        </dd>
      </div>
    </dl>
  );
}

/** Small cart lines with picture, for the summary cards */
function CartRows({ lines, currency }: { lines?: CouponFieldProps['lines']; currency: string }) {
  const locale = useLocale();
  if (!lines?.length) return null;
  return (
    <ul className="mb-5 space-y-3 border-b border-border pb-5">
      {lines.map((l, i) => (
        <li key={i} className="flex items-center gap-3">
          <span className="relative shrink-0">
            <ProductMedia className="size-12 rounded-lg">{l.image}</ProductMedia>
            {(l.quantity ?? 1) > 1 && <span className="absolute -right-1.5 -top-1.5 grid h-5 min-w-5 place-items-center rounded-full bg-foreground px-1 text-[10px] font-semibold tabular-nums text-background">{l.quantity}</span>}
          </span>
          <span className="min-w-0 flex-1">
            <span className="block truncate text-[13px] font-medium text-foreground">{l.name}</span>
            {l.variant && <span className="block truncate text-[12px] text-muted-foreground">{l.variant}</span>}
          </span>
          <span className="text-[13px] tabular-nums text-foreground">{formatPrice(locale, l.price * (l.quantity ?? 1), currency)}</span>
        </li>
      ))}
    </ul>
  );
}

/** The summary as a card from 1024 px, as a bar to open on smaller screens */
function Summary({ children, total, currency, t, className }: { children: React.ReactNode; total: number; currency: string; t: Texts; className?: string }) {
  const [open, setOpen] = useState(false);
  const id = useId();
  return (
    <div className={cn('rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
      <button type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)} className="flex min-h-14 w-full items-center gap-3 px-5 text-left text-[14px] font-medium text-foreground lg:hidden">
        <Icon name="receipt" size={17} className="text-muted-foreground" />
        {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')} />
        <TweenPrice value={total} currency={currency} className="ml-auto text-[16px] font-semibold text-foreground" />
      </button>
      <div id={id} className={cn('px-5 pb-5 lg:block lg:p-6', open ? 'block border-t border-border pt-4' : 'hidden')}>
        <h3 className="mb-4 text-[15px] font-semibold text-foreground max-lg:sr-only">{t.summary}</h3>
        {children}
      </div>
    </div>
  );
}

function useApplied(props: CouponFieldProps, subtotal: number, shipping: number) {
  const [applied, setApplied] = useState<Coupon[]>(() => (props.defaultApplied ?? []).map((c) => props.codes?.find((x) => x.code.toUpperCase() === c.toUpperCase())).filter((c): c is Coupon => !!c));
  const totals = totalsOf(applied, subtotal, shipping);
  const onChange = useRef(props.onChange);
  onChange.current = props.onChange;
  useEffect(() => {
    onChange.current?.(applied, totals.total);
  }, [applied, totals.total]);
  return { applied, setApplied, totals };
}

function Announce({ text }: { text: string }) {
  return (
    <p className="sr-only" role="status">
      {text}
    </p>
  );
}

/* ------------------------------------------------------------------ */
/* inline                                                              */
/* ------------------------------------------------------------------ */

function Inline(props: CouponFieldProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const subtotal = props.subtotal ?? 0;
  const shipping = props.shipping ?? 0;
  const { applied, setApplied, totals } = useApplied(props, subtotal, shipping);
  const [said, setSaid] = useState('');
  const c = applied[0];
  // The field and the chip swap places: focus follows, to the chip's remove button or back to the field
  const box = useRef<HTMLDivElement>(null);
  const seen = useRef(false);
  useEffect(() => {
    if (!seen.current) return void (seen.current = true);
    box.current?.querySelector<HTMLElement>(c ? 'button' : 'input')?.focus();
  }, [c?.code]);
  return (
    <div className={cn('mx-auto w-full max-w-[26rem] rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-md)] sm:p-6', props.className)}>
      <ShopMotion />
      <h3 className="mb-5 text-[15px] font-semibold text-foreground">{t.summary}</h3>
      <CartRows lines={props.lines} currency={currency} />
      <div ref={box} className="border-b border-border pb-5">
        {c ? (
          <div>
            <p className="mb-1.5 text-[13px] font-medium text-foreground">{t.label}</p>
            <ul>
              <Chip c={c} subtotal={subtotal} shipping={shipping} currency={currency} t={t} onRemove={() => { setApplied([]); setSaid(''); }} />
            </ul>
          </div>
        ) : (
          <CodeForm applied={applied} max={1} t={t} subtotal={subtotal} currency={currency} props={props} onApply={(x) => { setApplied([x]); setSaid(x.kind === 'shipping' ? fill(t.freeShippingApplied, { code: x.code }) : fill(t.applied, { code: x.code, amount: formatPrice(locale, saving(x, subtotal, shipping), currency) })); }} />
        )}
        <Announce text={said} />
      </div>
      {props.summary !== false && (
        <div className="mt-5">
          <Lines subtotal={subtotal} totals={totals} t={t} currency={currency} />
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* collapsible                                                         */
/* ------------------------------------------------------------------ */

function Collapsible(props: CouponFieldProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const subtotal = props.subtotal ?? 0;
  const shipping = props.shipping ?? 0;
  const max = props.maxCodes ?? 3;
  const { applied, setApplied, totals } = useApplied(props, subtotal, shipping);
  const [open, setOpen] = useState(!!props.defaultOpen);
  const [focusField, setFocusField] = useState(false);
  const [said, setSaid] = useState('');
  const panel = useId();
  const full = applied.length >= max;
  return (
    <div className={cn('mx-auto w-full max-w-[26rem] rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-md)] sm:p-6', props.className)}>
      <ShopMotion />
      <CartRows lines={props.lines} currency={currency} />
      {props.summary !== false && <Lines subtotal={subtotal} totals={totals} t={t} currency={currency} />}
      <div className={cn(props.summary !== false && 'mt-5 border-t border-border pt-2')}>
        {applied.length > 0 && (
          <ul className="mb-1 mt-3 space-y-2" aria-label={t.label}>
            {applied.map((c) => (
              <Chip key={c.code} c={c} subtotal={subtotal} shipping={shipping} currency={currency} t={t} onRemove={() => setApplied((xs) => xs.filter((x) => x.code !== c.code))} />
            ))}
          </ul>
        )}
        {!full && (
          <>
            <button
              type="button"
              aria-expanded={open}
              aria-controls={panel}
              onClick={() => { setOpen(!open); setFocusField(!open); }}
              className="flex min-h-11 w-full items-center gap-2 text-left text-[13px] font-medium text-foreground"
            >
              <span className="grid size-6 place-items-center rounded-full border border-border-strong text-muted-foreground">
                <Icon name="plus" size={13} className={cn('transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', open && 'rotate-45')} />
              </span>
              {applied.length ? t.addAnother : t.have}
            </button>
            <div id={panel} className={cn('grid transition-[grid-template-rows,opacity] duration-400 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr] opacity-100' : 'invisible grid-rows-[0fr] opacity-0')}>
              <div className="min-h-0 overflow-hidden px-0.5 pb-1">
                <div className="pb-2 pt-1">
                  <CodeForm
                    compact
                    autoFocus={focusField}
                    applied={applied}
                    max={max}
                    t={t}
                    subtotal={subtotal}
                    currency={currency}
                    props={props}
                    onApply={(x) => {
                      setApplied((xs) => [...xs, x]);
                      setSaid(x.kind === 'shipping' ? fill(t.freeShippingApplied, { code: x.code }) : fill(t.applied, { code: x.code, amount: formatPrice(locale, saving(x, subtotal, shipping), currency) }));
                      setOpen(false);
                    }}
                  />
                </div>
              </div>
            </div>
          </>
        )}
        <Announce text={said} />
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* wallet                                                              */
/* ------------------------------------------------------------------ */

/** A coupon ticket with punched sides, the notches cut with a mask */
function Ticket({ c, active, locked, left, onPick, t, currency, subtotal, shipping, index }: { c: Coupon; active: boolean; locked: boolean; left: number; onPick: () => void; t: Texts; currency: string; subtotal: number; shipping: number; index: number }) {
  const locale = useLocale();
  const amount = c.kind === 'percent' ? formatPercent(c.value ?? 0, locale) : c.kind === 'amount' ? new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency, maximumFractionDigits: Number.isInteger(c.value) ? 0 : 2 }).format(c.value ?? 0) : '';
  const pct = c.minOrder ? Math.min(100, Math.round((subtotal / c.minOrder) * 100)) : 100;
  const notch = 'radial-gradient(circle at 0 50%, transparent 9px, #000 9.5px) left / 51% 100% no-repeat, radial-gradient(circle at 100% 50%, transparent 9px, #000 9.5px) right / 51% 100% no-repeat';
  return (
    <li className="kit-rise" style={{ '--kit-delay': `${index * 70}ms` } as React.CSSProperties}>
      <label
        className={cn(
          'group/ticket relative flex h-full cursor-pointer items-stretch rounded-xl border transition-[border-color,background-color,transform] duration-300 ease-[var(--ease-kit)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid',
          active ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border-strong bg-card hover:-translate-y-0.5 hover:border-foreground/40',
          locked && 'cursor-not-allowed opacity-80 hover:translate-y-0 hover:border-border-strong',
        )}
        style={{ mask: notch, WebkitMask: notch } as React.CSSProperties}
      >
        <input type="radio" name="wallet" checked={active} disabled={locked} onChange={onPick} className="sr-only" />
        <span className={cn('grid w-[5.5rem] shrink-0 place-items-center border-r border-dashed px-2 text-center sm:w-24', active ? 'border-primary/50' : 'border-border-strong')}>
          {c.kind === 'shipping' ? (
            <Icon name="truck" size={26} className={active ? 'text-primary' : 'text-foreground'} />
          ) : (
            <span className={cn('text-[22px] font-semibold leading-none tracking-[-0.03em] tabular-nums', active ? 'text-primary' : 'text-foreground')}>
              {amount}
              <span className="mt-1 block text-[11px] font-medium tracking-normal text-muted-foreground">{t.off}</span>
            </span>
          )}
        </span>
        <span className="flex min-w-0 flex-1 flex-col py-3.5 pl-4 pr-3.5">
          <span className="flex items-start justify-between gap-2">
            <span className="min-w-0">
              <span className="block text-[14px] font-semibold text-foreground">{c.title ?? (c.kind === 'shipping' ? t.freeShipping : c.code)}</span>
              <span className="mt-0.5 block text-[12px] font-medium uppercase tracking-[0.08em] text-muted-foreground">{c.code}</span>
            </span>
            <span aria-hidden className={cn('mt-0.5 grid size-5 shrink-0 place-items-center rounded-full border-2 transition-colors', active ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>
              {active && <Icon name="check" size={11} strokeWidth={3} className="animate-[kitshop-pop_0.35s_var(--ease-kit)]" />}
            </span>
          </span>
          {locked ? (
            <span className="mt-auto block pt-3">
              <span className="block h-1 overflow-hidden rounded-full bg-track">
                <span className="block h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
              </span>
              <span className="mt-1.5 block text-[12px] text-muted-foreground">{fill(t.unlockAt, { left: formatPrice(locale, left, currency) })}</span>
            </span>
          ) : (
            <span className="mt-auto block pt-3 text-[12px] text-muted-foreground">
              {active && c.kind !== 'shipping' ? <span className="font-medium text-success-foreground">− {formatPrice(locale, saving(c, subtotal, shipping), currency)}</span> : c.validUntil ? fill(t.validUntil, { date: dateOf(locale, c.validUntil) }) : ' '}
            </span>
          )}
        </span>
      </label>
    </li>
  );
}

function Wallet(props: CouponFieldProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const subtotal = props.subtotal ?? 0;
  const shipping = props.shipping ?? 0;
  const { applied, setApplied, totals } = useApplied(props, subtotal, shipping);
  const wallet = (props.codes ?? []).filter((c) => c.inWallet);
  return (
    <div className={cn('grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-10', props.className)}>
      <ShopMotion />
      {props.summary !== false && (
        <Summary total={totals.total} currency={currency} t={t} className="lg:order-2 lg:self-start">
          <Lines subtotal={subtotal} totals={totals} t={t} currency={currency} />
        </Summary>
      )}
      <fieldset className="min-w-0">
        <legend className="text-[17px] font-semibold text-foreground">{t.walletTitle}</legend>
        <p className="mt-1 text-[13px] text-muted-foreground">{t.walletHint}</p>
        <ul className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
          {wallet.map((c, i) => {
            const left = c.minOrder ? round(c.minOrder - subtotal) : 0;
            return (
              <Ticket
                key={c.code}
                index={i}
                c={c}
                active={applied.some((a) => a.code === c.code)}
                locked={left > 0}
                left={left}
                onPick={() => setApplied([c])}
                t={t}
                currency={currency}
                subtotal={subtotal}
                shipping={shipping}
              />
            );
          })}
        </ul>
        <div className="mt-6 border-t border-border pt-5">
          <CodeForm
            applied={[]}
            max={1}
            t={{ ...t, label: t.other }}
            subtotal={subtotal}
            currency={currency}
            props={props}
            onApply={(x) => setApplied([x])}
          />
        </div>
      </fieldset>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* gift-card                                                           */
/* ------------------------------------------------------------------ */

const digits = (s: string) => s.replace(/\D/g, '');
const groups = (s: string) => digits(s).slice(0, 16).replace(/(\d{4})(?=\d)/g, '$1 ');

function CardArt({ number, balance, currency }: { number: string; balance?: number; currency: string }) {
  const locale = useLocale();
  const d = digits(number).padEnd(16, '•');
  const shown = d.replace(/(.{4})(?=.)/g, '$1 ');
  return (
    <div aria-hidden data-clip-ok className="relative isolate aspect-[1.586] w-full max-w-[22rem] overflow-hidden rounded-2xl bg-[linear-gradient(135deg,color-mix(in_oklab,var(--primary)_88%,black),color-mix(in_oklab,var(--primary)_55%,black))] p-5 text-white shadow-[var(--shadow-lg)] sm:p-6">
      <svg viewBox="0 0 300 190" className="absolute inset-0 -z-10 size-full opacity-30" preserveAspectRatio="xMidYMid slice">
        <circle cx="250" cy="20" r="90" fill="none" stroke="white" strokeOpacity="0.5" />
        <circle cx="250" cy="20" r="60" fill="none" stroke="white" strokeOpacity="0.35" />
        <circle cx="250" cy="20" r="30" fill="none" stroke="white" strokeOpacity="0.25" />
        <path d="M0 150 C 80 120, 160 190, 300 140" fill="none" stroke="white" strokeOpacity="0.35" />
      </svg>
      <div className="flex items-start justify-between">
        <Icon name="gift" size={24} />
        {balance !== undefined && <span className="animate-[kitshop-pop_0.45s_var(--ease-kit)] rounded-full bg-white/18 px-2.5 py-1 text-[12px] font-semibold tabular-nums backdrop-blur">{formatPrice(locale, balance, currency)}</span>}
      </div>
      <div className="absolute inset-x-5 bottom-5 sm:inset-x-6 sm:bottom-6">
        <div className="mb-3 h-7 w-10 rounded-md bg-[linear-gradient(135deg,oklch(0.86_0.08_85),oklch(0.7_0.09_75))] opacity-90" />
        <p className="whitespace-nowrap text-[clamp(14px,4.4vw,19px)] font-medium tabular-nums tracking-[0.08em]">{shown}</p>
      </div>
    </div>
  );
}

function GiftCardVariant(props: CouponFieldProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const subtotal = props.subtotal ?? 0;
  const shipping = props.shipping ?? 0;
  const { applied, totals } = useApplied(props, subtotal, shipping);
  const id = useId();
  const [number, setNumber] = useState('');
  const [pin, setPin] = useState('');
  const [showPin, setShowPin] = useState(false);
  const [errors, setErrors] = useState<{ number?: string; pin?: string; form?: string; amount?: string }>({});
  const [card, setCard] = useState<GiftCard | null>(null);
  const [busy, setBusy] = useState(false);
  const [amount, setAmount] = useState(0);
  const [used, setUsed] = useState<{ amount: number; last: string } | null>(null);
  const form = useRef<HTMLFormElement>(null);
  useShake(form, !!(errors.number || errors.pin || errors.form));
  const max = card ? round(Math.min(card.balance, totals.before)) : 0;
  const fp = (n: number) => formatPrice(locale, n, currency);
  const final = totalsOf(applied, subtotal, shipping, used?.amount ?? 0);

  const check = async (e: React.FormEvent) => {
    e.preventDefault();
    const next: typeof errors = {};
    if (digits(number).length !== 16) next.number = t.gcENumber;
    if (!/^\d{4}$/.test(pin)) next.pin = t.gcEPin;
    setErrors(next);
    if (next.number || next.pin) {
      form.current?.querySelector<HTMLInputElement>('[aria-invalid="true"]')?.focus();
      return;
    }
    setBusy(true);
    await new Promise((r) => setTimeout(r, 500));
    setBusy(false);
    const found = props.giftCards?.find((g) => digits(g.number) === digits(number) && g.pin === pin);
    if (!found) return setErrors({ form: t.gcEUnknown });
    if (found.balance <= 0) return setErrors({ form: t.gcEEmpty });
    setCard(found);
    setAmount(round(Math.min(found.balance, totals.before)));
  };

  const apply = () => {
    if (!card) return;
    if (!(amount > 0 && amount <= max)) return setErrors({ amount: fill(t.gcEAmount, { min: fp(0.01), max: fp(max) }) });
    setErrors({});
    setUsed({ amount: round(amount), last: digits(card.number).slice(-4) });
  };

  const field = 'h-11 w-full rounded-lg border border-input bg-background px-3.5 text-base tabular-nums text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] sm:text-[14px]';

  return (
    <div className={cn('grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-10', props.className)}>
      <ShopMotion />
      {props.summary !== false && (
        <Summary total={final.total} currency={currency} t={t} className="lg:order-2 lg:self-start">
          <Lines subtotal={subtotal} totals={final} t={t} currency={currency} giftCard={used?.amount} giftLabel={used ? fill(t.gcApplied, { last: used.last }) : undefined} />
        </Summary>
      )}
      <div className="min-w-0">
        <h3 className="text-[17px] font-semibold text-foreground">{t.gcTitle}</h3>
        <div className="mt-5 grid grid-cols-1 items-start gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:gap-8">
          <div className="md:order-2">
            <CardArt number={number} balance={card?.balance} currency={currency} />
          </div>
          {used && card ? (
            <div className="animate-[kitshop-in_0.45s_var(--ease-kit)] rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_7%,var(--card))] p-4" role="status">
              <p className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
                <Icon name="check-circle" size={17} className="text-success" />
                {fill(t.gcApplied, { last: used.last })}
              </p>
              <p className="mt-1 text-[13px] text-muted-foreground">
                − {fp(used.amount)} · {fill(t.gcLeft, { amount: fp(round(card.balance - used.amount)) })}
              </p>
              <button type="button" onClick={() => setUsed(null)} className="mt-3 inline-flex h-10 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-foreground hover:bg-accent max-sm:-ml-2 max-sm:h-11 sm:-ml-2">
                <Icon name="undo" size={14} />
                {t.gcRemove}
              </button>
            </div>
          ) : card ? (
            <div className="animate-[kitshop-in_0.45s_var(--ease-kit)]">
              <p className="text-[14px] font-medium text-foreground">{fill(t.gcBalance, { amount: fp(card.balance) })}</p>
              <label htmlFor={`${id}-amount`} className="mt-4 block text-[13px] font-medium text-foreground">
                {t.gcUse}
              </label>
              <div className="mt-1.5 flex items-center gap-3">
                <input
                  id={`${id}-amount`}
                  type="range"
                  min={0}
                  max={max}
                  step={0.5}
                  value={amount}
                  onChange={(e) => { setAmount(Number(e.target.value)); setErrors({}); }}
                  aria-valuetext={fp(amount)}
                  className="h-11 min-w-0 flex-1 cursor-pointer accent-[var(--primary)]"
                />
                <output className="w-24 shrink-0 text-right text-[15px] font-semibold tabular-nums text-foreground">{fp(amount)}</output>
              </div>
              {errors.amount && <p className="mt-1 text-[12px] text-destructive" role="alert">{errors.amount}</p>}
              <Button onClick={apply} className="mt-4 w-full sm:w-auto" icon="arrow-right">
                {fill(t.gcApply, { amount: fp(amount) })}
              </Button>
            </div>
          ) : (
            <form ref={form} onSubmit={check} noValidate className="space-y-4">
              <div>
                <label htmlFor={`${id}-n`} className="mb-1.5 block text-[13px] font-medium text-foreground">
                  {t.gcNumber}
                </label>
                <input id={`${id}-n`} inputMode="numeric" autoComplete="off" placeholder="0000 0000 0000 0000" value={groups(number)} onChange={(e) => { setNumber(digits(e.target.value).slice(0, 16)); setErrors({}); }} aria-invalid={!!errors.number || undefined} aria-describedby={errors.number ? `${id}-ne` : undefined} className={cn(field, 'tracking-[0.06em]')} />
                {errors.number && <p id={`${id}-ne`} className="mt-1.5 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{errors.number}</p>}
              </div>
              <div>
                <label htmlFor={`${id}-p`} className="mb-1.5 block text-[13px] font-medium text-foreground">
                  {t.gcPin}
                </label>
                <div className="relative w-36">
                  <input id={`${id}-p`} inputMode="numeric" type={showPin ? 'text' : 'password'} autoComplete="off" maxLength={4} placeholder="••••" value={pin} onChange={(e) => { setPin(digits(e.target.value).slice(0, 4)); setErrors({}); }} aria-invalid={!!errors.pin || undefined} aria-describedby={errors.pin ? `${id}-pe` : undefined} className={cn(field, 'pr-11 tracking-[0.3em]')} />
                  <button type="button" onClick={() => setShowPin(!showPin)} aria-label={showPin ? t.gcHidePin : t.gcShowPin} aria-pressed={showPin} className="absolute right-0 top-0 grid size-11 place-items-center rounded-r-lg text-muted-foreground hover:text-foreground">
                    <Icon name={showPin ? 'eye-off' : 'eye'} size={16} />
                  </button>
                </div>
                {errors.pin && <p id={`${id}-pe`} className="mt-1.5 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{errors.pin}</p>}
              </div>
              {errors.form && (
                <p role="alert" className="flex animate-[kitshop-in_0.3s_var(--ease-kit)] gap-2 rounded-lg bg-[color-mix(in_oklab,var(--destructive)_9%,var(--card))] px-3 py-2.5 text-[13px] text-foreground">
                  <Icon name="alert-circle" size={16} className="mt-0.5 shrink-0 text-destructive" />
                  {errors.form}
                </p>
              )}
              <Button type="submit" variant="outline" disabled={busy} className="w-full sm:w-auto">
                {busy && <Icon name="loader" size={16} className="animate-kit-spin motion-reduce:animate-none" />}
                {t.gcCheck}
              </Button>
            </form>
          )}
        </div>
      </div>
    </div>
  );
}

/**
 * Discount codes and gift cards with a live summary: one field, a field
 * that opens and stacks codes, a wallet of tickets or a gift card with
 * balance check. Every error says what to do next.
 */
export function CouponField(props: CouponFieldProps) {
  const locale = useLocale();
  const t = useTexts(TEXTS, props.labels);
  const currency = currencyOf(locale, props.currency);
  const { variant = 'inline' } = props;
  if (variant === 'collapsible') return <Collapsible {...props} t={t} currency={currency} />;
  if (variant === 'wallet') return <Wallet {...props} t={t} currency={currency} />;
  if (variant === 'gift-card') return <GiftCardVariant {...props} t={t} currency={currency} />;
  return <Inline {...props} t={t} currency={currency} />;
}

Häufige Fragen

Wie installiere ich Gutscheinfeld?

Mit aiw add @ai-website/coupon-field. Der Befehl legt die Datei components/ui/coupon-field.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, product-image und input. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Gutscheinfeld?

4 Varianten, Feld mit Knopf, Mehrere Codes, Gutschein-Wallet und Gutscheinkarte. Du wählst sie mit der Prop variant, zum Beispiel variant="collapsible".

Kann ich die Texte von Gutscheinfeld übersetzen?

Ja. Gutscheinfeld bringt 54 eigene Texte auf Deutsch und Englisch mit, etwa „Rabattcode“, „Code eingeben“ und „Einlösen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Gutscheinfeld einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert