Orders
Everything after the purchase, from order history and tracking to invoice, returns and subscriptions.
Variants
Details
What Orders brings along, read from the registry and the source.
- Category
- Orders and returns
- Area
- E-Commerce
- Variants
- 5
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/order.tsx
- Brings along
- utilskitbuttoniconrevealproduct-image
- npm packages
- None
- Languages
- English and German, 104 texts of its own
- Studio
- Adjust in code
- In collections
- Shop checkout
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The OrderProps interface from components/ui/order.tsx, with the comments from the code.
variantoptionalOrderVariantorder-historyorder-detailinvoicereturnssubscription
ordersoptionalOrderRecord[]order-history
orderoptionalOrderRecordorder-detail and returns
invoiceoptionalInvoiceDatasubscriptionoptionalSubscriptionDatareturnReasonsoptionalstring[]returns
returnByoptionalstringcurrencyoptionalstring
Built-in texts
Orders writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 104 texts
| Key | English | German |
|---|---|---|
| orders | English Orders | German Bestellungen |
| ordersIntro | English Track, return or buy again. Invoices are ready as soon as an order ships. | German Verfolgen, zurückschicken oder nachbestellen. Rechnungen liegen bereit, sobald eine Bestellung verschickt ist. |
| all | English All | German Alle |
| open | English Open | German Offen |
| delivered | English Delivered | German Zugestellt |
| returned | English Returned | German Retourniert |
| order | English Order | German Bestellung |
| placed | English Placed | German Bestellt am |
| total | English Total | German Summe |
| status | English Status | German Status |
| processing | English Processing | German In Bearbeitung |
| shipped | English On the way | German Unterwegs |
| cancelled | English Cancelled | German Storniert |
| viewOrder | English View order | German Bestellung ansehen |
| buyAgain | English Buy again | German Erneut kaufen |
| invoice | English Invoice | German Rechnung |
| more | English more | German weitere |
| noOrders | English No orders in this view. | German In dieser Ansicht gibt es keine Bestellungen. |
| backToOrders | English All orders | German Alle Bestellungen |
| help | English Get help | German Hilfe erhalten |
| shipment | English Shipment | German Sendung |
| eta | English Expected | German Voraussichtlich |
| trackingNo | English Tracking number | German Sendungsnummer |
| copy | English Copy tracking number | German Sendungsnummer kopieren |
| copied | English Copied | German Kopiert |
| stages | English Ordered,Packed,On the way,Delivered | German Bestellt,Verpackt,Unterwegs,Zugestellt |
| history | English Tracking history | German Sendungsverlauf |
| items | English Items | German Artikel |
| deliveryAddress | English Delivery address | German Lieferadresse |
| billingAddress | English Billing address | German Rechnungsadresse |
| payment | English Payment | German Zahlung |
| summary | English Summary | German Übersicht |
| subtotal | English Subtotal | German Zwischensumme |
| shipping | English Shipping | German Versand |
| free | English Free | German Kostenlos |
| vat | English VAT | German MwSt. |
| incl | English incl. | German inkl. |
| qty | English Qty | German Menge |
| English Print or save as PDF | German Drucken oder als PDF sichern | |
| printShort | English Print | German Drucken |
| download | English Download | German Herunterladen |
| invoiceTitle | English Invoice | German Rechnung |
| invoiceNo | English Invoice no. | German Rechnungsnr. |
| invoiceDate | English Invoice date | German Rechnungsdatum |
| serviceDate | English Delivery date | German Lieferdatum |
| orderNo | English Order no. | German Bestellnr. |
| customerNo | English Customer no. | German Kundennr. |
| billTo | English Bill to | German Rechnung an |
| pos | English Pos. | German Pos. |
| description | English Description | German Beschreibung |
| unitNet | English Unit price net | German Einzelpreis netto |
| amountNet | English Amount net | German Betrag netto |
| netTotal | English Total net | German Summe netto |
| grossTotal | English Total | German Gesamtbetrag |
| vatId | English VAT ID | German USt-IdNr. |
| bank | English Bank | German Bank |
| contact | English Contact | German Kontakt |
| shippingLine | English Shipping | German Versand |
| returnsTitle | English Return items | German Artikel zurücksenden |
| returnsIntro | English Pick what goes back. The label is free, the refund arrives within 5 days after we receive the parcel. | German Wähle aus, was zurückgeht. Das Etikett ist kostenlos, das Geld ist innerhalb von 5 Tagen nach Eingang des Pakets zurück. |
| returnBy | English Return by | German Rücksendung bis |
| reason | English Reason | German Grund |
| chooseReason | English Choose a reason | German Grund auswählen |
| refundTo | English Refund to | German Erstattung auf |
| original | English Original payment | German Ursprüngliche Zahlart |
| originalNote | English Back to the card you paid with | German Zurück auf die Karte, mit der du bezahlt hast |
| credit | English Store credit | German Guthaben |
| creditNote | English 10% extra, usable right away | German 10 % extra, sofort einlösbar |
| dropOff | English How it goes back | German So geht es zurück |
| shop | English Parcel shop | German Paketshop |
| shopNote | English Show a QR code, no printer needed | German QR-Code vorzeigen, kein Drucker nötig |
| pickup | English Pickup at your door | German Abholung an der Tür |
| pickupNote | English Tomorrow between 8 and 18 | German Morgen zwischen 8 und 18 Uhr |
| refund | English Your refund | German Deine Erstattung |
| bonus | English Credit bonus | German Guthaben-Bonus |
| label | English Return label | German Rücksendeetikett |
| request | English Request return | German Rücksendung anmelden |
| requested | English Return registered, label is on its way | German Angemeldet, das Etikett ist unterwegs |
| selectFirst | English Pick at least one item to return. | German Wähle mindestens einen Artikel für die Rücksendung. |
| itemsSelected | English items selected | German Artikel ausgewählt |
| itemSelected | English item selected | German Artikel ausgewählt |
| subTitle | English Subscription | German Abo |
| active | English Active | German Aktiv |
| paused | English Paused | German Pausiert |
| since | English Since | German Seit |
| nextDelivery | English Next delivery | German Nächste Lieferung |
| every | English Every | German Alle |
| weeks | English weeks | German Wochen |
| perDelivery | English per delivery | German pro Lieferung |
| save | English You save | German Du sparst |
| deliverNow | English Deliver now | German Jetzt liefern |
| skip | English Skip | German Aussetzen |
| skipped | English Skipped | German Ausgesetzt |
| unskip | English Undo skip | German Doch liefern |
| pause | English Pause | German Pausieren |
| resume | English Resume | German Fortsetzen |
| upcoming | English Upcoming deliveries | German Nächste Lieferungen |
| pastDeliveries | English Past deliveries | German Bisherige Lieferungen |
| manage | English Manage | German Verwalten |
| cancel | English Cancel subscription | German Abo kündigen |
| change | English Change | German Ändern |
| frequency | English Frequency | German Rhythmus |
| quantity | English Bags per delivery | German Tüten pro Lieferung |
| pausedNote | English No deliveries until you resume. Nothing is charged. | German Bis zum Fortsetzen keine Lieferungen. Es wird nichts abgebucht. |
Accessibility
Read from the source of the component.
- Orders handles the keys Arrow left and Arrow right itself.
- Sets aria-disabled, aria-label, aria-live and aria-selected, so screen readers announce purpose and state.
- Uses the roles alert, radiogroup, tab, tablist and tabpanel.
- 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. 8 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,267 lines
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { ProductMedia, Quantity, ShopMotion, Swap, TweenPrice, formatNumber, formatPercent, formatPrice, vatOf, type LineItem } from '@/components/ui/product-image';
export type OrderVariant = 'order-history' | 'order-detail' | 'invoice' | 'returns' | 'subscription';
export type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'returned' | 'cancelled';
export interface TrackingEvent {
date: string;
time?: string;
text: string;
location?: string;
}
export interface OrderRecord {
number: string;
/** Already formatted, e.g. "Oct 21, 2026" */
date: string;
status: OrderStatus;
/** One line about the status, e.g. "Delivered on Fri, Oct 3" */
statusNote?: string;
items: LineItem[];
shipping?: number;
address?: string[];
billing?: string[];
payment?: string;
tracking?: {
carrier: string;
code: string;
eta?: string;
/** Index of the reached stage: 0 ordered, 1 packed, 2 on the way, 3 delivered */
stage: number;
/** Newest first */
events: TrackingEvent[];
};
}
export interface InvoiceData {
number: string;
date: string;
serviceDate: string;
dueNote: string;
orderNumber: string;
customerNumber?: string;
seller: { name: string; lines: string[]; vatId: string; register?: string; bank: string; iban: string; bic: string; contact: string[] };
buyer: { name: string; lines: string[] };
/** Unit prices gross, VAT is taken out per line */
items: { description: string; detail?: string; quantity: number; price: number }[];
shipping?: number;
vatRate?: number;
note?: string;
}
export interface SubscriptionData {
name: string;
variant: string;
image: React.ReactNode;
/** Price per delivery at quantity 1 */
price: number;
/** Saving against a single purchase, in percent */
saving?: number;
/** ISO date of the next delivery, e.g. 2026-10-28 */
nextDate: string;
/** Weeks between deliveries */
every: number;
everyOptions: number[];
quantity: number;
since: string;
address: string[];
payment: string;
history: { date: string; number: string; total: number }[];
}
export interface OrderProps {
variant?: OrderVariant;
/** order-history */
orders?: OrderRecord[];
/** order-detail and returns */
order?: OrderRecord;
invoice?: InvoiceData;
subscription?: SubscriptionData;
/** returns */
returnReasons?: string[];
returnBy?: string;
currency?: string;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
orders: 'Orders',
ordersIntro: 'Track, return or buy again. Invoices are ready as soon as an order ships.',
all: 'All',
open: 'Open',
delivered: 'Delivered',
returned: 'Returned',
order: 'Order',
placed: 'Placed',
total: 'Total',
status: 'Status',
processing: 'Processing',
shipped: 'On the way',
cancelled: 'Cancelled',
viewOrder: 'View order',
buyAgain: 'Buy again',
invoice: 'Invoice',
more: 'more',
noOrders: 'No orders in this view.',
backToOrders: 'All orders',
help: 'Get help',
shipment: 'Shipment',
eta: 'Expected',
trackingNo: 'Tracking number',
copy: 'Copy tracking number',
copied: 'Copied',
stages: 'Ordered,Packed,On the way,Delivered',
history: 'Tracking history',
items: 'Items',
deliveryAddress: 'Delivery address',
billingAddress: 'Billing address',
payment: 'Payment',
summary: 'Summary',
subtotal: 'Subtotal',
shipping: 'Shipping',
free: 'Free',
vat: 'VAT',
incl: 'incl.',
qty: 'Qty',
print: 'Print or save as PDF',
printShort: 'Print',
download: 'Download',
invoiceTitle: 'Invoice',
invoiceNo: 'Invoice no.',
invoiceDate: 'Invoice date',
serviceDate: 'Delivery date',
orderNo: 'Order no.',
customerNo: 'Customer no.',
billTo: 'Bill to',
pos: 'Pos.',
description: 'Description',
unitNet: 'Unit price net',
amountNet: 'Amount net',
netTotal: 'Total net',
grossTotal: 'Total',
vatId: 'VAT ID',
bank: 'Bank',
contact: 'Contact',
shippingLine: 'Shipping',
returnsTitle: 'Return items',
returnsIntro: 'Pick what goes back. The label is free, the refund arrives within 5 days after we receive the parcel.',
returnBy: 'Return by',
reason: 'Reason',
chooseReason: 'Choose a reason',
refundTo: 'Refund to',
original: 'Original payment',
originalNote: 'Back to the card you paid with',
credit: 'Store credit',
creditNote: '10% extra, usable right away',
dropOff: 'How it goes back',
shop: 'Parcel shop',
shopNote: 'Show a QR code, no printer needed',
pickup: 'Pickup at your door',
pickupNote: 'Tomorrow between 8 and 18',
refund: 'Your refund',
bonus: 'Credit bonus',
label: 'Return label',
request: 'Request return',
requested: 'Return registered, label is on its way',
selectFirst: 'Pick at least one item to return.',
itemsSelected: 'items selected',
itemSelected: 'item selected',
subTitle: 'Subscription',
active: 'Active',
paused: 'Paused',
since: 'Since',
nextDelivery: 'Next delivery',
every: 'Every',
weeks: 'weeks',
perDelivery: 'per delivery',
save: 'You save',
deliverNow: 'Deliver now',
skip: 'Skip',
skipped: 'Skipped',
unskip: 'Undo skip',
pause: 'Pause',
resume: 'Resume',
upcoming: 'Upcoming deliveries',
pastDeliveries: 'Past deliveries',
manage: 'Manage',
cancel: 'Cancel subscription',
change: 'Change',
frequency: 'Frequency',
quantity: 'Bags per delivery',
pausedNote: 'No deliveries until you resume. Nothing is charged.',
},
de: {
orders: 'Bestellungen',
ordersIntro: 'Verfolgen, zurückschicken oder nachbestellen. Rechnungen liegen bereit, sobald eine Bestellung verschickt ist.',
all: 'Alle',
open: 'Offen',
delivered: 'Zugestellt',
returned: 'Retourniert',
order: 'Bestellung',
placed: 'Bestellt am',
total: 'Summe',
status: 'Status',
processing: 'In Bearbeitung',
shipped: 'Unterwegs',
cancelled: 'Storniert',
viewOrder: 'Bestellung ansehen',
buyAgain: 'Erneut kaufen',
invoice: 'Rechnung',
more: 'weitere',
noOrders: 'In dieser Ansicht gibt es keine Bestellungen.',
backToOrders: 'Alle Bestellungen',
help: 'Hilfe erhalten',
shipment: 'Sendung',
eta: 'Voraussichtlich',
trackingNo: 'Sendungsnummer',
copy: 'Sendungsnummer kopieren',
copied: 'Kopiert',
stages: 'Bestellt,Verpackt,Unterwegs,Zugestellt',
history: 'Sendungsverlauf',
items: 'Artikel',
deliveryAddress: 'Lieferadresse',
billingAddress: 'Rechnungsadresse',
payment: 'Zahlung',
summary: 'Übersicht',
subtotal: 'Zwischensumme',
shipping: 'Versand',
free: 'Kostenlos',
vat: 'MwSt.',
incl: 'inkl.',
qty: 'Menge',
print: 'Drucken oder als PDF sichern',
printShort: 'Drucken',
download: 'Herunterladen',
invoiceTitle: 'Rechnung',
invoiceNo: 'Rechnungsnr.',
invoiceDate: 'Rechnungsdatum',
serviceDate: 'Lieferdatum',
orderNo: 'Bestellnr.',
customerNo: 'Kundennr.',
billTo: 'Rechnung an',
pos: 'Pos.',
description: 'Beschreibung',
unitNet: 'Einzelpreis netto',
amountNet: 'Betrag netto',
netTotal: 'Summe netto',
grossTotal: 'Gesamtbetrag',
vatId: 'USt-IdNr.',
bank: 'Bank',
contact: 'Kontakt',
shippingLine: 'Versand',
returnsTitle: 'Artikel zurücksenden',
returnsIntro: 'Wähle aus, was zurückgeht. Das Etikett ist kostenlos, das Geld ist innerhalb von 5 Tagen nach Eingang des Pakets zurück.',
returnBy: 'Rücksendung bis',
reason: 'Grund',
chooseReason: 'Grund auswählen',
refundTo: 'Erstattung auf',
original: 'Ursprüngliche Zahlart',
originalNote: 'Zurück auf die Karte, mit der du bezahlt hast',
credit: 'Guthaben',
creditNote: '10 % extra, sofort einlösbar',
dropOff: 'So geht es zurück',
shop: 'Paketshop',
shopNote: 'QR-Code vorzeigen, kein Drucker nötig',
pickup: 'Abholung an der Tür',
pickupNote: 'Morgen zwischen 8 und 18 Uhr',
refund: 'Deine Erstattung',
bonus: 'Guthaben-Bonus',
label: 'Rücksendeetikett',
request: 'Rücksendung anmelden',
requested: 'Angemeldet, das Etikett ist unterwegs',
selectFirst: 'Wähle mindestens einen Artikel für die Rücksendung.',
itemsSelected: 'Artikel ausgewählt',
itemSelected: 'Artikel ausgewählt',
subTitle: 'Abo',
active: 'Aktiv',
paused: 'Pausiert',
since: 'Seit',
nextDelivery: 'Nächste Lieferung',
every: 'Alle',
weeks: 'Wochen',
perDelivery: 'pro Lieferung',
save: 'Du sparst',
deliverNow: 'Jetzt liefern',
skip: 'Aussetzen',
skipped: 'Ausgesetzt',
unskip: 'Doch liefern',
pause: 'Pausieren',
resume: 'Fortsetzen',
upcoming: 'Nächste Lieferungen',
pastDeliveries: 'Bisherige Lieferungen',
manage: 'Verwalten',
cancel: 'Abo kündigen',
change: 'Ändern',
frequency: 'Rhythmus',
quantity: 'Tüten pro Lieferung',
pausedNote: 'Bis zum Fortsetzen keine Lieferungen. Es wird nichts abgebucht.',
},
};
type T = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
const STATUS: Record<OrderStatus, { icon: IconName; cls: string }> = {
processing: { icon: 'clock', cls: 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' },
shipped: { icon: 'truck', cls: 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' },
delivered: { icon: 'check-circle', cls: 'bg-[color-mix(in_oklch,var(--success)_14%,transparent)] text-[color-mix(in_oklch,var(--success)_75%,var(--foreground))]' },
returned: { icon: 'undo', cls: 'bg-muted text-muted-foreground' },
cancelled: { icon: 'x-circle', cls: 'bg-[color-mix(in_oklch,var(--destructive)_12%,transparent)] text-[color-mix(in_oklch,var(--destructive)_80%,var(--foreground))]' },
};
const statusLabel = (t: T, s: OrderStatus) => ({ processing: t.processing, shipped: t.shipped, delivered: t.delivered, returned: t.returned, cancelled: t.cancelled })[s];
function StatusBadge({ status, t }: { status: OrderStatus; t: T }) {
const s = STATUS[status];
return (
<span className={cn('inline-flex h-7 items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 text-[12px] font-medium', s.cls)}>
<Icon name={s.icon} size={13} />
{statusLabel(t, status)}
</span>
);
}
/**
* A marker that glides to the active item of a row (tabs, segments). Items
* carry data-glide. Until it is measured, every item draws its own state.
*/
function useGlide<T extends HTMLElement>(index: number) {
const ref = useRef<T>(null);
const [box, setBox] = useState<{ x: number; w: number; ready: boolean } | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => {
const item = el.querySelectorAll<HTMLElement>('[data-glide]')[index];
if (!item) return setBox(null);
setBox((b) => ({ x: item.offsetLeft, w: item.offsetWidth, ready: !!b }));
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [index]);
return [ref, box] as const;
}
const sumItems = (items: LineItem[]) => round(items.reduce((a, i) => a + i.price * i.quantity, 0));
function Card({ className, children }: { className?: string; children: React.ReactNode }) {
return <div className={cn('rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>{children}</div>;
}
function Address({ title, lines, icon }: { title: string; lines?: string[]; icon: IconName }) {
if (!lines?.length) return null;
return (
<div className="flex gap-3.5 py-4">
<Icon name={icon} size={17} className="-mt-px shrink-0 text-muted-foreground" />
<div>
<p className="text-[12px] text-muted-foreground">{title}</p>
<p className="mt-0.5 text-[14px] leading-relaxed text-foreground">
{lines.map((l) => (
<span key={l} className="block">
{l}
</span>
))}
</p>
</div>
</div>
);
}
/** Dates for the subscription: fixed to UTC so server and browser print the same */
function addWeeks(iso: string, weeks: number) {
const d = new Date(`${iso}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + weeks * 7);
return d.toISOString().slice(0, 10);
}
function formatDate(locale: Locale, iso: string, long = false) {
return new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', long ? { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' } : { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }).format(new Date(`${iso}T12:00:00Z`));
}
/* ------------------------------------------------------------------ */
/* order-history */
/* ------------------------------------------------------------------ */
function History(props: OrderProps & { t: T }) {
const { t, orders = [], currency } = props;
const locale = useLocale();
const tabs: { id: string; label: string; match: (o: OrderRecord) => boolean }[] = [
{ id: 'all', label: t.all, match: () => true },
{ id: 'open', label: t.open, match: (o) => o.status === 'processing' || o.status === 'shipped' },
{ id: 'delivered', label: t.delivered, match: (o) => o.status === 'delivered' },
{ id: 'returned', label: t.returned, match: (o) => o.status === 'returned' || o.status === 'cancelled' },
];
const [tab, setTab] = useState('all');
const [switched, setSwitched] = useState(false);
const list = orders.filter(tabs.find((x) => x.id === tab)!.match);
const [tabRow, mark] = useGlide<HTMLDivElement>(tabs.findIndex((x) => x.id === tab));
const pick = (id: string) => {
setTab(id);
setSwitched(true);
};
return (
<section className={cn('pb-20 pt-10 sm:pt-14 lg:pb-28', props.className)}>
<div className="kit-container max-w-[60rem] lg:max-w-[64rem]">
<h1 className="text-h2 font-semibold text-foreground">{t.orders}</h1>
<p className="mt-3 max-w-[34rem] text-pretty text-[15px] text-muted-foreground">{t.ordersIntro}</p>
<div className="-mx-6 mt-8 border-b border-border sm:mx-0">
<div
ref={tabRow}
role="tablist"
aria-label={t.orders}
onKeyDown={(e) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
const i = (tabs.findIndex((x) => x.id === tab) + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length;
pick(tabs[i].id);
e.currentTarget.querySelectorAll<HTMLElement>('[role=tab]')[i]?.focus();
}}
className="kit-no-scrollbar relative flex gap-5 overflow-x-auto scroll-px-6 px-6 max-sm:kit-fade-x sm:gap-6 sm:px-0"
>
{tabs.map((x) => {
const on = tab === x.id;
const n = orders.filter(x.match).length;
return (
<button key={x.id} data-glide role="tab" type="button" aria-selected={on} tabIndex={on ? 0 : -1} onClick={() => pick(x.id)} className={cn('relative flex h-11 shrink-0 items-center gap-2 text-[14px] font-medium transition-colors', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
{x.label}
<span className={cn('grid h-5 min-w-5 place-items-center rounded-full px-1.5 text-[11px] tabular-nums transition-colors duration-300', on ? 'bg-foreground text-background' : 'bg-muted text-muted-foreground')}>{n}</span>
{!mark && <span aria-hidden className={cn('absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-foreground', on ? 'opacity-100' : 'opacity-0')} />}
</button>
);
})}
{/* The underline glides from tab to tab */}
{mark && <span aria-hidden className={cn('absolute bottom-0 left-0 h-0.5 rounded-full bg-foreground', mark.ready && 'transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')} style={{ width: mark.w, transform: `translateX(${mark.x}px)` }} />}
</div>
</div>
<ShopMotion />
{/* A new tab lets its orders rise in one after another */}
<ul key={tab} className="mt-8 space-y-6" role="tabpanel">
{list.map((o, n) => {
const total = sumItems(o.items) + (o.shipping ?? 0);
const shown = o.items.slice(0, 2);
return (
<Reveal as="li" key={o.number} delay={n * 70} className={cn(switched && 'animate-[kitshop-in_0.5s_var(--ease-kit)_both]')} style={switched ? { animationDelay: `${n * 70}ms` } : undefined}>
<Card className="overflow-hidden transition-[box-shadow,transform] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:transition-none">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 border-b border-border bg-surface px-5 py-4 sm:flex sm:items-center sm:gap-10 sm:px-6">
<div>
<p className="text-[12px] text-muted-foreground">{t.order}</p>
<p className="text-[14px] font-semibold tabular-nums text-foreground">{o.number}</p>
</div>
<div>
<p className="text-[12px] text-muted-foreground">{t.placed}</p>
<p className="text-[14px] text-foreground">{o.date}</p>
</div>
<div>
<p className="text-[12px] text-muted-foreground">{t.total}</p>
<p className="text-[14px] font-medium tabular-nums text-foreground">{formatPrice(locale, total, currency)}</p>
</div>
<div className="sm:ml-auto">
<StatusBadge status={o.status} t={t} />
</div>
</div>
<div className="grid gap-5 px-5 py-5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center sm:px-6">
<div className="min-w-0">
{o.statusNote && <p className="mb-4 text-[14px] font-medium text-foreground">{o.statusNote}</p>}
<ul className="space-y-3">
{shown.map((i) => (
<li key={i.id} className="flex items-center gap-3.5">
<ProductMedia className="size-14 shrink-0 rounded-lg">{i.image}</ProductMedia>
<div className="min-w-0">
<p className="truncate text-[14px] text-foreground">{i.name}</p>
<p className="truncate text-[12px] text-muted-foreground">
{i.variant}
{i.quantity > 1 && ` · ${i.quantity}×`}
</p>
</div>
</li>
))}
</ul>
{o.items.length > shown.length && (
<p className="mt-3 text-[12px] text-muted-foreground">
+ {o.items.length - shown.length} {t.more}
</p>
)}
</div>
<div className="flex flex-wrap gap-2 sm:w-48 sm:flex-col">
<Button href="#" variant="primary" size="sm" className="h-10 flex-1 sm:flex-none">
{t.viewOrder}
</Button>
<Button variant="outline" size="sm" iconLeft="refresh" className="h-10 flex-1 sm:flex-none">
{t.buyAgain}
</Button>
{o.status !== 'processing' && o.status !== 'cancelled' && (
<Button href="#" variant="ghost" size="sm" iconLeft="download" className="h-10 flex-1 text-muted-foreground sm:flex-none">
{t.invoice}
</Button>
)}
</div>
</div>
</Card>
</Reveal>
);
})}
{!list.length && <li className="rounded-2xl border border-dashed border-border-strong py-16 text-center text-[14px] text-muted-foreground">{t.noOrders}</li>}
</ul>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* order-detail */
/* ------------------------------------------------------------------ */
function Totals({ items, shipping = 0, currency, t, vatRate = 19 }: { items: LineItem[]; shipping?: number; currency?: string; t: T; vatRate?: number }) {
const locale = useLocale();
const sub = sumItems(items);
const total = round(sub + shipping);
const fp = (n: number) => formatPrice(locale, n, currency);
return (
<dl className="space-y-2.5 text-[14px]">
<div className="flex justify-between">
<dt className="text-muted-foreground">{t.subtotal}</dt>
<dd className="tabular-nums text-foreground">{fp(sub)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">{t.shipping}</dt>
<dd className={cn('tabular-nums', shipping ? 'text-foreground' : 'text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))] dark:text-success')}>{shipping ? fp(shipping) : t.free}</dd>
</div>
<div className="flex items-baseline justify-between border-t border-border pt-3">
<dt className="font-semibold text-foreground">{t.total}</dt>
<dd className="text-right">
<span className="text-[18px] font-semibold tabular-nums text-foreground">{fp(total)}</span>
<span className="block text-[12px] text-muted-foreground">
{t.incl} {fp(vatOf(total, vatRate))} {t.vat} ({formatPercent(locale, vatRate)})
</span>
</dd>
</div>
</dl>
);
}
function Detail(props: OrderProps & { t: T }) {
const { t, order, currency } = props;
const locale = useLocale();
const [copied, setCopied] = useState(false);
// The stage bars and the event line fill once the tracking card is in view
const [trackRef, trackIn] = useInView<HTMLDivElement>('0px 0px -15% 0px');
if (!order) return null;
const tr = order.tracking;
const stages = t.stages.split(',');
return (
<section className={cn('pb-20 pt-8 sm:pt-12 lg:pb-28', props.className)}>
<div className="kit-container">
<a href="#" className="group mb-6 inline-flex h-10 items-center gap-1.5 text-[14px] text-muted-foreground hover:text-foreground">
<Icon name="arrow-left" size={15} className="transition-transform group-hover:-translate-x-0.5" />
{t.backToOrders}
</a>
<div className="flex flex-col gap-5 border-b border-border pb-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-[30px] font-semibold tracking-[-0.03em] text-foreground sm:text-[36px]">
{t.order} {order.number}
</h1>
<StatusBadge status={order.status} t={t} />
</div>
<p className="mt-2 text-[14px] text-muted-foreground">
{t.placed} {order.date}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" iconLeft="download">
{t.invoice}
</Button>
<Button variant="ghost" iconLeft="help-circle">
{t.help}
</Button>
</div>
</div>
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-10">
<div className="min-w-0 space-y-8">
{tr && (
<div ref={trackRef}>
<Card className="p-5 sm:p-7">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[13px] text-muted-foreground">{t.eta}</p>
<p className="mt-0.5 text-[22px] font-semibold tracking-[-0.02em] text-foreground">{tr.eta}</p>
</div>
<div className="sm:text-right">
<p className="text-[12px] text-muted-foreground">
{tr.carrier} · {t.trackingNo}
</p>
<button
type="button"
onClick={() => { navigator.clipboard?.writeText(tr.code).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1600); }}
aria-label={t.copy}
className="group -ml-2 mt-0.5 inline-flex h-9 items-center gap-2 rounded-md px-2 text-[14px] font-medium tabular-nums text-foreground hover:bg-accent sm:-mr-2 sm:ml-0"
>
{tr.code}
<Swap value={copied}>
<Icon name={copied ? 'check' : 'copy'} size={14} className={copied ? 'text-success' : 'text-muted-foreground group-hover:text-foreground'} />
</Swap>
</button>
<span className="sr-only" aria-live="polite">
{copied ? t.copied : ''}
</span>
</div>
</div>
<ol className="mt-7 grid grid-cols-4 gap-1.5" aria-label={t.shipment}>
{stages.map((s, n) => {
const done = n <= tr.stage;
return (
<li key={s}>
<span className="relative block h-1.5 overflow-hidden rounded-full bg-track">
{done && (
<span
className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none"
style={{ transform: `scaleX(${trackIn ? 1 : 0})`, transitionDelay: trackIn ? `${n * 260}ms` : '0ms' }}
/>
)}
{n === tr.stage + 1 && <span className="absolute inset-y-0 left-0 w-1/3 animate-kit-pulse rounded-full bg-[color-mix(in_oklab,var(--primary)_45%,transparent)] motion-reduce:animate-none" />}
</span>
<span className={cn('mt-2 block text-[12px] sm:text-[13px]', n === tr.stage ? 'font-medium text-foreground' : done ? 'text-foreground' : 'text-subtle-foreground')}>{s}</span>
</li>
);
})}
</ol>
<h2 className="mt-9 text-[14px] font-semibold text-foreground">{t.history}</h2>
<ol className="relative mt-4">
{tr.events.map((e, n) => (
<li
key={n}
className={cn('relative grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-4 pb-6 transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] last:pb-0 motion-reduce:transition-none', trackIn ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0')}
style={{ transitionDelay: trackIn ? `${600 + n * 90}ms` : '0ms' }}
>
{n < tr.events.length - 1 && <span aria-hidden className="absolute left-[9.5px] top-5 bottom-0 w-px bg-border" />}
<span aria-hidden className={cn('relative mt-1 grid size-5 place-items-center rounded-full', n === 0 ? 'bg-[color-mix(in_oklab,var(--primary)_18%,transparent)]' : '')}>
<span className={cn('size-2 rounded-full', n === 0 ? 'bg-primary' : 'bg-border-strong')} />
</span>
<div className="flex flex-col gap-0.5 sm:flex-row sm:justify-between sm:gap-6">
<div>
<p className={cn('text-[14px]', n === 0 ? 'font-medium text-foreground' : 'text-foreground')}>{e.text}</p>
{e.location && <p className="text-[13px] text-muted-foreground">{e.location}</p>}
</div>
<p className="shrink-0 text-[13px] tabular-nums text-muted-foreground sm:text-right">
{e.date}
{e.time && <span className="sm:block"> {e.time}</span>}
</p>
</div>
</li>
))}
</ol>
</Card>
</div>
)}
<Card className="px-5 sm:px-7">
<h2 className="pb-1 pt-5 text-[16px] font-semibold text-foreground sm:pt-6">
{t.items} <span className="font-normal text-muted-foreground">({order.items.reduce((a, i) => a + i.quantity, 0)})</span>
</h2>
<Stagger as="ul" step={80} className="divide-y divide-border">
{order.items.map((i) => (
<li key={i.id} className="group/it flex items-center gap-4 py-4">
<ProductMedia className="size-16 shrink-0 rounded-lg sm:size-20 [&>*]:transition-transform [&>*]:duration-500 group-hover/it:[&>*]:scale-[1.06] motion-reduce:[&>*]:transition-none">{i.image}</ProductMedia>
<div className="min-w-0 flex-1">
<a href={i.href ?? '#'} className="block truncate text-[15px] font-medium text-foreground hover:underline hover:underline-offset-4">
{i.name}
</a>
<p className="text-[13px] text-muted-foreground sm:truncate">
{i.variant} · {t.qty} {i.quantity}
</p>
<button type="button" className="mt-1 inline-flex h-8 items-center gap-1.5 text-[13px] font-medium text-foreground underline decoration-border-strong underline-offset-4 sm:hidden">
{t.buyAgain}
</button>
</div>
<p className="text-[14px] font-medium tabular-nums text-foreground">{formatPrice(locale, i.price * i.quantity, currency)}</p>
<Button variant="outline" size="sm" iconLeft="refresh" className="h-10 max-sm:hidden">
{t.buyAgain}
</Button>
</li>
))}
</Stagger>
</Card>
</div>
<div className="space-y-6 lg:sticky lg:top-8 lg:self-start">
<Card className="divide-y divide-border px-5 sm:px-6">
<Address title={t.deliveryAddress} lines={order.address} icon="map-pin" />
<Address title={t.billingAddress} lines={order.billing ?? order.address} icon="file" />
{order.payment && <Address title={t.payment} lines={[order.payment]} icon="credit-card" />}
</Card>
<Card className="p-5 sm:p-6">
<h2 className="mb-4 text-[16px] font-semibold text-foreground">{t.summary}</h2>
<Totals items={order.items} shipping={order.shipping} currency={currency} t={t} />
</Card>
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* invoice */
/* ------------------------------------------------------------------ */
function Invoice(props: OrderProps & { t: T }) {
const { t, invoice: inv, currency } = props;
const locale = useLocale();
const [rows, rowsIn] = useInView<HTMLTableSectionElement>();
if (!inv) return null;
const rate = inv.vatRate ?? 19;
const net = (gross: number) => round(gross / (1 + rate / 100));
const fp = (n: number) => formatPrice(locale, n, currency);
const lines = [...inv.items, ...(inv.shipping ? [{ description: t.shippingLine, quantity: 1, price: inv.shipping }] : [])];
// Rows rise one after another when the table scrolls in
const rowStyle = (n: number): React.CSSProperties => ({ transitionDelay: rowsIn ? `${n * 70}ms` : '0ms' });
const rowCls = cn('transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', rowsIn ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0');
const gross = round(lines.reduce((a, l) => a + l.price * l.quantity, 0));
const grossFormat: Intl.NumberFormatOptions = { style: 'currency', currency: currency ?? 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 };
const netTotal = round(lines.reduce((a, l) => a + net(l.price) * l.quantity, 0));
const vat = round(gross - netTotal);
const meta: [string, string][] = [
[t.invoiceNo, inv.number],
[t.invoiceDate, inv.date],
[t.serviceDate, inv.serviceDate],
[t.orderNo, inv.orderNumber],
...(inv.customerNumber ? ([[t.customerNo, inv.customerNumber]] as [string, string][]) : []),
];
return (
<section className={cn('bg-surface pb-20 pt-8 sm:pt-12 lg:pb-28 print:bg-transparent print:p-0', props.className)}>
<div className="mx-auto w-full max-w-[52rem] px-4 sm:px-6 print:max-w-none print:px-0">
<div className="mb-5 flex flex-wrap items-center justify-between gap-3 print:hidden">
<a href="#" className="group inline-flex h-10 items-center gap-1.5 text-[14px] text-muted-foreground hover:text-foreground">
<Icon name="arrow-left" size={15} className="transition-transform group-hover:-translate-x-0.5" />
{t.backToOrders}
</a>
<div className="flex gap-2">
<Button variant="outline" size="sm" iconLeft="download" className="h-10">
{t.download}
</Button>
<Button size="sm" iconLeft="printer" className="h-10" onClick={() => window.print()}>
<span className="sm:hidden">{t.printShort}</span>
<span className="max-sm:hidden">{t.print}</span>
</Button>
</div>
</div>
{/* The sheet settles in like paper laid on a desk */}
<Reveal effect="scale">
<article className="rounded-xl border border-border bg-card px-5 py-8 text-foreground shadow-[var(--highlight),var(--shadow-lg)] sm:px-12 sm:py-12 print:rounded-none print:border-0 print:bg-white print:px-0 print:py-0 print:text-black print:shadow-none">
<header className="flex flex-col gap-8 sm:flex-row sm:justify-between">
<div>
<p className="text-[22px] font-semibold tracking-[-0.03em]">{inv.seller.name}</p>
<p className="mt-1 text-[12px] leading-relaxed text-muted-foreground print:text-neutral-600">{inv.seller.lines.join(' · ')}</p>
</div>
<div className="sm:text-right">
<h1 className="text-[28px] font-semibold tracking-[-0.03em]">{t.invoiceTitle}</h1>
<p className="text-[14px] tabular-nums text-muted-foreground print:text-neutral-600">{inv.number}</p>
</div>
</header>
<div className="mt-10 grid gap-8 sm:grid-cols-2">
<div>
<p className="text-[11px] font-medium uppercase tracking-[0.1em] text-subtle-foreground print:text-neutral-500">{t.billTo}</p>
<p className="mt-2 text-[14px] leading-relaxed">
<span className="block font-medium">{inv.buyer.name}</span>
{inv.buyer.lines.map((l) => (
<span key={l} className="block">
{l}
</span>
))}
</p>
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-[13px] sm:justify-self-end">
{meta.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted-foreground print:text-neutral-600">{k}</dt>
<dd className="text-right font-medium tabular-nums">{v}</dd>
</div>
))}
</dl>
</div>
{/* Table on wide screens and paper, stacked rows on phones */}
<table className="mt-10 w-full border-collapse text-[13px] max-sm:hidden print:table">
<thead>
<tr className="border-b-2 border-foreground text-left text-[11px] uppercase tracking-[0.08em] text-muted-foreground print:border-black print:text-neutral-600">
<th className="w-10 py-2.5 font-medium">{t.pos}</th>
<th className="py-2.5 font-medium">{t.description}</th>
<th className="py-2.5 text-right font-medium">{t.qty}</th>
<th className="py-2.5 text-right font-medium">{t.unitNet}</th>
<th className="py-2.5 text-right font-medium">{t.vat}</th>
<th className="py-2.5 text-right font-medium">{t.amountNet}</th>
</tr>
</thead>
<tbody ref={rows}>
{lines.map((l, n) => (
<tr key={n} className={cn('border-b border-border align-top print:border-neutral-300', rowCls)} style={rowStyle(n)}>
<td className="py-3.5 tabular-nums text-muted-foreground">{n + 1}</td>
<td className="py-3.5 pr-4">
<span className="font-medium">{l.description}</span>
{'detail' in l && l.detail && <span className="block text-[12px] text-muted-foreground">{l.detail}</span>}
</td>
<td className="py-3.5 text-right tabular-nums">{l.quantity}</td>
<td className="py-3.5 text-right tabular-nums">{fp(net(l.price))}</td>
<td className="py-3.5 text-right tabular-nums">{formatPercent(locale, rate)}</td>
<td className="py-3.5 text-right tabular-nums">{fp(round(net(l.price) * l.quantity))}</td>
</tr>
))}
</tbody>
</table>
<Stagger as="ul" step={70} className="mt-8 divide-y divide-border border-y border-border sm:hidden print:hidden">
{lines.map((l, n) => (
<li key={n} className="flex justify-between gap-4 py-3.5 text-[13px]">
<div>
<p className="font-medium">{l.description}</p>
<p className="text-muted-foreground tabular-nums">
{l.quantity} × {fp(net(l.price))} · {formatPercent(locale, rate)} {t.vat}
</p>
</div>
<p className="tabular-nums">{fp(round(net(l.price) * l.quantity))}</p>
</li>
))}
</Stagger>
<div className="mt-6 flex justify-end">
<dl className="w-full space-y-2 text-[13px] sm:max-w-[18rem]">
<div className="flex justify-between">
<dt className="text-muted-foreground">{t.netTotal}</dt>
<dd className="tabular-nums">{fp(netTotal)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">
{t.vat} {formatPercent(locale, rate)}
</dt>
<dd className="tabular-nums">{fp(vat)}</dd>
</div>
<div className="flex items-baseline justify-between border-t-2 border-foreground pt-2.5 print:border-black">
<dt className="text-[14px] font-semibold">{t.grossTotal}</dt>
<NumberTicker as="dd" value={gross} format={grossFormat} duration={1400} className="text-[18px] font-semibold" />
</div>
</dl>
</div>
<p className="mt-10 rounded-lg bg-surface-2 px-4 py-3 text-[13px] print:bg-transparent print:px-0">
<Icon name="check-circle" size={14} className="mr-1.5 inline text-success print:hidden" />
{inv.dueNote}
</p>
{inv.note && <p className="mt-4 text-pretty text-[13px] text-muted-foreground print:text-neutral-600">{inv.note}</p>}
<footer className="mt-12 grid gap-6 border-t border-border pt-6 text-[11px] leading-relaxed text-muted-foreground sm:grid-cols-3 print:border-neutral-300 print:text-neutral-600">
<div>
<p className="font-medium text-foreground print:text-black">{inv.seller.name}</p>
{inv.seller.lines.map((l) => (
<p key={l}>{l}</p>
))}
{inv.seller.register && <p>{inv.seller.register}</p>}
</div>
<div>
<p className="font-medium text-foreground print:text-black">{t.contact}</p>
{inv.seller.contact.map((l) => (
<p key={l}>{l}</p>
))}
<p>
{t.vatId} {inv.seller.vatId}
</p>
</div>
<div>
<p className="font-medium text-foreground print:text-black">{t.bank}</p>
<p>{inv.seller.bank}</p>
<p className="tabular-nums">IBAN {inv.seller.iban}</p>
<p>BIC {inv.seller.bic}</p>
</div>
</footer>
</article>
</Reveal>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* returns */
/* ------------------------------------------------------------------ */
function Choice({ name, value, on, onChange, title, note, icon, right }: { name: string; value: string; on: 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,box-shadow] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]', on ? 'border-primary shadow-[inset_0_0_0_1px_var(--primary)]' : 'border-border-strong hover:border-foreground/40')}>
<input type="radio" name={name} value={value} checked={on} onChange={onChange} className="sr-only" />
<span className={cn('grid size-9 shrink-0 place-items-center rounded-lg', on ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground')}>
<Icon name={icon} size={17} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="text-[14px] font-medium text-foreground">{title}</span>
{right}
</span>
<span className="mt-0.5 block text-[13px] text-muted-foreground">{note}</span>
</span>
</label>
);
}
function Returns(props: OrderProps & { t: T }) {
const { t, order, currency, returnReasons = [] } = props;
const locale = useLocale();
const items = order?.items ?? [];
const [sel, setSel] = useState<Record<string, { qty: number; reason: string }>>(items[0] ? { [items[0].id]: { qty: 1, reason: returnReasons[0] ?? '' } } : {});
const [refund, setRefund] = useState<'original' | 'credit'>('original');
const [drop, setDrop] = useState<'shop' | 'pickup'>('shop');
const [tried, setTried] = useState(0);
const [sent, setSent] = useState(false);
const fp = (n: number) => formatPrice(locale, n, currency);
const base = round(items.reduce((a, i) => a + (sel[i.id] ? i.price * sel[i.id].qty : 0), 0));
const bonus = refund === 'credit' ? round(base * 0.1) : 0;
const count = Object.values(sel).reduce((a, s) => a + s.qty, 0);
const reasonId = useId();
if (!order) return null;
const toggle = (i: LineItem) => setSel((s) => {
const next = { ...s };
if (next[i.id]) delete next[i.id];
else next[i.id] = { qty: 1, reason: '' };
return next;
});
return (
<section className={cn('pb-20 pt-10 sm:pt-14 lg:pb-28', props.className)}>
<div className="kit-container">
<div className="mb-10 max-w-[40rem]">
<p className="text-[13px] text-muted-foreground">
{t.order} {order.number} · {order.date}
</p>
<h1 className="mt-2 text-h2 font-semibold text-foreground">{t.returnsTitle}</h1>
<p className="mt-3 text-pretty text-[15px] text-muted-foreground">{t.returnsIntro}</p>
</div>
<form
noValidate
onSubmit={(e) => { e.preventDefault(); setTried((n) => n + 1); if (count > 0) setSent(true); }}
className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-14"
>
<div className="min-w-0 space-y-10">
<fieldset className="min-w-0">
<legend className="mb-4 text-[16px] font-semibold text-foreground">{t.items}</legend>
<ul className="space-y-3">
{items.map((i) => {
const s = sel[i.id];
return (
<li key={i.id} className={cn('rounded-xl border transition-[border-color,box-shadow]', s ? 'border-primary shadow-[inset_0_0_0_1px_var(--primary)]' : 'border-border-strong')}>
<label className="flex cursor-pointer items-center gap-4 p-4 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] rounded-xl">
<input type="checkbox" checked={!!s} onChange={() => toggle(i)} className="peer sr-only" />
<span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-[6px] border transition-colors', s ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
{s && <Icon name="check" size={13} strokeWidth={2.4} className="animate-[kitshop-pop_0.35s_var(--ease-kit)]" />}
</span>
<ProductMedia className={cn('size-16 shrink-0 rounded-lg transition-[opacity,filter] duration-300', !s && 'opacity-70 saturate-50')}>{i.image}</ProductMedia>
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-medium leading-snug text-foreground">{i.name}</span>
<span className="mt-0.5 block text-[13px] text-muted-foreground">
{i.variant} · {t.qty} {i.quantity}
</span>
</span>
<span className="text-[14px] tabular-nums text-foreground">{fp(i.price)}</span>
</label>
{s && (
<div className="kit-rise grid gap-3 border-t border-border px-4 pb-4 pt-4 [animation-duration:0.4s] sm:grid-cols-[auto_minmax(0,1fr)] sm:items-end">
{i.quantity > 1 && (
<div>
<p className="mb-1.5 text-[13px] font-medium text-foreground">{t.qty}</p>
<Quantity value={s.qty} max={i.quantity} onChange={(q) => setSel((x) => ({ ...x, [i.id]: { ...x[i.id], qty: q } }))} />
</div>
)}
<div className={cn(i.quantity <= 1 && 'sm:col-span-2')}>
<label htmlFor={`${reasonId}-${i.id}`} className="mb-1.5 block text-[13px] font-medium text-foreground">
{t.reason}
</label>
<div className="relative">
<select
id={`${reasonId}-${i.id}`}
value={s.reason}
onChange={(e) => setSel((x) => ({ ...x, [i.id]: { ...x[i.id], reason: e.target.value } }))}
className="h-11 w-full appearance-none rounded-lg border border-input bg-background px-3.5 pr-10 text-[14px] text-foreground shadow-[var(--shadow-sm)] outline-none focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-[color-mix(in_oklab,var(--primary)_16%,transparent)]"
>
<option value="" disabled>
{t.chooseReason}
</option>
{returnReasons.map((r) => (
<option key={r}>{r}</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>
</div>
</div>
)}
</li>
);
})}
</ul>
</fieldset>
<fieldset className="min-w-0">
<legend className="mb-4 text-[16px] font-semibold text-foreground">{t.refundTo}</legend>
<div className="grid gap-3 sm:grid-cols-2">
<Choice name="refund" value="original" on={refund === 'original'} onChange={() => setRefund('original')} title={t.original} note={t.originalNote} icon="credit-card" />
<Choice name="refund" value="credit" on={refund === 'credit'} onChange={() => setRefund('credit')} title={t.credit} note={t.creditNote} icon="gift" right={<span className="text-[12px] font-semibold text-success">+{formatPercent(locale, 10)}</span>} />
</div>
</fieldset>
<fieldset className="min-w-0">
<legend className="mb-4 text-[16px] font-semibold text-foreground">{t.dropOff}</legend>
<div className="grid gap-3 sm:grid-cols-2">
<Choice name="drop" value="shop" on={drop === 'shop'} onChange={() => setDrop('shop')} title={t.shop} note={t.shopNote} icon="qr-code" />
<Choice name="drop" value="pickup" on={drop === 'pickup'} onChange={() => setDrop('pickup')} title={t.pickup} note={t.pickupNote} icon="truck" />
</div>
</fieldset>
</div>
<aside className="lg:sticky lg:top-8 lg:self-start">
<Card className="p-5 sm:p-6">
<h2 className="text-[16px] font-semibold text-foreground">{t.refund}</h2>
<p className="mt-1 text-[13px] text-muted-foreground">
{count} {count === 1 ? t.itemSelected : t.itemsSelected}
</p>
<dl className="mt-5 space-y-2.5 text-[14px]">
<div className="flex justify-between">
<dt className="text-muted-foreground">{t.subtotal}</dt>
<dd className="text-foreground">
<TweenPrice value={base} currency={currency} />
</dd>
</div>
{bonus > 0 && (
<div className="flex animate-[kitshop-in_0.4s_var(--ease-kit)] justify-between">
<dt className="text-muted-foreground">{t.bonus}</dt>
<dd className="text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))] dark:text-success">
+ <TweenPrice value={bonus} currency={currency} />
</dd>
</div>
)}
<div className="flex justify-between">
<dt className="text-muted-foreground">{t.label}</dt>
<dd className="text-[color-mix(in_oklab,var(--success)_70%,var(--foreground))] dark:text-success">{t.free}</dd>
</div>
<div className="flex items-baseline justify-between border-t border-border pt-3">
<dt className="font-semibold text-foreground">{t.total}</dt>
<dd className="text-[24px] font-semibold tracking-[-0.02em] text-foreground" aria-live="polite">
<TweenPrice value={round(base + bonus)} currency={currency} />
</dd>
</div>
</dl>
{props.returnBy && (
<p className="mt-5 flex items-center gap-2 rounded-lg bg-surface-2 px-3.5 py-2.5 text-[13px] text-foreground">
<Icon name="calendar" size={15} className="text-muted-foreground" />
{t.returnBy} {props.returnBy}
</p>
)}
<ShopMotion />
{tried > 0 && count === 0 && (
<p key={tried} role="alert" className="mt-4 flex animate-[kitshop-shake_0.42s_var(--ease-kit)] items-start gap-2 text-[13px] text-destructive">
<Icon name="alert-circle" size={15} className="mt-px" />
{t.selectFirst}
</p>
)}
<Button type="submit" size="lg" aria-disabled={(sent && count > 0) || undefined} className={cn('mt-5 w-full overflow-hidden', sent && count > 0 && 'bg-success text-background hover:bg-success')}>
<Swap value={sent && count > 0} className="min-w-0 gap-2">
<span className="truncate">{sent && count > 0 ? t.requested : t.request}</span>
<Icon name={sent && count > 0 ? 'check' : 'arrow-right'} size={17} className="shrink-0" />
</Swap>
</Button>
</Card>
</aside>
</form>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* subscription */
/* ------------------------------------------------------------------ */
function Subscription(props: OrderProps & { t: T }) {
const { t, subscription: s, currency } = props;
const locale = useLocale();
const [every, setEvery] = useState(s?.every ?? 2);
const [qty, setQty] = useState(s?.quantity ?? 1);
const [paused, setPaused] = useState(false);
const [skips, setSkips] = useState<Record<string, boolean>>({});
const upcoming = useMemo(() => (s ? Array.from({ length: 4 }, (_, n) => addWeeks(s.nextDate, n * every)) : []), [s, every]);
if (!s) return null;
const fp = (n: number) => formatPrice(locale, n, currency);
const next = upcoming.find((d) => !skips[d]) ?? upcoming[0];
const perDelivery = round(s.price * qty);
return (
<section className={cn('pb-20 pt-10 sm:pt-14 lg:pb-28', props.className)}>
<div className="kit-container">
<div className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[13px] text-muted-foreground">{t.subTitle}</p>
<h1 className="mt-1 text-h2 font-semibold text-foreground">{s.name}</h1>
</div>
<span className={cn('inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium', paused ? 'bg-muted text-muted-foreground' : 'bg-[color-mix(in_oklch,var(--success)_14%,transparent)] text-[color-mix(in_oklch,var(--success)_75%,var(--foreground))]')}>
<span className={cn('size-1.5 rounded-full', paused ? 'bg-muted-foreground' : 'animate-kit-pulse bg-success')} />
{paused ? t.paused : t.active} · {t.since} {s.since}
</span>
</div>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-10">
<div className="min-w-0 space-y-8">
<Card className="kit-glow overflow-hidden">
<div className="grid sm:grid-cols-[14rem_minmax(0,1fr)]">
<ProductMedia className="aspect-[4/3] rounded-none sm:aspect-auto sm:h-full">{s.image}</ProductMedia>
<div className="p-5 sm:p-7">
<p className="text-[13px] text-muted-foreground">{t.nextDelivery}</p>
<ShopMotion />
{/* A new date (other rhythm, skipped delivery) slides in */}
<p key={next} className={cn('mt-1 animate-[kitshop-in_0.45s_var(--ease-kit)] text-[26px] font-semibold tracking-[-0.025em] text-foreground transition-colors duration-300 sm:text-[30px]', paused && 'text-muted-foreground line-through decoration-2')}>{formatDate(locale, next, true)}</p>
{paused && <p className="mt-1 text-[13px] text-muted-foreground">{t.pausedNote}</p>}
<p className="mt-3 text-[14px] text-muted-foreground">
{qty} × {s.variant} · <TweenPrice value={perDelivery} currency={currency} className="font-medium text-foreground" /> {t.perDelivery}
{s.saving ? (
<span className="ml-2 inline-flex h-6 items-center rounded-full bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-2 text-[11px] font-semibold text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
{t.save} {formatPercent(locale, s.saving)}
</span>
) : null}
</p>
<div className="mt-6 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<Button size="sm" className="col-span-2 h-11 sm:h-10" iconLeft="bolt" disabled={paused}>
{t.deliverNow}
</Button>
<Button size="sm" variant="outline" className="h-11 sm:h-10" iconLeft="skip-forward" disabled={paused} onClick={() => setSkips((x) => ({ ...x, [next]: true }))}>
{t.skip}
</Button>
<Button size="sm" variant="outline" className="h-11 sm:h-10" iconLeft={paused ? 'play' : 'pause'} onClick={() => setPaused(!paused)}>
{paused ? t.resume : t.pause}
</Button>
</div>
</div>
</div>
</Card>
<div className="grid gap-6 sm:grid-cols-2">
<fieldset className="min-w-0">
<legend className="mb-3 text-[14px] font-semibold text-foreground">{t.frequency}</legend>
<div className="relative isolate grid grid-cols-3 gap-1 rounded-xl bg-muted p-1" role="radiogroup">
{/* The light pill glides to the picked rhythm: equal columns, so one third plus the gap per step */}
<span
aria-hidden
className="absolute bottom-1 left-1 top-1 -z-10 w-[calc((100%-1rem)/3)] rounded-lg bg-background shadow-[var(--shadow-sm)] transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none"
style={{ transform: `translateX(calc(${Math.max(0, s.everyOptions.indexOf(every))} * (100% + 0.25rem)))` }}
/>
{s.everyOptions.map((w) => (
<label key={w} className={cn('grid h-11 cursor-pointer place-items-center rounded-lg text-[13px] font-medium transition-colors duration-300 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]', every === w ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
<input type="radio" name="every" checked={every === w} onChange={() => { setEvery(w); setSkips({}); }} className="sr-only" />
{t.every} {w} {t.weeks}
</label>
))}
</div>
</fieldset>
<div>
<p className="mb-3 text-[14px] font-semibold text-foreground">{t.quantity}</p>
<div className="flex items-center gap-4">
<Quantity value={qty} onChange={setQty} max={6} />
<p className="text-[13px] tabular-nums text-muted-foreground">
{formatNumber(locale, qty * 250)} g · <TweenPrice value={perDelivery} currency={currency} />
</p>
</div>
</div>
</div>
<Reveal>
<h2 className="mb-4 text-[16px] font-semibold text-foreground">{t.upcoming}</h2>
{/* A new rhythm deals out the new dates one by one */}
<ol key={every} className={cn('overflow-hidden rounded-xl border border-border transition-opacity duration-300', paused && 'opacity-50')}>
{upcoming.map((d, n) => {
const skipped = !!skips[d];
return (
<li key={d} className={cn('flex min-h-16 items-center gap-4 px-4 py-3 transition-colors duration-300', n > 0 && 'border-t border-border', d === next && !paused && 'bg-surface')}>
<span
className={cn('grid size-10 shrink-0 animate-[kitshop-pop_0.45s_var(--ease-kit)_both] place-items-center rounded-lg text-center leading-none transition-colors duration-300', d === next && !paused ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground')}
style={{ animationDelay: `${n * 70}ms` }}
>
<span className="text-[15px] font-semibold tabular-nums">{new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', timeZone: 'UTC' }).format(new Date(`${d}T12:00:00Z`))}</span>
</span>
<div className="min-w-0 flex-1">
<p className={cn('text-[14px] font-medium', skipped ? 'text-muted-foreground line-through' : 'text-foreground')}>{formatDate(locale, d)}</p>
<p className="text-[12px] text-muted-foreground">{skipped ? t.skipped : `${qty} × ${s.variant} · ${fp(perDelivery)}`}</p>
</div>
<button
type="button"
disabled={paused}
onClick={() => setSkips((x) => ({ ...x, [d]: !x[d] }))}
className="h-9 shrink-0 rounded-md px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none"
>
{skipped ? t.unskip : t.skip}
</button>
</li>
);
})}
</ol>
</Reveal>
</div>
<div className="space-y-6 lg:sticky lg:top-8 lg:self-start">
<Card className="divide-y divide-border px-5 sm:px-6">
<Address title={t.deliveryAddress} lines={s.address} icon="map-pin" />
<Address title={t.payment} lines={[s.payment]} icon="credit-card" />
</Card>
<Card className="px-5 pb-2 pt-5 sm:px-6">
<h2 className="text-[14px] font-semibold text-foreground">{t.pastDeliveries}</h2>
<ul className="mt-2 divide-y divide-border">
{s.history.map((h) => (
<li key={h.number} className="flex items-center justify-between gap-3 py-3 text-[13px]">
<div>
<p className="text-foreground">{h.date}</p>
<p className="tabular-nums text-muted-foreground">{h.number}</p>
</div>
<div className="flex items-center gap-2">
<span className="tabular-nums text-foreground">{fp(h.total)}</span>
<a href="#" aria-label={`${t.invoice} ${h.number}`} className="grid size-9 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground">
<Icon name="download" size={15} />
</a>
</div>
</li>
))}
</ul>
</Card>
<Button variant="destructive" className="h-11 w-full" iconLeft="x-circle">
{t.cancel}
</Button>
</div>
</div>
</div>
</section>
);
}
/**
* After the purchase: order history, an order with its tracking, a printable
* invoice, a return flow and a subscription to manage.
*/
export function Order(props: OrderProps) {
const t = useTexts(TEXTS, props.labels);
const { variant = 'order-history' } = props;
if (variant === 'order-detail') return <Detail {...props} t={t} />;
if (variant === 'invoice') return <Invoice {...props} t={t} />;
if (variant === 'returns') return <Returns {...props} t={t} />;
if (variant === 'subscription') return <Subscription {...props} t={t} />;
return <History {...props} t={t} />;
}Frequent questions
How do I install Orders?
With aiw add @ai-website/order. The command puts components/ui/order.tsx into your project, the code is yours from then on. It brings along button, icon, reveal and product-image. It needs no further npm packages.
Which variants does Orders have?
5 variants, Order history, Order detail, Invoice, Returns and Subscription. You pick one with the variant prop, for example variant="order-detail".
Can I translate the texts of Orders?
Yes. Orders carries 104 texts of its own in English and German, such as “Orders”, “Track, return or buy again. Invoices are ready as soon as an order ships.” and “All”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Orders?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Orders with aiw_components and install it with aiw_add @ai-website/order. Name the variant in your prompt, for example variant="order-history", and the assistant writes your content straight into the props.