Zum Inhalt springen
UI Kit
Accent

Gutscheine

Gutscheine zum Gestalten und Verschicken, fertige Karten nach Anlass, eine Guthabenabfrage und das Codefeld im Warenkorb. Die Kartenmotive sind gezeichnet, eigene Motive lassen sich einsetzen.

Eine von 4 Components in Konto und Treue

4 Variants

Variants

Gutschein gestalten

Motiv, Betrag, Nachricht und Datum. Die Karte zoomt bei jedem neuen Motiv herein, neigt sich zum Zeiger mit vorbeiziehendem Glanz, schreibt Name und Nachricht mit und rollt zum neuen Betrag, die Summe zählt mit.

Im Studio öffnen

Fertige Gutscheine

Fertige Karten nach Anlass und Betrag in einer Reihe. Die Karten steigen nacheinander auf, neigen sich zum Zeiger mit einem Glanz, der darüber läuft, und der Knopf wechselt mit Haken zu „Im Warenkorb“.

Im Studio öffnen

Guthaben prüfen

Der Code ordnet sich beim Tippen in Vierergruppen. Eine kurze Prüfung, dann zeichnet sich ein Ring bis zum Restbetrag und der Verlauf der Karte läuft ein. Ein unbekannter Code schüttelt das Feld und sagt, wo der Code steht.

Im Studio öffnen

Im Warenkorb einlösen

Das Gutscheinfeld der Warenkorb-Summe öffnet mit weicher Höhe. Ein gültiger Code springt als Zeile hinein, die Summe zählt herunter und der Rest, der auf dem Gutschein bleibt, blendet ein. Ein falscher Code wackelt und sagt, was zu prüfen ist.

Im Studio öffnen

Details

Was Gutscheine mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
4
Version
1.0.1 · seit 1.1.0
Datei
components/ui/gift-cards.tsx
Bringt mit
utilskitbuttoniconrevealmotionsectionproduct-image
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 43 eigene Texte

Props

Die Schnittstelle GiftCardsProps aus components/ui/gift-cards.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    GiftCardsVariant

    designeramountsbalanceredeem

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • brandoptional
    string

    The shop name printed on the card

  • designsoptional
    GiftDesign[]
  • amountsoptional
    number[]
  • rangeoptional
    [number, number]

    designer: lowest and highest free amount

  • offersoptional
    GiftOffer[]
  • balanceoptional
    { code: string; amount: number; initial: number; expires?: string; history?: { date: string; text: string; amount: number }[] }

    balance: what the demo code shows

  • defaultCheckedoptional
    boolean

    balance: show the result right away (previews)

  • cartoptional
    { subtotal: number; shipping?: number; validCode: string; codeValue: number }

    redeem: the cart total and a code that works

  • defaultCodeoptional
    string

    redeem: a code already in the field, e.g. from a link

  • currencyoptional
    string
  • defaultValuesoptional
    { to?: string; from?: string; message?: string; amount?: number; designId?: string }

    designer: start values, e.g. from a "Gift this" button

  • onBuyoptional
    (card: { designId: string; amount: number; to: string; from: string; message: string; date: string }) => void

Eingebaute Texte

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

Alle 43 Texte anzeigen
giftCardEnglisch Gift cardDeutsch Gutschein
designEnglisch DesignDeutsch Motiv
amountEnglisch AmountDeutsch Betrag
otherEnglisch Other amountDeutsch Anderer Betrag
betweenEnglisch Any amount fromDeutsch Jeder Betrag von
andEnglisch toDeutsch bis
fillInEnglisch Fill in name, email and your name so the card can go out.Deutsch Trag Name, E-Mail und deinen Namen ein, damit die Karte rausgehen kann.
toEnglisch ForDeutsch Für
toPlaceholderEnglisch Their nameDeutsch Name der beschenkten Person
toEmailEnglisch Their emailDeutsch E-Mail der beschenkten Person
fromEnglisch FromDeutsch Von
fromPlaceholderEnglisch Your nameDeutsch Dein Name
messageEnglisch MessageDeutsch Nachricht
messagePlaceholderEnglisch A few words for the cardDeutsch Ein paar Worte für die Karte
sendOnEnglisch Send onDeutsch Senden am
todayEnglisch Right awayDeutsch Sofort
laterEnglisch On a dateDeutsch An einem Datum
buyEnglisch Add gift card to cartDeutsch Gutschein in den Warenkorb
addedEnglisch Gift card in your cartDeutsch Gutschein im Warenkorb
validForEnglisch Valid for three years, for everything in the shop and in our stores.Deutsch Drei Jahre gültig, für alles im Shop und in unseren Läden.
chooseEnglisch Add to cartDeutsch In den Warenkorb
chosenEnglisch In your cartDeutsch Im Warenkorb
codeEnglisch Gift card codeDeutsch Gutscheincode
codePlaceholderEnglisch XXXX XXXX XXXXDeutsch XXXX XXXX XXXX
checkEnglisch Check balanceDeutsch Guthaben prüfen
checkingEnglisch CheckingDeutsch Wird geprüft
leftEnglisch leftDeutsch übrig
ofEnglisch ofDeutsch von
expiresEnglisch Valid untilDeutsch Gültig bis
spentEnglisch SpentDeutsch Eingelöst
checkAnotherEnglisch Check another cardDeutsch Anderen Gutschein prüfen
notFoundEnglisch This code is not known. It has 12 characters and stands under the scratch field.Deutsch Diesen Code kennen wir nicht. Er hat 12 Zeichen und steht unter dem Rubbelfeld.
haveCardEnglisch Have a gift card?Deutsch Hast du einen Gutschein?
applyEnglisch ApplyDeutsch Einlösen
appliedEnglisch Gift card appliedDeutsch Gutschein eingelöst
removeEnglisch Remove gift cardDeutsch Gutschein entfernen
wrongCodeEnglisch This code does not work. Check the spelling, it has 12 characters.Deutsch Dieser Code passt nicht. Prüf die Schreibweise, er hat 12 Zeichen.
subtotalEnglisch SubtotalDeutsch Zwischensumme
shippingEnglisch ShippingDeutsch Versand
freeEnglisch FreeDeutsch Kostenlos
totalEnglisch TotalDeutsch Gesamt
stillOnCardEnglisch stays on your cardDeutsch bleiben auf deinem Gutschein
payEnglisch Continue to paymentDeutsch Weiter zur Zahlung

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-describedby, aria-expanded, aria-invalid, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert und radiogroup.
  • 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. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 781 Zeilen
