Zum Inhalt springen
UI Kit
Accent

Retourenablauf

Eine Retoure in vier Schritten mit Artikel, Grund, Rückweg und Etikett. Als Assistent, Umtausch, auf einer Seite oder als Etikett.

4 VariantsEine von 3 Components in Bestellungen und Retouren

Eine Retoure, die den Kunden hält

Eine einfache Retoure ist oft der Grund für die nächste Bestellung. Dieser Ablauf führt in vier klaren Schritten durch Artikel, Grund, Rückweg und Etikett, jeder Schritt prüft sich selbst und die Erstattung zählt mit, Kunden wissen also, was sie zurückbekommen.

„Schritt für Schritt“ schiebt vier Schritte unter einem Stepper seitlich weiter, beschädigte Artikel fragen nach ein paar Worten, am Handy steht die Erstattung in einer Leiste unten. „Umtausch“ tauscht die Größe statt zu erstatten, ausverkaufte Größen sind durchgestrichen und ein Schalter schickt die neue, bevor die alte ankommt. „Eine Seite“ zeigt alle Teile zusammen, jede Nummer wird zum Haken, sobald der Teil vollständig ist, und was fehlt, steht oben als Liste von Links. Das „Rücksendeetikett“ ist der letzte Bildschirm, mit Code für den Schalter, Retourennummer, Packschritten, Abgabestellen auf einer gezeichneten Karte und den Terminen bis zur Erstattung.

Biete den Umtausch vor der Erstattung an, viele Retouren sind einfach die falsche Größe. Zeig die Termine der Erstattung auf dem Etikett-Bildschirm, das beantwortet die häufigste Nachfrage. Nimm „Eine Seite“ für Shops mit wenigen Artikeln pro Bestellung und die Schritte für größere Warenkörbe.

Variants

Schritt für SchrittNeu in 1.2

Ein Stepper über vier Schritte, die seitlich gleiten. Jeder Schritt prüft sich selbst, beschädigte Artikel fragen nach ein paar Worten, die Erstattung zählt seitlich mit und steht am Telefon in einer Leiste unten.

UmtauschNeu in 1.2

Eine Größe tauschen statt Geld zurück. Die aktuelle Größe ist markiert, ausverkaufte sind durchgestrichen, die neue leuchtet rechts auf und ein Schalter schickt sie, bevor die alte ankommt.

Eine SeiteNeu in 1.2

Alle drei Teile auf einer Seite, jede Nummer wird zum Haken, sobald der Teil vollständig ist. Ein Knopf am Ende, und was fehlt, steht oben als Liste mit Links.

RücksendeetikettNeu in 1.2

Die letzte Seite. Ein Code für den Schalter, der sich aus der Mitte öffnet, die Retourennummer zum Kopieren, drei Schritte zum Packen, Annahmestellen auf einer gezeichneten Karte und die Daten bis zur Erstattung.

Details

Was Retourenablauf mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle ReturnsFlowProps aus components/ui/returns-flow.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ReturnsFlowVariant

    wizardexchangeone-pagelabel

  • orderoptional
    { number: string; date?: string; items: ReturnItem[] }
  • reasonsoptional
    ReturnReason[]
  • methodsoptional
    ReturnMethod[]
  • creditBonusoptional
    number

    Bonus in percent when the refund goes to store credit

  • daysLeftoptional
    number

    Days left to return

  • returnIdoptional
    string

    label: return number and points nearby

  • dropOffsoptional
    DropOff[]
  • datesoptional
    { dropBy?: string; received?: string; refunded?: string }

    label: expected dates for received and refunded, "YYYY-MM-DD"

  • refundoptional
    number

    label: the refund, if no flow came before

  • defaultStepoptional
    number

    Starts on this step (wizard, 0 to 3), for previews

  • onSubmitoptional
    (data: { items: { id: string; quantity: number; reason?: string; text?: string; option?: string }[]; method?: string; refundTo?: string }) => void
  • currencyoptional
    string

Eingebaute Texte

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

Alle 74 Texte anzeigen
titleEnglisch Return itemsDeutsch Artikel zurücksenden
orderEnglisch OrderDeutsch Bestellung
daysLeftEnglisch {n} days left to returnDeutsch Noch {n} Tage Rückgabe
stepsEnglisch Items|Reason|Way back|LabelDeutsch Artikel|Grund|Rückweg|Etikett
stepOfEnglisch Step {n} of {total}Deutsch Schritt {n} von {total}
pickEnglisch Which items go back?Deutsch Welche Artikel gehen zurück?
pickHintEnglisch Tick everything you want to return. Several of one kind? Set how many.Deutsch Hak alles an, was zurückgehen soll. Mehrere gleiche? Stell die Anzahl ein.
notReturnableEnglisch Not returnableDeutsch Nicht rückgabefähig
reasonEnglisch Why does it go back?Deutsch Warum geht es zurück?
reasonHintEnglisch Your answer helps us make better products. It does not change your refund.Deutsch Deine Antwort hilft uns, bessere Produkte zu machen. Sie ändert nichts an deiner Erstattung.
reasonForEnglisch Reason for {name}Deutsch Grund für {name}
chooseEnglisch Choose a reasonDeutsch Grund wählen
tellEnglisch Tell us a bit moreDeutsch Erzähl uns etwas mehr
tellPlaceholderEnglisch What did you notice?Deutsch Was ist dir aufgefallen?
wayEnglisch How does it come back?Deutsch Wie kommt es zurück?
refundToEnglisch Where does the money go?Deutsch Wohin geht das Geld?
originalEnglisch Original paymentDeutsch Ursprüngliche Zahlart
originalNoteEnglisch Back on your card or account in 3 to 5 daysDeutsch In 3 bis 5 Tagen zurück auf Karte oder Konto
creditEnglisch Store creditDeutsch Shop-Guthaben
creditNoteEnglisch {pct} extra, ready to use right awayDeutsch {pct} extra, sofort einsetzbar
freeEnglisch FreeDeutsch Kostenlos
feeEnglisch {amount} from the refundDeutsch {amount} von der Erstattung
summaryEnglisch Your refundDeutsch Deine Erstattung
itemsEnglisch ItemsDeutsch Artikel
returnFeeEnglisch Return shippingDeutsch Rückversand
bonusEnglisch Credit bonusDeutsch Guthaben-Bonus
refundEnglisch RefundDeutsch Erstattung
nextEnglisch ContinueDeutsch Weiter
backEnglisch BackDeutsch Zurück
submitEnglisch Create return labelDeutsch Rücksendeetikett erstellen
submittingEnglisch Creating labelDeutsch Etikett wird erstellt
eItemsEnglisch Pick at least one item to return.Deutsch Wähl mindestens einen Artikel für die Rücksendung.
eReasonEnglisch Choose a reason for every item.Deutsch Wähl für jeden Artikel einen Grund.
eTextEnglisch Add a few words for the items marked, at least 10 characters.Deutsch Schreib bei den markierten Artikeln ein paar Worte dazu, mindestens 10 Zeichen.
eMethodEnglisch Choose how the parcel comes back.Deutsch Wähl, wie das Paket zurückkommt.
fixListEnglisch A few things are still missingDeutsch Ein paar Angaben fehlen noch
doneEnglisch Your return is registeredDeutsch Deine Rücksendung ist angemeldet
doneTextEnglisch Pack the items, show the code at a drop-off point, and the money is on its way once we scan it.Deutsch Pack die Artikel ein, zeig den Code an einer Annahmestelle, und das Geld ist unterwegs, sobald wir ihn scannen.
returnNoEnglisch ReturnDeutsch Retoure
showCodeEnglisch Show this code at the counter. No printer needed.Deutsch Zeig diesen Code am Schalter. Kein Drucker nötig.
printEnglisch PrintDeutsch Drucken
downloadEnglisch Save PDFDeutsch Als PDF
submitShortEnglisch Create labelDeutsch Etikett erstellen
fewerEnglisch One lessDeutsch Einer weniger
moreEnglisch One moreDeutsch Einer mehr
copyCodeEnglisch Copy return numberDeutsch Retourennummer kopieren
copiedEnglisch CopiedDeutsch Kopiert
dropOffsEnglisch Drop-off points near youDeutsch Annahmestellen in deiner Nähe
openEnglisch Open {hours}Deutsch Geöffnet {hours}
howEnglisch Three stepsDeutsch Drei Schritte
how1Englisch Pack the items in the original box or any sturdy one.Deutsch Pack die Artikel in den Originalkarton oder einen anderen stabilen.
how2Englisch Close it well. The code does the rest.Deutsch Gut verschließen. Den Rest erledigt der Code.
how3Englisch Drop it off by {date} and keep the receipt.Deutsch Bis {date} abgeben und den Beleg aufheben.
whenMoneyEnglisch When the money comesDeutsch Wann das Geld kommt
droppedEnglisch You drop it offDeutsch Du gibst es ab
receivedEnglisch We receive itDeutsch Wir erhalten es
refundedEnglisch Refund sentDeutsch Erstattung raus
exchangeTitleEnglisch Swap for another sizeDeutsch Gegen eine andere Größe tauschen
exchangeHintEnglisch The new piece leaves our warehouse as soon as your return is scanned. The price stays the same and your money stays where it is.Deutsch Das neue Stück verlässt unser Lager, sobald deine Rücksendung gescannt ist. Der Preis bleibt gleich, und dein Geld bleibt, wo es ist.
exchangeWhichEnglisch Which item do you want to swap?Deutsch Welchen Artikel möchtest du tauschen?
newOptionEnglisch New sizeDeutsch Neue Größe
currentEnglisch Your sizeDeutsch Deine Größe
chooseSizeEnglisch Pick a sizeDeutsch Größe wählen
soldOutEnglisch sold outDeutsch ausverkauft
sendNowEnglisch Send the new one nowDeutsch Das neue Stück sofort schicken
sendNowNoteEnglisch We reserve the amount on your card and release it when your return arrives.Deutsch Wir reservieren den Betrag auf deiner Karte und geben ihn frei, sobald deine Rücksendung ankommt.
swapEnglisch Swap nowDeutsch Jetzt tauschen
swappedEnglisch Swap confirmedDeutsch Tausch bestätigt
swappedTextEnglisch {item} in {option} is reserved for you. Use the label below for the old one.Deutsch {item} in {option} ist für dich reserviert. Für das alte Stück nimm das Etikett unten.
eOptionEnglisch Choose the new size.Deutsch Wähl die neue Größe.
priceSameEnglisch Same price, nothing to payDeutsch Gleicher Preis, nichts zu zahlen
qtyEnglisch Quantity to returnDeutsch Anzahl für die Rücksendung
noItemsEnglisch No items selected yetDeutsch Noch keine Artikel gewählt
sectionEnglisch Part {n}Deutsch Teil {n}

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-current, aria-describedby, aria-invalid, aria-label und aria-labelledby, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, group, status und switch.
  • 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. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 1.093 Zeilen
components/ui/returns-flow.tsx
'use client';

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

/*
 * A return in steps: items, reason, way back, label.
 *
 *   wizard    four steps with a stepper, the refund counts along at the side (a bar at the bottom on phones)
 *   exchange  swap a size or color instead of a refund, the new piece leaves when the old one is scanned
 *   one-page  all four parts on one page, one check at the end with a list of what is missing
 *   label     the last screen: code for the counter, drop-off points, packing steps and when the money comes
 */

export type ReturnsFlowVariant = 'wizard' | 'exchange' | 'one-page' | 'label';

export interface ReturnItem {
  id: string;
  name: string;
  variant?: string;
  /** Unit price paid */
  price: number;
  quantity: number;
  image?: React.ReactNode;
  /** Final sale and hygiene items cannot go back */
  returnable?: boolean;
  /** Why it cannot go back */
  note?: string;
  /** exchange: the options to swap to, e.g. sizes */
  options?: { label: string; soldOut?: boolean }[];
}

export interface ReturnReason {
  id: string;
  label: string;
  /** Ask for a few words */
  needsText?: boolean;
}

export interface ReturnMethod {
  id: string;
  label: string;
  description?: string;
  /** Fee taken from the refund, 0 for free */
  price: number;
  icon?: IconName;
}

export interface DropOff {
  name: string;
  address: string;
  /** e.g. "350 m" */
  distance: string;
  hours?: string;
}

export interface ReturnsFlowProps {
  variant?: ReturnsFlowVariant;
  order?: { number: string; date?: string; items: ReturnItem[] };
  reasons?: ReturnReason[];
  methods?: ReturnMethod[];
  /** Bonus in percent when the refund goes to store credit */
  creditBonus?: number;
  /** Days left to return */
  daysLeft?: number;
  /** label: return number and points nearby */
  returnId?: string;
  dropOffs?: DropOff[];
  /** label: expected dates for received and refunded, "YYYY-MM-DD" */
  dates?: { dropBy?: string; received?: string; refunded?: string };
  /** label: the refund, if no flow came before */
  refund?: number;
  /** Starts on this step (wizard, 0 to 3), for previews */
  defaultStep?: number;
  onSubmit?: (data: { items: { id: string; quantity: number; reason?: string; text?: string; option?: string }[]; method?: string; refundTo?: string }) => void;
  currency?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    title: 'Return items',
    order: 'Order',
    daysLeft: '{n} days left to return',
    steps: 'Items|Reason|Way back|Label',
    stepOf: 'Step {n} of {total}',
    pick: 'Which items go back?',
    pickHint: 'Tick everything you want to return. Several of one kind? Set how many.',
    notReturnable: 'Not returnable',
    reason: 'Why does it go back?',
    reasonHint: 'Your answer helps us make better products. It does not change your refund.',
    reasonFor: 'Reason for {name}',
    choose: 'Choose a reason',
    tell: 'Tell us a bit more',
    tellPlaceholder: 'What did you notice?',
    way: 'How does it come back?',
    refundTo: 'Where does the money go?',
    original: 'Original payment',
    originalNote: 'Back on your card or account in 3 to 5 days',
    credit: 'Store credit',
    creditNote: '{pct} extra, ready to use right away',
    free: 'Free',
    fee: '{amount} from the refund',
    summary: 'Your refund',
    items: 'Items',
    returnFee: 'Return shipping',
    bonus: 'Credit bonus',
    refund: 'Refund',
    next: 'Continue',
    back: 'Back',
    submit: 'Create return label',
    submitting: 'Creating label',
    eItems: 'Pick at least one item to return.',
    eReason: 'Choose a reason for every item.',
    eText: 'Add a few words for the items marked, at least 10 characters.',
    eMethod: 'Choose how the parcel comes back.',
    fixList: 'A few things are still missing',
    done: 'Your return is registered',
    doneText: 'Pack the items, show the code at a drop-off point, and the money is on its way once we scan it.',
    returnNo: 'Return',
    showCode: 'Show this code at the counter. No printer needed.',
    print: 'Print',
    download: 'Save PDF',
    submitShort: 'Create label',
    fewer: 'One less',
    more: 'One more',
    copyCode: 'Copy return number',
    copied: 'Copied',
    dropOffs: 'Drop-off points near you',
    open: 'Open {hours}',
    how: 'Three steps',
    how1: 'Pack the items in the original box or any sturdy one.',
    how2: 'Close it well. The code does the rest.',
    how3: 'Drop it off by {date} and keep the receipt.',
    whenMoney: 'When the money comes',
    dropped: 'You drop it off',
    received: 'We receive it',
    refunded: 'Refund sent',
    exchangeTitle: 'Swap for another size',
    exchangeHint: 'The new piece leaves our warehouse as soon as your return is scanned. The price stays the same and your money stays where it is.',
    exchangeWhich: 'Which item do you want to swap?',
    newOption: 'New size',
    current: 'Your size',
    chooseSize: 'Pick a size',
    soldOut: 'sold out',
    sendNow: 'Send the new one now',
    sendNowNote: 'We reserve the amount on your card and release it when your return arrives.',
    swap: 'Swap now',
    swapped: 'Swap confirmed',
    swappedText: '{item} in {option} is reserved for you. Use the label below for the old one.',
    eOption: 'Choose the new size.',
    priceSame: 'Same price, nothing to pay',
    qty: 'Quantity to return',
    noItems: 'No items selected yet',
    section: 'Part {n}',
  },
  de: {
    title: 'Artikel zurücksenden',
    order: 'Bestellung',
    daysLeft: 'Noch {n} Tage Rückgabe',
    steps: 'Artikel|Grund|Rückweg|Etikett',
    stepOf: 'Schritt {n} von {total}',
    pick: 'Welche Artikel gehen zurück?',
    pickHint: 'Hak alles an, was zurückgehen soll. Mehrere gleiche? Stell die Anzahl ein.',
    notReturnable: 'Nicht rückgabefähig',
    reason: 'Warum geht es zurück?',
    reasonHint: 'Deine Antwort hilft uns, bessere Produkte zu machen. Sie ändert nichts an deiner Erstattung.',
    reasonFor: 'Grund für {name}',
    choose: 'Grund wählen',
    tell: 'Erzähl uns etwas mehr',
    tellPlaceholder: 'Was ist dir aufgefallen?',
    way: 'Wie kommt es zurück?',
    refundTo: 'Wohin geht das Geld?',
    original: 'Ursprüngliche Zahlart',
    originalNote: 'In 3 bis 5 Tagen zurück auf Karte oder Konto',
    credit: 'Shop-Guthaben',
    creditNote: '{pct} extra, sofort einsetzbar',
    free: 'Kostenlos',
    fee: '{amount} von der Erstattung',
    summary: 'Deine Erstattung',
    items: 'Artikel',
    returnFee: 'Rückversand',
    bonus: 'Guthaben-Bonus',
    refund: 'Erstattung',
    next: 'Weiter',
    back: 'Zurück',
    submit: 'Rücksendeetikett erstellen',
    submitting: 'Etikett wird erstellt',
    eItems: 'Wähl mindestens einen Artikel für die Rücksendung.',
    eReason: 'Wähl für jeden Artikel einen Grund.',
    eText: 'Schreib bei den markierten Artikeln ein paar Worte dazu, mindestens 10 Zeichen.',
    eMethod: 'Wähl, wie das Paket zurückkommt.',
    fixList: 'Ein paar Angaben fehlen noch',
    done: 'Deine Rücksendung ist angemeldet',
    doneText: 'Pack die Artikel ein, zeig den Code an einer Annahmestelle, und das Geld ist unterwegs, sobald wir ihn scannen.',
    returnNo: 'Retoure',
    showCode: 'Zeig diesen Code am Schalter. Kein Drucker nötig.',
    print: 'Drucken',
    download: 'Als PDF',
    submitShort: 'Etikett erstellen',
    fewer: 'Einer weniger',
    more: 'Einer mehr',
    copyCode: 'Retourennummer kopieren',
    copied: 'Kopiert',
    dropOffs: 'Annahmestellen in deiner Nähe',
    open: 'Geöffnet {hours}',
    how: 'Drei Schritte',
    how1: 'Pack die Artikel in den Originalkarton oder einen anderen stabilen.',
    how2: 'Gut verschließen. Den Rest erledigt der Code.',
    how3: 'Bis {date} abgeben und den Beleg aufheben.',
    whenMoney: 'Wann das Geld kommt',
    dropped: 'Du gibst es ab',
    received: 'Wir erhalten es',
    refunded: 'Erstattung raus',
    exchangeTitle: 'Gegen eine andere Größe tauschen',
    exchangeHint: 'Das neue Stück verlässt unser Lager, sobald deine Rücksendung gescannt ist. Der Preis bleibt gleich, und dein Geld bleibt, wo es ist.',
    exchangeWhich: 'Welchen Artikel möchtest du tauschen?',
    newOption: 'Neue Größe',
    current: 'Deine Größe',
    chooseSize: 'Größe wählen',
    soldOut: 'ausverkauft',
    sendNow: 'Das neue Stück sofort schicken',
    sendNowNote: 'Wir reservieren den Betrag auf deiner Karte und geben ihn frei, sobald deine Rücksendung ankommt.',
    swap: 'Jetzt tauschen',
    swapped: 'Tausch bestätigt',
    swappedText: '{item} in {option} ist für dich reserviert. Für das alte Stück nimm das Etikett unten.',
    eOption: 'Wähl die neue Größe.',
    priceSame: 'Gleicher Preis, nichts zu zahlen',
    qty: 'Anzahl für die Rücksendung',
    noItems: 'Noch keine Artikel gewählt',
    section: 'Teil {n}',
  },
};

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

const REASONS = {
  en: [
    { id: 'small', label: 'Too small' },
    { id: 'large', label: 'Too large' },
    { id: 'look', label: 'Looks different than in the pictures' },
    { id: 'damaged', label: 'Arrived damaged', needsText: true },
    { id: 'changed', label: 'Changed my mind' },
    { id: 'other', label: 'Something else', needsText: true },
  ],
  de: [
    { id: 'small', label: 'Zu klein' },
    { id: 'large', label: 'Zu groß' },
    { id: 'look', label: 'Sieht anders aus als auf den Bildern' },
    { id: 'damaged', label: 'Beschädigt angekommen', needsText: true },
    { id: 'changed', label: 'Doch nicht das Richtige' },
    { id: 'other', label: 'Etwas anderes', needsText: true },
  ],
};

const METHODS = {
  en: [
    { id: 'drop', label: 'Drop-off point', description: 'At a parcel shop near you, with a code instead of a printed label', price: 0, icon: 'map-pin' as IconName },
    { id: 'pickup', label: 'Pickup at your door', description: 'A courier collects it on a day you choose', price: 4.9, icon: 'truck' as IconName },
    { id: 'store', label: 'In a store', description: 'Bring it by, the refund starts on the spot', price: 0, icon: 'store' as IconName },
  ],
  de: [
    { id: 'drop', label: 'Annahmestelle', description: 'In einem Paketshop in deiner Nähe, mit Code statt gedrucktem Etikett', price: 0, icon: 'map-pin' as IconName },
    { id: 'pickup', label: 'Abholung an der Tür', description: 'Ein Kurier holt es an einem Tag deiner Wahl ab', price: 4.9, icon: 'truck' as IconName },
    { id: 'store', label: 'Im Laden', description: 'Vorbeibringen, die Erstattung startet sofort', price: 0, icon: 'store' as IconName },
  ],
};

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 pctText = (locale: Locale, n: number) => (locale === 'de' ? `${n} %` : `${n}%`);
const dateOf = (locale: Locale, iso: string) => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }).format(new Date(iso + 'T12:00:00Z'));

/* ------------------------------------------------------------------ */
/* State shared by wizard and one-page                                 */
/* ------------------------------------------------------------------ */

interface Pick {
  qty: number;
  reason?: string;
  text?: string;
}

function useReturn(props: ReturnsFlowProps, locale: Locale) {
  const items = props.order?.items ?? [];
  const reasons = props.reasons ?? REASONS[locale];
  const methods = props.methods ?? METHODS[locale];
  const [picked, setPicked] = useState<Record<string, Pick>>({});
  const [method, setMethod] = useState<string>('');
  const [refundTo, setRefundTo] = useState<'original' | 'credit'>('original');
  const ids = Object.keys(picked);
  const value = round(items.filter((i) => picked[i.id]).reduce((a, i) => a + i.price * picked[i.id].qty, 0));
  const fee = methods.find((m) => m.id === method)?.price ?? 0;
  const bonus = refundTo === 'credit' ? round((value * (props.creditBonus ?? 0)) / 100) : 0;
  const refund = round(Math.max(0, value - fee + bonus));
  const toggle = (i: ReturnItem) => setPicked((p) => {
    const n = { ...p };
    if (n[i.id]) delete n[i.id];
    else n[i.id] = { qty: i.quantity };
    return n;
  });
  const patch = (id: string, v: Partial<Pick>) => setPicked((p) => ({ ...p, [id]: { ...p[id], ...v } }));
  const needs = (id: string) => !!reasons.find((r) => r.id === picked[id]?.reason)?.needsText;
  const errors = {
    items: ids.length === 0,
    reason: ids.filter((id) => !picked[id].reason),
    text: ids.filter((id) => needs(id) && (picked[id].text ?? '').trim().length < 10),
    method: !method,
  };
  return { items, reasons, methods, picked, toggle, patch, method, setMethod, refundTo, setRefundTo, value, fee, bonus, refund, errors, needs, ids };
}

