Skip to content
UI Kit
Accent color

Coupon code field

Discount codes and gift cards at checkout. Codes are checked, errors say what to do next and the summary counts along.

4 variantsOne of 4 components in Cart and checkout

Codes that apply clearly

A coupon field is a small moment with a big effect, the buyer wants to see the discount land. This one checks every code, explains each error with what to do next and counts the discount into the summary right away.

Inline field puts one field with a button in the order summary, the check spins briefly, an error explains itself and an applied code becomes a chip. Stacked codes opens the field from a quiet link, codes that combine stack as chips and free shipping turns the shipping line green. Coupon wallet shows the codes of a signed in customer as punched tickets, picking one applies it at once and a locked ticket shows a bar to its minimum order. Gift card fills a drawn card as number and PIN are typed, checks the balance and uses a chosen amount, the rest stays on the card.

Keep the field behind a quiet link when codes are the exception. Write errors as help, Code expired on 31 May, here are our current offers. Show the saving in the total as well as next to the code.

Variants

Inline fieldNew in 1.2

One field with a button in the order summary. The check spins briefly, an error shakes the row and explains itself, an applied code becomes a chip and the discount counts into the total.

Stacked codesNew in 1.2

A quiet "Have a discount code?" that opens the field with its plus turning into a cross. Codes that combine stack as chips, free shipping turns the shipping line green.

Coupon walletNew in 1.2

The codes of a signed-in customer as punched tickets. Picking one applies it at once, a locked ticket shows a bar to its minimum order, another code still fits in below.

Gift cardNew in 1.2

Number and PIN fill a drawn card as they are typed. The balance check names each wrong field, a slider picks the amount to use and the rest stays on the card.

Details

What Coupon field brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/coupon-field.tsx
Brings along
utilskitbuttoniconproduct-imageinput
npm packages
None
Languages
English and German, 54 texts of its own
Studio
Adjust in code
In collections
Shop checkout

Props

The CouponFieldProps interface from components/ui/coupon-field.tsx, with the comments from the code.

  • 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

Built-in texts

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

Show all 54 texts
labelEnglish Discount codeGerman Rabattcode
placeholderEnglish Enter codeGerman Code eingeben
applyEnglish ApplyGerman Einlösen
checkingEnglish CheckingGerman Wird geprüft
removeEnglish Remove code {code}German Code {code} entfernen
haveEnglish Have a discount code?German Hast du einen Rabattcode?
addAnotherEnglish Add another codeGerman Weiteren Code hinzufügen
appliedEnglish {code} applied, you save {amount}German {code} eingelöst, du sparst {amount}
appliedShortEnglish appliedGerman eingelöst
freeShippingAppliedEnglish {code} applied, shipping is on usGerman {code} eingelöst, der Versand geht auf uns
eEmptyEnglish Enter your code to apply it.German Gib deinen Code ein, um ihn einzulösen.
eFormatEnglish Codes use letters and numbers only, 3 to 20 characters.German Codes bestehen aus Buchstaben und Ziffern, 3 bis 20 Zeichen.
eUnknownEnglish This code is not in our system. Check the spelling, codes are often printed in capitals.German Diesen Code kennen wir nicht. Prüf die Schreibweise, Codes stehen meist in Großbuchstaben.
eExpiredEnglish This code was valid until {date}. Your other codes still work.German Dieser Code galt bis {date}. Deine anderen Codes gelten weiter.
eMinEnglish This code works from {amount}. Add {left} more to use it.German Dieser Code gilt ab {amount}. Noch {left} mehr, dann greift er.
eTwiceEnglish This code is already applied.German Dieser Code ist schon eingelöst.
eSingleEnglish This code works on its own. Remove the other code to use it.German Dieser Code gilt allein. Entferne den anderen Code, um ihn zu nutzen.
eMaxEnglish Up to {n} codes per order.German Bis zu {n} Codes pro Bestellung.
summaryEnglish Order summaryGerman Bestellübersicht
showSummaryEnglish Show summaryGerman Übersicht zeigen
hideSummaryEnglish Hide summaryGerman Übersicht ausblenden
subtotalEnglish SubtotalGerman Zwischensumme
discountEnglish DiscountGerman Rabatt
shippingEnglish ShippingGerman Versand
freeEnglish FreeGerman Kostenlos
totalEnglish TotalGerman Gesamt
walletTitleEnglish Your codesGerman Deine Codes
walletHintEnglish Pick one, it applies right away.German Wähl einen aus, er gilt sofort.
useEnglish Use this codeGerman Diesen Code nutzen
inUseEnglish In useGerman Aktiv
unlockAtEnglish {left} more to unlockGerman Noch {left} bis zur Freischaltung
validUntilEnglish Valid until {date}German Gültig bis {date}
otherEnglish Another codeGerman Anderer Code
offEnglish offGerman Rabatt
freeShippingEnglish Free shippingGerman Gratisversand
gcTitleEnglish Pay with a gift cardGerman Mit Gutscheinkarte zahlen
gcNumberEnglish Card numberGerman Kartennummer
gcPinEnglish PINGerman PIN
gcShowPinEnglish Show PINGerman PIN zeigen
gcHidePinEnglish Hide PINGerman PIN verbergen
gcCheckEnglish Check balanceGerman Guthaben prüfen
gcBalanceEnglish Balance {amount}German Guthaben {amount}
gcUseEnglish Amount to useGerman Betrag einlösen
gcApplyEnglish Use {amount}German {amount} einlösen
gcAppliedEnglish Gift card ending {last}German Gutscheinkarte mit Endung {last}
gcLeftEnglish {amount} stays on the cardGerman {amount} bleiben auf der Karte
gcENumberEnglish A gift card number has 16 digits, you find it on the back.German Eine Kartennummer hat 16 Ziffern, du findest sie auf der Rückseite.
gcEPinEnglish The PIN has 4 digits and sits under the scratch field.German Die PIN hat 4 Ziffern und steht unter dem Rubbelfeld.
gcEUnknownEnglish Number and PIN do not match a card. Check both and try again.German Nummer und PIN passen zu keiner Karte. Prüf beides und versuch es noch einmal.
gcEEmptyEnglish This card has no balance left.German Auf dieser Karte ist kein Guthaben mehr.
gcEAmountEnglish Pick an amount between {min} and {max}.German Wähl einen Betrag zwischen {min} und {max}.
giftCardEnglish Gift cardGerman Gutscheinkarte
gcRemoveEnglish Remove gift cardGerman Gutscheinkarte entfernen
toPayEnglish To payGerman Zu zahlen

Accessibility

Read from the source of the component.

  • Sets aria-busy, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-live, aria-pressed and aria-valuetext, so screen readers announce purpose and state.
  • Uses the roles alert and status.
  • 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. 5 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, 867 lines
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} />;
}

Frequent questions

How do I install Coupon field?

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

Which variants does Coupon field have?

4 variants, Inline field, Stacked codes, Coupon wallet and Gift card. You pick one with the variant prop, for example variant="collapsible".

Can I translate the texts of Coupon field?

Yes. Coupon field carries 54 texts of its own in English and German, such as “Discount code”, “Enter code” and “Apply”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Coupon field?

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

Often searched

Jump to

Copied to the clipboard