components/ui/gift-cards.tsx
'use client';

import { useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { ShopMotion, Swap, TweenPrice, formatPrice } from '@/components/ui/product-image';

/*
 * Gift cards: design and send one, buy a ready one, check a balance and
 * redeem a code at checkout.
 *
 *   designer  pick a design, an amount, a message and a date, the card writes along
 *   amounts   ready cards by amount and occasion as a row of cards that tilt toward the pointer
 *   balance   enter the code, see what is left and where it was spent
 *   redeem    the field in the cart: apply a code, the total counts down
 *
 * The card motifs are drawn. Pass `designs` with your own artwork to replace them.
 */

export type GiftCardsVariant = 'designer' | 'amounts' | 'balance' | 'redeem';
export type GiftMotif = 'waves' | 'sun' | 'leaves' | 'confetti' | 'stars' | 'plain';

export interface GiftDesign {
  id: string;
  name: string;
  motif?: GiftMotif;
  /** Two colors for the card, any CSS color */
  from: string;
  to: string;
  /** Text on the card is dark (light designs) */
  dark?: boolean;
  /** Your own artwork instead of the drawn motif */
  art?: React.ReactNode;
}

export interface GiftOffer {
  id: string;
  amount: number;
  /** e.g. "Birthday" */
  occasion?: string;
  designId?: string;
  note?: string;
  href?: string;
}

export interface GiftCardsProps {
  variant?: GiftCardsVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  /** The shop name printed on the card */
  brand?: string;
  designs?: GiftDesign[];
  amounts?: number[];
  /** designer: lowest and highest free amount */
  range?: [number, number];
  offers?: GiftOffer[];
  /** balance: what the demo code shows */
  balance?: { code: string; amount: number; initial: number; expires?: string; history?: { date: string; text: string; amount: number }[] };
  /** balance: show the result right away (previews) */
  defaultChecked?: boolean;
  /** redeem: the cart total and a code that works */
  cart?: { subtotal: number; shipping?: number; validCode: string; codeValue: number };
  /** redeem: a code already in the field, e.g. from a link */
  defaultCode?: string;
  currency?: string;
  /** designer: start values, e.g. from a "Gift this" button */
  defaultValues?: { to?: string; from?: string; message?: string; amount?: number; designId?: string };
  onBuy?: (card: { designId: string; amount: number; to: string; from: string; message: string; date: string }) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    giftCard: 'Gift card',
    design: 'Design',
    amount: 'Amount',
    other: 'Other amount',
    between: 'Any amount from',
    and: 'to',
    fillIn: 'Fill in name, email and your name so the card can go out.',
    to: 'For',
    toPlaceholder: 'Their name',
    toEmail: 'Their email',
    from: 'From',
    fromPlaceholder: 'Your name',
    message: 'Message',
    messagePlaceholder: 'A few words for the card',
    sendOn: 'Send on',
    today: 'Right away',
    later: 'On a date',
    buy: 'Add gift card to cart',
    added: 'Gift card in your cart',
    validFor: 'Valid for three years, for everything in the shop and in our stores.',
    choose: 'Add to cart',
    chosen: 'In your cart',
    code: 'Gift card code',
    codePlaceholder: 'XXXX XXXX XXXX',
    check: 'Check balance',
    checking: 'Checking',
    left: 'left',
    of: 'of',
    expires: 'Valid until',
    spent: 'Spent',
    checkAnother: 'Check another card',
    notFound: 'This code is not known. It has 12 characters and stands under the scratch field.',
    haveCard: 'Have a gift card?',
    apply: 'Apply',
    applied: 'Gift card applied',
    remove: 'Remove gift card',
    wrongCode: 'This code does not work. Check the spelling, it has 12 characters.',
    subtotal: 'Subtotal',
    shipping: 'Shipping',
    free: 'Free',
    total: 'Total',
    stillOnCard: 'stays on your card',
    pay: 'Continue to payment',
  },
  de: {
    giftCard: 'Gutschein',
    design: 'Motiv',
    amount: 'Betrag',
    other: 'Anderer Betrag',
    between: 'Jeder Betrag von',
    and: 'bis',
    fillIn: 'Trag Name, E-Mail und deinen Namen ein, damit die Karte rausgehen kann.',
    to: 'Für',
    toPlaceholder: 'Name der beschenkten Person',
    toEmail: 'E-Mail der beschenkten Person',
    from: 'Von',
    fromPlaceholder: 'Dein Name',
    message: 'Nachricht',
    messagePlaceholder: 'Ein paar Worte für die Karte',
    sendOn: 'Senden am',
    today: 'Sofort',
    later: 'An einem Datum',
    buy: 'Gutschein in den Warenkorb',
    added: 'Gutschein im Warenkorb',
    validFor: 'Drei Jahre gültig, für alles im Shop und in unseren Läden.',
    choose: 'In den Warenkorb',
    chosen: 'Im Warenkorb',
    code: 'Gutscheincode',
    codePlaceholder: 'XXXX XXXX XXXX',
    check: 'Guthaben prüfen',
    checking: 'Wird geprüft',
    left: 'übrig',
    of: 'von',
    expires: 'Gültig bis',
    spent: 'Eingelöst',
    checkAnother: 'Anderen Gutschein prüfen',
    notFound: 'Diesen Code kennen wir nicht. Er hat 12 Zeichen und steht unter dem Rubbelfeld.',
    haveCard: 'Hast du einen Gutschein?',
    apply: 'Einlösen',
    applied: 'Gutschein eingelöst',
    remove: 'Gutschein entfernen',
    wrongCode: 'Dieser Code passt nicht. Prüf die Schreibweise, er hat 12 Zeichen.',
    subtotal: 'Zwischensumme',
    shipping: 'Versand',
    free: 'Kostenlos',
    total: 'Gesamt',
    stillOnCard: 'bleiben auf deinem Gutschein',
    pay: 'Weiter zur Zahlung',
  },
};

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

