Coupon code field
Discount codes and gift cards at checkout. Codes are checked, errors say what to do next and the summary counts along.
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
Details
What Coupon field brings along, read from the registry and the source.
- Category
- Cart and checkout
- Area
- E-Commerce
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CouponFieldProps interface from components/ui/coupon-field.tsx, with the comments from the code.
variantoptionalCouponFieldVariantinlinecollapsiblewalletgift-card
codesoptionalCoupon[]Codes the shop knows
giftCardsoptionalGiftCard[]Gift cards for the gift-card variant
defaultAppliedoptionalstring[]Codes applied on load
maxCodesoptionalnumbercollapsible: most codes at once
subtotaloptionalnumberSummary: subtotal of the cart and shipping before codes
shippingoptionalnumbersummaryoptionalbooleanShows 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
defaultOpenoptionalbooleanOpens the collapsible field on load
todayoptionalstring"Today" for expiry checks, ISO date. Default the real today.
onCheckoptional(code: string) => Promise<Coupon | CouponError> | Coupon | CouponErrorYour own check, e.g. a request to the server. Return the coupon or an error.
onChangeoptional(applied: Coupon[], total: number) => voidcurrencyoptionalstring
Built-in texts
Coupon field writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 54 texts
| Key | English | German |
|---|---|---|
| label | English Discount code | German Rabattcode |
| placeholder | English Enter code | German Code eingeben |
| apply | English Apply | German Einlösen |
| checking | English Checking | German Wird geprüft |
| remove | English Remove code {code} | German Code {code} entfernen |
| have | English Have a discount code? | German Hast du einen Rabattcode? |
| addAnother | English Add another code | German Weiteren Code hinzufügen |
| applied | English {code} applied, you save {amount} | German {code} eingelöst, du sparst {amount} |
| appliedShort | English applied | German eingelöst |
| freeShippingApplied | English {code} applied, shipping is on us | German {code} eingelöst, der Versand geht auf uns |
| eEmpty | English Enter your code to apply it. | German Gib deinen Code ein, um ihn einzulösen. |
| eFormat | English Codes use letters and numbers only, 3 to 20 characters. | German Codes bestehen aus Buchstaben und Ziffern, 3 bis 20 Zeichen. |
| eUnknown | English 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. |
| eExpired | English This code was valid until {date}. Your other codes still work. | German Dieser Code galt bis {date}. Deine anderen Codes gelten weiter. |
| eMin | English This code works from {amount}. Add {left} more to use it. | German Dieser Code gilt ab {amount}. Noch {left} mehr, dann greift er. |
| eTwice | English This code is already applied. | German Dieser Code ist schon eingelöst. |
| eSingle | English 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. |
| eMax | English Up to {n} codes per order. | German Bis zu {n} Codes pro Bestellung. |
| summary | English Order summary | German Bestellübersicht |
| showSummary | English Show summary | German Übersicht zeigen |
| hideSummary | English Hide summary | German Übersicht ausblenden |
| subtotal | English Subtotal | German Zwischensumme |
| discount | English Discount | German Rabatt |
| shipping | English Shipping | German Versand |
| free | English Free | German Kostenlos |
| total | English Total | German Gesamt |
| walletTitle | English Your codes | German Deine Codes |
| walletHint | English Pick one, it applies right away. | German Wähl einen aus, er gilt sofort. |
| use | English Use this code | German Diesen Code nutzen |
| inUse | English In use | German Aktiv |
| unlockAt | English {left} more to unlock | German Noch {left} bis zur Freischaltung |
| validUntil | English Valid until {date} | German Gültig bis {date} |
| other | English Another code | German Anderer Code |
| off | English off | German Rabatt |
| freeShipping | English Free shipping | German Gratisversand |
| gcTitle | English Pay with a gift card | German Mit Gutscheinkarte zahlen |
| gcNumber | English Card number | German Kartennummer |
| gcPin | English PIN | German PIN |
| gcShowPin | English Show PIN | German PIN zeigen |
| gcHidePin | English Hide PIN | German PIN verbergen |
| gcCheck | English Check balance | German Guthaben prüfen |
| gcBalance | English Balance {amount} | German Guthaben {amount} |
| gcUse | English Amount to use | German Betrag einlösen |
| gcApply | English Use {amount} | German {amount} einlösen |
| gcApplied | English Gift card ending {last} | German Gutscheinkarte mit Endung {last} |
| gcLeft | English {amount} stays on the card | German {amount} bleiben auf der Karte |
| gcENumber | English 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. |
| gcEPin | English The PIN has 4 digits and sits under the scratch field. | German Die PIN hat 4 Ziffern und steht unter dem Rubbelfeld. |
| gcEUnknown | English 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. |
| gcEEmpty | English This card has no balance left. | German Auf dieser Karte ist kein Guthaben mehr. |
| gcEAmount | English Pick an amount between {min} and {max}. | German Wähl einen Betrag zwischen {min} und {max}. |
| giftCard | English Gift card | German Gutscheinkarte |
| gcRemove | English Remove gift card | German Gutscheinkarte entfernen |
| toPay | English To pay | German 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
'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.