type R = ReturnType<typeof useReturn>;

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

function Quantity({ value, max, onChange, label, t }: { value: number; max: number; onChange: (n: number) => void; label: string; t: Texts }) {
  return (
    <div role="group" aria-label={label} className="inline-flex items-center overflow-hidden rounded-lg border border-border-strong bg-background">
      <button type="button" disabled={value <= 1} onClick={() => onChange(value - 1)} aria-label={t.fewer} className="relative grid size-9 place-items-center text-muted-foreground after:absolute after:-inset-1 hover:bg-accent hover:text-foreground disabled:opacity-35">
        <Icon name="minus" size={14} />
      </button>
      <output className="min-w-7 text-center text-[13px] font-medium tabular-nums text-foreground">{value}</output>
      <button type="button" disabled={value >= max} onClick={() => onChange(value + 1)} aria-label={t.more} className="relative grid size-9 place-items-center text-muted-foreground after:absolute after:-inset-1 hover:bg-accent hover:text-foreground disabled:opacity-35">
        <Icon name="plus" size={14} />
      </button>
    </div>
  );
}

function ItemsPick({ r, t, currency, invalid }: { r: R; t: Texts; currency: string; invalid?: boolean }) {
  const locale = useLocale();
  return (
    <ul className="space-y-3">
      {r.items.map((i, n) => {
        const on = !!r.picked[i.id];
        const off = i.returnable === false;
        return (
          <li key={i.id} className="kit-rise" style={{ '--kit-delay': `${n * 60}ms` } as React.CSSProperties}>
            <div className={cn('flex gap-4 rounded-xl border p-3 transition-[border-color,background-color] duration-300 sm:p-4', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))]' : 'border-border bg-card', off && 'bg-surface opacity-75', invalid && !on && !off && 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]')}>
              <label className={cn('flex min-w-0 flex-1 gap-4', off ? 'cursor-not-allowed' : 'cursor-pointer')}>
                <input type="checkbox" checked={on} disabled={off} onChange={() => r.toggle(i)} aria-invalid={(invalid && !off) || undefined} className="peer sr-only" />
                <span aria-hidden className={cn('mt-1 grid size-5 shrink-0 place-items-center rounded-[6px] border transition-colors peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)] peer-focus-visible:outline-solid', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
                  {on && <Icon name="check" size={13} strokeWidth={2.6} className="animate-[kitshop-pop_0.3s_var(--ease-kit)]" />}
                </span>
                <ProductMedia className="size-16 shrink-0 rounded-lg sm:size-20">{i.image}</ProductMedia>
                <span className="min-w-0 flex-1">
                  <span className="block text-[14px] font-medium text-foreground sm:text-[15px]">{i.name}</span>
                  {i.variant && <span className="mt-0.5 block text-[13px] text-muted-foreground">{i.variant}</span>}
                  <span className="mt-1 block text-[13px] tabular-nums text-foreground">
                    {i.quantity > 1 ? `${i.quantity} × ` : ''}
                    {formatPrice(locale, i.price, currency)}
                  </span>
                  {off && (
                    <span className="mt-1.5 inline-flex items-center gap-1 text-[12px] text-muted-foreground">
                      <Icon name="info" size={13} />
                      {i.note ?? t.notReturnable}
                    </span>
                  )}
                </span>
              </label>
              {on && i.quantity > 1 && (
                <div className="self-end">
                  <Quantity value={r.picked[i.id].qty} max={i.quantity} onChange={(q) => r.patch(i.id, { qty: q })} label={`${t.qty}, ${i.name}`} t={t} />
                </div>
              )}
            </div>
          </li>
        );
      })}
    </ul>
  );
}

const selectClass =
  'h-11 w-full cursor-pointer appearance-none rounded-lg border border-input bg-background pl-3.5 pr-10 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] 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]';

function Reasons({ r, t, show }: { r: R; t: Texts; show: boolean }) {
  const id = useId();
  const chosen = r.items.filter((i) => r.picked[i.id]);
  if (!chosen.length) return <p className="rounded-xl border border-dashed border-border-strong p-5 text-[14px] text-muted-foreground">{t.noItems}</p>;
  return (
    <ul className="space-y-4">
      {chosen.map((i) => {
        const p = r.picked[i.id];
        const badReason = show && r.errors.reason.includes(i.id);
        const badText = show && r.errors.text.includes(i.id);
        return (
          <li key={i.id} className="rounded-xl border border-border bg-card p-4">
            <div className="flex items-center gap-3">
              <ProductMedia className="size-11 shrink-0 rounded-md">{i.image}</ProductMedia>
              <p className="min-w-0 truncate text-[14px] font-medium text-foreground">{i.name}</p>
            </div>
            <label htmlFor={`${id}-${i.id}`} className="sr-only">
              {fill(t.reasonFor, { name: i.name })}
            </label>
            <div className="relative mt-3">
              <select id={`${id}-${i.id}`} value={p.reason ?? ''} onChange={(e) => r.patch(i.id, { reason: e.target.value })} aria-invalid={badReason || undefined} aria-describedby={badReason ? `${id}-${i.id}-e` : undefined} className={selectClass}>
                <option value="" disabled>
                  {t.choose}
                </option>
                {r.reasons.map((x) => (
                  <option key={x.id} value={x.id}>
                    {x.label}
                  </option>
                ))}
              </select>
              <Icon name="chevrons-up-down" size={15} className="pointer-events-none absolute right-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
            </div>
            {badReason && <p id={`${id}-${i.id}-e`} className="mt-1.5 text-[12px] text-destructive">{t.eReason}</p>}
            {r.needs(i.id) && (
              <div className="mt-3 animate-[kitshop-in_0.35s_var(--ease-kit)]">
                <label htmlFor={`${id}-${i.id}-t`} className="mb-1.5 block text-[13px] font-medium text-foreground">
                  {t.tell}
                </label>
                <textarea id={`${id}-${i.id}-t`} rows={2} value={p.text ?? ''} onChange={(e) => r.patch(i.id, { text: e.target.value })} placeholder={t.tellPlaceholder} aria-invalid={badText || undefined} aria-describedby={badText ? `${id}-${i.id}-te` : undefined} className={cn(selectClass, 'h-auto cursor-text resize-none py-2.5 pr-3.5')} />
                {badText && <p id={`${id}-${i.id}-te`} className="mt-1.5 text-[12px] text-destructive">{t.eText}</p>}
              </div>
            )}
          </li>
        );
      })}
    </ul>
  );
}

function Choice({ name, checked, onChange, title, note, icon, right }: { name: string; checked: boolean; onChange: () => void; title: string; note?: string; icon?: IconName; right?: React.ReactNode }) {
  return (
    <label className={cn('flex cursor-pointer items-start gap-3.5 rounded-xl border p-4 transition-[border-color,background-color] duration-300 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', checked ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))]' : 'border-border-strong bg-card hover:border-foreground/40')}>
      <input type="radio" name={name} checked={checked} onChange={onChange} className="sr-only" />
      {icon && (
        <span className={cn('grid size-10 shrink-0 place-items-center rounded-lg transition-colors', checked ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground')}>
          <Icon name={icon} size={19} />
        </span>
      )}
      <span className="min-w-0 flex-1">
        <span className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
          <span className="text-[14px] font-medium text-foreground">{title}</span>
          {right && <span className="text-[13px] tabular-nums text-muted-foreground">{right}</span>}
        </span>
        {note && <span className="mt-0.5 block text-[13px] text-muted-foreground">{note}</span>}
      </span>
      <span aria-hidden className={cn('mt-0.5 grid size-5 shrink-0 place-items-center rounded-full border-2 transition-colors', checked ? 'border-primary' : 'border-border-strong')}>
        {checked && <span className="size-2.5 animate-[kitshop-pop_0.3s_var(--ease-kit)] rounded-full bg-primary" />}
      </span>
    </label>
  );
}

function Way({ r, t, currency, show, credit, titled }: { r: R; t: Texts; currency: string; show: boolean; credit?: number; titled?: boolean }) {
  const locale = useLocale();
  const id = useId();
  return (
    <div className="space-y-8">
      <fieldset aria-invalid={(show && r.errors.method) || undefined} aria-describedby={show && r.errors.method ? `${id}-e` : undefined}>
        <legend className={cn('mb-3 text-[15px] font-semibold text-foreground', titled && 'sr-only')}>{t.way}</legend>
        <div className="grid grid-cols-1 gap-3">
          {r.methods.map((m) => (
            <Choice key={m.id} name={`${id}-m`} checked={r.method === m.id} onChange={() => r.setMethod(m.id)} title={m.label} note={m.description} icon={m.icon} right={m.price ? fill(t.fee, { amount: formatPrice(locale, m.price, currency) }) : t.free} />
          ))}
        </div>
        {show && r.errors.method && <p id={`${id}-e`} className="mt-2 text-[12px] text-destructive">{t.eMethod}</p>}
      </fieldset>
      <fieldset>
        <legend className="mb-3 text-[15px] font-semibold text-foreground">{t.refundTo}</legend>
        <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
          <Choice name={`${id}-r`} checked={r.refundTo === 'original'} onChange={() => r.setRefundTo('original')} title={t.original} note={t.originalNote} icon="credit-card" />
          <Choice name={`${id}-r`} checked={r.refundTo === 'credit'} onChange={() => r.setRefundTo('credit')} title={t.credit} note={fill(t.creditNote, { pct: pctText(locale, credit ?? 0) })} icon="gift" />
        </div>
      </fieldset>
    </div>
  );
}

function SummaryCard({ r, t, currency, children }: { r: R; t: Texts; currency: string; children?: React.ReactNode }) {
  const locale = useLocale();
  const chosen = r.items.filter((i) => r.picked[i.id]);
  return (
    <div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-md)] sm:p-6">
      <h3 className="text-[15px] font-semibold text-foreground">{t.summary}</h3>
      {chosen.length ? (
        <ul className="mt-4 space-y-2.5">
          {chosen.map((i) => (
            <li key={i.id} className="flex animate-[kitshop-in_0.35s_var(--ease-kit)] items-center gap-3">
              <ProductMedia className="size-10 shrink-0 rounded-md">{i.image}</ProductMedia>
              <span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
                {r.picked[i.id].qty > 1 ? `${r.picked[i.id].qty} × ` : ''}
                {i.name}
              </span>
              <span className="text-[13px] tabular-nums text-muted-foreground">{formatPrice(locale, i.price * r.picked[i.id].qty, currency)}</span>
            </li>
          ))}
        </ul>
      ) : (
        <p className="mt-3 text-[13px] text-muted-foreground">{t.noItems}</p>
      )}
      <dl className="mt-5 space-y-2 border-t border-border pt-4 text-[14px]">
        <div className="flex justify-between gap-4">
          <dt className="text-muted-foreground">{t.items}</dt>
          <dd className="tabular-nums text-foreground">{formatPrice(locale, r.value, currency)}</dd>
        </div>
        {r.method && (
          <div className="flex justify-between gap-4">
            <dt className="text-muted-foreground">{t.returnFee}</dt>
            <dd className={cn('tabular-nums', r.fee ? 'text-foreground' : 'text-success-foreground')}>{r.fee ? `− ${formatPrice(locale, r.fee, currency)}` : t.free}</dd>
          </div>
        )}
        {r.bonus > 0 && (
          <div className="flex animate-[kitshop-in_0.35s_var(--ease-kit)] justify-between gap-4">
            <dt className="text-muted-foreground">{t.bonus}</dt>
            <dd className="tabular-nums text-success-foreground">+ {formatPrice(locale, r.bonus, currency)}</dd>
          </div>
        )}
        <div className="flex items-baseline justify-between gap-4 border-t border-border pt-3">
          <dt className="text-[15px] font-semibold text-foreground">{t.refund}</dt>
          <dd>
            <TweenPrice value={r.refund} currency={currency} className="text-[20px] font-semibold tracking-[-0.02em] text-foreground" />
          </dd>
        </div>
      </dl>
      {children}
    </div>
  );
}

function Head({ props, t, title }: { props: ReturnsFlowProps; t: Texts; title?: string }) {
  return (
    <div className="mb-8 flex flex-wrap items-end justify-between gap-x-6 gap-y-3 sm:mb-10">
      <div>
        {props.order && (
          <p className="text-[13px] text-muted-foreground">
            {t.order} <span className="font-medium tabular-nums text-foreground">{props.order.number}</span>
          </p>
        )}
        <h1 className="mt-1 text-h2 font-semibold text-foreground">{title ?? t.title}</h1>
      </div>
      {props.daysLeft !== undefined && (
        <p className="inline-flex h-8 items-center gap-1.5 rounded-full bg-[color-mix(in_oklab,var(--success)_12%,var(--card))] px-3 text-[12px] font-medium text-success-foreground">
          <Icon name="clock" size={13} />
          {fill(t.daysLeft, { n: props.daysLeft })}
        </p>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Label: the screen after a return is created                         */
/* ------------------------------------------------------------------ */

function DropMap({ count }: { count: number }) {
  const spots = [
    [30, 40],
    [62, 28],
    [74, 66],
    [42, 72],
  ].slice(0, count);
  return (
    <div aria-hidden data-clip-ok className="relative aspect-[16/7] overflow-hidden rounded-xl bg-surface-2">
      <svg viewBox="0 0 320 140" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 size-full">
        {[24, 62, 100].map((y) => <path key={y} d={`M-5 ${y}H325`} strokeWidth={y === 62 ? 8 : 5} strokeLinecap="round" style={{ stroke: 'var(--background)' }} />)}
        {[40, 120, 200, 270].map((x) => <path key={x} d={`M${x} -5V145`} strokeWidth={x === 120 ? 8 : 5} strokeLinecap="round" style={{ stroke: 'var(--background)' }} />)}
        <rect x="210" y="74" width="50" height="20" rx="4" style={{ fill: 'color-mix(in oklab, var(--success) 16%, var(--surface-2))' }} />
      </svg>
      <span className="absolute left-1/2 top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-background bg-foreground shadow-[var(--shadow-md)]" />
      {spots.map(([x, y], i) => (
        <span key={i} className="absolute grid size-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground shadow-[var(--shadow-md)] kit-rise" style={{ left: `${x}%`, top: `${y}%`, '--kit-delay': `${200 + i * 120}ms` } as React.CSSProperties}>
          {i + 1}
        </span>
      ))}
    </div>
  );
}

function LabelScreen({ props, t, currency, refund }: { props: ReturnsFlowProps; t: Texts; currency: string; refund: number }) {
  const locale = useLocale();
  const [copied, setCopied] = useState(false);
  const id = props.returnId ?? 'RT-20931';
  const drops = props.dropOffs ?? [];
  const dates = props.dates ?? {};
  const timeline = [
    { label: t.dropped, date: dates.dropBy, icon: 'package' as IconName },
    { label: t.received, date: dates.received, icon: 'home' as IconName },
    { label: t.refunded, date: dates.refunded, icon: 'wallet' as IconName },
  ];
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-[22rem_minmax(0,1fr)] lg:gap-12">
      <div className="lg:sticky lg:top-8 lg:self-start">
        <div className="kit-rise rounded-2xl border border-border bg-card p-5 text-center shadow-[var(--highlight),var(--shadow-lg)] sm:p-6">
          <div className="mx-auto w-full max-w-[13rem] overflow-hidden rounded-xl border border-border">
            <QrGraphic value={`RETURN:${id}`} label={`${t.returnNo} ${id}`} />
          </div>
          <p className="mt-4 text-[13px] text-muted-foreground">{t.returnNo}</p>
          <p className="flex items-center justify-center gap-1 text-[19px] font-semibold tabular-nums tracking-[0.04em] text-foreground">
            {id}
            <button
              type="button"
              onClick={() => { navigator.clipboard?.writeText(id).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1600); }}
              aria-label={copied ? t.copied : t.copyCode}
              className="relative grid size-9 place-items-center rounded-md text-muted-foreground after:absolute after:-inset-1 hover:bg-accent hover:text-foreground"
            >
              <Icon name={copied ? 'check' : 'copy'} size={15} className={cn(copied && 'text-success')} />
            </button>
          </p>
          <p className="mx-auto mt-2 max-w-[16rem] text-pretty text-[13px] text-muted-foreground">{t.showCode}</p>
          <div className="mt-5 grid grid-cols-2 gap-2">
            <Button variant="outline" iconLeft="printer" onClick={() => window.print()}>
              {t.print}
            </Button>
            <Button variant="outline" iconLeft="download" href="#">
              {t.download}
            </Button>
          </div>
          <p className="mt-5 flex items-baseline justify-between gap-3 border-t border-border pt-4 text-[14px]">
            <span className="text-muted-foreground">{t.refund}</span>
            <span className="text-[18px] font-semibold tabular-nums text-foreground">{formatPrice(locale, refund, currency)}</span>
          </p>
        </div>
      </div>
      <div className="min-w-0 space-y-10">
        <section>
          <h2 className="text-[17px] font-semibold text-foreground">{t.how}</h2>
          <ol className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
            {[t.how1, t.how2, fill(t.how3, { date: dates.dropBy ? dateOf(locale, dates.dropBy) : '' })].map((s, i) => (
              <li key={i} className="kit-rise rounded-xl border border-border bg-card p-4" style={{ '--kit-delay': `${100 + i * 80}ms` } as React.CSSProperties}>
                <span className="grid size-7 place-items-center rounded-full bg-foreground text-[13px] font-semibold text-background">{i + 1}</span>
                <p className="mt-3 text-pretty text-[14px] text-foreground">{s}</p>
              </li>
            ))}
          </ol>
        </section>
        {drops.length > 0 && (
          <section>
            <h2 className="text-[17px] font-semibold text-foreground">{t.dropOffs}</h2>
            <div className="mt-4">
              <DropMap count={drops.length} />
            </div>
            <ul className="mt-3 divide-y divide-border rounded-xl border border-border bg-card">
              {drops.map((d, i) => (
                <li key={d.name} className="flex items-start gap-3 p-4">
                  <span className="grid size-6 shrink-0 place-items-center rounded-full bg-primary text-[11px] font-semibold text-primary-foreground">{i + 1}</span>
                  <div className="min-w-0 flex-1">
                    <p className="text-[14px] font-medium text-foreground">{d.name}</p>
                    <p className="text-[13px] text-muted-foreground">{d.address}</p>
                    {d.hours && <p className="mt-0.5 text-[12px] text-success-foreground">{fill(t.open, { hours: d.hours })}</p>}
                  </div>
                  <span className="shrink-0 text-[13px] tabular-nums text-muted-foreground">{d.distance}</span>
                </li>
              ))}
            </ul>
          </section>
        )}
        <section>
          <h2 className="text-[17px] font-semibold text-foreground">{t.whenMoney}</h2>
          <ol className="relative mt-5 grid grid-cols-3">
            <span aria-hidden className="absolute left-[16.6%] right-[16.6%] top-4 h-0.5 bg-track" />
            {timeline.map((s, i) => (
              <li key={s.label} className="relative flex flex-col items-center gap-2 text-center">
                <span className={cn('grid size-8 place-items-center rounded-full border-2', i === 0 ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background text-muted-foreground')}>
                  <Icon name={s.icon} size={15} />
                </span>
                <span className="text-[13px] font-medium text-foreground">{s.label}</span>
                {s.date && <span className="text-[12px] text-muted-foreground">{dateOf(locale, s.date)}</span>}
              </li>
            ))}
          </ol>
        </section>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* wizard                                                              */
/* ------------------------------------------------------------------ */

function Stepper({ step, t, onGo }: { step: number; t: Texts; onGo: (i: number) => void }) {
  const names = t.steps.split('|');
  return (
    <nav aria-label={fill(t.stepOf, { n: step + 1, total: names.length })} className="mb-8">
      <p className="mb-2 text-[13px] font-medium text-muted-foreground sm:hidden">
        {fill(t.stepOf, { n: step + 1, total: names.length })} · <span className="text-foreground">{names[step]}</span>
      </p>
      <div className="h-1 overflow-hidden rounded-full bg-track sm:hidden" aria-hidden>
        <div className="h-full rounded-full bg-primary transition-[width] duration-500 ease-[var(--ease-kit)]" style={{ width: `${((step + 1) / names.length) * 100}%` }} />
      </div>
      <ol className="flex items-center gap-2 max-sm:hidden">
        {names.map((n, i) => {
          const done = i < step;
          const now = i === step;
          return (
            <li key={n} className={cn('flex min-w-0 items-center gap-2', i < names.length - 1 ? 'flex-1' : 'flex-none')}>
              <button
                type="button"
                disabled={!done}
                onClick={() => onGo(i)}
                aria-current={now ? 'step' : undefined}
                className={cn('flex min-h-11 items-center gap-2.5 rounded-lg pr-2 text-left text-[14px] transition-colors disabled:cursor-default', done && 'hover:text-primary', now ? 'font-semibold text-foreground' : done ? 'text-foreground' : 'text-muted-foreground')}
              >
                <span className={cn('grid size-7 shrink-0 place-items-center rounded-full border-2 text-[12px] font-semibold transition-colors duration-300', done ? 'border-primary bg-primary text-primary-foreground' : now ? 'border-primary text-primary' : 'border-border-strong')}>
                  {done ? <Icon name="check" size={13} strokeWidth={2.6} /> : i + 1}
                </span>
                {n}
              </button>
              {i < names.length - 1 && <span aria-hidden className={cn('h-px min-w-4 flex-1 transition-colors duration-500', done ? 'bg-primary' : 'bg-border')} />}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

function Wizard(props: ReturnsFlowProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const r = useReturn(props, locale);
  const [step, setStep] = useState(Math.min(3, props.defaultStep ?? 0));
  const [show, setShow] = useState(false);
  const [busy, setBusy] = useState(false);
  const panel = useRef<HTMLDivElement>(null);
  const heading = useRef<HTMLHeadingElement>(null);
  const [dir, setDir] = useState<1 | -1>(1);
  useShake(panel, show && ((step === 0 && r.errors.items) || (step === 1 && (r.errors.reason.length > 0 || r.errors.text.length > 0)) || (step === 2 && r.errors.method)));
  const first = useRef(true);
  useEffect(() => {
    if (first.current) { first.current = false; return; }
    heading.current?.focus();
  }, [step]);
  const valid = [!r.errors.items, !r.errors.reason.length && !r.errors.text.length, !r.errors.method];
  const go = (i: number) => { setDir(i > step ? 1 : -1); setShow(false); setStep(i); };
  const next = async () => {
    if (!valid[step]) {
      setShow(true);
      requestAnimationFrame(() => {
        // A group (fieldset) carries the error for its options: focus its first option instead
        const bad = panel.current?.querySelector<HTMLElement>('[aria-invalid="true"]');
        (bad?.matches('fieldset') ? bad.querySelector<HTMLElement>('input:not(:disabled)') : bad)?.focus();
      });
      return;
    }
    if (step === 2) {
      setBusy(true);
      await new Promise((res) => setTimeout(res, 700));
      setBusy(false);
      props.onSubmit?.({ items: r.ids.map((id) => ({ id, quantity: r.picked[id].qty, reason: r.picked[id].reason, text: r.picked[id].text })), method: r.method, refundTo: r.refundTo });
    }
    go(step + 1);
  };
  const titles = [t.pick, t.reason, t.way, t.done];
  const hints = [t.pickHint, t.reasonHint, '', t.doneText];
  return (
    <section className={cn('pb-28 pt-10 sm:pt-14 lg:pb-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} t={t} />
        <Stepper step={step} t={t} onGo={go} />
        {step === 3 ? (
          <div key="done" className="animate-[kitshop-next_0.45s_var(--ease-kit)]">
            <h2 ref={heading} tabIndex={-1} className="flex items-center gap-3 text-h3 font-semibold text-foreground outline-none">
              <span className="grid size-9 animate-[kitshop-pop_0.5s_var(--ease-kit)] place-items-center rounded-full bg-success text-[var(--background)]">
                <Icon name="check" size={18} strokeWidth={2.6} />
              </span>
              {t.done}
            </h2>
            <p className="mb-8 mt-2 max-w-[38rem] text-pretty text-[15px] text-muted-foreground">{t.doneText}</p>
            <LabelScreen props={props} t={t} currency={currency} refund={r.ids.length ? r.refund : (props.refund ?? 0)} />
          </div>
        ) : (
          <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-12">
            <div ref={panel} key={step} className={cn('min-w-0', dir === 1 ? 'animate-[kitshop-next_0.4s_var(--ease-kit)]' : 'animate-[kitshop-prev_0.4s_var(--ease-kit)]')}>
              <h2 ref={heading} tabIndex={-1} className="text-[19px] font-semibold text-foreground outline-none">
                {titles[step]}
              </h2>
              {hints[step] && <p className="mb-5 mt-1 text-[14px] text-muted-foreground">{hints[step]}</p>}
              {show && step === 0 && r.errors.items && (
                <p role="alert" className="mb-4 flex 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" />
                  {t.eItems}
                </p>
              )}
              <div className={cn(step === 2 && 'mt-5')}>
                {step === 0 && <ItemsPick r={r} t={t} currency={currency} invalid={show && r.errors.items} />}
                {step === 1 && <Reasons r={r} t={t} show={show} />}
                {step === 2 && <Way r={r} t={t} currency={currency} show={show} credit={props.creditBonus} titled />}
              </div>
              <div className="mt-8 flex gap-3 max-lg:hidden">
                {step > 0 && (
                  <Button variant="outline" iconLeft="arrow-left" onClick={() => go(step - 1)}>
                    {t.back}
                  </Button>
                )}
                <Button onClick={next} disabled={busy} icon={busy ? undefined : 'arrow-right'} className="ml-auto">
                  {busy && <Icon name="loader" size={16} className="animate-kit-spin motion-reduce:animate-none" />}
                  {step === 2 ? (busy ? t.submitting : t.submit) : t.next}
                </Button>
              </div>
            </div>
            <aside className="max-lg:hidden lg:sticky lg:top-8 lg:self-start">
              <SummaryCard r={r} t={t} currency={currency} />
            </aside>
          </div>
        )}
      </div>
      {/* Phone and tablet: the refund and the buttons in a bar at the bottom */}
      {step < 3 && (
        <div className="fixed inset-x-0 bottom-0 z-30 border-t border-border bg-background/92 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur-xl lg:hidden">
          <div className="mx-auto flex max-w-[40rem] items-center gap-3">
            {step > 0 && (
              <Button variant="outline" onClick={() => go(step - 1)} aria-label={t.back} className="w-11 px-0">
                <Icon name="arrow-left" size={17} />
              </Button>
            )}
            <div className="min-w-0 flex-1">
              <p className="text-[11px] text-muted-foreground">{t.refund}</p>
              <TweenPrice value={r.refund} currency={currency} className="text-[17px] font-semibold text-foreground" />
            </div>
            <Button onClick={next} disabled={busy} icon={busy ? undefined : 'arrow-right'}>
              {busy && <Icon name="loader" size={16} className="animate-kit-spin motion-reduce:animate-none" />}
              {step === 2 ? (busy ? t.submitting : t.submitShort) : t.next}
            </Button>
          </div>
        </div>
      )}
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* one-page                                                            */
/* ------------------------------------------------------------------ */

function Part({ n, id, title, children, ok, t }: { n: number; id: string; title: string; children: React.ReactNode; ok: boolean; t: Texts }) {
  return (
    <section id={id} aria-labelledby={`${id}-h`} className="scroll-mt-8 border-t border-border pt-8 first:border-t-0 first:pt-0">
      <div className="mb-5 flex items-center gap-3">
        <span className={cn('grid size-8 shrink-0 place-items-center rounded-full border-2 text-[13px] font-semibold transition-colors duration-300', ok ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong text-muted-foreground')}>
          {ok ? <Icon name="check" size={14} strokeWidth={2.6} /> : n}
        </span>
        <h2 id={`${id}-h`} className="text-[18px] font-semibold text-foreground">
          <span className="sr-only">{fill(t.section, { n })}, </span>
          {title}
        </h2>
      </div>
      {children}
    </section>
  );
}

function OnePage(props: ReturnsFlowProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const r = useReturn(props, locale);
  const [show, setShow] = useState(false);
  const [busy, setBusy] = useState(false);
  const [done, setDone] = useState(false);
  const summary = useRef<HTMLDivElement>(null);
  const base = useId();
  const problems = [
    r.errors.items && { id: `${base}-1`, text: t.eItems },
    r.errors.reason.length > 0 && { id: `${base}-2`, text: t.eReason },
    r.errors.text.length > 0 && { id: `${base}-2`, text: t.eText },
    r.errors.method && { id: `${base}-3`, text: t.eMethod },
  ].filter(Boolean) as { id: string; text: string }[];
  const submit = async () => {
    if (problems.length) {
      setShow(true);
      requestAnimationFrame(() => summary.current?.focus());
      return;
    }
    setBusy(true);
    await new Promise((res) => setTimeout(res, 700));
    setBusy(false);
    props.onSubmit?.({ items: r.ids.map((id) => ({ id, quantity: r.picked[id].qty, reason: r.picked[id].reason, text: r.picked[id].text })), method: r.method, refundTo: r.refundTo });
    setDone(true);
  };
  if (done)
    return (
      <section className={cn('py-10 sm:py-14', props.className)}>
        <ShopMotion />
        <div className="kit-container">
          <Head props={props} t={t} title={t.done} />
          <LabelScreen props={props} t={t} currency={currency} refund={r.refund} />
        </div>
      </section>
    );
  return (
    <section className={cn('py-10 sm:py-14 lg:py-16', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} t={t} />
        <div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-12">
          <div className="min-w-0 space-y-8">
            {show && problems.length > 0 && (
              <div ref={summary} tabIndex={-1} role="alert" className="animate-[kitshop-in_0.35s_var(--ease-kit)] rounded-xl border border-[color-mix(in_oklab,var(--destructive)_40%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_7%,var(--card))] p-4 outline-none">
                <p className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
                  <Icon name="alert-circle" size={17} className="text-destructive" />
                  {t.fixList}
                </p>
                <ul className="mt-2 space-y-1 pl-7 text-[13px]">
                  {problems.map((p) => (
                    <li key={p.text}>
                      <a href={`#${p.id}`} className="text-foreground underline decoration-border-strong underline-offset-2 hover:decoration-foreground">
                        {p.text}
                      </a>
                    </li>
                  ))}
                </ul>
              </div>
            )}
            <Part t={t} n={1} id={`${base}-1`} title={t.pick} ok={!r.errors.items}>
              <ItemsPick r={r} t={t} currency={currency} invalid={show && r.errors.items} />
            </Part>
            <Part t={t} n={2} id={`${base}-2`} title={t.reason} ok={!r.errors.items && !r.errors.reason.length && !r.errors.text.length}>
              <Reasons r={r} t={t} show={show} />
            </Part>
            <Part t={t} n={3} id={`${base}-3`} title={t.way} ok={!r.errors.method}>
              <Way r={r} t={t} currency={currency} show={show} credit={props.creditBonus} titled />
            </Part>
          </div>
          <aside className="lg:sticky lg:top-8 lg:self-start">
            <SummaryCard r={r} t={t} currency={currency}>
              <Button onClick={submit} disabled={busy} size="lg" icon={busy ? undefined : 'arrow-right'} className="mt-5 w-full">
                {busy && <Icon name="loader" size={16} className="animate-kit-spin motion-reduce:animate-none" />}
                {busy ? t.submitting : t.submit}
              </Button>
            </SummaryCard>
          </aside>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* exchange                                                            */
/* ------------------------------------------------------------------ */

function Exchange(props: ReturnsFlowProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  const locale = useLocale();
  const items = (props.order?.items ?? []).filter((i) => i.options?.length && i.returnable !== false);
  const [itemId, setItemId] = useState(items[0]?.id ?? '');
  const [option, setOption] = useState('');
  const [now, setNow] = useState(true);
  const [error, setError] = useState(false);
  const [done, setDone] = useState(false);
  const opts = useRef<HTMLFieldSetElement>(null);
  useShake(opts, error);
  const item = items.find((i) => i.id === itemId) ?? items[0];
  const base = useId();
  if (!item) return null;
  const confirm = () => {
    if (!option) {
      setError(true);
      opts.current?.querySelector<HTMLInputElement>('input:not(:disabled)')?.focus();
      return;
    }
    setDone(true);
  };
  return (
    <section className={cn('py-10 sm:py-14 lg:py-16', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} t={t} title={done ? t.swapped : t.exchangeTitle} />
        {done ? (
          <div className="animate-[kitshop-in_0.45s_var(--ease-kit)]">
            <p className="mb-8 flex max-w-[40rem] items-start gap-3 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 text-[14px] text-foreground" role="status">
              <Icon name="check-circle" size={18} className="mt-0.5 shrink-0 text-success" />
              {fill(t.swappedText, { item: item.name, option })}
            </p>
            <LabelScreen props={props} t={t} currency={currency} refund={0} />
          </div>
        ) : (
          <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] lg:gap-12">
            <div className="min-w-0">
              <p className="max-w-[38rem] text-pretty text-[15px] text-muted-foreground">{t.exchangeHint}</p>
              <fieldset className="mt-8">
                <legend className="mb-3 text-[15px] font-semibold text-foreground">{t.exchangeWhich}</legend>
                <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
                  {items.map((i) => (
                    <label key={i.id} className={cn('flex cursor-pointer items-center gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', itemId === i.id ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))]' : 'border-border-strong bg-card hover:border-foreground/40')}>
                      <input type="radio" name={`${base}-item`} checked={itemId === i.id} onChange={() => { setItemId(i.id); setOption(''); setError(false); }} className="sr-only" />
                      <ProductMedia className="size-14 shrink-0 rounded-lg">{i.image}</ProductMedia>
                      <span className="min-w-0 flex-1">
                        <span className="block truncate text-[14px] font-medium text-foreground">{i.name}</span>
                        <span className="block text-[12px] text-muted-foreground">{i.variant}</span>
                      </span>
                    </label>
                  ))}
                </div>
              </fieldset>
              <fieldset ref={opts} className="mt-8" aria-describedby={error ? `${base}-e` : undefined}>
                <legend className="mb-3 text-[15px] font-semibold text-foreground">
                  {t.newOption}
                  {item.variant && <span className="ml-2 text-[13px] font-normal text-muted-foreground">{t.current} {item.options!.find((o) => item.variant!.includes(o.label))?.label}</span>}
                </legend>
                <div className="flex flex-wrap gap-2">
                  {item.options!.map((o) => {
                    const current = item.variant?.includes(o.label);
                    return (
                      <label key={o.label} className={cn('relative grid h-12 min-w-14 place-items-center rounded-lg border px-4 text-[14px] font-medium transition-[border-color,background-color,color] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', o.soldOut || current ? 'cursor-not-allowed border-dashed border-border text-subtle-foreground' : 'cursor-pointer', option === o.label ? 'border-foreground bg-foreground text-background' : !o.soldOut && !current && 'border-border-strong bg-card text-foreground hover:border-foreground/50')}>
                        <input type="radio" name={`${base}-opt`} disabled={o.soldOut || current} checked={option === o.label} onChange={() => { setOption(o.label); setError(false); }} className="sr-only" />
                        <span className={cn(o.soldOut && 'line-through')}>{o.label}</span>
                        <span className="sr-only">{o.soldOut ? `, ${t.soldOut}` : current ? `, ${t.current}` : ''}</span>
                      </label>
                    );
                  })}
                </div>
                {error && <p id={`${base}-e`} className="mt-2 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{t.eOption}</p>}
              </fieldset>
              <label className="mt-8 flex cursor-pointer items-start justify-between gap-4 rounded-xl border border-border bg-surface p-4">
                <span>
                  <span className="block text-[14px] font-medium text-foreground">{t.sendNow}</span>
                  <span className="mt-0.5 block text-[13px] text-muted-foreground">{t.sendNowNote}</span>
                </span>
                <input type="checkbox" role="switch" checked={now} onChange={(e) => setNow(e.target.checked)} className="peer sr-only" />
                <span aria-hidden className={cn('relative mt-0.5 h-7 w-12 shrink-0 rounded-full transition-colors duration-300 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)] peer-focus-visible:outline-solid', now ? 'bg-primary' : 'bg-track')}>
                  <span className={cn('absolute left-0.5 top-0.5 size-6 rounded-full bg-white shadow-[var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]', now && 'translate-x-5')} />
                </span>
              </label>
            </div>
            <aside className="lg:sticky lg:top-8 lg:self-start">
              <div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-md)] sm:p-6">
                <div className="flex items-center gap-3">
                  <div className="relative min-w-0 flex-1">
                    <ProductMedia className="aspect-square rounded-xl opacity-70">{item.image}</ProductMedia>
                    <span className="mt-2 block truncate text-center text-[12px] text-muted-foreground">{item.variant}</span>
                  </div>
                  <span className="grid size-9 shrink-0 place-items-center rounded-full border border-border bg-background text-foreground" aria-hidden>
                    <Icon name="arrows-left-right" size={16} />
                  </span>
                  <div className="relative min-w-0 flex-1">
                    <ProductMedia className={cn('aspect-square rounded-xl ring-2 transition-[box-shadow] duration-300', option ? 'ring-primary' : 'ring-transparent')}>{item.image}</ProductMedia>
                    <span key={option} className={cn('mt-2 block truncate text-center text-[12px] font-medium', option ? 'animate-[kitshop-swap_0.4s_var(--ease-kit)] text-foreground' : 'text-subtle-foreground')}>{option || t.chooseSize}</span>
                  </div>
                </div>
                <p className="mt-5 flex items-center gap-2 border-t border-border pt-4 text-[13px] text-success-foreground">
                  <Icon name="check-circle" size={15} />
                  {t.priceSame} · {formatPrice(locale, item.price, currency)}
                </p>
                <Button onClick={confirm} size="lg" icon="arrow-right" className="mt-5 w-full">
                  {t.swap}
                </Button>
              </div>
            </aside>
          </div>
        )}
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* label                                                               */
/* ------------------------------------------------------------------ */

function LabelVariant(props: ReturnsFlowProps & { t: Texts; currency: string }) {
  const { t, currency } = props;
  return (
    <section className={cn('py-10 sm:py-14 lg:py-16', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <Head props={props} t={t} title={t.done} />
        <LabelScreen props={props} t={t} currency={currency} refund={props.refund ?? 0} />
      </div>
    </section>
  );
}

/**
 * Returns after the purchase: a wizard in four steps, an exchange for
 * another size, everything on one page with one check, or the label screen
 * with a code for the counter, drop-off points and the refund dates.
 */
export function ReturnsFlow(props: ReturnsFlowProps) {
  const locale = useLocale();
  const t = useTexts(TEXTS, props.labels);
  const currency = currencyOf(locale, props.currency);
  const { variant = 'wizard' } = props;
  if (variant === 'exchange') return <Exchange {...props} t={t} currency={currency} />;
  if (variant === 'one-page') return <OnePage {...props} t={t} currency={currency} />;
  if (variant === 'label') return <LabelVariant {...props} t={t} currency={currency} />;
  return <Wizard {...props} t={t} currency={currency} />;
}

Häufige Fragen

Wie installiere ich Retourenablauf?

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

Welche Varianten hat Retourenablauf?

4 Varianten, Schritt für Schritt, Umtausch, Eine Seite und Rücksendeetikett. Du wählst sie mit der Prop variant, zum Beispiel variant="exchange".

Kann ich die Texte von Retourenablauf übersetzen?

Ja. Retourenablauf bringt 74 eigene Texte auf Deutsch und Englisch mit, etwa „Artikel zurücksenden“, „Bestellung“ und „Artikel|Grund|Rückweg|Etikett“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Retourenablauf einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert