Skip to content
UI Kit
Accent color

Returns flow

A return in four steps with items, reason, way back and label. As a wizard, an exchange, on one page or the label with a code.

4 variantsOne of 3 components in Orders and returns

A return that keeps the customer

An easy return is often what makes the next order happen. This flow covers items, reason, the way back and the label in four clear steps, each step checks itself and the refund counts along, so customers know what they get back.

Step by step slides four steps sideways under a stepper, damaged items ask for a few words and the refund sits in a bar at the bottom on phones. Exchange swaps a size instead of a refund, sold out sizes are struck through and a switch sends the new size before the old one arrives. One page shows all parts together, each number turns into a check when complete and what is missing appears as links at the top. Return label is the last screen, with a code for the counter, the return number, packing steps, drop off points on a drawn map and the dates until the refund.

Offer the exchange before the refund, many returns are simply the wrong size. Show the refund dates on the label screen, they answer the most common follow up question. Choose one page for shops with few items per order and the steps for larger baskets.

Variants

Step by stepNew in 1.2

A stepper over four steps that slide sideways. Each step checks itself, damaged items ask for a few words, the refund counts along at the side and sits in a bar at the bottom on phones.

ExchangeNew in 1.2

Swap a size instead of a refund. The current size is marked, sold out sizes are struck through, the new one lights up on the right and a switch sends it before the old one arrives.

One pageNew in 1.2

All three parts on one page, each number turns into a check when complete. One button at the end, and what is missing appears as a list of links at the top.

Return labelNew in 1.2

The last screen. A code for the counter that opens from the middle, the return number to copy, three packing steps, drop-off points on a drawn map and the dates until the refund.

Details

What Returns flow brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/returns-flow.tsx
npm packages
None
Languages
English and German, 74 texts of its own
Studio
Adjust in code
In collections
Shop checkout

Props

The ReturnsFlowProps interface from components/ui/returns-flow.tsx, with the comments from the code.

  • 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

Built-in texts

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

Show all 74 texts
titleEnglish Return itemsGerman Artikel zurücksenden
orderEnglish OrderGerman Bestellung
daysLeftEnglish {n} days left to returnGerman Noch {n} Tage Rückgabe
stepsEnglish Items|Reason|Way back|LabelGerman Artikel|Grund|Rückweg|Etikett
stepOfEnglish Step {n} of {total}German Schritt {n} von {total}
pickEnglish Which items go back?German Welche Artikel gehen zurück?
pickHintEnglish Tick everything you want to return. Several of one kind? Set how many.German Hak alles an, was zurückgehen soll. Mehrere gleiche? Stell die Anzahl ein.
notReturnableEnglish Not returnableGerman Nicht rückgabefähig
reasonEnglish Why does it go back?German Warum geht es zurück?
reasonHintEnglish Your answer helps us make better products. It does not change your refund.German Deine Antwort hilft uns, bessere Produkte zu machen. Sie ändert nichts an deiner Erstattung.
reasonForEnglish Reason for {name}German Grund für {name}
chooseEnglish Choose a reasonGerman Grund wählen
tellEnglish Tell us a bit moreGerman Erzähl uns etwas mehr
tellPlaceholderEnglish What did you notice?German Was ist dir aufgefallen?
wayEnglish How does it come back?German Wie kommt es zurück?
refundToEnglish Where does the money go?German Wohin geht das Geld?
originalEnglish Original paymentGerman Ursprüngliche Zahlart
originalNoteEnglish Back on your card or account in 3 to 5 daysGerman In 3 bis 5 Tagen zurück auf Karte oder Konto
creditEnglish Store creditGerman Shop-Guthaben
creditNoteEnglish {pct} extra, ready to use right awayGerman {pct} extra, sofort einsetzbar
freeEnglish FreeGerman Kostenlos
feeEnglish {amount} from the refundGerman {amount} von der Erstattung
summaryEnglish Your refundGerman Deine Erstattung
itemsEnglish ItemsGerman Artikel
returnFeeEnglish Return shippingGerman Rückversand
bonusEnglish Credit bonusGerman Guthaben-Bonus
refundEnglish RefundGerman Erstattung
nextEnglish ContinueGerman Weiter
backEnglish BackGerman Zurück
submitEnglish Create return labelGerman Rücksendeetikett erstellen
submittingEnglish Creating labelGerman Etikett wird erstellt
eItemsEnglish Pick at least one item to return.German Wähl mindestens einen Artikel für die Rücksendung.
eReasonEnglish Choose a reason for every item.German Wähl für jeden Artikel einen Grund.
eTextEnglish Add a few words for the items marked, at least 10 characters.German Schreib bei den markierten Artikeln ein paar Worte dazu, mindestens 10 Zeichen.
eMethodEnglish Choose how the parcel comes back.German Wähl, wie das Paket zurückkommt.
fixListEnglish A few things are still missingGerman Ein paar Angaben fehlen noch
doneEnglish Your return is registeredGerman Deine Rücksendung ist angemeldet
doneTextEnglish Pack the items, show the code at a drop-off point, and the money is on its way once we scan it.German Pack die Artikel ein, zeig den Code an einer Annahmestelle, und das Geld ist unterwegs, sobald wir ihn scannen.
returnNoEnglish ReturnGerman Retoure
showCodeEnglish Show this code at the counter. No printer needed.German Zeig diesen Code am Schalter. Kein Drucker nötig.
printEnglish PrintGerman Drucken
downloadEnglish Save PDFGerman Als PDF
submitShortEnglish Create labelGerman Etikett erstellen
fewerEnglish One lessGerman Einer weniger
moreEnglish One moreGerman Einer mehr
copyCodeEnglish Copy return numberGerman Retourennummer kopieren
copiedEnglish CopiedGerman Kopiert
dropOffsEnglish Drop-off points near youGerman Annahmestellen in deiner Nähe
openEnglish Open {hours}German Geöffnet {hours}
howEnglish Three stepsGerman Drei Schritte
how1English Pack the items in the original box or any sturdy one.German Pack die Artikel in den Originalkarton oder einen anderen stabilen.
how2English Close it well. The code does the rest.German Gut verschließen. Den Rest erledigt der Code.
how3English Drop it off by {date} and keep the receipt.German Bis {date} abgeben und den Beleg aufheben.
whenMoneyEnglish When the money comesGerman Wann das Geld kommt
droppedEnglish You drop it offGerman Du gibst es ab
receivedEnglish We receive itGerman Wir erhalten es
refundedEnglish Refund sentGerman Erstattung raus
exchangeTitleEnglish Swap for another sizeGerman Gegen eine andere Größe tauschen
exchangeHintEnglish 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.German 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.
exchangeWhichEnglish Which item do you want to swap?German Welchen Artikel möchtest du tauschen?
newOptionEnglish New sizeGerman Neue Größe
currentEnglish Your sizeGerman Deine Größe
chooseSizeEnglish Pick a sizeGerman Größe wählen
soldOutEnglish sold outGerman ausverkauft
sendNowEnglish Send the new one nowGerman Das neue Stück sofort schicken
sendNowNoteEnglish We reserve the amount on your card and release it when your return arrives.German Wir reservieren den Betrag auf deiner Karte und geben ihn frei, sobald deine Rücksendung ankommt.
swapEnglish Swap nowGerman Jetzt tauschen
swappedEnglish Swap confirmedGerman Tausch bestätigt
swappedTextEnglish {item} in {option} is reserved for you. Use the label below for the old one.German {item} in {option} ist für dich reserviert. Für das alte Stück nimm das Etikett unten.
eOptionEnglish Choose the new size.German Wähl die neue Größe.
priceSameEnglish Same price, nothing to payGerman Gleicher Preis, nichts zu zahlen
qtyEnglish Quantity to returnGerman Anzahl für die Rücksendung
noItemsEnglish No items selected yetGerman Noch keine Artikel gewählt
sectionEnglish Part {n}German Teil {n}

Accessibility

Read from the source of the component.

  • Sets aria-current, aria-describedby, aria-invalid, aria-label and aria-labelledby, so screen readers announce purpose and state.
  • Uses the roles alert, group, status and switch.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,093 lines
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} />;
}

Frequent questions

How do I install Returns flow?

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

Which variants does Returns flow have?

4 variants, Step by step, Exchange, One page and Return label. You pick one with the variant prop, for example variant="exchange".

Can I translate the texts of Returns flow?

Yes. Returns flow carries 74 texts of its own in English and German, such as “Return items”, “Order” and “Items|Reason|Way back|Label”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Returns flow?

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

Often searched

Jump to

Copied to the clipboard