const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const FIELD = 'h-11 w-full rounded-lg border border-input bg-background px-3.5 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]';

function Head({ eyebrow, title, description, center }: { eyebrow?: string; title?: string; description?: string; center?: boolean }) {
  if (!title) return null;
  return (
    <div className={cn('mb-10 max-w-[40rem] sm:mb-12', center && 'mx-auto text-center')}>
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
      {description && (
        <p className={cn('kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
          {description}
        </p>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* The drawn card                                                      */
/* ------------------------------------------------------------------ */

function Motif({ motif = 'plain' }: { motif?: GiftMotif }) {
  const stroke = { fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' as const, vectorEffect: 'non-scaling-stroke' as const };
  return (
    <svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 size-full opacity-30" aria-hidden data-clip-ok>
      {motif === 'waves' && [0, 1, 2, 3, 4, 5].map((i) => <path key={i} d={`M-10 ${70 + i * 22} C 60 ${50 + i * 22}, 120 ${95 + i * 22}, 190 ${70 + i * 22} S 300 ${50 + i * 22}, 340 ${72 + i * 22}`} {...stroke} />)}
      {motif === 'sun' && (
        <g>
          <circle cx="250" cy="70" r="42" fill="currentColor" opacity="0.5" />
          {Array.from({ length: 12 }, (_, i) => {
            const a = (i / 12) * Math.PI * 2;
            return <path key={i} d={`M${round(250 + Math.cos(a) * 56)} ${round(70 + Math.sin(a) * 56)} L${round(250 + Math.cos(a) * 74)} ${round(70 + Math.sin(a) * 74)}`} {...stroke} />;
          })}
          <path d="M-10 170 C 80 140, 200 190, 340 150" {...stroke} />
        </g>
      )}
      {motif === 'leaves' &&
        [
          [60, 160, -30],
          [130, 40, 20],
          [250, 140, -10],
          [290, 30, 40],
          [20, 50, 60],
          [190, 190, 0],
        ].map(([x, y, r], i) => (
          <g key={i} transform={`translate(${x} ${y}) rotate(${r})`}>
            <path d="M0 -34 C 22 -18, 22 18, 0 34 C -22 18, -22 -18, 0 -34 Z" fill="currentColor" opacity="0.35" />
            <path d="M0 -30 V 30" {...stroke} />
          </g>
        ))}
      {motif === 'confetti' &&
        Array.from({ length: 26 }, (_, i) => {
          const x = round((i * 73) % 320);
          const y = round((i * 47 + (i % 3) * 31) % 200);
          return i % 3 === 0 ? <circle key={i} cx={x} cy={y} r="4" fill="currentColor" /> : <rect key={i} x={x} y={y} width="10" height="4" rx="2" fill="currentColor" transform={`rotate(${(i * 37) % 180} ${x + 5} ${y + 2})`} />;
        })}
      {motif === 'stars' &&
        Array.from({ length: 14 }, (_, i) => {
          const x = round((i * 61 + 20) % 300);
          const y = round((i * 43 + 15) % 190);
          const s = 4 + (i % 3) * 3;
          return <path key={i} d={`M${x} ${y - s} L${x + s * 0.3} ${y - s * 0.3} L${x + s} ${y} L${x + s * 0.3} ${y + s * 0.3} L${x} ${y + s} L${x - s * 0.3} ${y + s * 0.3} L${x - s} ${y} L${x - s * 0.3} ${y - s * 0.3} Z`} fill="currentColor" />;
        })}
    </svg>
  );
}

function GiftCard({ design, amount, brand, to, message, className, live }: { design: GiftDesign; amount?: number; brand?: string; to?: string; message?: string; className?: string; live?: boolean }) {
  const locale = useLocale();
  const t = useTexts(TEXTS);
  return (
    <div
      className={cn('relative aspect-[1.6] w-full overflow-hidden rounded-2xl p-5 shadow-[0_24px_48px_-24px_rgb(0_0_0/0.6),inset_0_1px_0_rgb(255_255_255/0.3)] sm:p-6', design.dark ? 'text-[oklch(0.24_0.02_60)]' : 'text-white', className)}
      style={{ background: `linear-gradient(135deg, ${design.from}, ${design.to})` }}
    >
      {design.art ? <div className="absolute inset-0 [&>*]:size-full [&>*]:object-cover">{design.art}</div> : <Motif motif={design.motif} />}
      {/* A soft shade under the text, so name, message and amount read on any motif */}
      <div aria-hidden className={cn('absolute inset-x-0 bottom-0 h-3/5', design.dark ? 'bg-[linear-gradient(to_top,rgb(255_255_255/0.35),transparent)]' : 'bg-[linear-gradient(to_top,rgb(0_0_0/0.32),transparent)]')} />
      <div aria-hidden className="absolute inset-0 bg-[linear-gradient(115deg,transparent_30%,rgb(255_255_255/0.22)_45%,transparent_60%)] bg-[length:250%_100%] bg-[position:120%_0] transition-[background-position] duration-[1200ms] ease-[var(--ease-kit)] group-hover/gc:bg-[position:-30%_0]" />
      <div className="relative flex h-full flex-col">
        <div className="flex items-start justify-between gap-3">
          <span className="text-[13px] font-semibold uppercase tracking-[0.12em] opacity-90">{brand}</span>
          <span className="text-[12px] font-medium opacity-80">{t.giftCard}</span>
        </div>
        <div className="mt-auto">
          {(to || message) && (
            <p className={cn('mb-2 line-clamp-2 max-w-[85%] text-pretty text-[13px] leading-snug opacity-90', live && 'transition-opacity')}>
              {to && <span className="font-semibold">{to}</span>}
              {to && message && ' · '}
              {message}
            </p>
          )}
          {amount !== undefined && (
            <Swap value={amount}>
              <span className="text-[34px] font-semibold leading-none tracking-[-0.03em] tabular-nums sm:text-[40px]">{formatPrice(locale, amount).replace(/[.,]00(?=\s|$| )/, '')}</span>
            </Swap>
          )}
        </div>
      </div>
    </div>
  );
}

/** Tilts its child toward a mouse pointer, flat on touch and with reduced motion */
function Tilt({ children, className }: { children: React.ReactNode; className?: string }) {
  const ref = useRef<HTMLDivElement>(null);
  return (
    <div
      ref={ref}
      className={cn('transition-transform duration-500 ease-[var(--ease-kit)] [transform-style:preserve-3d] motion-reduce:!transform-none', className)}
      onPointerMove={(e) => {
        const el = ref.current;
        if (!el || e.pointerType !== 'mouse') return;
        const r = el.getBoundingClientRect();
        const x = (e.clientX - r.left) / r.width - 0.5;
        const y = (e.clientY - r.top) / r.height - 0.5;
        el.style.transform = `perspective(900px) rotateY(${(x * 10).toFixed(2)}deg) rotateX(${(-y * 10).toFixed(2)}deg) translateY(-4px)`;
      }}
      onPointerLeave={() => {
        if (ref.current) ref.current.style.transform = '';
      }}
    >
      {children}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* designer                                                            */
/* ------------------------------------------------------------------ */

function Designer(p: GiftCardsProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const designs = p.designs ?? [];
  const amounts = p.amounts ?? [25, 50, 100];
  const [lo, hi] = p.range ?? [10, 500];
  const dv = p.defaultValues ?? {};
  const [designId, setDesignId] = useState(dv.designId ?? designs[0]?.id ?? '');
  const [amount, setAmount] = useState(dv.amount ?? amounts[1] ?? amounts[0] ?? 50);
  const [custom, setCustom] = useState('');
  const [to, setTo] = useState(dv.to ?? '');
  const [email, setEmail] = useState('');
  const [from, setFrom] = useState(dv.from ?? '');
  const [message, setMessage] = useState(dv.message ?? '');
  const [tried, setTried] = useState(false);
  const [when, setWhen] = useState<'now' | 'later'>('now');
  const [date, setDate] = useState('');
  const [added, setAdded] = useState(false);
  const id = useId();
  const design = designs.find((d) => d.id === designId) ?? designs[0];
  const customValue = Number(custom.replace(',', '.'));
  const customOk = custom === '' || (customValue >= lo && customValue <= hi);
  const shown = custom && customOk ? customValue : amount;
  const valid = to.trim() && /\S+@\S+\.\S+/.test(email) && from.trim() && customOk && (when === 'now' || date);
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)] lg:gap-16">
        <div className="mx-auto w-full max-w-[30rem] lg:sticky lg:top-8 lg:max-w-none lg:self-start">
          {design && (
            <Reveal effect="scale">
              <Tilt className="group/gc">
                <div key={design.id} className="animate-[kitshop-zoom_0.5s_var(--ease-kit)]">
                  <GiftCard design={design} amount={shown} brand={p.brand} to={to} message={message} live />
                </div>
              </Tilt>
            </Reveal>
          )}
          <p className="mt-5 flex items-start gap-2 text-[13.5px] text-muted-foreground">
            <Icon name="shield" size={15} className="mt-0.5 shrink-0" />
            {t.validFor}
          </p>
        </div>

        <form
          className="space-y-7"
          onSubmit={(e) => {
            e.preventDefault();
            setTried(true);
            if (!valid || !design) {
              const first = !to.trim() ? `${id}-to` : !/S+@S+.S+/.test(email) ? `${id}-em` : !from.trim() ? `${id}-from` : null;
              if (first) document.getElementById(first)?.focus();
              return;
            }
            setAdded(true);
            p.onBuy?.({ designId: design.id, amount: shown, to, from, message, date: when === 'now' ? '' : date });
          }}
        >
          <fieldset>
            <legend className="mb-3 text-[14px] font-medium text-foreground">{t.design}</legend>
            <div role="radiogroup" aria-label={t.design} className="grid grid-cols-5 gap-1.5 sm:gap-2">
              {designs.map((d) => (
                <label key={d.id} className={cn('group/d relative cursor-pointer rounded-xl p-1 transition-shadow has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]', d.id === designId ? 'shadow-[0_0_0_2px_var(--foreground)]' : 'hover:shadow-[0_0_0_1px_var(--border-strong)]')}>
                  <input type="radio" name={`${id}-d`} checked={d.id === designId} onChange={() => setDesignId(d.id)} className="sr-only" />
                  <span className="relative block aspect-[1.6] overflow-hidden rounded-lg transition-transform duration-300 group-hover/d:scale-[1.03]" style={{ background: `linear-gradient(135deg, ${d.from}, ${d.to})` }}>
                    <span className={cn('absolute inset-0', d.dark ? 'text-[oklch(0.24_0.02_60)]' : 'text-white')}>{d.art ?? <Motif motif={d.motif} />}</span>
                  </span>
                  <span className="mt-1.5 block truncate text-center text-[11.5px] text-muted-foreground sm:text-[12px]">{d.name}</span>
                </label>
              ))}
            </div>
          </fieldset>

          <fieldset>
            <legend className="mb-3 text-[14px] font-medium text-foreground">{t.amount}</legend>
            <div className="grid grid-cols-3 gap-2 sm:grid-cols-[repeat(3,minmax(0,1fr))_minmax(0,1.3fr)]">
              {amounts.map((a) => {
                const on = !custom && a === amount;
                return (
                  <label key={a} className={cn('grid h-12 cursor-pointer place-items-center rounded-lg border text-[15px] font-semibold tabular-nums transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]', on ? 'border-foreground bg-foreground text-background' : 'border-border-strong bg-background text-foreground hover:bg-accent')}>
                    <input type="radio" name={`${id}-a`} checked={on} onChange={() => { setAmount(a); setCustom(''); }} className="sr-only" />
                    {formatPrice(locale, a, currency).replace(/[.,]00(?=\s|$| )/, '')}
                  </label>
                );
              })}
              <div className="col-span-3 sm:col-span-1">
                <label htmlFor={`${id}-c`} className="sr-only">
                  {t.other}
                </label>
                <span className="relative block">
                <input
                  id={`${id}-c`}
                  value={custom}
                  onChange={(e) => setCustom(e.target.value.replace(/[^\d,.]/g, ''))}
                  inputMode="decimal"
                  placeholder={t.other}
                  aria-invalid={!customOk || undefined}
                  aria-describedby={`${id}-ch`}
                  className={cn(FIELD, 'h-12 pe-9 tabular-nums', custom && customOk && 'border-foreground', !customOk && 'border-destructive')}
                />
                {custom && <span aria-hidden className="pointer-events-none absolute end-3.5 top-1/2 -translate-y-1/2 text-[14px] text-muted-foreground">{currency === 'USD' ? '$' : '€'}</span>}
                </span>
              </div>
            </div>
            <p id={`${id}-ch`} className={cn('mt-2 text-[12.5px]', customOk ? 'text-muted-foreground' : 'text-destructive')}>
              {t.between} {formatPrice(locale, lo, currency).replace(/[.,]00(?=\s|$| )/, '')} {t.and} {formatPrice(locale, hi, currency).replace(/[.,]00(?=\s|$| )/, '')}
            </p>
          </fieldset>

          <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
            <div>
              <label htmlFor={`${id}-to`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
                {t.to}
              </label>
              <input id={`${id}-to`} value={to} onChange={(e) => setTo(e.target.value)} placeholder={t.toPlaceholder} maxLength={30} aria-invalid={(tried && !to.trim()) || undefined} className={cn(FIELD, tried && !to.trim() && 'border-destructive')} />
            </div>
            <div>
              <label htmlFor={`${id}-em`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
                {t.toEmail}
              </label>
              <input id={`${id}-em`} type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={(tried && !/S+@S+.S+/.test(email)) || undefined} className={cn(FIELD, tried && !/S+@S+.S+/.test(email) && 'border-destructive')} />
            </div>
            <div className="sm:col-span-2">
              <label htmlFor={`${id}-msg`} className="mb-1.5 flex items-baseline justify-between text-[13.5px] font-medium text-foreground">
                {t.message}
                <span className="text-[12px] font-normal tabular-nums text-muted-foreground">{message.length} / 140</span>
              </label>
              <textarea id={`${id}-msg`} value={message} onChange={(e) => setMessage(e.target.value.slice(0, 140))} rows={3} placeholder={t.messagePlaceholder} className={cn(FIELD, 'h-auto resize-none py-3 leading-relaxed')} />
            </div>
            <div>
              <label htmlFor={`${id}-from`} className="mb-1.5 block text-[13.5px] font-medium text-foreground">
                {t.from}
              </label>
              <input id={`${id}-from`} value={from} onChange={(e) => setFrom(e.target.value)} placeholder={t.fromPlaceholder} aria-invalid={(tried && !from.trim()) || undefined} className={cn(FIELD, tried && !from.trim() && 'border-destructive')} />
            </div>
            <fieldset>
              <legend className="mb-1.5 text-[13.5px] font-medium text-foreground">{t.sendOn}</legend>
              <div className="relative grid h-11 grid-cols-2 rounded-lg border border-border-strong bg-surface p-1">
                <span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]" style={{ transform: `translateX(${when === 'later' ? 100 : 0}%)` }} />
                {(['now', 'later'] as const).map((w) => (
                  <label key={w} className={cn('relative z-10 grid cursor-pointer place-items-center after:absolute after:inset-x-0 after:-inset-y-[5px] after:content-[""] rounded-md text-[13.5px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]', when === w ? 'text-foreground' : 'text-muted-foreground')}>
                    <input type="radio" name={`${id}-w`} checked={when === w} onChange={() => setWhen(w)} className="sr-only" />
                    {w === 'now' ? t.today : t.later}
                  </label>
                ))}
              </div>
            </fieldset>
            {when === 'later' && (
              <div className="animate-[kitshop-in_0.35s_var(--ease-kit)] sm:col-span-2">
                <label htmlFor={`${id}-date`} className="sr-only">
                  {t.sendOn}
                </label>
                <input id={`${id}-date`} type="date" value={date} onChange={(e) => setDate(e.target.value)} className={FIELD} />
              </div>
            )}
          </div>

          {tried && !valid && (
            <p role="alert" className="flex items-start gap-2 text-[13.5px] text-destructive">
              <Icon name="alert-circle" size={15} className="mt-0.5 shrink-0" />
              {t.fillIn}
            </p>
          )}
          <div className="flex flex-col gap-3 border-t border-border pt-6 sm:flex-row sm:items-center sm:justify-between">
            <TweenPrice value={shown} currency={currency} className="text-[26px] font-semibold tracking-[-0.02em] text-foreground" />
            <Button type="submit" size="lg" disabled={added} iconLeft={added ? 'check' : 'gift'}>
              <Swap value={added}>{added ? t.added : t.buy}</Swap>
            </Button>
          </div>
        </form>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* amounts                                                             */
/* ------------------------------------------------------------------ */

function Amounts(p: GiftCardsProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const designs = p.designs ?? [];
  const offers = p.offers ?? [];
  const [picked, setPicked] = useState<Record<string, boolean>>({});
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} center />
      <Stagger step={90} as="ul" className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
        {offers.map((o, i) => {
          const d = designs.find((x) => x.id === o.designId) ?? designs[i % Math.max(1, designs.length)];
          const on = !!picked[o.id];
          return (
            <li key={o.id} className="flex flex-col">
              {d && (
                <Tilt className="group/gc">
                  <GiftCard design={d} amount={o.amount} brand={p.brand} />
                </Tilt>
              )}
              <div className="mt-4 flex flex-1 flex-col px-1">
                <div className="flex items-baseline justify-between gap-3">
                  <h3 className="text-[16px] font-semibold text-foreground">{o.occasion ?? d?.name}</h3>
                  <span className="text-[15px] font-semibold tabular-nums text-foreground">{formatPrice(locale, o.amount, currency)}</span>
                </div>
                {o.note && <p className="mt-1 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{o.note}</p>}
                <div className="mt-auto pt-4">
                  <Button variant={on ? 'secondary' : 'outline'} className="w-full" iconLeft={on ? 'check' : 'gift'} aria-pressed={on} onClick={() => setPicked((x) => ({ ...x, [o.id]: !on }))}>
                    <Swap value={on}>{on ? t.chosen : t.choose}</Swap>
                  </Button>
                </div>
              </div>
            </li>
          );
        })}
      </Stagger>
      <p className="mt-10 text-center text-[13.5px] text-muted-foreground">{t.validFor}</p>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* balance                                                             */
/* ------------------------------------------------------------------ */

const formatCode = (v: string) => v.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 12).replace(/(.{4})(?=.)/g, '$1 ');

function Balance(p: GiftCardsProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const bal = p.balance;
  const [code, setCode] = useState(p.defaultChecked && bal ? formatCode(bal.code) : '');
  const [state, setState] = useState<'idle' | 'checking' | 'done' | 'wrong'>(p.defaultChecked ? 'done' : 'idle');
  const [shake, setShake] = useState(0);
  const id = useId();
  const share = bal ? Math.max(0, Math.min(1, bal.amount / Math.max(1, bal.initial))) : 0;
  const ring = round(2 * Math.PI * 54);
  const check = (e: React.FormEvent) => {
    e.preventDefault();
    setState('checking');
    window.setTimeout(() => {
      const ok = !!bal && code.replace(/\s/g, '') === bal.code.replace(/\s/g, '').toUpperCase();
      setState(ok ? 'done' : 'wrong');
      if (!ok) setShake((s) => s + 1);
    }, 700);
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className={cn(CARD, 'mx-auto grid max-w-[56rem] grid-cols-1 overflow-hidden md:grid-cols-2')}>
        <form onSubmit={check} className="flex flex-col p-6 sm:p-8" key={`f${shake}`}>
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="text-balance text-h3 font-semibold text-foreground">{p.title}</h2>
          {p.description && <p className="mt-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">{p.description}</p>}
          <label htmlFor={id} className="mb-1.5 mt-7 block text-[13.5px] font-medium text-foreground">
            {t.code}
          </label>
          <input
            id={id}
            value={code}
            onChange={(e) => { setCode(formatCode(e.target.value)); if (state !== 'checking') setState('idle'); }}
            placeholder={t.codePlaceholder}
            autoComplete="off"
            spellCheck={false}
            aria-invalid={state === 'wrong' || undefined}
            aria-describedby={state === 'wrong' ? `${id}-e` : undefined}
            className={cn(FIELD, 'h-12 text-[17px] uppercase tracking-[0.14em] tabular-nums', state === 'wrong' && 'animate-[kitshop-shake_0.4s_var(--ease-kit)] border-destructive')}
          />
          {state === 'wrong' && (
            <p id={`${id}-e`} role="alert" className="mt-2 flex items-start gap-1.5 text-[13px] text-destructive">
              <Icon name="alert-circle" size={14} className="mt-0.5 shrink-0" />
              {t.notFound}
            </p>
          )}
          <Button type="submit" size="lg" className="mt-5 w-full" iconLeft={state === 'checking' ? 'loader' : 'search'} disabled={state === 'checking' || code.replace(/\s/g, '').length < 12}>
            <Swap value={state === 'checking'}>{state === 'checking' ? t.checking : t.check}</Swap>
          </Button>
        </form>

        <div className="relative border-t border-border bg-surface p-6 sm:p-8 md:border-l md:border-t-0" aria-live="polite">
          {state === 'done' && bal ? (
            <div className="animate-[kitshop-in_0.5s_var(--ease-kit)]">
              <div className="flex items-center gap-5 sm:gap-6">
                <span className="relative grid size-28 shrink-0 place-items-center sm:size-32">
                  <svg viewBox="0 0 120 120" className="absolute inset-0 -rotate-90" aria-hidden>
                    <circle cx="60" cy="60" r="54" fill="none" strokeWidth="8" className="stroke-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
                    <circle cx="60" cy="60" r="54" fill="none" strokeWidth="8" strokeLinecap="round" className="stroke-primary [animation:kitshop-draw_1.2s_var(--ease-kit)_both]" strokeDasharray={ring} strokeDashoffset={round(ring * (1 - share))} style={{ '--kitshop-len': ring } as React.CSSProperties} />
                  </svg>
                  <span className="relative text-center">
                    <span className="block text-[22px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground">{formatPrice(locale, bal.amount, currency)}</span>
                    <span className="mt-1 block text-[12px] text-muted-foreground">{t.left}</span>
                  </span>
                </span>
                <div className="min-w-0 text-[14px] text-muted-foreground">
                  <p>
                    {t.of} <span className="font-medium tabular-nums text-foreground">{formatPrice(locale, bal.initial, currency)}</span>
                  </p>
                  {bal.expires && (
                    <p className="mt-2">
                      {t.expires}
                      <span className="block font-medium text-foreground">{bal.expires}</span>
                    </p>
                  )}
                </div>
              </div>
              {bal.history?.length ? (
                <ul className="mt-6 divide-y divide-border border-t border-border">
                  {bal.history.map((h, i) => (
                    <li key={i} className="flex items-center justify-between gap-4 py-3 text-[14px] animate-[kitshop-in_0.4s_var(--ease-kit)_both]" style={{ animationDelay: `${200 + i * 70}ms` }}>
                      <span className="min-w-0">
                        <span className="block truncate text-foreground">{h.text}</span>
                        <span className="block text-[12.5px] text-muted-foreground">{h.date}</span>
                      </span>
                      <span className={cn('shrink-0 font-medium tabular-nums', h.amount < 0 ? 'text-foreground' : 'text-success-foreground')}>
                        {h.amount < 0 ? '-' : '+'}
                        {formatPrice(locale, Math.abs(h.amount), currency)}
                      </span>
                    </li>
                  ))}
                </ul>
              ) : null}
              <Button variant="ghost" className="mt-3 -ms-3" onClick={() => { setState('idle'); setCode(''); }}>
                {t.checkAnother}
              </Button>
            </div>
          ) : (
            <div className="grid h-full min-h-[14rem] place-items-center">
              {(p.designs ?? [])[0] && (
                <div className="w-full max-w-[18rem] -rotate-3 opacity-90 transition-transform duration-700 ease-[var(--ease-kit)] hover:rotate-0">
                  <GiftCard design={(p.designs ?? [])[0]} brand={p.brand} />
                </div>
              )}
            </div>
          )}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* redeem                                                              */
/* ------------------------------------------------------------------ */

function Redeem(p: GiftCardsProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const cart = p.cart ?? { subtotal: 0, validCode: '', codeValue: 0 };
  const [open, setOpen] = useState(true);
  const [code, setCode] = useState(p.defaultCode ? formatCode(p.defaultCode) : '');
  const [applied, setApplied] = useState(false);
  const [wrong, setWrong] = useState(0);
  const id = useId();
  const before = cart.subtotal + (cart.shipping ?? 0);
  const used = applied ? Math.min(cart.codeValue, before) : 0;
  const total = before - used;
  const rest = applied ? Math.max(0, cart.codeValue - before) : 0;
  const apply = (e: React.FormEvent) => {
    e.preventDefault();
    if (code.replace(/\s/g, '') === cart.validCode.replace(/\s/g, '').toUpperCase()) {
      setApplied(true);
    } else setWrong((w) => w + 1);
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="mx-auto max-w-[28rem]">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <div className={cn(CARD, 'p-5 sm:p-6')}>
          <dl className="space-y-2.5 text-[14.5px]">
            <div className="flex justify-between gap-4">
              <dt className="text-muted-foreground">{t.subtotal}</dt>
              <dd className="tabular-nums text-foreground">{formatPrice(locale, cart.subtotal, currency)}</dd>
            </div>
            <div className="flex justify-between gap-4">
              <dt className="text-muted-foreground">{t.shipping}</dt>
              <dd className={cn('tabular-nums', cart.shipping ? 'text-foreground' : 'text-success-foreground')}>{cart.shipping ? formatPrice(locale, cart.shipping, currency) : t.free}</dd>
            </div>
            {applied && (
              <div className="flex animate-[kitshop-in_0.4s_var(--ease-kit)] justify-between gap-4">
                <dt className="flex items-center gap-1.5 text-success-foreground">
                  <Icon name="gift" size={14} />
                  {t.giftCard} ·· {cart.validCode.slice(-4)}
                </dt>
                <dd className="tabular-nums text-success-foreground">-{formatPrice(locale, used, currency)}</dd>
              </div>
            )}
          </dl>

          <div className="mt-4 border-t border-border pt-4">
            {applied ? (
              <div className="flex animate-[kitshop-pop_0.4s_var(--ease-kit)] items-center justify-between gap-3 rounded-xl bg-[color-mix(in_oklab,var(--success)_12%,transparent)] px-3.5 py-2.5">
                <span className="flex items-center gap-2 text-[13.5px] font-medium text-success-foreground">
                  <Icon name="check-circle" size={16} />
                  {t.applied}
                </span>
                <button type="button" onClick={() => { setApplied(false); setCode(''); }} aria-label={t.remove} className={cn('grid size-9 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground', FOCUS)}>
                  <Icon name="x" size={15} />
                </button>
              </div>
            ) : (
              <>
                <button type="button" aria-expanded={open} onClick={() => setOpen(!open)} className={cn('flex min-h-11 w-full items-center justify-between gap-3 rounded-md text-[14.5px] font-medium text-foreground', FOCUS)}>
                  <span className="flex items-center gap-2">
                    <Icon name="gift" size={16} className="text-muted-foreground" />
                    {t.haveCard}
                  </span>
                  <Icon name="chevron-down" size={16} className={cn('text-muted-foreground transition-transform duration-300', open && 'rotate-180')} />
                </button>
                <div className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
                  <form onSubmit={apply} className="overflow-hidden">
                    <div className="flex gap-2 pt-2" key={`w${wrong}`}>
                      <label htmlFor={id} className="sr-only">
                        {t.code}
                      </label>
                      <input
                        id={id}
                        value={code}
                        onChange={(e) => setCode(formatCode(e.target.value))}
                        placeholder={t.codePlaceholder}
                        spellCheck={false}
                        autoComplete="off"
                        aria-invalid={wrong > 0 || undefined}
                        aria-describedby={wrong ? `${id}-e` : undefined}
                        className={cn(FIELD, 'w-0 min-w-0 flex-1 uppercase tracking-[0.1em] tabular-nums', wrong > 0 && 'animate-[kitshop-shake_0.4s_var(--ease-kit)] border-destructive')}
                      />
                      <Button type="submit" variant="outline" disabled={code.replace(/\s/g, '').length < 12}>
                        {t.apply}
                      </Button>
                    </div>
                    {wrong > 0 && (
                      <p id={`${id}-e`} role="alert" className="mt-2 text-[12.5px] text-destructive">
                        {t.wrongCode}
                      </p>
                    )}
                  </form>
                </div>
              </>
            )}
          </div>

          <div className="mt-4 flex items-baseline justify-between gap-4 border-t border-border pt-4">
            <span className="text-[15px] font-semibold text-foreground">{t.total}</span>
            <TweenPrice value={total} currency={currency} className="text-[22px] font-semibold tracking-[-0.02em] text-foreground" />
          </div>
          {rest > 0 && (
            <p className="mt-1 animate-[kitshop-fade_0.4s] text-right text-[12.5px] text-muted-foreground">
              <span className="tabular-nums text-foreground">{formatPrice(locale, rest, currency)}</span> {t.stillOnCard}
            </p>
          )}
          <Button size="lg" className="mt-5 w-full" icon="arrow-right">
            {t.pay}
          </Button>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* The block                                                           */
/* ------------------------------------------------------------------ */

/**
 * Gift cards in four layouts. designer: design and send one. amounts: ready
 * cards by occasion. balance: check what is left. redeem: apply a code in the cart.
 */
export function GiftCards(props: GiftCardsProps) {
  const { variant = 'designer' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'amounts') return <Amounts {...props} t={t} />;
  if (variant === 'balance') return <Balance {...props} t={t} />;
  if (variant === 'redeem') return <Redeem {...props} t={t} />;
  return <Designer {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Gutscheine?

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

Welche Varianten hat Gutscheine?

4 Varianten, Gutschein gestalten, Fertige Gutscheine, Guthaben prüfen und Im Warenkorb einlösen. Du wählst sie mit der Prop variant, zum Beispiel variant="amounts".

Kann ich die Texte von Gutscheine übersetzen?

Ja. Gutscheine bringt 43 eigene Texte auf Deutsch und Englisch mit, etwa „Gutschein“, „Motiv“ und „Betrag“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Gutscheine einbauen?

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

Kann ich Gutscheine ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden GiftCards-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert