Zum Inhalt springen
UI Kit
Accent

Sendungsverfolgung im Shop

Wo die Bestellung gerade ist. Ein Verlauf mit jeder Meldung, eine Karte mit Lieferwagen, eine Suche für Gäste und mehrere Pakete.

4 VariantsEine von 3 Components in Bestellungen und Retouren

Wo die Bestellung gerade ist

Nach dem Bezahlen ist die häufigste Frage, wo die Bestellung gerade ist. Eine Seite zur Sendungsverfolgung beantwortet sie im eigenen Shop, mit großem Lieferfenster und jeder Station in klaren Worten, der Support bekommt so weniger Nachfragen.

„Verlauf“ zeigt das Lieferfenster und fünf Stationen, die sich nacheinander füllen, die aktuelle pulsiert, darunter jede Meldung, eine SMS-Benachrichtigung zum Einschalten und die Sendungsnummer zum Kopieren. Die „Live-Karte“ lässt einen Lieferwagen auf einem gezeichneten Stadtplan vom Depot heranfahren, die Minuten zählen herunter und ein Balken zeigt die Stopps davor. Die „Suche für Gäste“ nimmt Bestellnummer und E-Mail oder Postleitzahl, jedes Feld mit eigener Meldung, was zu korrigieren ist. „Mehrere Pakete“ teilt eine Bestellung in Pakete als Reiter mit Pfeiltasten, jedes mit Station und Inhalt.

Verlinke die Seite aus jeder Versandmail, dann bleibt der Besuch im Shop. Stell das Lieferfenster nach vorn, danach suchen Menschen. Zeig mehrere Pakete als eine Bestellung, dann liest sich eine Teillieferung als Fortschritt.

Variants

VerlaufNeu in 1.2

Das Lieferfenster groß, fünf Etappen, die sich nacheinander füllen, die aktuelle pulsiert, darunter jede Meldung mit mehr auf Wunsch, eine SMS zum Einschalten und die kopierbare Sendungsnummer.

Live-KarteNeu in 1.2

Eine gezeichnete Stadtkarte, auf der der Lieferwagen vom Depot hereinfährt und an seiner Position hält. Die Minuten zählen herunter, ein Balken zeigt die Stopps davor, auf dem Desktop schwebt die Karte über der freien Seite.

Suche für GästeNeu in 1.2

Bestellnummer und E-Mail oder Postleitzahl, jedes Feld mit eigenem Fehler, der sagt, was zu tun ist. Das Ergebnis steigt mit Etappe, Lieferfenster und letzter Meldung auf.

Mehrere PaketeNeu in 1.2

Eine Bestellung in drei Paketen als Tabs mit Pfeiltasten. Jeder Tab zeigt Etappe, einen kleinen Balken und den Inhalt, das Feld darunter Lieferfenster, Etappen, Meldungen und Inhalt.

Details

Was Sendungsverfolgung mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/order-tracking.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 49 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle OrderTrackingProps aus components/ui/order-tracking.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    OrderTrackingVariant

    timelinemaplookupparcels

  • orderoptional
    TrackingOrder
  • ordersoptional
    TrackingOrder[]

    lookup: the orders the form can find. Or pass onLookup.

  • onLookupoptional
    (number: string, contact: string) => Promise<TrackingOrder | null> | TrackingOrder | null
  • liveoptional
    { stopsAway: number; minutes: number; driver?: string }

    map: stops before yours and minutes until arrival

  • linksoptional
    { help?: string; reschedule?: string }

    Links for the actions

Eingebaute Texte

Diese Texte schreibt Sendungsverfolgung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 49 Texte anzeigen
orderedEnglisch OrderedDeutsch Bestellt
packedEnglisch PackedDeutsch Verpackt
shippedEnglisch On its wayDeutsch Unterwegs
outEnglisch Out for deliveryDeutsch In Zustellung
deliveredEnglisch DeliveredDeutsch Zugestellt
orderEnglisch OrderDeutsch Bestellung
trackingEnglisch Tracking numberDeutsch Sendungsnummer
copyEnglisch Copy tracking numberDeutsch Sendungsnummer kopieren
copiedEnglisch CopiedDeutsch Kopiert
arrivesEnglisch ArrivesDeutsch Ankunft
arrivedEnglisch DeliveredDeutsch Zugestellt
betweenEnglisch between {from} and {to}Deutsch zwischen {from} und {to}
historyEnglisch Shipment historyDeutsch Sendungsverlauf
showAllEnglisch Show all {n} updatesDeutsch Alle {n} Meldungen zeigen
showLessEnglisch Show fewer updatesDeutsch Weniger Meldungen zeigen
notifyEnglisch Text me before it arrivesDeutsch Per SMS vorher Bescheid geben
notifyOnEnglisch We will text you about 30 minutes beforeDeutsch Wir schreiben dir etwa 30 Minuten vorher
rescheduleEnglisch Change deliveryDeutsch Zustellung ändern
helpEnglisch Get help with this orderDeutsch Hilfe zu dieser Bestellung
deliverToEnglisch Delivering toDeutsch Lieferung an
stopsAwayEnglisch {n} stops before yoursDeutsch Noch {n} Stopps vor dir
oneStopEnglisch One stop before yoursDeutsch Noch ein Stopp vor dir
nextStopEnglisch You are the next stopDeutsch Du bist der nächste Stopp
minutesEnglisch about {n} minDeutsch etwa {n} Min.
liveNoteEnglisch Live position, updated every minuteDeutsch Live-Position, jede Minute aktualisiert
driverEnglisch Your driverDeutsch Dein Fahrer
messageEnglisch MessageDeutsch Nachricht
depotEnglisch DepotDeutsch Depot
homeEnglisch YouDeutsch Du
lookupTitleEnglisch Track your orderDeutsch Bestellung verfolgen
lookupTextEnglisch Your order number is all it takes, you will find it in the confirmation email.Deutsch Deine Bestellnummer genügt, sie steht in der Bestätigungsmail.
numberEnglisch Order numberDeutsch Bestellnummer
numberHintEnglisch e.g. NH-48210Deutsch z. B. NH-48210
contactEnglisch Email or postcodeDeutsch E-Mail oder Postleitzahl
findEnglisch Track orderDeutsch Bestellung verfolgen
findingEnglisch SearchingDeutsch Wird gesucht
eNumberEnglisch Enter your order number, it starts with two letters.Deutsch Gib deine Bestellnummer ein, sie beginnt mit zwei Buchstaben.
eNumberFormatEnglisch Order numbers look like NH-48210. Check the confirmation email.Deutsch Bestellnummern sehen aus wie NH-48210. Schau in die Bestätigungsmail.
eContactEnglisch Enter the email of the order or the postcode of the delivery address.Deutsch Gib die E-Mail der Bestellung oder die Postleitzahl der Lieferadresse ein.
eNotFoundEnglisch We found no order with these details. Check the number and the email or postcode.Deutsch Zu diesen Angaben finden wir keine Bestellung. Prüf die Nummer und die E-Mail oder Postleitzahl.
anotherEnglisch Track another orderDeutsch Andere Bestellung verfolgen
latestEnglisch Latest updateDeutsch Letzte Meldung
parcelEnglisch ParcelDeutsch Paket
parcelsOfEnglisch {n} parcelsDeutsch {n} Pakete
insideEnglisch In this parcelDeutsch In diesem Paket
progressEnglisch ProgressDeutsch Fortschritt
currentEnglisch current stageDeutsch aktuelle Etappe
doneEnglisch doneDeutsch erledigt
nextEnglisch to comeDeutsch folgt

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Sendungsverfolgung reagiert selbst auf die Tasten Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-checked, aria-controls, aria-current, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen alert, img, status, switch, 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, 856 Zeilen
components/ui/order-tracking.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, formatTime, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { useShake } from '@/components/ui/input';
import { ProductMedia, ShopMotion } from '@/components/ui/product-image';

