Bestellungen
Alles nach dem Kauf, von Bestellübersicht und Sendungsverlauf bis zu Rechnung, Rücksendung und Abo.
Details
Was Bestellungen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Bestellungen und Retouren
- Bereich
- E-Commerce
- Variants
- 5
- Version
- 1.0.1 · seit 1.0.0
- Datei
- components/ui/order.tsx
- Bringt mit
- utilskitbuttoniconrevealproduct-image
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 104 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- Shop-Checkout
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle OrderProps aus components/ui/order.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalOrderVariantorder-historyorder-detailinvoicereturnssubscription
ordersoptionalOrderRecord[]order-history
orderoptionalOrderRecordorder-detail and returns
invoiceoptionalInvoiceDatasubscriptionoptionalSubscriptionDatareturnReasonsoptionalstring[]returns
returnByoptionalstringcurrencyoptionalstring
Eingebaute Texte
Diese Texte schreibt Bestellungen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 104 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| orders | Englisch Orders | Deutsch Bestellungen |
| ordersIntro | Englisch Track, return or buy again. Invoices are ready as soon as an order ships. | Deutsch Verfolgen, zurückschicken oder nachbestellen. Rechnungen liegen bereit, sobald eine Bestellung verschickt ist. |
| all | Englisch All | Deutsch Alle |
| open | Englisch Open | Deutsch Offen |
| delivered | Englisch Delivered | Deutsch Zugestellt |
| returned | Englisch Returned | Deutsch Retourniert |
| order | Englisch Order | Deutsch Bestellung |
| placed | Englisch Placed | Deutsch Bestellt am |
| total | Englisch Total | Deutsch Summe |
| status | Englisch Status | Deutsch Status |
| processing | Englisch Processing | Deutsch In Bearbeitung |
| shipped | Englisch On the way | Deutsch Unterwegs |
| cancelled | Englisch Cancelled | Deutsch Storniert |
| viewOrder | Englisch View order | Deutsch Bestellung ansehen |
| buyAgain | Englisch Buy again | Deutsch Erneut kaufen |
| invoice | Englisch Invoice | Deutsch Rechnung |
| more | Englisch more | Deutsch weitere |
| noOrders | Englisch No orders in this view. | Deutsch In dieser Ansicht gibt es keine Bestellungen. |
| backToOrders | Englisch All orders | Deutsch Alle Bestellungen |
| help | Englisch Get help | Deutsch Hilfe erhalten |
| shipment | Englisch Shipment | Deutsch Sendung |
| eta | Englisch Expected | Deutsch Voraussichtlich |
| trackingNo | Englisch Tracking number | Deutsch Sendungsnummer |
| copy | Englisch Copy tracking number | Deutsch Sendungsnummer kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| stages | Englisch Ordered,Packed,On the way,Delivered | Deutsch Bestellt,Verpackt,Unterwegs,Zugestellt |
| history | Englisch Tracking history | Deutsch Sendungsverlauf |
| items | Englisch Items | Deutsch Artikel |
| deliveryAddress | Englisch Delivery address | Deutsch Lieferadresse |
| billingAddress | Englisch Billing address | Deutsch Rechnungsadresse |
| payment | Englisch Payment | Deutsch Zahlung |
| summary | Englisch Summary | Deutsch Übersicht |
| subtotal | Englisch Subtotal | Deutsch Zwischensumme |
| shipping | Englisch Shipping | Deutsch Versand |
| free | Englisch Free | Deutsch Kostenlos |
| vat | Englisch VAT | Deutsch MwSt. |
| incl | Englisch incl. | Deutsch inkl. |
| qty | Englisch Qty | Deutsch Menge |
| Englisch Print or save as PDF | Deutsch Drucken oder als PDF sichern | |
| printShort | Englisch Print | Deutsch Drucken |
| download | Englisch Download | Deutsch Herunterladen |
| invoiceTitle | Englisch Invoice | Deutsch Rechnung |
| invoiceNo | Englisch Invoice no. | Deutsch Rechnungsnr. |
| invoiceDate | Englisch Invoice date | Deutsch Rechnungsdatum |
| serviceDate | Englisch Delivery date | Deutsch Lieferdatum |
| orderNo | Englisch Order no. | Deutsch Bestellnr. |
| customerNo | Englisch Customer no. | Deutsch Kundennr. |
| billTo | Englisch Bill to | Deutsch Rechnung an |
| pos | Englisch Pos. | Deutsch Pos. |
| description | Englisch Description | Deutsch Beschreibung |
| unitNet | Englisch Unit price net | Deutsch Einzelpreis netto |
| amountNet | Englisch Amount net | Deutsch Betrag netto |
| netTotal | Englisch Total net | Deutsch Summe netto |
| grossTotal | Englisch Total | Deutsch Gesamtbetrag |
| vatId | Englisch VAT ID | Deutsch USt-IdNr. |
| bank | Englisch Bank | Deutsch Bank |
| contact | Englisch Contact | Deutsch Kontakt |
| shippingLine | Englisch Shipping | Deutsch Versand |
| returnsTitle | Englisch Return items | Deutsch Artikel zurücksenden |
| returnsIntro | Englisch Pick what goes back. The label is free, the refund arrives within 5 days after we receive the parcel. | Deutsch Wähle aus, was zurückgeht. Das Etikett ist kostenlos, das Geld ist innerhalb von 5 Tagen nach Eingang des Pakets zurück. |
| returnBy | Englisch Return by | Deutsch Rücksendung bis |
| reason | Englisch Reason | Deutsch Grund |
| chooseReason | Englisch Choose a reason | Deutsch Grund auswählen |
| refundTo | Englisch Refund to | Deutsch Erstattung auf |
| original | Englisch Original payment | Deutsch Ursprüngliche Zahlart |
| originalNote | Englisch Back to the card you paid with | Deutsch Zurück auf die Karte, mit der du bezahlt hast |
| credit | Englisch Store credit | Deutsch Guthaben |
| creditNote | Englisch 10% extra, usable right away | Deutsch 10 % extra, sofort einlösbar |
| dropOff | Englisch How it goes back | Deutsch So geht es zurück |
| shop | Englisch Parcel shop | Deutsch Paketshop |
| shopNote | Englisch Show a QR code, no printer needed | Deutsch QR-Code vorzeigen, kein Drucker nötig |
| pickup | Englisch Pickup at your door | Deutsch Abholung an der Tür |
| pickupNote | Englisch Tomorrow between 8 and 18 | Deutsch Morgen zwischen 8 und 18 Uhr |
| refund | Englisch Your refund | Deutsch Deine Erstattung |
| bonus | Englisch Credit bonus | Deutsch Guthaben-Bonus |
| label | Englisch Return label | Deutsch Rücksendeetikett |
| request | Englisch Request return | Deutsch Rücksendung anmelden |
| requested | Englisch Return registered, label is on its way | Deutsch Angemeldet, das Etikett ist unterwegs |
| selectFirst | Englisch Pick at least one item to return. | Deutsch Wähle mindestens einen Artikel für die Rücksendung. |
| itemsSelected | Englisch items selected | Deutsch Artikel ausgewählt |
| itemSelected | Englisch item selected | Deutsch Artikel ausgewählt |
| subTitle | Englisch Subscription | Deutsch Abo |
| active | Englisch Active | Deutsch Aktiv |
| paused | Englisch Paused | Deutsch Pausiert |
| since | Englisch Since | Deutsch Seit |
| nextDelivery | Englisch Next delivery | Deutsch Nächste Lieferung |
| every | Englisch Every | Deutsch Alle |
| weeks | Englisch weeks | Deutsch Wochen |
| perDelivery | Englisch per delivery | Deutsch pro Lieferung |
| save | Englisch You save | Deutsch Du sparst |
| deliverNow | Englisch Deliver now | Deutsch Jetzt liefern |
| skip | Englisch Skip | Deutsch Aussetzen |
| skipped | Englisch Skipped | Deutsch Ausgesetzt |
| unskip | Englisch Undo skip | Deutsch Doch liefern |
| pause | Englisch Pause | Deutsch Pausieren |
| resume | Englisch Resume | Deutsch Fortsetzen |
| upcoming | Englisch Upcoming deliveries | Deutsch Nächste Lieferungen |
| pastDeliveries | Englisch Past deliveries | Deutsch Bisherige Lieferungen |
| manage | Englisch Manage | Deutsch Verwalten |
| cancel | Englisch Cancel subscription | Deutsch Abo kündigen |
| change | Englisch Change | Deutsch Ändern |
| frequency | Englisch Frequency | Deutsch Rhythmus |
| quantity | Englisch Bags per delivery | Deutsch Tüten pro Lieferung |
| pausedNote | Englisch No deliveries until you resume. Nothing is charged. | Deutsch Bis zum Fortsetzen keine Lieferungen. Es wird nichts abgebucht. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Bestellungen reagiert selbst auf die Tasten Pfeil links und Pfeil rechts.
- Setzt aria-disabled, aria-label, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, radiogroup, tab, tablist und tabpanel.
- 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. 8 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.267 Zeilen
'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} />;
}Häufige Fragen
Wie installiere ich Bestellungen?
Mit aiw add @ai-website/order. Der Befehl legt die Datei components/ui/order.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal und product-image. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Bestellungen?
5 Varianten, Bestellübersicht, Bestelldetail, Rechnung, Rücksendung und Abo. Du wählst sie mit der Prop variant, zum Beispiel variant="order-detail".
Kann ich die Texte von Bestellungen übersetzen?
Ja. Bestellungen bringt 104 eigene Texte auf Deutsch und Englisch mit, etwa „Bestellungen“, „Verfolgen, zurückschicken oder nachbestellen. Rechnungen liegen bereit, sobald eine Bestellung verschickt ist.“ und „Alle“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Bestellungen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Bestellungen mit aiw_components und installieren sie mit aiw_add @ai-website/order. Nenn im Prompt die Variante, etwa variant="order-history", dann schreibt der Assistent deine Inhalte direkt in die Props.