Retourenablauf
Eine Retoure in vier Schritten mit Artikel, Grund, Rückweg und Etikett. Als Assistent, Umtausch, auf einer Seite oder als Etikett.
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
Details
Was Retourenablauf mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Bestellungen und Retouren
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/returns-flow.tsx
- Bringt mit
- utilskitbuttoniconproduct-imageinputqr-code
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 74 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- Shop-Checkout
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ReturnsFlowProps aus components/ui/returns-flow.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalReturnsFlowVariantwizardexchangeone-pagelabel
orderoptional{ number: string; date?: string; items: ReturnItem[] }reasonsoptionalReturnReason[]methodsoptionalReturnMethod[]creditBonusoptionalnumberBonus in percent when the refund goes to store credit
daysLeftoptionalnumberDays left to return
returnIdoptionalstringlabel: return number and points nearby
dropOffsoptionalDropOff[]datesoptional{ dropBy?: string; received?: string; refunded?: string }label: expected dates for received and refunded, "YYYY-MM-DD"
refundoptionalnumberlabel: the refund, if no flow came before
defaultStepoptionalnumberStarts 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 }) => voidcurrencyoptionalstring
Eingebaute Texte
Diese Texte schreibt Retourenablauf selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 74 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| title | Englisch Return items | Deutsch Artikel zurücksenden |
| order | Englisch Order | Deutsch Bestellung |
| daysLeft | Englisch {n} days left to return | Deutsch Noch {n} Tage Rückgabe |
| steps | Englisch Items|Reason|Way back|Label | Deutsch Artikel|Grund|Rückweg|Etikett |
| stepOf | Englisch Step {n} of {total} | Deutsch Schritt {n} von {total} |
| pick | Englisch Which items go back? | Deutsch Welche Artikel gehen zurück? |
| pickHint | Englisch 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. |
| notReturnable | Englisch Not returnable | Deutsch Nicht rückgabefähig |
| reason | Englisch Why does it go back? | Deutsch Warum geht es zurück? |
| reasonHint | Englisch 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. |
| reasonFor | Englisch Reason for {name} | Deutsch Grund für {name} |
| choose | Englisch Choose a reason | Deutsch Grund wählen |
| tell | Englisch Tell us a bit more | Deutsch Erzähl uns etwas mehr |
| tellPlaceholder | Englisch What did you notice? | Deutsch Was ist dir aufgefallen? |
| way | Englisch How does it come back? | Deutsch Wie kommt es zurück? |
| refundTo | Englisch Where does the money go? | Deutsch Wohin geht das Geld? |
| original | Englisch Original payment | Deutsch Ursprüngliche Zahlart |
| originalNote | Englisch Back on your card or account in 3 to 5 days | Deutsch In 3 bis 5 Tagen zurück auf Karte oder Konto |
| credit | Englisch Store credit | Deutsch Shop-Guthaben |
| creditNote | Englisch {pct} extra, ready to use right away | Deutsch {pct} extra, sofort einsetzbar |
| free | Englisch Free | Deutsch Kostenlos |
| fee | Englisch {amount} from the refund | Deutsch {amount} von der Erstattung |
| summary | Englisch Your refund | Deutsch Deine Erstattung |
| items | Englisch Items | Deutsch Artikel |
| returnFee | Englisch Return shipping | Deutsch Rückversand |
| bonus | Englisch Credit bonus | Deutsch Guthaben-Bonus |
| refund | Englisch Refund | Deutsch Erstattung |
| next | Englisch Continue | Deutsch Weiter |
| back | Englisch Back | Deutsch Zurück |
| submit | Englisch Create return label | Deutsch Rücksendeetikett erstellen |
| submitting | Englisch Creating label | Deutsch Etikett wird erstellt |
| eItems | Englisch Pick at least one item to return. | Deutsch Wähl mindestens einen Artikel für die Rücksendung. |
| eReason | Englisch Choose a reason for every item. | Deutsch Wähl für jeden Artikel einen Grund. |
| eText | Englisch 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. |
| eMethod | Englisch Choose how the parcel comes back. | Deutsch Wähl, wie das Paket zurückkommt. |
| fixList | Englisch A few things are still missing | Deutsch Ein paar Angaben fehlen noch |
| done | Englisch Your return is registered | Deutsch Deine Rücksendung ist angemeldet |
| doneText | Englisch 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. |
| returnNo | Englisch Return | Deutsch Retoure |
| showCode | Englisch Show this code at the counter. No printer needed. | Deutsch Zeig diesen Code am Schalter. Kein Drucker nötig. |
| Englisch Print | Deutsch Drucken | |
| download | Englisch Save PDF | Deutsch Als PDF |
| submitShort | Englisch Create label | Deutsch Etikett erstellen |
| fewer | Englisch One less | Deutsch Einer weniger |
| more | Englisch One more | Deutsch Einer mehr |
| copyCode | Englisch Copy return number | Deutsch Retourennummer kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| dropOffs | Englisch Drop-off points near you | Deutsch Annahmestellen in deiner Nähe |
| open | Englisch Open {hours} | Deutsch Geöffnet {hours} |
| how | Englisch Three steps | Deutsch Drei Schritte |
| how1 | Englisch Pack the items in the original box or any sturdy one. | Deutsch Pack die Artikel in den Originalkarton oder einen anderen stabilen. |
| how2 | Englisch Close it well. The code does the rest. | Deutsch Gut verschließen. Den Rest erledigt der Code. |
| how3 | Englisch Drop it off by {date} and keep the receipt. | Deutsch Bis {date} abgeben und den Beleg aufheben. |
| whenMoney | Englisch When the money comes | Deutsch Wann das Geld kommt |
| dropped | Englisch You drop it off | Deutsch Du gibst es ab |
| received | Englisch We receive it | Deutsch Wir erhalten es |
| refunded | Englisch Refund sent | Deutsch Erstattung raus |
| exchangeTitle | Englisch Swap for another size | Deutsch Gegen eine andere Größe tauschen |
| exchangeHint | Englisch 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. |
| exchangeWhich | Englisch Which item do you want to swap? | Deutsch Welchen Artikel möchtest du tauschen? |
| newOption | Englisch New size | Deutsch Neue Größe |
| current | Englisch Your size | Deutsch Deine Größe |
| chooseSize | Englisch Pick a size | Deutsch Größe wählen |
| soldOut | Englisch sold out | Deutsch ausverkauft |
| sendNow | Englisch Send the new one now | Deutsch Das neue Stück sofort schicken |
| sendNowNote | Englisch 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. |
| swap | Englisch Swap now | Deutsch Jetzt tauschen |
| swapped | Englisch Swap confirmed | Deutsch Tausch bestätigt |
| swappedText | Englisch {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. |
| eOption | Englisch Choose the new size. | Deutsch Wähl die neue Größe. |
| priceSame | Englisch Same price, nothing to pay | Deutsch Gleicher Preis, nichts zu zahlen |
| qty | Englisch Quantity to return | Deutsch Anzahl für die Rücksendung |
| noItems | Englisch No items selected yet | Deutsch Noch keine Artikel gewählt |
| section | Englisch 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
'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.