/*
 * Where is my order. Five stages from ordered to delivered.
 *
 *   timeline  the delivery window large, the stages as a bar (a column on phones), every scan below
 *   map       a drawn map with the route, the van on its way and the stops still ahead
 *   lookup    order number and email or postcode, then the status, for guests without an account
 *   parcels   an order in several parcels as tabs, each with its own progress and contents
 *
 * Times are "HH:MM" and dates "YYYY-MM-DD", both shown in the format of the language.
 */

export type OrderTrackingVariant = 'timeline' | 'map' | 'lookup' | 'parcels';
export type TrackingStage = 'ordered' | 'packed' | 'shipped' | 'out' | 'delivered';

export interface TrackingEvent {
  /** "YYYY-MM-DD" */
  date: string;
  /** "HH:MM" */
  time: string;
  text: string;
  place?: string;
  stage?: TrackingStage;
}

export interface TrackingItem {
  name: string;
  quantity?: number;
  variant?: string;
  image?: React.ReactNode;
}

export interface TrackingParcel {
  id: string;
  /** e.g. "Parcel 1" or what is inside */
  label?: string;
  trackingNumber: string;
  stage: TrackingStage;
  /** Expected delivery: a date and a window */
  eta?: { date: string; from?: string; to?: string };
  /** Newest first */
  events: TrackingEvent[];
  items?: TrackingItem[];
}

export interface TrackingOrder {
  number: string;
  /** For the lookup check */
  email?: string;
  postcode?: string;
  /** "YYYY-MM-DD" */
  placed?: string;
  /** Delivery address, one line */
  address?: string;
  parcels: TrackingParcel[];
}

export interface OrderTrackingProps {
  variant?: OrderTrackingVariant;
  order?: TrackingOrder;
  /** lookup: the orders the form can find. Or pass onLookup. */
  orders?: TrackingOrder[];
  onLookup?: (number: string, contact: string) => Promise<TrackingOrder | null> | TrackingOrder | null;
  /** map: stops before yours and minutes until arrival */
  live?: { stopsAway: number; minutes: number; driver?: string };
  /** Links for the actions */
  links?: { help?: string; reschedule?: string };
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    ordered: 'Ordered',
    packed: 'Packed',
    shipped: 'On its way',
    out: 'Out for delivery',
    delivered: 'Delivered',
    order: 'Order',
    tracking: 'Tracking number',
    copy: 'Copy tracking number',
    copied: 'Copied',
    arrives: 'Arrives',
    arrived: 'Delivered',
    between: 'between {from} and {to}',
    history: 'Shipment history',
    showAll: 'Show all {n} updates',
    showLess: 'Show fewer updates',
    notify: 'Text me before it arrives',
    notifyOn: 'We will text you about 30 minutes before',
    reschedule: 'Change delivery',
    help: 'Get help with this order',
    deliverTo: 'Delivering to',
    stopsAway: '{n} stops before yours',
    oneStop: 'One stop before yours',
    nextStop: 'You are the next stop',
    minutes: 'about {n} min',
    liveNote: 'Live position, updated every minute',
    driver: 'Your driver',
    message: 'Message',
    depot: 'Depot',
    home: 'You',
    lookupTitle: 'Track your order',
    lookupText: 'Your order number is all it takes, you will find it in the confirmation email.',
    number: 'Order number',
    numberHint: 'e.g. NH-48210',
    contact: 'Email or postcode',
    find: 'Track order',
    finding: 'Searching',
    eNumber: 'Enter your order number, it starts with two letters.',
    eNumberFormat: 'Order numbers look like NH-48210. Check the confirmation email.',
    eContact: 'Enter the email of the order or the postcode of the delivery address.',
    eNotFound: 'We found no order with these details. Check the number and the email or postcode.',
    another: 'Track another order',
    latest: 'Latest update',
    parcel: 'Parcel',
    parcelsOf: '{n} parcels',
    inside: 'In this parcel',
    progress: 'Progress',
    current: 'current stage',
    done: 'done',
    next: 'to come',
  },
  de: {
    ordered: 'Bestellt',
    packed: 'Verpackt',
    shipped: 'Unterwegs',
    out: 'In Zustellung',
    delivered: 'Zugestellt',
    order: 'Bestellung',
    tracking: 'Sendungsnummer',
    copy: 'Sendungsnummer kopieren',
    copied: 'Kopiert',
    arrives: 'Ankunft',
    arrived: 'Zugestellt',
    between: 'zwischen {from} und {to}',
    history: 'Sendungsverlauf',
    showAll: 'Alle {n} Meldungen zeigen',
    showLess: 'Weniger Meldungen zeigen',
    notify: 'Per SMS vorher Bescheid geben',
    notifyOn: 'Wir schreiben dir etwa 30 Minuten vorher',
    reschedule: 'Zustellung ändern',
    help: 'Hilfe zu dieser Bestellung',
    deliverTo: 'Lieferung an',
    stopsAway: 'Noch {n} Stopps vor dir',
    oneStop: 'Noch ein Stopp vor dir',
    nextStop: 'Du bist der nächste Stopp',
    minutes: 'etwa {n} Min.',
    liveNote: 'Live-Position, jede Minute aktualisiert',
    driver: 'Dein Fahrer',
    message: 'Nachricht',
    depot: 'Depot',
    home: 'Du',
    lookupTitle: 'Bestellung verfolgen',
    lookupText: 'Deine Bestellnummer genügt, sie steht in der Bestätigungsmail.',
    number: 'Bestellnummer',
    numberHint: 'z. B. NH-48210',
    contact: 'E-Mail oder Postleitzahl',
    find: 'Bestellung verfolgen',
    finding: 'Wird gesucht',
    eNumber: 'Gib deine Bestellnummer ein, sie beginnt mit zwei Buchstaben.',
    eNumberFormat: 'Bestellnummern sehen aus wie NH-48210. Schau in die Bestätigungsmail.',
    eContact: 'Gib die E-Mail der Bestellung oder die Postleitzahl der Lieferadresse ein.',
    eNotFound: 'Zu diesen Angaben finden wir keine Bestellung. Prüf die Nummer und die E-Mail oder Postleitzahl.',
    another: 'Andere Bestellung verfolgen',
    latest: 'Letzte Meldung',
    parcel: 'Paket',
    parcelsOf: '{n} Pakete',
    inside: 'In diesem Paket',
    progress: 'Fortschritt',
    current: 'aktuelle Etappe',
    done: 'erledigt',
    next: 'folgt',
  },
};

type Texts = (typeof TEXTS)['en'];

const STAGES: TrackingStage[] = ['ordered', 'packed', 'shipped', 'out', 'delivered'];
const STAGE_ICON: Record<TrackingStage, IconName> = { ordered: 'receipt', packed: 'package', shipped: 'truck', out: 'map-pin', delivered: 'home' };
const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const idx = (s: TrackingStage) => STAGES.indexOf(s);

function formatDate(locale: Locale, iso: string, style: 'long' | 'short' = 'short') {
  const d = new Date(iso + 'T12:00:00Z');
  return new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', style === 'long' ? { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' } : { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }).format(d);
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

/** The five stages as a bar from 768 px and as a column below */
function Stages({ stage, t, compact }: { stage: TrackingStage; t: Texts; compact?: boolean }) {
  const at = idx(stage);
  const [ref, inView] = useInView<HTMLOListElement>();
  const pct = (at / (STAGES.length - 1)) * 100;
  return (
    <ol ref={ref} aria-label={t.progress} className={cn('relative', compact ? 'grid grid-cols-5' : 'max-md:space-y-0 md:grid md:grid-cols-5')}>
      {/* The line under the dots: across from 768 px, down on phones */}
      <span aria-hidden className={cn('absolute bg-track', compact ? 'left-[10%] right-[10%] top-4 h-0.5' : 'bottom-5 left-[19px] top-5 w-0.5 md:bottom-auto md:left-[10%] md:right-[10%] md:top-5 md:h-0.5 md:w-auto')}>
        <span
          className={cn('absolute left-0 top-0 bg-primary transition-[width,height] duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none', compact ? 'h-full' : 'w-full md:h-full md:w-auto max-md:hidden')}
          style={{ width: `${inView ? pct : 0}%` }}
        />
        {!compact && <span className="absolute left-0 top-0 w-full bg-primary transition-[height] duration-[1200ms] ease-[var(--ease-kit)] motion-reduce:transition-none md:hidden" style={{ height: `${inView ? pct : 0}%` }} />}
      </span>
      {STAGES.map((s, i) => {
        const done = i < at;
        const now = i === at;
        return (
          <li key={s} className={cn('relative flex items-center gap-4', compact ? 'flex-col gap-2 text-center' : 'py-2 md:flex-col md:gap-3 md:py-0 md:text-center')} aria-current={now ? 'step' : undefined}>
            <span
              className={cn(
                'relative grid shrink-0 place-items-center rounded-full border-2 transition-[background-color,border-color,color] duration-500',
                compact ? 'size-8' : 'size-10',
                done || now ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background text-muted-foreground',
              )}
              style={{ transitionDelay: inView ? `${200 + i * 220}ms` : '0ms' }}
            >
              {now && i < STAGES.length - 1 && <span aria-hidden className="absolute -inset-1.5 animate-kit-pulse rounded-full border-2 border-primary/40 motion-reduce:animate-none" />}
              <Icon name={done ? 'check' : STAGE_ICON[s]} size={compact ? 14 : 17} strokeWidth={done ? 2.4 : 1.8} />
            </span>
            <span className={cn('text-[13px] leading-tight', compact && 'max-sm:text-[11px]', now ? 'font-semibold text-foreground' : done ? 'text-foreground' : 'text-muted-foreground')}>
              {t[s]}
              <span className="sr-only">, {now ? t.current : done ? t.done : t.next}</span>
            </span>
          </li>
        );
      })}
    </ol>
  );
}

function CopyNumber({ value, t }: { value: string; t: Texts }) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(timer.current), []);
  return (
    <span className="inline-flex items-center gap-1">
      <span className="text-[13px] tabular-nums tracking-[0.04em] text-foreground">{value}</span>
      <button
        type="button"
        onClick={() => {
          navigator.clipboard?.writeText(value).catch(() => {});
          setCopied(true);
          clearTimeout(timer.current);
          timer.current = setTimeout(() => setCopied(false), 1600);
        }}
        aria-label={copied ? t.copied : t.copy}
        className="relative grid size-8 place-items-center rounded-md text-muted-foreground transition-colors after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground"
      >
        <Icon name={copied ? 'check' : 'copy'} size={14} className={cn(copied && 'animate-[kitshop-pop_0.35s_var(--ease-kit)] text-success')} />
      </button>
      <span role="status" className="sr-only">
        {copied ? t.copied : ''}
      </span>
    </span>
  );
}

