Skip to content
UI Kit
Accent color

Order tracking page

Where the order is now. A timeline with every scan, a drawn map with the van on its way, a lookup for guests and several parcels.

4 variantsOne of 3 components in Orders and returns

Where the order is right now

After paying, the most asked question is where the order is. An order tracking page answers it on the shop's own site, with the delivery window large and every stage in plain words, so support gets fewer questions.

Timeline shows the delivery window, five stages that fill one after another with the current one pulsing, every scan below, a text alert to switch on and the copyable tracking number. Live map drives a van in from the depot on a drawn city map, the minutes count down and a bar shows the stops ahead. Guest lookup takes the order number and email or postcode, each with its own error that says what to fix. Several parcels splits one order into parcels as tabs with arrow keys, each with its stage and its contents.

Link the tracking page from every shipping email, then the visit stays in the shop. Put the delivery window first, that is what people look for. Show several parcels as one order, then a partial delivery reads as progress.

Variants

TimelineNew in 1.2

The delivery window large, five stages that fill one after another with the current one pulsing, every scan below with more on request, a text alert to switch on and the copyable tracking number.

Live mapNew in 1.2

A drawn city map where the van drives in from the depot and stops at its position. The minutes count down, a bar shows the stops ahead, on desktop the card floats over the free side of the map.

Guest lookupNew in 1.2

Order number and email or postcode, each with its own error that says what to fix. The result rises in with the stage, the window and the latest scan.

Several parcelsNew in 1.2

One order in three parcels as tabs with arrow keys. Each tab shows its stage, a small bar and what is inside, the panel below has the window, the stages, the scans and the contents.

Details

What Order tracking brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/order-tracking.tsx
Brings along
utilskitbuttoniconproduct-imageinput
npm packages
None
Languages
English and German, 49 texts of its own
Studio
Adjust in code

Props

The OrderTrackingProps interface from components/ui/order-tracking.tsx, with the comments from the code.

  • 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

Built-in texts

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

Show all 49 texts
orderedEnglish OrderedGerman Bestellt
packedEnglish PackedGerman Verpackt
shippedEnglish On its wayGerman Unterwegs
outEnglish Out for deliveryGerman In Zustellung
deliveredEnglish DeliveredGerman Zugestellt
orderEnglish OrderGerman Bestellung
trackingEnglish Tracking numberGerman Sendungsnummer
copyEnglish Copy tracking numberGerman Sendungsnummer kopieren
copiedEnglish CopiedGerman Kopiert
arrivesEnglish ArrivesGerman Ankunft
arrivedEnglish DeliveredGerman Zugestellt
betweenEnglish between {from} and {to}German zwischen {from} und {to}
historyEnglish Shipment historyGerman Sendungsverlauf
showAllEnglish Show all {n} updatesGerman Alle {n} Meldungen zeigen
showLessEnglish Show fewer updatesGerman Weniger Meldungen zeigen
notifyEnglish Text me before it arrivesGerman Per SMS vorher Bescheid geben
notifyOnEnglish We will text you about 30 minutes beforeGerman Wir schreiben dir etwa 30 Minuten vorher
rescheduleEnglish Change deliveryGerman Zustellung ändern
helpEnglish Get help with this orderGerman Hilfe zu dieser Bestellung
deliverToEnglish Delivering toGerman Lieferung an
stopsAwayEnglish {n} stops before yoursGerman Noch {n} Stopps vor dir
oneStopEnglish One stop before yoursGerman Noch ein Stopp vor dir
nextStopEnglish You are the next stopGerman Du bist der nächste Stopp
minutesEnglish about {n} minGerman etwa {n} Min.
liveNoteEnglish Live position, updated every minuteGerman Live-Position, jede Minute aktualisiert
driverEnglish Your driverGerman Dein Fahrer
messageEnglish MessageGerman Nachricht
depotEnglish DepotGerman Depot
homeEnglish YouGerman Du
lookupTitleEnglish Track your orderGerman Bestellung verfolgen
lookupTextEnglish Your order number is all it takes, you will find it in the confirmation email.German Deine Bestellnummer genügt, sie steht in der Bestätigungsmail.
numberEnglish Order numberGerman Bestellnummer
numberHintEnglish e.g. NH-48210German z. B. NH-48210
contactEnglish Email or postcodeGerman E-Mail oder Postleitzahl
findEnglish Track orderGerman Bestellung verfolgen
findingEnglish SearchingGerman Wird gesucht
eNumberEnglish Enter your order number, it starts with two letters.German Gib deine Bestellnummer ein, sie beginnt mit zwei Buchstaben.
eNumberFormatEnglish Order numbers look like NH-48210. Check the confirmation email.German Bestellnummern sehen aus wie NH-48210. Schau in die Bestätigungsmail.
eContactEnglish Enter the email of the order or the postcode of the delivery address.German Gib die E-Mail der Bestellung oder die Postleitzahl der Lieferadresse ein.
eNotFoundEnglish We found no order with these details. Check the number and the email or postcode.German Zu diesen Angaben finden wir keine Bestellung. Prüf die Nummer und die E-Mail oder Postleitzahl.
anotherEnglish Track another orderGerman Andere Bestellung verfolgen
latestEnglish Latest updateGerman Letzte Meldung
parcelEnglish ParcelGerman Paket
parcelsOfEnglish {n} parcelsGerman {n} Pakete
insideEnglish In this parcelGerman In diesem Paket
progressEnglish ProgressGerman Fortschritt
currentEnglish current stageGerman aktuelle Etappe
doneEnglish doneGerman erledigt
nextEnglish to comeGerman folgt

Accessibility

Read from the source of the component.

  • Order tracking handles the keys Arrow left, Arrow right, Home, End and Space itself.
  • Sets aria-checked, aria-controls, aria-current, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby and aria-selected, so screen readers announce purpose and state.
  • Uses the roles alert, img, status, switch, 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, 856 lines
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} />;
}

Frequent questions

How do I install Order tracking?

With aiw add @ai-website/order-tracking. The command puts components/ui/order-tracking.tsx into your project, the code is yours from then on. It brings along button, icon, product-image and input. It needs no further npm packages.

Which variants does Order tracking have?

4 variants, Timeline, Live map, Guest lookup and Several parcels. You pick one with the variant prop, for example variant="map".

Can I translate the texts of Order tracking?

Yes. Order tracking carries 49 texts of its own in English and German, such as “Ordered”, “Packed” and “On its way”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Order tracking?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Order tracking with aiw_components and install it with aiw_add @ai-website/order-tracking. Name the variant in your prompt, for example variant="timeline", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard