Skip to content
UI Kit
Accent color

Orders

Everything after the purchase, from order history and tracking to invoice, returns and subscriptions.

5 variantsOne of 3 components in Orders and returns

Variants

Order history

All orders with status, filters by tab and actions per order.

Order detail

Delivery stages, the full tracking history, items, addresses and totals.

Invoice

A complete invoice with net, VAT and gross, laid out for paper. Print hides everything else.

Returns

Pick items and reasons, choose refund and drop-off, see the refund add up.

Subscription

Next delivery, rhythm, quantity, skip and pause, with past deliveries.

Details

What Orders brings along, read from the registry and the source.

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

Props

The OrderProps interface from components/ui/order.tsx, with the comments from the code.

  • variantoptional
    OrderVariant

    order-historyorder-detailinvoicereturnssubscription

  • ordersoptional
    OrderRecord[]

    order-history

  • orderoptional
    OrderRecord

    order-detail and returns

  • invoiceoptional
    InvoiceData
  • subscriptionoptional
    SubscriptionData
  • returnReasonsoptional
    string[]

    returns

  • returnByoptional
    string
  • currencyoptional
    string

Built-in texts

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

Show all 104 texts
ordersEnglish OrdersGerman Bestellungen
ordersIntroEnglish 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.
allEnglish AllGerman Alle
openEnglish OpenGerman Offen
deliveredEnglish DeliveredGerman Zugestellt
returnedEnglish ReturnedGerman Retourniert
orderEnglish OrderGerman Bestellung
placedEnglish PlacedGerman Bestellt am
totalEnglish TotalGerman Summe
statusEnglish StatusGerman Status
processingEnglish ProcessingGerman In Bearbeitung
shippedEnglish On the wayGerman Unterwegs
cancelledEnglish CancelledGerman Storniert
viewOrderEnglish View orderGerman Bestellung ansehen
buyAgainEnglish Buy againGerman Erneut kaufen
invoiceEnglish InvoiceGerman Rechnung
moreEnglish moreGerman weitere
noOrdersEnglish No orders in this view.German In dieser Ansicht gibt es keine Bestellungen.
backToOrdersEnglish All ordersGerman Alle Bestellungen
helpEnglish Get helpGerman Hilfe erhalten
shipmentEnglish ShipmentGerman Sendung
etaEnglish ExpectedGerman Voraussichtlich
trackingNoEnglish Tracking numberGerman Sendungsnummer
copyEnglish Copy tracking numberGerman Sendungsnummer kopieren
copiedEnglish CopiedGerman Kopiert
stagesEnglish Ordered,Packed,On the way,DeliveredGerman Bestellt,Verpackt,Unterwegs,Zugestellt
historyEnglish Tracking historyGerman Sendungsverlauf
itemsEnglish ItemsGerman Artikel
deliveryAddressEnglish Delivery addressGerman Lieferadresse
billingAddressEnglish Billing addressGerman Rechnungsadresse
paymentEnglish PaymentGerman Zahlung
summaryEnglish SummaryGerman Übersicht
subtotalEnglish SubtotalGerman Zwischensumme
shippingEnglish ShippingGerman Versand
freeEnglish FreeGerman Kostenlos
vatEnglish VATGerman MwSt.
inclEnglish incl.German inkl.
qtyEnglish QtyGerman Menge
printEnglish Print or save as PDFGerman Drucken oder als PDF sichern
printShortEnglish PrintGerman Drucken
downloadEnglish DownloadGerman Herunterladen
invoiceTitleEnglish InvoiceGerman Rechnung
invoiceNoEnglish Invoice no.German Rechnungsnr.
invoiceDateEnglish Invoice dateGerman Rechnungsdatum
serviceDateEnglish Delivery dateGerman Lieferdatum
orderNoEnglish Order no.German Bestellnr.
customerNoEnglish Customer no.German Kundennr.
billToEnglish Bill toGerman Rechnung an
posEnglish Pos.German Pos.
descriptionEnglish DescriptionGerman Beschreibung
unitNetEnglish Unit price netGerman Einzelpreis netto
amountNetEnglish Amount netGerman Betrag netto
netTotalEnglish Total netGerman Summe netto
grossTotalEnglish TotalGerman Gesamtbetrag
vatIdEnglish VAT IDGerman USt-IdNr.
bankEnglish BankGerman Bank
contactEnglish ContactGerman Kontakt
shippingLineEnglish ShippingGerman Versand
returnsTitleEnglish Return itemsGerman Artikel zurücksenden
returnsIntroEnglish 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.
returnByEnglish Return byGerman Rücksendung bis
reasonEnglish ReasonGerman Grund
chooseReasonEnglish Choose a reasonGerman Grund auswählen
refundToEnglish Refund toGerman Erstattung auf
originalEnglish Original paymentGerman Ursprüngliche Zahlart
originalNoteEnglish Back to the card you paid withGerman Zurück auf die Karte, mit der du bezahlt hast
creditEnglish Store creditGerman Guthaben
creditNoteEnglish 10% extra, usable right awayGerman 10 % extra, sofort einlösbar
dropOffEnglish How it goes backGerman So geht es zurück
shopEnglish Parcel shopGerman Paketshop
shopNoteEnglish Show a QR code, no printer neededGerman QR-Code vorzeigen, kein Drucker nötig
pickupEnglish Pickup at your doorGerman Abholung an der Tür
pickupNoteEnglish Tomorrow between 8 and 18German Morgen zwischen 8 und 18 Uhr
refundEnglish Your refundGerman Deine Erstattung
bonusEnglish Credit bonusGerman Guthaben-Bonus
labelEnglish Return labelGerman Rücksendeetikett
requestEnglish Request returnGerman Rücksendung anmelden
requestedEnglish Return registered, label is on its wayGerman Angemeldet, das Etikett ist unterwegs
selectFirstEnglish Pick at least one item to return.German Wähle mindestens einen Artikel für die Rücksendung.
itemsSelectedEnglish items selectedGerman Artikel ausgewählt
itemSelectedEnglish item selectedGerman Artikel ausgewählt
subTitleEnglish SubscriptionGerman Abo
activeEnglish ActiveGerman Aktiv
pausedEnglish PausedGerman Pausiert
sinceEnglish SinceGerman Seit
nextDeliveryEnglish Next deliveryGerman Nächste Lieferung
everyEnglish EveryGerman Alle
weeksEnglish weeksGerman Wochen
perDeliveryEnglish per deliveryGerman pro Lieferung
saveEnglish You saveGerman Du sparst
deliverNowEnglish Deliver nowGerman Jetzt liefern
skipEnglish SkipGerman Aussetzen
skippedEnglish SkippedGerman Ausgesetzt
unskipEnglish Undo skipGerman Doch liefern
pauseEnglish PauseGerman Pausieren
resumeEnglish ResumeGerman Fortsetzen
upcomingEnglish Upcoming deliveriesGerman Nächste Lieferungen
pastDeliveriesEnglish Past deliveriesGerman Bisherige Lieferungen
manageEnglish ManageGerman Verwalten
cancelEnglish Cancel subscriptionGerman Abo kündigen
changeEnglish ChangeGerman Ändern
frequencyEnglish FrequencyGerman Rhythmus
quantityEnglish Bags per deliveryGerman Tüten pro Lieferung
pausedNoteEnglish 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
components/ui/order.tsx
'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.

Often searched

Jump to

Copied to the clipboard