function Eta({ parcel, t, size = 'lg' }: { parcel: TrackingParcel; t: Texts; size?: 'lg' | 'md' }) {
  const locale = useLocale();
  const delivered = parcel.stage === 'delivered';
  const e = parcel.eta;
  const last = parcel.events[0];
  return (
    <div>
      <p className="text-[13px] font-medium text-muted-foreground">{delivered ? t.arrived : t.arrives}</p>
      <p className={cn('mt-1 text-balance font-semibold tracking-[-0.02em] text-foreground', size === 'lg' ? 'text-h3' : 'text-[19px]')}>
        {delivered && last ? formatDate(locale, last.date, 'long') : e ? formatDate(locale, e.date, 'long') : ''}
      </p>
      {!delivered && e?.from && e.to && <p className="mt-1 text-[15px] text-foreground">{fill(t.between, { from: formatTime(e.from, locale), to: formatTime(e.to, locale) })}</p>}
      {delivered && last && <p className="mt-1 text-[15px] text-foreground">{formatTime(last.time, locale)}{last.place ? ` · ${last.place}` : ''}</p>}
    </div>
  );
}

function Events({ events, t, initial = 3 }: { events: TrackingEvent[]; t: Texts; initial?: number }) {
  const locale = useLocale();
  const [all, setAll] = useState(false);
  const shown = all ? events : events.slice(0, initial);
  const list = useId();
  return (
    <div>
      <ol id={list} className="relative">
        {shown.map((e, i) => {
          const day = i === 0 || shown[i - 1].date !== e.date;
          return (
            <li key={`${e.date}${e.time}${i}`} className={cn('relative grid grid-cols-[4.75rem_1.5rem_minmax(0,1fr)] gap-x-2 sm:grid-cols-[7.5rem_1.5rem_minmax(0,1fr)] sm:gap-x-3', i >= initial && 'animate-[kitshop-in_0.4s_var(--ease-kit)_both]')} style={i >= initial ? { animationDelay: `${(i - initial) * 50}ms` } : undefined}>
              <span className="pt-0.5 text-right text-[12px] leading-tight tabular-nums text-muted-foreground">
                {day && <span className="block font-medium text-foreground">{formatDate(locale, e.date)}</span>}
                {formatTime(e.time, locale)}
              </span>
              <span className="relative flex justify-center" aria-hidden>
                {i < shown.length - 1 && <span className="absolute bottom-0 top-3 w-px bg-border" />}
                <span className={cn('relative mt-1 size-2.5 rounded-full', i === 0 ? 'bg-primary ring-4 ring-[color-mix(in_oklab,var(--primary)_18%,transparent)]' : 'bg-border-strong')} />
              </span>
              <span className="pb-5">
                <span className={cn('block text-[14px] leading-snug', i === 0 ? 'font-medium text-foreground' : 'text-foreground')}>{e.text}</span>
                {e.place && <span className="mt-0.5 block text-[12px] text-muted-foreground">{e.place}</span>}
              </span>
            </li>
          );
        })}
      </ol>
      {events.length > initial && (
        <button type="button" onClick={() => setAll(!all)} aria-expanded={all} aria-controls={list} className="ml-[5.25rem] inline-flex h-10 items-center gap-1.5 rounded-md px-2 text-[13px] font-medium text-foreground hover:bg-accent max-sm:h-11 sm:ml-[8.75rem]">
          {all ? t.showLess : fill(t.showAll, { n: events.length })}
          <Icon name="chevron-down" size={15} className={cn('transition-transform motion-reduce:transition-none', all && 'rotate-180')} />
        </button>
      )}
    </div>
  );
}

function NotifySwitch({ t }: { t: Texts }) {
  const [on, setOn] = useState(false);
  const id = useId();
  return (
    <div className="flex items-start justify-between gap-4">
      <div>
        <p id={id} className="text-[14px] font-medium text-foreground">
          {t.notify}
        </p>
        <p className={cn('mt-0.5 text-[12px] text-muted-foreground transition-opacity', !on && 'opacity-0')} aria-hidden={!on}>
          {t.notifyOn}
        </p>
      </div>
      <button
        type="button"
        role="switch"
        aria-checked={on}
        aria-labelledby={id}
        onClick={() => setOn(!on)}
        className={cn('relative mt-0.5 h-7 w-12 shrink-0 rounded-full transition-colors duration-300 after:absolute after:-inset-2', on ? 'bg-primary' : 'bg-track')}
      >
        <span className={cn('absolute left-0.5 top-0.5 size-6 rounded-full bg-white shadow-[var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', on && 'translate-x-5')} />
      </button>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* timeline                                                            */
/* ------------------------------------------------------------------ */

function Timeline(props: OrderTrackingProps & { t: Texts }) {
  const { t } = props;
  const order = props.order;
  const p = order?.parcels[0];
  if (!order || !p) return null;
  return (
    <section className={cn('py-12 sm:py-16 lg:py-20', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <div className="mx-auto max-w-[58rem]">
          <div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-2">
            <p className="text-[13px] text-muted-foreground">
              {t.order} <span className="font-medium tabular-nums text-foreground">{order.number}</span>
            </p>
            <p className="flex items-center gap-2 text-[13px] text-muted-foreground">
              {t.tracking} <CopyNumber value={p.trackingNumber} t={t} />
            </p>
          </div>
          <div className="mt-4 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
            <div className="grid grid-cols-1 gap-6 p-5 sm:p-8 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
              <div className="kit-rise">
                <Eta parcel={p} t={t} />
              </div>
              {order.address && (
                <p className="kit-rise flex items-start gap-2 text-[13px] text-muted-foreground md:max-w-[16rem] md:text-right" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
                  <Icon name="map-pin" size={15} className="mt-0.5 shrink-0 md:order-2" />
                  <span>
                    <span className="block font-medium text-foreground">{t.deliverTo}</span>
                    {order.address}
                  </span>
                </p>
              )}
            </div>
            <div className="border-t border-border px-5 py-6 sm:px-8 sm:py-8">
              <Stages stage={p.stage} t={t} />
            </div>
          </div>
          <div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_18rem] lg:gap-12">
            <div>
              <h2 className="mb-5 text-[16px] font-semibold text-foreground">{t.history}</h2>
              <Events events={p.events} t={t} />
            </div>
            <aside className="space-y-5 rounded-2xl border border-border bg-surface p-5 lg:self-start">
              <NotifySwitch t={t} />
              <div className="grid gap-2 border-t border-border pt-5">
                <Button href={props.links?.reschedule ?? '#'} variant="outline" iconLeft="calendar" className="w-full">
                  {t.reschedule}
                </Button>
                <Button href={props.links?.help ?? '#'} variant="ghost" iconLeft="help-circle" className="w-full">
                  {t.help}
                </Button>
              </div>
            </aside>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* map                                                                 */
/* ------------------------------------------------------------------ */

const ROUTE: [number, number][] = [
  [118, 382], [118, 300], [250, 300], [250, 196], [410, 196], [410, 120], [560, 120], [560, 236], [682, 236], [682, 150],
];

function pointAt(f: number) {
  const segs = ROUTE.slice(1).map((p, i) => Math.hypot(p[0] - ROUTE[i][0], p[1] - ROUTE[i][1]));
  const total = segs.reduce((a, b) => a + b, 0);
  let left = Math.max(0, Math.min(1, f)) * total;
  for (let i = 0; i < segs.length; i++) {
    if (left <= segs[i]) {
      const k = segs[i] ? left / segs[i] : 0;
      const [x0, y0] = ROUTE[i];
      const [x1, y1] = ROUTE[i + 1];
      return { x: round(x0 + (x1 - x0) * k, 1), y: round(y0 + (y1 - y0) * k, 1), dx: x1 - x0, dy: y1 - y0 };
    }
    left -= segs[i];
  }
  const [x, y] = ROUTE[ROUTE.length - 1];
  return { x, y, dx: 1, dy: 0 };
}

/** The drawing, in map units. Roads and water reach past the frame, so a wider or taller window never shows an edge. */
function MapDrawing({ f, t, viewBox, labelScale = 1 }: { f: number; t: Texts; viewBox: string; labelScale?: number }) {
  const at = pointAt(f);
  const pts = ROUTE.map((p) => p.join(' ')).join(' L');
  const block = { fill: 'color-mix(in oklab, var(--foreground) 5%, var(--surface-2))' };
  const park = { fill: 'color-mix(in oklab, var(--success) 15%, var(--surface-2))' };
  const road = { fill: 'none', stroke: 'var(--background)', strokeLinecap: 'round' as const };
  const [dx, dy] = ROUTE[0];
  const [hx, hy] = ROUTE[ROUTE.length - 1];
  const label = (x: number, y: number, text: string, strong?: boolean) => {
    const w = Math.round(text.length * 7.4 + 20);
    return (
      <g transform={`translate(${x} ${y}) scale(${labelScale})`}>
        <rect x={-w / 2} y="-13" width={w} height="26" rx="7" style={{ fill: strong ? 'var(--primary)' : 'var(--background)', filter: 'drop-shadow(0 3px 6px rgb(0 0 0 / 0.2))' }} />
        <text y="4.5" textAnchor="middle" fontSize="13" fontWeight={strong ? 700 : 600} style={{ fill: strong ? 'var(--primary-foreground)' : 'var(--foreground)' }}>
          {text}
        </text>
      </g>
    );
  };
  return (
    <svg viewBox={viewBox} preserveAspectRatio="xMidYMid meet" className="absolute inset-0 size-full">
      <rect x="-800" y="-400" width="2400" height="1300" style={{ fill: 'var(--surface-2)' }} />
      <path d="M-800 430 C -300 380, -100 470, 140 420 S 300 470, 520 420 S 760 380, 1600 400 V 900 H -800 Z" style={{ fill: 'color-mix(in oklab, var(--primary) 16%, var(--surface-2))' }} />
      <rect x="300" y="230" width="90" height="56" rx="10" style={park} />
      <circle cx="740" cy="60" r="70" style={park} />
      <rect x="-200" y="120" width="120" height="70" rx="12" style={park} />
      {[-60, 60, 120, 196, 236, 300, 360].map((y) => <path key={`h${y}`} d={`M-800 ${y} H1600`} style={{ ...road, strokeWidth: y === 196 ? 13 : 7 }} />)}
      {[-250, -150, -40, 40, 118, 190, 250, 330, 410, 480, 560, 620, 682, 750, 840, 930].map((x) => <path key={`v${x}`} d={`M${x} -400 V430`} style={{ ...road, strokeWidth: x === 410 ? 13 : 7 }} />)}
      {[[136, 316, 40, 28], [266, 132, 50, 48], [424, 132, 40, 48], [496, 246, 50, 40], [576, 132, 30, 38], [636, 246, 32, 40], [200, 246, 36, 40], [700, 166, 36, 52], [496, 316, 110, 30], [136, 132, 40, 48], [-120, 246, 60, 40], [-20, 132, 44, 48], [770, 246, 50, 40], [860, 132, 50, 48]].map(([x, y, w, h], i) => (
        <rect key={i} x={x} y={y} width={w} height={h} rx="4" style={block} />
      ))}
      <path d={`M${pts}`} fill="none" strokeWidth="6" strokeLinejoin="round" strokeLinecap="round" strokeDasharray="0.012 0.012" pathLength={1} style={{ stroke: 'color-mix(in oklab, var(--primary) 45%, var(--surface-2))' }} />
      <path d={`M${pts}`} fill="none" strokeWidth="6" strokeLinejoin="round" strokeLinecap="round" pathLength={1} strokeDasharray={`${round(f, 4)} 1`} style={{ stroke: 'var(--primary)' }} />
      <g transform={`translate(${dx} ${dy})`}>
        <rect x="-14" y="-14" width="28" height="28" rx="7" style={{ fill: 'var(--foreground)' }} />
        <path d="M-7 4V-2l7-5 7 5v6Z" fill="none" strokeWidth="2" strokeLinejoin="round" style={{ stroke: 'var(--background)' }} />
      </g>
      {label(dx, dy + 20 + 14 * labelScale, t.depot)}
      <g transform={`translate(${hx} ${hy})`}>
        <circle r="30" className="animate-kit-pulse motion-reduce:animate-none" style={{ fill: 'color-mix(in oklab, var(--primary) 22%, transparent)' }} />
        <circle r="13" style={{ fill: 'var(--primary)', stroke: 'var(--background)', strokeWidth: 4 }} />
      </g>
      {label(hx, hy - 32 - 14 * labelScale, t.home, true)}
      <g transform={`translate(${at.x} ${at.y})`}>
        <circle r="21" style={{ fill: 'var(--background)', filter: 'drop-shadow(0 4px 8px rgb(0 0 0 / 0.25))' }} />
        <g transform={at.dx < 0 ? 'scale(-1 1)' : undefined}>
          <path d="M-11 5V-6h12l5 5h4v6Z" strokeWidth="2.2" strokeLinejoin="round" fill="none" style={{ stroke: 'var(--foreground)' }} />
          <circle cx="-6" cy="6.5" r="2.6" style={{ fill: 'var(--foreground)' }} />
          <circle cx="6" cy="6.5" r="2.6" style={{ fill: 'var(--foreground)' }} />
        </g>
      </g>
    </svg>
  );
}

function RouteMap({ progress, t }: { progress: number; t: Texts }) {
  const [ref, inView] = useInView<HTMLDivElement>();
  const [f, setF] = useState(progress);
  // Drive in from the depot once the map is on screen
  useEffect(() => {
    if (!inView || prefersReducedMotion()) return setF(progress);
    let raf = 0;
    const t0 = performance.now();
    const from = Math.max(0, progress - 0.35);
    const tick = (now: number) => {
      const k = Math.min(1, (now - t0) / 1800);
      setF(from + (progress - from) * (1 - Math.pow(1 - k, 3)));
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [inView, progress]);
  return (
    <div ref={ref} data-clip-ok className="relative size-full overflow-hidden bg-surface-2" role="img" aria-label={`${t.depot} → ${t.home}`}>
      {/* Below 1024 px the route fills the frame. From 1024 px it moves right, the card stands on the free left side. */}
      <div className="absolute inset-0 lg:hidden">
        <MapDrawing f={f} t={t} viewBox="70 50 670 380" labelScale={1.7} />
      </div>
      <div className="absolute inset-0 max-lg:hidden">
        <MapDrawing f={f} t={t} viewBox="-420 40 1160 400" />
      </div>
    </div>
  );
}

function MapVariant(props: OrderTrackingProps & { t: Texts }) {
  const { t } = props;
  const locale = useLocale();
  const order = props.order;
  const p = order?.parcels[0];
  const live = props.live ?? { stopsAway: 3, minutes: 25 };
  // The minutes count down while the page is open, once a minute
  const [minutes, setMinutes] = useState(live.minutes);
  useEffect(() => {
    setMinutes(live.minutes);
    const id = setInterval(() => setMinutes((m) => Math.max(1, m - 1)), 60_000);
    return () => clearInterval(id);
  }, [live.minutes]);
  if (!order || !p) return null;
  const stops = live.stopsAway;
  const progress = Math.max(0.2, Math.min(0.94, 1 - stops * 0.07 - 0.12));
  const card = (
    <div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-6">
      <p className="inline-flex items-center gap-2 text-[12px] font-medium text-success-foreground">
        <span className="relative flex size-2">
          <span className="absolute inset-0 animate-ping rounded-full bg-success opacity-60 motion-reduce:animate-none" />
          <span className="relative size-2 rounded-full bg-success" />
        </span>
        {t.out}
      </p>
      <p className="mt-3 text-h3 font-semibold tracking-[-0.02em] text-foreground">
        <span className="tabular-nums">{fill(t.minutes, { n: minutes })}</span>
      </p>
      <p className="mt-1 text-[14px] text-foreground">{stops > 1 ? fill(t.stopsAway, { n: stops }) : stops === 1 ? t.oneStop : t.nextStop}</p>
      <div className="mt-4 flex gap-1" aria-hidden>
        {Array.from({ length: Math.max(1, stops) + 1 }, (_, i) => (
          <span key={i} className={cn('h-1.5 flex-1 rounded-full', i === Math.max(1, stops) ? 'bg-primary' : 'bg-track')} />
        ))}
      </div>
      {live.driver && (
        <div className="mt-5 flex items-center gap-3 border-t border-border pt-4">
          <span className="grid size-10 place-items-center rounded-full bg-muted text-[14px] font-semibold text-foreground">{live.driver.slice(0, 1)}</span>
          <div className="min-w-0 flex-1">
            <p className="text-[12px] text-muted-foreground">{t.driver}</p>
            <p className="truncate text-[14px] font-medium text-foreground">{live.driver}</p>
          </div>
          <Button href={props.links?.help ?? '#'} variant="outline" size="sm" iconLeft="chat">
            {t.message}
          </Button>
        </div>
      )}
      <div className="mt-5 border-t border-border pt-4 text-[13px] text-muted-foreground">
        <p className="flex items-center justify-between gap-3">
          <span>{t.order}</span>
          <span className="font-medium tabular-nums text-foreground">{order.number}</span>
        </p>
        {p.eta?.from && p.eta.to && (
          <p className="mt-1.5 flex items-center justify-between gap-3">
            <span>{t.arrives}</span>
            <span className="text-right text-foreground">{fill(t.between, { from: formatTime(p.eta.from, locale), to: formatTime(p.eta.to, locale) })}</span>
          </p>
        )}
      </div>
      <p className="mt-4 text-[11px] text-subtle-foreground">{t.liveNote}</p>
    </div>
  );
  return (
    <section className={cn('py-10 sm:py-14 lg:py-16', props.className)}>
      <div className="kit-container">
        {/* Desktop: the card floats over the map's free left side. Below 1024 px it follows the map. */}
        <div className="relative overflow-hidden rounded-2xl border border-border aspect-[16/10] max-sm:aspect-[4/3] lg:aspect-auto lg:h-[34rem]">
          <RouteMap progress={progress} t={t} />
          <div className="absolute bottom-6 left-6 top-6 hidden w-[21rem] flex-col justify-end lg:flex">{card}</div>
        </div>
        <div className="mt-4 lg:hidden">{card}</div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* lookup                                                              */
/* ------------------------------------------------------------------ */

function Lookup(props: OrderTrackingProps & { t: Texts }) {
  const { t } = props;
  const locale = useLocale();
  const id = useId();
  const [number, setNumber] = useState('');
  const [contact, setContact] = useState('');
  const [errors, setErrors] = useState<{ number?: string; contact?: string; form?: string }>({});
  const [busy, setBusy] = useState(false);
  const [found, setFound] = useState<TrackingOrder | null>(null);
  const form = useRef<HTMLFormElement>(null);
  const result = useRef<HTMLDivElement>(null);
  useShake(form, !!(errors.number || errors.contact || errors.form));
  const submit = async (e: React.FormEvent) => {
    e.preventDefault();
    const n = number.trim().toUpperCase();
    const next: typeof errors = {};
    if (!n) next.number = t.eNumber;
    else if (!/^[A-Z]{2}-?\d{4,8}$/.test(n)) next.number = t.eNumberFormat;
    if (!contact.trim()) next.contact = t.eContact;
    setErrors(next);
    if (next.number || next.contact) {
      requestAnimationFrame(() => form.current?.querySelector<HTMLInputElement>('[aria-invalid="true"]')?.focus());
      return;
    }
    setBusy(true);
    let o: TrackingOrder | null | undefined;
    if (props.onLookup) o = await props.onLookup(n, contact.trim());
    else {
      await new Promise((r) => setTimeout(r, 600));
      const c = contact.trim().toLowerCase();
      o = props.orders?.find((x) => x.number.replace('-', '') === n.replace('-', '') && (x.email?.toLowerCase() === c || x.postcode?.toLowerCase() === c));
    }
    setBusy(false);
    if (!o) return setErrors({ form: t.eNotFound });
    setFound(o);
    requestAnimationFrame(() => result.current?.focus());
  };
  const field = 'h-12 w-full rounded-lg border border-input bg-background px-4 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] sm:text-[15px]';
  const p = found?.parcels[0];
  return (
    <section className={cn('kit-glow py-14 sm:py-20 lg:py-24', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <div className="mx-auto max-w-[34rem]">
          {found && p ? (
            <div ref={result} tabIndex={-1} className="animate-[kitshop-zoom_0.5s_var(--ease-kit)] rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] outline-none sm:p-7">
              <div className="flex items-start justify-between gap-4">
                <div>
                  <p className="text-[13px] text-muted-foreground">
                    {t.order} <span className="font-medium tabular-nums text-foreground">{found.number}</span>
                  </p>
                  <div className="mt-3">
                    <Eta parcel={p} t={t} size="md" />
                  </div>
                </div>
                <span className="grid size-12 shrink-0 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-primary">
                  <Icon name={STAGE_ICON[p.stage]} size={22} />
                </span>
              </div>
              <div className="mt-6">
                <Stages stage={p.stage} t={t} compact />
              </div>
              {p.events[0] && (
                <div className="mt-6 rounded-xl bg-surface-2 p-4">
                  <p className="text-[12px] font-medium text-muted-foreground">{t.latest}</p>
                  <p className="mt-1 text-[14px] font-medium text-foreground">{p.events[0].text}</p>
                  <p className="mt-0.5 text-[12px] text-muted-foreground">
                    {formatDate(locale, p.events[0].date)} · {formatTime(p.events[0].time, locale)}
                    {p.events[0].place ? ` · ${p.events[0].place}` : ''}
                  </p>
                </div>
              )}
              <div className="mt-6 flex flex-col gap-2 sm:flex-row">
                <Button href={props.links?.help ?? '#'} variant="outline" className="flex-1">
                  {t.help}
                </Button>
                <Button variant="ghost" iconLeft="arrow-left" onClick={() => { setFound(null); setNumber(''); setContact(''); }} className="flex-1">
                  {t.another}
                </Button>
              </div>
            </div>
          ) : (
            <div className="rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
              <span className="grid size-12 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-primary">
                <Icon name="package" size={22} />
              </span>
              <h2 className="mt-5 text-h3 font-semibold text-foreground">{t.lookupTitle}</h2>
              <p className="mt-2 text-pretty text-[15px] text-muted-foreground">{t.lookupText}</p>
              <form ref={form} onSubmit={submit} noValidate className="mt-6 space-y-4">
                <div>
                  <label htmlFor={`${id}-n`} className="mb-1.5 block text-[13px] font-medium text-foreground">
                    {t.number}
                  </label>
                  <input id={`${id}-n`} value={number} onChange={(e) => { setNumber(e.target.value); setErrors({}); }} placeholder={t.numberHint} autoComplete="off" autoCapitalize="characters" aria-invalid={!!errors.number || undefined} aria-describedby={errors.number ? `${id}-ne` : undefined} className={cn(field, 'uppercase placeholder:normal-case')} />
                  {errors.number && <p id={`${id}-ne`} className="mt-1.5 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{errors.number}</p>}
                </div>
                <div>
                  <label htmlFor={`${id}-c`} className="mb-1.5 block text-[13px] font-medium text-foreground">
                    {t.contact}
                  </label>
                  <input id={`${id}-c`} value={contact} onChange={(e) => { setContact(e.target.value); setErrors({}); }} autoComplete="email" aria-invalid={!!errors.contact || undefined} aria-describedby={errors.contact ? `${id}-ce` : undefined} className={field} />
                  {errors.contact && <p id={`${id}-ce`} className="mt-1.5 animate-[kitshop-in_0.3s_var(--ease-kit)] text-[12px] text-destructive">{errors.contact}</p>}
                </div>
                {errors.form && (
                  <p role="alert" className="flex animate-[kitshop-in_0.3s_var(--ease-kit)] gap-2 rounded-lg bg-[color-mix(in_oklab,var(--destructive)_9%,var(--card))] px-3 py-2.5 text-[13px] text-foreground">
                    <Icon name="alert-circle" size={16} className="mt-0.5 shrink-0 text-destructive" />
                    {errors.form}
                  </p>
                )}
                <Button type="submit" size="lg" disabled={busy} icon={busy ? undefined : 'arrow-right'} className="w-full">
                  {busy && <Icon name="loader" size={17} className="animate-kit-spin motion-reduce:animate-none" />}
                  {busy ? t.finding : t.find}
                </Button>
              </form>
            </div>
          )}
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* parcels                                                             */
/* ------------------------------------------------------------------ */

function Parcels(props: OrderTrackingProps & { t: Texts }) {
  const { t } = props;
  const order = props.order;
  const [active, setActive] = useState(0);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const base = useId();
  if (!order) return null;
  const parcels = order.parcels;
  const p = parcels[Math.min(active, parcels.length - 1)];
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const n = parcels.length;
    const go = e.key === 'ArrowRight' ? (i + 1) % n : e.key === 'ArrowLeft' ? (i - 1 + n) % n : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1;
    if (go < 0) return;
    e.preventDefault();
    setActive(go);
    tabs.current[go]?.focus();
  };
  return (
    <section className={cn('py-12 sm:py-16 lg:py-20', props.className)}>
      <ShopMotion />
      <div className="kit-container">
        <div className="mx-auto max-w-[60rem]">
          <div className="flex flex-wrap items-end justify-between gap-3">
            <div>
              <p className="text-[13px] text-muted-foreground">
                {t.order} <span className="font-medium tabular-nums text-foreground">{order.number}</span>
              </p>
              <h2 className="mt-1 text-h3 font-semibold text-foreground">{fill(t.parcelsOf, { n: parcels.length })}</h2>
            </div>
          </div>
          <div role="tablist" aria-label={fill(t.parcelsOf, { n: parcels.length })} className="mt-6 grid grid-cols-1 gap-2 sm:grid-cols-3 sm:gap-3">
            {parcels.map((x, i) => {
              const on = i === active;
              const pct = (idx(x.stage) / (STAGES.length - 1)) * 100;
              return (
                <button
                  key={x.id}
                  ref={(el) => { tabs.current[i] = el; }}
                  role="tab"
                  id={`${base}-t${i}`}
                  aria-selected={on}
                  aria-controls={`${base}-p`}
                  tabIndex={on ? 0 : -1}
                  onClick={() => setActive(i)}
                  onKeyDown={(e) => onKey(e, i)}
                  className={cn('group/tab rounded-xl border p-4 text-left transition-[border-color,background-color,box-shadow] duration-300', on ? 'border-primary bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))] shadow-[var(--shadow-sm)]' : 'border-border bg-card hover:border-border-strong')}
                >
                  <span className="flex items-center justify-between gap-3">
                    <span className="text-[14px] font-semibold text-foreground">{x.label ?? `${t.parcel} ${i + 1}`}</span>
                    <span className={cn('inline-flex items-center gap-1 text-[12px] font-medium', x.stage === 'delivered' ? 'text-success-foreground' : 'text-muted-foreground')}>
                      <Icon name={x.stage === 'delivered' ? 'check-circle' : STAGE_ICON[x.stage]} size={13} />
                      {t[x.stage]}
                    </span>
                  </span>
                  <span className="mt-3 block h-1 overflow-hidden rounded-full bg-track" aria-hidden>
                    <span className={cn('block h-full rounded-full transition-[width] duration-700 ease-[var(--ease-kit)]', x.stage === 'delivered' ? 'bg-success' : 'bg-primary')} style={{ width: `${Math.max(6, pct)}%` }} />
                  </span>
                  {x.items?.length ? (
                    <span className="mt-3 flex -space-x-2" aria-hidden>
                      {x.items.slice(0, 4).map((it, k) => (
                        <ProductMedia key={k} className="size-8 rounded-full ring-2 ring-card">
                          {it.image}
                        </ProductMedia>
                      ))}
                    </span>
                  ) : null}
                </button>
              );
            })}
          </div>
          <div key={p.id} id={`${base}-p`} role="tabpanel" aria-labelledby={`${base}-t${active}`} tabIndex={0} className="mt-6 animate-[kitshop-fade_0.35s_var(--ease-kit)] rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
            <div className="grid grid-cols-1 gap-6 p-5 sm:p-7 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
              <Eta parcel={p} t={t} size="md" />
              <p className="flex flex-wrap items-center gap-2 text-[13px] text-muted-foreground">
                {t.tracking} <CopyNumber value={p.trackingNumber} t={t} />
              </p>
            </div>
            <div className="border-t border-border px-5 py-6 sm:px-7">
              <Stages stage={p.stage} t={t} />
            </div>
            <div className="grid grid-cols-1 gap-8 border-t border-border p-5 sm:p-7 lg:grid-cols-[minmax(0,1fr)_17rem]">
              <div>
                <h3 className="mb-4 text-[15px] font-semibold text-foreground">{t.history}</h3>
                <Events events={p.events} t={t} initial={3} />
              </div>
              {p.items?.length ? (
                <div className="lg:border-l lg:border-border lg:pl-8">
                  <h3 className="mb-4 text-[15px] font-semibold text-foreground">{t.inside}</h3>
                  <ul className="space-y-3">
                    {p.items.map((it, k) => (
                      <li key={k} className="flex items-center gap-3">
                        <ProductMedia className="size-14 shrink-0 rounded-lg">{it.image}</ProductMedia>
                        <div className="min-w-0">
                          <p className="truncate text-[14px] font-medium text-foreground">{it.name}</p>
                          <p className="text-[12px] text-muted-foreground">
                            {it.variant}
                            {it.quantity && it.quantity > 1 ? ` · ${it.quantity}×` : ''}
                          </p>
                        </div>
                      </li>
                    ))}
                  </ul>
                </div>
              ) : null}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

/**
 * Order tracking after the purchase: a timeline with every scan, a drawn
 * map with the van on its way, a lookup for guests and an order split into
 * parcels. Five stages from ordered to delivered.
 */
export function OrderTracking(props: OrderTrackingProps) {
  const t = useTexts(TEXTS, props.labels);
  const { variant = 'timeline' } = props;
  if (variant === 'map') return <MapVariant {...props} t={t} />;
  if (variant === 'lookup') return <Lookup {...props} t={t} />;
  if (variant === 'parcels') return <Parcels {...props} t={t} />;
  return <Timeline {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Sendungsverfolgung?

Mit aiw add @ai-website/order-tracking. Der Befehl legt die Datei components/ui/order-tracking.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, product-image und input. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Sendungsverfolgung?

4 Varianten, Verlauf, Live-Karte, Suche für Gäste und Mehrere Pakete. Du wählst sie mit der Prop variant, zum Beispiel variant="map".

Kann ich die Texte von Sendungsverfolgung übersetzen?

Ja. Sendungsverfolgung bringt 49 eigene Texte auf Deutsch und Englisch mit, etwa „Bestellt“, „Verpackt“ und „Unterwegs“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Sendungsverfolgung einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Sendungsverfolgung mit aiw_components und installieren sie mit aiw_add @ai-website/order-tracking. Nenn im Prompt die Variante, etwa variant="timeline", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert