Gutscheinfeld für den Checkout
Rabattcodes und Gutscheinkarten an der Kasse. Codes werden geprüft, Fehler sagen, was hilft, und die Übersicht zählt mit.
Codes, die klar greifen
Das Gutscheinfeld ist ein kleiner Moment mit großer Wirkung, Käufer wollen sehen, dass der Rabatt ankommt. Dieses prüft jeden Code, erklärt jeden Fehler mit dem, was hilft, und rechnet den Rabatt sofort in die Übersicht.
„Feld mit Knopf“ setzt ein Feld mit Knopf in die Bestellübersicht, die Prüfung dreht sich kurz, ein Fehler erklärt sich selbst und ein angewendeter Code wird zum Chip. „Mehrere Codes“ öffnet das Feld über einen leisen Link, kombinierbare Codes stapeln sich als Chips und Gratisversand färbt die Versandzeile grün. Die „Gutschein-Wallet“ zeigt die Codes angemeldeter Kunden als gelochte Tickets, ein Klick wendet einen an, ein gesperrtes zeigt einen Balken bis zum Mindestbestellwert. Die „Gutscheinkarte“ füllt eine gezeichnete Karte beim Tippen von Nummer und PIN, prüft das Guthaben und nutzt einen gewählten Betrag, der Rest bleibt auf der Karte.
Leg das Feld hinter einen leisen Link, wenn Codes die Ausnahme sind. Schreib Fehler als Hilfe, „Code am 31. Mai abgelaufen, hier sind unsere aktuellen Angebote“. Zeig die Ersparnis in der Summe und neben dem Code.
Variants
Details
Was Gutscheinfeld mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Cart und Checkout
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/coupon-field.tsx
- Bringt mit
- utilskitbuttoniconproduct-imageinput
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 54 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- Shop-Checkout
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CouponFieldProps aus components/ui/coupon-field.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Gutscheinfeld selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 54 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| label | Englisch Discount code | Deutsch Rabattcode |
| placeholder | Englisch Enter code | Deutsch Code eingeben |
| apply | Englisch Apply | Deutsch Einlösen |
| checking | Englisch Checking | Deutsch Wird geprüft |
| remove | Englisch Remove code {code} | Deutsch Code {code} entfernen |
| have | Englisch Have a discount code? | Deutsch Hast du einen Rabattcode? |
| addAnother | Englisch Add another code | Deutsch Weiteren Code hinzufügen |
| applied | Englisch {code} applied, you save {amount} | Deutsch {code} eingelöst, du sparst {amount} |
| appliedShort | Englisch applied | Deutsch eingelöst |
| freeShippingApplied | Englisch {code} applied, shipping is on us | Deutsch {code} eingelöst, der Versand geht auf uns |
| eEmpty | Englisch Enter your code to apply it. | Deutsch Gib deinen Code ein, um ihn einzulösen. |
| eFormat | Englisch Codes use letters and numbers only, 3 to 20 characters. | Deutsch Codes bestehen aus Buchstaben und Ziffern, 3 bis 20 Zeichen. |
| eUnknown | Englisch This code is not in our system. Check the spelling, codes are often printed in capitals. | Deutsch Diesen Code kennen wir nicht. Prüf die Schreibweise, Codes stehen meist in Großbuchstaben. |
| eExpired | Englisch This code was valid until {date}. Your other codes still work. | Deutsch Dieser Code galt bis {date}. Deine anderen Codes gelten weiter. |
| eMin | Englisch This code works from {amount}. Add {left} more to use it. | Deutsch Dieser Code gilt ab {amount}. Noch {left} mehr, dann greift er. |
| eTwice | Englisch This code is already applied. | Deutsch Dieser Code ist schon eingelöst. |
| eSingle | Englisch This code works on its own. Remove the other code to use it. | Deutsch Dieser Code gilt allein. Entferne den anderen Code, um ihn zu nutzen. |
| eMax | Englisch Up to {n} codes per order. | Deutsch Bis zu {n} Codes pro Bestellung. |
| summary | Englisch Order summary | Deutsch Bestellübersicht |
| showSummary | Englisch Show summary | Deutsch Übersicht zeigen |
| hideSummary | Englisch Hide summary | Deutsch Übersicht ausblenden |
| subtotal | Englisch Subtotal | Deutsch Zwischensumme |
| discount | Englisch Discount | Deutsch Rabatt |
| shipping | Englisch Shipping | Deutsch Versand |
| free | Englisch Free | Deutsch Kostenlos |
| total | Englisch Total | Deutsch Gesamt |
| walletTitle | Englisch Your codes | Deutsch Deine Codes |
| walletHint | Englisch Pick one, it applies right away. | Deutsch Wähl einen aus, er gilt sofort. |
| use | Englisch Use this code | Deutsch Diesen Code nutzen |
| inUse | Englisch In use | Deutsch Aktiv |
| unlockAt | Englisch {left} more to unlock | Deutsch Noch {left} bis zur Freischaltung |
| validUntil | Englisch Valid until {date} | Deutsch Gültig bis {date} |
| other | Englisch Another code | Deutsch Anderer Code |
| off | Englisch off | Deutsch Rabatt |
| freeShipping | Englisch Free shipping | Deutsch Gratisversand |
| gcTitle | Englisch Pay with a gift card | Deutsch Mit Gutscheinkarte zahlen |
| gcNumber | Englisch Card number | Deutsch Kartennummer |
| gcPin | Englisch PIN | Deutsch PIN |
| gcShowPin | Englisch Show PIN | Deutsch PIN zeigen |
| gcHidePin | Englisch Hide PIN | Deutsch PIN verbergen |
| gcCheck | Englisch Check balance | Deutsch Guthaben prüfen |
| gcBalance | Englisch Balance {amount} | Deutsch Guthaben {amount} |
| gcUse | Englisch Amount to use | Deutsch Betrag einlösen |
| gcApply | Englisch Use {amount} | Deutsch {amount} einlösen |
| gcApplied | Englisch Gift card ending {last} | Deutsch Gutscheinkarte mit Endung {last} |
| gcLeft | Englisch {amount} stays on the card | Deutsch {amount} bleiben auf der Karte |
| gcENumber | Englisch A gift card number has 16 digits, you find it on the back. | Deutsch Eine Kartennummer hat 16 Ziffern, du findest sie auf der Rückseite. |
| gcEPin | Englisch The PIN has 4 digits and sits under the scratch field. | Deutsch Die PIN hat 4 Ziffern und steht unter dem Rubbelfeld. |
| gcEUnknown | Englisch Number and PIN do not match a card. Check both and try again. | Deutsch Nummer und PIN passen zu keiner Karte. Prüf beides und versuch es noch einmal. |
| gcEEmpty | Englisch This card has no balance left. | Deutsch Auf dieser Karte ist kein Guthaben mehr. |
| gcEAmount | Englisch Pick an amount between {min} and {max}. | Deutsch Wähl einen Betrag zwischen {min} und {max}. |
| giftCard | Englisch Gift card | Deutsch Gutscheinkarte |
| gcRemove | Englisch Remove gift card | Deutsch Gutscheinkarte entfernen |
| toPay | Englisch To pay | Deutsch Zu zahlen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-busy, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-live, aria-pressed und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 5 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 867 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { useShake } from '@/components/ui/input';
import { ProductMedia, ShopMotion, TweenPrice, formatPrice } from '@/components/ui/product-image';
/*
* Discount codes and gift cards at checkout, with a live summary.
*
* inline one field and a button, an applied code turns into a chip
* collapsible "Have a code?" opens the field, several codes stack as chips
* wallet the codes of the customer as tickets to pick, locked ones say what is missing
* gift-card number and PIN, balance check and the amount to use, drawn on a card
*
* The check is a function you pass (`onCheck`), so the server decides. Without
* it the block checks against `codes`, which is enough for a demo or a static shop.
*/
export type CouponFieldVariant = 'inline' | 'collapsible' | 'wallet' | 'gift-card';
export interface Coupon {
code: string;
/** percent off the subtotal, a fixed amount off, or free shipping */
kind: 'percent' | 'amount' | 'shipping';
/** Percent or amount, ignored for shipping */
value?: number;
/** Short line on the ticket, e.g. "Welcome to the club" */
title?: string;
/** Valid from this subtotal on */
minOrder?: number;
/** ISO date until which the code works */
validUntil?: string;
/** Combines with other codes (collapsible) */
stackable?: boolean;
/** wallet: shown as a ticket */
inWallet?: boolean;
}
export interface GiftCard {
/** 16 digits, spaces are ignored */
number: string;
pin: string;
balance: number;
}
export type CouponError = 'empty' | 'format' | 'unknown' | 'expired' | 'min' | 'twice' | 'single' | 'max';
export interface CouponFieldProps {
variant?: CouponFieldVariant;
/** Codes the shop knows */
codes?: Coupon[];
/** Gift cards for the gift-card variant */
giftCards?: GiftCard[];
/** Codes applied on load */
defaultApplied?: string[];
/** collapsible: most codes at once */
maxCodes?: number;
/** Summary: subtotal of the cart and shipping before codes */
subtotal?: number;
shipping?: number;
/** Shows the summary next to or under the field */
summary?: boolean;
/** inline and collapsible: the cart lines above the summary */
lines?: { name: string; variant?: string; price: number; quantity?: number; image?: React.ReactNode }[];
/** Opens the collapsible field on load */
defaultOpen?: boolean;
/** "Today" for expiry checks, ISO date. Default the real today. */
today?: string;
/** Your own check, e.g. a request to the server. Return the coupon or an error. */
onCheck?: (code: string) => Promise<Coupon | CouponError> | Coupon | CouponError;
onChange?: (applied: Coupon[], total: number) => void;
currency?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
label: 'Discount code',
placeholder: 'Enter code',
apply: 'Apply',
checking: 'Checking',
remove: 'Remove code {code}',
have: 'Have a discount code?',
addAnother: 'Add another code',
applied: '{code} applied, you save {amount}',
appliedShort: 'applied',
freeShippingApplied: '{code} applied, shipping is on us',
eEmpty: 'Enter your code to apply it.',
eFormat: 'Codes use letters and numbers only, 3 to 20 characters.',
eUnknown: 'This code is not in our system. Check the spelling, codes are often printed in capitals.',
eExpired: 'This code was valid until {date}. Your other codes still work.',
eMin: 'This code works from {amount}. Add {left} more to use it.',
eTwice: 'This code is already applied.',
eSingle: 'This code works on its own. Remove the other code to use it.',
eMax: 'Up to {n} codes per order.',
summary: 'Order summary',
showSummary: 'Show summary',
hideSummary: 'Hide summary',
subtotal: 'Subtotal',
discount: 'Discount',
shipping: 'Shipping',
free: 'Free',
total: 'Total',
walletTitle: 'Your codes',
walletHint: 'Pick one, it applies right away.',
use: 'Use this code',
inUse: 'In use',
unlockAt: '{left} more to unlock',
validUntil: 'Valid until {date}',
other: 'Another code',
off: 'off',
freeShipping: 'Free shipping',
gcTitle: 'Pay with a gift card',
gcNumber: 'Card number',
gcPin: 'PIN',
gcShowPin: 'Show PIN',
gcHidePin: 'Hide PIN',
gcCheck: 'Check balance',
gcBalance: 'Balance {amount}',
gcUse: 'Amount to use',
gcApply: 'Use {amount}',
gcApplied: 'Gift card ending {last}',
gcLeft: '{amount} stays on the card',
gcENumber: 'A gift card number has 16 digits, you find it on the back.',
gcEPin: 'The PIN has 4 digits and sits under the scratch field.',
gcEUnknown: 'Number and PIN do not match a card. Check both and try again.',
gcEEmpty: 'This card has no balance left.',
gcEAmount: 'Pick an amount between {min} and {max}.',
giftCard: 'Gift card',
gcRemove: 'Remove gift card',
toPay: 'To pay',
},
de: {
label: 'Rabattcode',
placeholder: 'Code eingeben',
apply: 'Einlösen',
checking: 'Wird geprüft',
remove: 'Code {code} entfernen',
have: 'Hast du einen Rabattcode?',
addAnother: 'Weiteren Code hinzufügen',
applied: '{code} eingelöst, du sparst {amount}',
appliedShort: 'eingelöst',
freeShippingApplied: '{code} eingelöst, der Versand geht auf uns',
eEmpty: 'Gib deinen Code ein, um ihn einzulösen.',
eFormat: 'Codes bestehen aus Buchstaben und Ziffern, 3 bis 20 Zeichen.',
eUnknown: 'Diesen Code kennen wir nicht. Prüf die Schreibweise, Codes stehen meist in Großbuchstaben.',
eExpired: 'Dieser Code galt bis {date}. Deine anderen Codes gelten weiter.',
eMin: 'Dieser Code gilt ab {amount}. Noch {left} mehr, dann greift er.',
eTwice: 'Dieser Code ist schon eingelöst.',
eSingle: 'Dieser Code gilt allein. Entferne den anderen Code, um ihn zu nutzen.',
eMax: 'Bis zu {n} Codes pro Bestellung.',
summary: 'Bestellübersicht',
showSummary: 'Übersicht zeigen',
hideSummary: 'Übersicht ausblenden',
subtotal: 'Zwischensumme',
discount: 'Rabatt',
shipping: 'Versand',
free: 'Kostenlos',
total: 'Gesamt',
walletTitle: 'Deine Codes',
walletHint: 'Wähl einen aus, er gilt sofort.',
use: 'Diesen Code nutzen',
inUse: 'Aktiv',
unlockAt: 'Noch {left} bis zur Freischaltung',
validUntil: 'Gültig bis {date}',
other: 'Anderer Code',
off: 'Rabatt',
freeShipping: 'Gratisversand',
gcTitle: 'Mit Gutscheinkarte zahlen',
gcNumber: 'Kartennummer',
gcPin: 'PIN',
gcShowPin: 'PIN zeigen',
gcHidePin: 'PIN verbergen',
gcCheck: 'Guthaben prüfen',
gcBalance: 'Guthaben {amount}',
gcUse: 'Betrag einlösen',
gcApply: '{amount} einlösen',
gcApplied: 'Gutscheinkarte mit Endung {last}',
gcLeft: '{amount} bleiben auf der Karte',
gcENumber: 'Eine Kartennummer hat 16 Ziffern, du findest sie auf der Rückseite.',
gcEPin: 'Die PIN hat 4 Ziffern und steht unter dem Rubbelfeld.',
gcEUnknown: 'Nummer und PIN passen zu keiner Karte. Prüf beides und versuch es noch einmal.',
gcEEmpty: 'Auf dieser Karte ist kein Guthaben mehr.',
gcEAmount: 'Wähl einen Betrag zwischen {min} und {max}.',
giftCard: 'Gutscheinkarte',
gcRemove: 'Gutscheinkarte entfernen',
toPay: 'Zu zahlen',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const currencyOf = (locale: Locale, currency?: string) => currency ?? (locale === 'de' ? 'EUR' : 'USD');
const norm = (s: string) => s.trim().toUpperCase().replace(/\s+/g, '');
const dateOf = (locale: Locale, iso: string) => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(iso + 'T00:00:00Z'));
const todayIso = () => new Date().toISOString().slice(0, 10);
/** What a coupon takes off a subtotal */
function saving(c: Coupon, subtotal: number, shipping: number) {
if (c.kind === 'shipping') return shipping;
if (c.kind === 'percent') return round((subtotal * (c.value ?? 0)) / 100);
return Math.min(subtotal, c.value ?? 0);
}
function totalsOf(applied: Coupon[], subtotal: number, shipping: number, giftCard = 0) {
const free = applied.some((c) => c.kind === 'shipping');
const discount = round(Math.min(subtotal, applied.filter((c) => c.kind !== 'shipping').reduce((a, c) => a + saving(c, subtotal, shipping), 0)));
const ship = free ? 0 : shipping;
const total = round(Math.max(0, subtotal - discount + ship - giftCard));
return { discount, shipping: ship, total, before: round(subtotal - discount + ship) };
}
/** The shop's check against the list, or the one passed in */
function useCheck(props: CouponFieldProps, subtotal: number) {
const today = props.today ?? todayIso();
return async (raw: string, applied: Coupon[], max: number): Promise<Coupon | CouponError> => {
const code = norm(raw);
if (!code) return 'empty';
if (!/^[A-Z0-9-]{3,20}$/.test(code)) return 'format';
if (applied.some((c) => c.code.toUpperCase() === code)) return 'twice';
let found: Coupon | CouponError | undefined;
if (props.onCheck) found = await props.onCheck(code);
else {
// A short pause, as a real check takes a moment
await new Promise((r) => setTimeout(r, 450));
found = props.codes?.find((c) => c.code.toUpperCase() === code) ?? 'unknown';
}
if (typeof found === 'string') return found;
if (found.validUntil && found.validUntil < today) return 'expired';
if (found.minOrder && subtotal < found.minOrder) return 'min';
if (applied.length >= max) return 'max';
if (applied.length && (!found.stackable || applied.some((c) => !c.stackable))) return 'single';
return found;
};
}
function errorText(e: CouponError, t: Texts, ctx: { locale: Locale; coupon?: Coupon; subtotal: number; currency: string; max: number }) {
const fp = (n: number) => formatPrice(ctx.locale, n, ctx.currency);
switch (e) {
case 'empty': return t.eEmpty;
case 'format': return t.eFormat;
case 'expired': return fill(t.eExpired, { date: ctx.coupon?.validUntil ? dateOf(ctx.locale, ctx.coupon.validUntil) : '' });
case 'min': return fill(t.eMin, { amount: fp(ctx.coupon?.minOrder ?? 0), left: fp(round((ctx.coupon?.minOrder ?? 0) - ctx.subtotal)) });
case 'twice': return t.eTwice;
case 'single': return t.eSingle;
case 'max': return fill(t.eMax, { n: ctx.max });
default: return t.eUnknown;
}
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
const inputClass =
'h-11 w-0 min-w-0 flex-1 rounded-lg border border-input bg-background px-3.5 text-base uppercase tracking-[0.06em] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:normal-case placeholder:tracking-normal placeholder:text-subtle-foreground hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] sm:text-[14px]';
/** Field, button and message. Calls onApply with the coupon once the check says yes. */
function CodeForm({ applied, max, onApply, t, subtotal, currency, props, autoFocus, compact }: { applied: Coupon[]; max: number; onApply: (c: Coupon) => void; t: Texts; subtotal: number; currency: string; props: CouponFieldProps; autoFocus?: boolean; compact?: boolean }) {
const locale = useLocale();
const id = useId();
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<{ e: CouponError; coupon?: Coupon } | null>(null);
const row = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const check = useCheck(props, subtotal);
useShake(row, !!error);
useEffect(() => {
if (autoFocus) input.current?.focus();
}, [autoFocus]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (busy) return;
setBusy(true);
const r = await check(code, applied, max);
setBusy(false);
if (typeof r === 'string') {
const coupon = props.codes?.find((c) => c.code.toUpperCase() === norm(code));
setError({ e: r, coupon });
input.current?.focus();
return;
}
setError(null);
setCode('');
onApply(r);
};
return (
<form onSubmit={submit} noValidate>
<label htmlFor={id} className={cn('mb-1.5 block text-[13px] font-medium text-foreground', compact && 'sr-only')}>
{t.label}
</label>
<div ref={row} className="flex gap-2">
<div className="relative flex min-w-0 flex-1">
<Icon name="tag" size={16} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input
ref={input}
id={id}
value={code}
onChange={(e) => { setCode(e.target.value); if (error) setError(null); }}
placeholder={t.placeholder}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
aria-invalid={!!error || undefined}
aria-describedby={`${id}-msg`}
className={cn(inputClass, 'pl-10')}
/>
</div>
<Button type="submit" variant="outline" disabled={busy} aria-busy={busy || undefined} className="min-w-[6.5rem] max-sm:min-w-0">
{busy ? (
<>
<Icon name="loader" size={16} className="animate-kit-spin motion-reduce:animate-none" />
<span className="max-sm:sr-only">{t.checking}</span>
</>
) : (
t.apply
)}
</Button>
</div>
<p id={`${id}-msg`} aria-live="polite" className={cn('text-[12px] leading-[1.45] text-destructive', error ? 'mt-2 animate-[kitshop-in_0.35s_var(--ease-kit)]' : 'sr-only')}>
{error && errorText(error.e, t, { locale, coupon: error.coupon, subtotal, currency, max })}
</p>
</form>
);
}
/** An applied code: code, what it saves, remove */
function Chip({ c, subtotal, shipping, currency, onRemove, t }: { c: Coupon; subtotal: number; shipping: number; currency: string; onRemove: () => void; t: Texts }) {
const locale = useLocale();
return (
<li className="flex animate-[kitshop-pop_0.4s_var(--ease-kit)] items-center gap-2.5 rounded-lg border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] py-1 pl-3 pr-1">
<Icon name={c.kind === 'shipping' ? 'truck' : 'tag'} size={15} className="shrink-0 text-success" />
<span className="min-w-0 flex-1 text-[13px]">
<span className="font-semibold uppercase tracking-[0.06em] text-foreground">{c.code}</span>{' '}
<span className="text-success-foreground">{c.kind === 'shipping' ? t.freeShipping : `− ${formatPrice(locale, saving(c, subtotal, shipping), currency)}`}</span>
</span>
<button type="button" onClick={onRemove} aria-label={fill(t.remove, { code: c.code })} className="grid size-9 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground max-sm:size-11">
<Icon name="x" size={15} />
</button>
</li>
);
}
function Lines({ subtotal, totals, t, currency, giftCard, giftLabel }: { subtotal: number; totals: ReturnType<typeof totalsOf>; t: Texts; currency: string; giftCard?: number; giftLabel?: string }) {
const locale = useLocale();
return (
<dl className="space-y-2.5 text-[14px]">
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{t.subtotal}</dt>
<dd className="tabular-nums text-foreground">{formatPrice(locale, subtotal, currency)}</dd>
</div>
{totals.discount > 0 && (
<div className="flex animate-[kitshop-in_0.4s_var(--ease-kit)] justify-between gap-4">
<dt className="text-muted-foreground">{t.discount}</dt>
<dd className="text-success-foreground">
− <TweenPrice value={totals.discount} currency={currency} />
</dd>
</div>
)}
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{t.shipping}</dt>
<dd className={cn('tabular-nums', totals.shipping ? 'text-foreground' : 'font-medium text-success-foreground')}>{totals.shipping ? formatPrice(locale, totals.shipping, currency) : t.free}</dd>
</div>
{!!giftCard && (
<div className="flex animate-[kitshop-in_0.4s_var(--ease-kit)] justify-between gap-4">
<dt className="min-w-0 truncate text-muted-foreground">{giftLabel ?? t.giftCard}</dt>
<dd className="text-success-foreground">
− <TweenPrice value={giftCard} currency={currency} />
</dd>
</div>
)}
<div className="flex items-baseline justify-between gap-4 border-t border-border pt-3.5">
<dt className="text-[15px] font-semibold text-foreground">{giftCard ? t.toPay : t.total}</dt>
<dd>
<TweenPrice value={totals.total} currency={currency} className="text-[20px] font-semibold tracking-[-0.02em] text-foreground" />
</dd>
</div>
</dl>
);
}
/** Small cart lines with picture, for the summary cards */
function CartRows({ lines, currency }: { lines?: CouponFieldProps['lines']; currency: string }) {
const locale = useLocale();
if (!lines?.length) return null;
return (
<ul className="mb-5 space-y-3 border-b border-border pb-5">
{lines.map((l, i) => (
<li key={i} className="flex items-center gap-3">
<span className="relative shrink-0">
<ProductMedia className="size-12 rounded-lg">{l.image}</ProductMedia>
{(l.quantity ?? 1) > 1 && <span className="absolute -right-1.5 -top-1.5 grid h-5 min-w-5 place-items-center rounded-full bg-foreground px-1 text-[10px] font-semibold tabular-nums text-background">{l.quantity}</span>}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-foreground">{l.name}</span>
{l.variant && <span className="block truncate text-[12px] text-muted-foreground">{l.variant}</span>}
</span>
<span className="text-[13px] tabular-nums text-foreground">{formatPrice(locale, l.price * (l.quantity ?? 1), currency)}</span>
</li>
))}
</ul>
);
}
/** The summary as a card from 1024 px, as a bar to open on smaller screens */
function Summary({ children, total, currency, t, className }: { children: React.ReactNode; total: number; currency: string; t: Texts; className?: string }) {
const [open, setOpen] = useState(false);
const id = useId();
return (
<div className={cn('rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
<button type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)} className="flex min-h-14 w-full items-center gap-3 px-5 text-left text-[14px] font-medium text-foreground lg:hidden">
<Icon name="receipt" size={17} className="text-muted-foreground" />
{open ? t.hideSummary : t.showSummary}
<Icon name="chevron-down" size={15} className={cn('text-muted-foreground transition-transform motion-reduce:transition-none', open && 'rotate-180')} />
<TweenPrice value={total} currency={currency} className="ml-auto text-[16px] font-semibold text-foreground" />
</button>
<div id={id} className={cn('px-5 pb-5 lg:block lg:p-6', open ? 'block border-t border-border pt-4' : 'hidden')}>
<h3 className="mb-4 text-[15px] font-semibold text-foreground max-lg:sr-only">{t.summary}</h3>
{children}
</div>
</div>
);
}
function useApplied(props: CouponFieldProps, subtotal: number, shipping: number) {
const [applied, setApplied] = useState<Coupon[]>(() => (props.defaultApplied ?? []).map((c) => props.codes?.find((x) => x.code.toUpperCase() === c.toUpperCase())).filter((c): c is Coupon => !!c));
const totals = totalsOf(applied, subtotal, shipping);
const onChange = useRef(props.onChange);
onChange.current = props.onChange;
useEffect(() => {
onChange.current?.(applied, totals.total);
}, [applied, totals.total]);
return { applied, setApplied, totals };
}
function Announce({ text }: { text: string }) {
return (
<p className="sr-only" role="status">
{text}
</p>
);
}
/* ------------------------------------------------------------------ */
/* inline */
/* ------------------------------------------------------------------ */
function Inline(props: CouponFieldProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const subtotal = props.subtotal ?? 0;
const shipping = props.shipping ?? 0;
const { applied, setApplied, totals } = useApplied(props, subtotal, shipping);
const [said, setSaid] = useState('');
const c = applied[0];
// The field and the chip swap places: focus follows, to the chip's remove button or back to the field
const box = useRef<HTMLDivElement>(null);
const seen = useRef(false);
useEffect(() => {
if (!seen.current) return void (seen.current = true);
box.current?.querySelector<HTMLElement>(c ? 'button' : 'input')?.focus();
}, [c?.code]);
return (
<div className={cn('mx-auto w-full max-w-[26rem] rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-md)] sm:p-6', props.className)}>
<ShopMotion />
<h3 className="mb-5 text-[15px] font-semibold text-foreground">{t.summary}</h3>
<CartRows lines={props.lines} currency={currency} />
<div ref={box} className="border-b border-border pb-5">
{c ? (
<div>
<p className="mb-1.5 text-[13px] font-medium text-foreground">{t.label}</p>
<ul>
<Chip c={c} subtotal={subtotal} shipping={shipping} currency={currency} t={t} onRemove={() => { setApplied([]); setSaid(''); }} />
</ul>
</div>
) : (
<CodeForm applied={applied} max={1} t={t} subtotal={subtotal} currency={currency} props={props} onApply={(x) => { setApplied([x]); setSaid(x.kind === 'shipping' ? fill(t.freeShippingApplied, { code: x.code }) : fill(t.applied, { code: x.code, amount: formatPrice(locale, saving(x, subtotal, shipping), currency) })); }} />
)}
<Announce text={said} />
</div>
{props.summary !== false && (
<div className="mt-5">
<Lines subtotal={subtotal} totals={totals} t={t} currency={currency} />
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* collapsible */
/* ------------------------------------------------------------------ */
function Collapsible(props: CouponFieldProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const subtotal = props.subtotal ?? 0;
const shipping = props.shipping ?? 0;
const max = props.maxCodes ?? 3;
const { applied, setApplied, totals } = useApplied(props, subtotal, shipping);
const [open, setOpen] = useState(!!props.defaultOpen);
const [focusField, setFocusField] = useState(false);
const [said, setSaid] = useState('');
const panel = useId();
const full = applied.length >= max;
return (
<div className={cn('mx-auto w-full max-w-[26rem] rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-md)] sm:p-6', props.className)}>
<ShopMotion />
<CartRows lines={props.lines} currency={currency} />
{props.summary !== false && <Lines subtotal={subtotal} totals={totals} t={t} currency={currency} />}
<div className={cn(props.summary !== false && 'mt-5 border-t border-border pt-2')}>
{applied.length > 0 && (
<ul className="mb-1 mt-3 space-y-2" aria-label={t.label}>
{applied.map((c) => (
<Chip key={c.code} c={c} subtotal={subtotal} shipping={shipping} currency={currency} t={t} onRemove={() => setApplied((xs) => xs.filter((x) => x.code !== c.code))} />
))}
</ul>
)}
{!full && (
<>
<button
type="button"
aria-expanded={open}
aria-controls={panel}
onClick={() => { setOpen(!open); setFocusField(!open); }}
className="flex min-h-11 w-full items-center gap-2 text-left text-[13px] font-medium text-foreground"
>
<span className="grid size-6 place-items-center rounded-full border border-border-strong text-muted-foreground">
<Icon name="plus" size={13} className={cn('transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', open && 'rotate-45')} />
</span>
{applied.length ? t.addAnother : t.have}
</button>
<div id={panel} className={cn('grid transition-[grid-template-rows,opacity] duration-400 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr] opacity-100' : 'invisible grid-rows-[0fr] opacity-0')}>
<div className="min-h-0 overflow-hidden px-0.5 pb-1">
<div className="pb-2 pt-1">
<CodeForm
compact
autoFocus={focusField}
applied={applied}
max={max}
t={t}
subtotal={subtotal}
currency={currency}
props={props}
onApply={(x) => {
setApplied((xs) => [...xs, x]);
setSaid(x.kind === 'shipping' ? fill(t.freeShippingApplied, { code: x.code }) : fill(t.applied, { code: x.code, amount: formatPrice(locale, saving(x, subtotal, shipping), currency) }));
setOpen(false);
}}
/>
</div>
</div>
</div>
</>
)}
<Announce text={said} />
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* wallet */
/* ------------------------------------------------------------------ */
/** A coupon ticket with punched sides, the notches cut with a mask */
function Ticket({ c, active, locked, left, onPick, t, currency, subtotal, shipping, index }: { c: Coupon; active: boolean; locked: boolean; left: number; onPick: () => void; t: Texts; currency: string; subtotal: number; shipping: number; index: number }) {
const locale = useLocale();
const amount = c.kind === 'percent' ? formatPercent(c.value ?? 0, locale) : c.kind === 'amount' ? new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency, maximumFractionDigits: Number.isInteger(c.value) ? 0 : 2 }).format(c.value ?? 0) : '';
const pct = c.minOrder ? Math.min(100, Math.round((subtotal / c.minOrder) * 100)) : 100;
const notch = 'radial-gradient(circle at 0 50%, transparent 9px, #000 9.5px) left / 51% 100% no-repeat, radial-gradient(circle at 100% 50%, transparent 9px, #000 9.5px) right / 51% 100% no-repeat';
return (
<li className="kit-rise" style={{ '--kit-delay': `${index * 70}ms` } as React.CSSProperties}>
<label
className={cn(
'group/ticket relative flex h-full cursor-pointer items-stretch rounded-xl border transition-[border-color,background-color,transform] duration-300 ease-[var(--ease-kit)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid',
active ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border-strong bg-card hover:-translate-y-0.5 hover:border-foreground/40',
locked && 'cursor-not-allowed opacity-80 hover:translate-y-0 hover:border-border-strong',
)}
style={{ mask: notch, WebkitMask: notch } as React.CSSProperties}
>
<input type="radio" name="wallet" checked={active} disabled={locked} onChange={onPick} className="sr-only" />
<span className={cn('grid w-[5.5rem] shrink-0 place-items-center border-r border-dashed px-2 text-center sm:w-24', active ? 'border-primary/50' : 'border-border-strong')}>
{c.kind === 'shipping' ? (
<Icon name="truck" size={26} className={active ? 'text-primary' : 'text-foreground'} />
) : (
<span className={cn('text-[22px] font-semibold leading-none tracking-[-0.03em] tabular-nums', active ? 'text-primary' : 'text-foreground')}>
{amount}
<span className="mt-1 block text-[11px] font-medium tracking-normal text-muted-foreground">{t.off}</span>
</span>
)}
</span>
<span className="flex min-w-0 flex-1 flex-col py-3.5 pl-4 pr-3.5">
<span className="flex items-start justify-between gap-2">
<span className="min-w-0">
<span className="block text-[14px] font-semibold text-foreground">{c.title ?? (c.kind === 'shipping' ? t.freeShipping : c.code)}</span>
<span className="mt-0.5 block text-[12px] font-medium uppercase tracking-[0.08em] text-muted-foreground">{c.code}</span>
</span>
<span aria-hidden className={cn('mt-0.5 grid size-5 shrink-0 place-items-center rounded-full border-2 transition-colors', active ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>
{active && <Icon name="check" size={11} strokeWidth={3} className="animate-[kitshop-pop_0.35s_var(--ease-kit)]" />}
</span>
</span>
{locked ? (
<span className="mt-auto block pt-3">
<span className="block h-1 overflow-hidden rounded-full bg-track">
<span className="block h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
</span>
<span className="mt-1.5 block text-[12px] text-muted-foreground">{fill(t.unlockAt, { left: formatPrice(locale, left, currency) })}</span>
</span>
) : (
<span className="mt-auto block pt-3 text-[12px] text-muted-foreground">
{active && c.kind !== 'shipping' ? <span className="font-medium text-success-foreground">− {formatPrice(locale, saving(c, subtotal, shipping), currency)}</span> : c.validUntil ? fill(t.validUntil, { date: dateOf(locale, c.validUntil) }) : ' '}
</span>
)}
</span>
</label>
</li>
);
}
function Wallet(props: CouponFieldProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const subtotal = props.subtotal ?? 0;
const shipping = props.shipping ?? 0;
const { applied, setApplied, totals } = useApplied(props, subtotal, shipping);
const wallet = (props.codes ?? []).filter((c) => c.inWallet);
return (
<div className={cn('grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-10', props.className)}>
<ShopMotion />
{props.summary !== false && (
<Summary total={totals.total} currency={currency} t={t} className="lg:order-2 lg:self-start">
<Lines subtotal={subtotal} totals={totals} t={t} currency={currency} />
</Summary>
)}
<fieldset className="min-w-0">
<legend className="text-[17px] font-semibold text-foreground">{t.walletTitle}</legend>
<p className="mt-1 text-[13px] text-muted-foreground">{t.walletHint}</p>
<ul className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
{wallet.map((c, i) => {
const left = c.minOrder ? round(c.minOrder - subtotal) : 0;
return (
<Ticket
key={c.code}
index={i}
c={c}
active={applied.some((a) => a.code === c.code)}
locked={left > 0}
left={left}
onPick={() => setApplied([c])}
t={t}
currency={currency}
subtotal={subtotal}
shipping={shipping}
/>
);
})}
</ul>
<div className="mt-6 border-t border-border pt-5">
<CodeForm
applied={[]}
max={1}
t={{ ...t, label: t.other }}
subtotal={subtotal}
currency={currency}
props={props}
onApply={(x) => setApplied([x])}
/>
</div>
</fieldset>
</div>
);
}
/* ------------------------------------------------------------------ */
/* gift-card */
/* ------------------------------------------------------------------ */
const digits = (s: string) => s.replace(/\D/g, '');
const groups = (s: string) => digits(s).slice(0, 16).replace(/(\d{4})(?=\d)/g, '$1 ');
function CardArt({ number, balance, currency }: { number: string; balance?: number; currency: string }) {
const locale = useLocale();
const d = digits(number).padEnd(16, '•');
const shown = d.replace(/(.{4})(?=.)/g, '$1 ');
return (
<div aria-hidden data-clip-ok className="relative isolate aspect-[1.586] w-full max-w-[22rem] overflow-hidden rounded-2xl bg-[linear-gradient(135deg,color-mix(in_oklab,var(--primary)_88%,black),color-mix(in_oklab,var(--primary)_55%,black))] p-5 text-white shadow-[var(--shadow-lg)] sm:p-6">
<svg viewBox="0 0 300 190" className="absolute inset-0 -z-10 size-full opacity-30" preserveAspectRatio="xMidYMid slice">
<circle cx="250" cy="20" r="90" fill="none" stroke="white" strokeOpacity="0.5" />
<circle cx="250" cy="20" r="60" fill="none" stroke="white" strokeOpacity="0.35" />
<circle cx="250" cy="20" r="30" fill="none" stroke="white" strokeOpacity="0.25" />
<path d="M0 150 C 80 120, 160 190, 300 140" fill="none" stroke="white" strokeOpacity="0.35" />
</svg>
<div className="flex items-start justify-between">
<Icon name="gift" size={24} />
{balance !== undefined && <span className="animate-[kitshop-pop_0.45s_var(--ease-kit)] rounded-full bg-white/18 px-2.5 py-1 text-[12px] font-semibold tabular-nums backdrop-blur">{formatPrice(locale, balance, currency)}</span>}
</div>
<div className="absolute inset-x-5 bottom-5 sm:inset-x-6 sm:bottom-6">
<div className="mb-3 h-7 w-10 rounded-md bg-[linear-gradient(135deg,oklch(0.86_0.08_85),oklch(0.7_0.09_75))] opacity-90" />
<p className="whitespace-nowrap text-[clamp(14px,4.4vw,19px)] font-medium tabular-nums tracking-[0.08em]">{shown}</p>
</div>
</div>
);
}
function GiftCardVariant(props: CouponFieldProps & { t: Texts; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const subtotal = props.subtotal ?? 0;
const shipping = props.shipping ?? 0;
const { applied, totals } = useApplied(props, subtotal, shipping);
const id = useId();
const [number, setNumber] = useState('');
const [pin, setPin] = useState('');
const [showPin, setShowPin] = useState(false);
const [errors, setErrors] = useState<{ number?: string; pin?: string; form?: string; amount?: string }>({});
const [card, setCard] = useState<GiftCard | null>(null);
const [busy, setBusy] = useState(false);
const [amount, setAmount] = useState(0);
const [used, setUsed] = useState<{ amount: number; last: string } | null>(null);
const form = useRef<HTMLFormElement>(null);
useShake(form, !!(errors.number || errors.pin || errors.form));
const max = card ? round(Math.min(card.balance, totals.before)) : 0;
const fp = (n: number) => formatPrice(locale, n, currency);
const final = totalsOf(applied, subtotal, shipping, used?.amount ?? 0);
const check = async (e: React.FormEvent) => {
e.preventDefault();
const next: typeof errors = {};
if (digits(number).length !== 16) next.number = t.gcENumber;
if (!/^\d{4}$/.test(pin)) next.pin = t.gcEPin;
setErrors(next);
if (next.number || next.pin) {
form.current?.querySelector<HTMLInputElement>('[aria-invalid="true"]')?.focus();
return;
}
setBusy(true);
await new Promise((r) => setTimeout(r, 500));
setBusy(false);
const found = props.giftCards?.find((g) => digits(g.number) === digits(number) && g.pin === pin);
if (!found) return setErrors({ form: t.gcEUnknown });
if (found.balance <= 0) return setErrors({ form: t.gcEEmpty });
setCard(found);
setAmount(round(Math.min(found.balance, totals.before)));
};
const apply = () => {
if (!card) return;
if (!(amount > 0 && amount <= max)) return setErrors({ amount: fill(t.gcEAmount, { min: fp(0.01), max: fp(max) }) });
setErrors({});
setUsed({ amount: round(amount), last: digits(card.number).slice(-4) });
};
const field = 'h-11 w-full rounded-lg border border-input bg-background px-3.5 text-base tabular-nums text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] sm:text-[14px]';
return (
<div className={cn('grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-10', props.className)}>
<ShopMotion />
{props.summary !== false && (
<Summary total={final.total} currency={currency} t={t} className="lg:order-2 lg:self-start">
<Lines subtotal={subtotal} totals={final} t={t} currency={currency} giftCard={used?.amount} giftLabel={used ? fill(t.gcApplied, { last: used.last }) : undefined} />
</Summary>
)}
<div className="min-w-0">
<h3 className="text-[17px] font-semibold text-foreground">{t.gcTitle}</h3>
<div className="mt-5 grid grid-cols-1 items-start gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:gap-8">
<div className="md:order-2">
<CardArt number={number} balance={card?.balance} currency={currency} />
</div>
{used && card ? (
<div className="animate-[kitshop-in_0.45s_var(--ease-kit)] rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_7%,var(--card))] p-4" role="status">
<p className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
<Icon name="check-circle" size={17} className="text-success" />
{fill(t.gcApplied, { last: used.last })}
</p>
<p className="mt-1 text-[13px] text-muted-foreground">
− {fp(used.amount)} · {fill(t.gcLeft, { amount: fp(round(card.balance - used.amount)) })}
</p>
<button type="button" onClick={() => setUsed(null)} className="mt-3 inline-flex h-10 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-foreground hover:bg-accent max-sm:-ml-2 max-sm:h-11 sm:-ml-2">
<Icon name="undo" size={14} />
{t.gcRemove}
</button>
</div>
) : card ? (
<div className="animate-[kitshop-in_0.45s_var(--ease-kit)]">
<p className="text-[14px] font-medium text-foreground">{fill(t.gcBalance, { amount: fp(card.balance) })}</p>
<label htmlFor={`${id}-amount`} className="mt-4 block text-[13px] font-medium text-foreground">
{t.gcUse}
</label>
<div className="mt-1.5 flex items-center gap-3">
<input
id={`${id}-amount`}
type="range"
min={0}
max={max}
step={0.5}
value={amount}
onChange={(e) => { setAmount(Number(e.target.value)); setErrors({}); }}
aria-valuetext={fp(amount)}
className="h-11 min-w-0 flex-1 cursor-pointer accent-[var(--primary)]"
/>
<output className="w-24 shrink-0 text-right text-[15px] font-semibold tabular-nums text-foreground">{fp(amount)}</output>
</div>
{errors.amount && <p className="mt-1 text-[12px] text-destructive" role="alert">{errors.amount}</p>}
<Button onClick={apply} className="mt-4 w-full sm:w-auto" icon="arrow-right">
{fill(t.gcApply, { amount: fp(amount) })}
</Button>
</div>
) : (
<form ref={form} onSubmit={check} noValidate className="space-y-4">
<div>
<label htmlFor={`${id}-n`} className="mb-1.5 block text-[13px] font-medium text-foreground">
{t.gcNumber}
</label>
<input id={`${id}-n`} inputMode="numeric" autoComplete="off" placeholder="0000 0000 0000 0000" value={groups(number)} onChange={(e) => { setNumber(digits(e.target.value).slice(0, 16)); setErrors({}); }} aria-invalid={!!errors.number || undefined} aria-describedby={errors.number ? `${id}-ne` : undefined} className={cn(field, 'tracking-[0.06em]')} />
{errors.number && <p id={`${id}-ne`} className="mt-1.5 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{errors.number}</p>}
</div>
<div>
<label htmlFor={`${id}-p`} className="mb-1.5 block text-[13px] font-medium text-foreground">
{t.gcPin}
</label>
<div className="relative w-36">
<input id={`${id}-p`} inputMode="numeric" type={showPin ? 'text' : 'password'} autoComplete="off" maxLength={4} placeholder="••••" value={pin} onChange={(e) => { setPin(digits(e.target.value).slice(0, 4)); setErrors({}); }} aria-invalid={!!errors.pin || undefined} aria-describedby={errors.pin ? `${id}-pe` : undefined} className={cn(field, 'pr-11 tracking-[0.3em]')} />
<button type="button" onClick={() => setShowPin(!showPin)} aria-label={showPin ? t.gcHidePin : t.gcShowPin} aria-pressed={showPin} className="absolute right-0 top-0 grid size-11 place-items-center rounded-r-lg text-muted-foreground hover:text-foreground">
<Icon name={showPin ? 'eye-off' : 'eye'} size={16} />
</button>
</div>
{errors.pin && <p id={`${id}-pe`} className="mt-1.5 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{errors.pin}</p>}
</div>
{errors.form && (
<p role="alert" className="flex animate-[kitshop-in_0.3s_var(--ease-kit)] gap-2 rounded-lg bg-[color-mix(in_oklab,var(--destructive)_9%,var(--card))] px-3 py-2.5 text-[13px] text-foreground">
<Icon name="alert-circle" size={16} className="mt-0.5 shrink-0 text-destructive" />
{errors.form}
</p>
)}
<Button type="submit" variant="outline" disabled={busy} className="w-full sm:w-auto">
{busy && <Icon name="loader" size={16} className="animate-kit-spin motion-reduce:animate-none" />}
{t.gcCheck}
</Button>
</form>
)}
</div>
</div>
</div>
);
}
/**
* Discount codes and gift cards with a live summary: one field, a field
* that opens and stacks codes, a wallet of tickets or a gift card with
* balance check. Every error says what to do next.
*/
export function CouponField(props: CouponFieldProps) {
const locale = useLocale();
const t = useTexts(TEXTS, props.labels);
const currency = currencyOf(locale, props.currency);
const { variant = 'inline' } = props;
if (variant === 'collapsible') return <Collapsible {...props} t={t} currency={currency} />;
if (variant === 'wallet') return <Wallet {...props} t={t} currency={currency} />;
if (variant === 'gift-card') return <GiftCardVariant {...props} t={t} currency={currency} />;
return <Inline {...props} t={t} currency={currency} />;
}Häufige Fragen
Wie installiere ich Gutscheinfeld?
Mit aiw add @ai-website/coupon-field. Der Befehl legt die Datei components/ui/coupon-field.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, product-image und input. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Gutscheinfeld?
4 Varianten, Feld mit Knopf, Mehrere Codes, Gutschein-Wallet und Gutscheinkarte. Du wählst sie mit der Prop variant, zum Beispiel variant="collapsible".
Kann ich die Texte von Gutscheinfeld übersetzen?
Ja. Gutscheinfeld bringt 54 eigene Texte auf Deutsch und Englisch mit, etwa „Rabattcode“, „Code eingeben“ und „Einlösen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Gutscheinfeld einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Gutscheinfeld mit aiw_components und installieren sie mit aiw_add @ai-website/coupon-field. Nenn im Prompt die Variante, etwa variant="inline", dann schreibt der Assistent deine Inhalte direkt in die Props.