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.
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
Details
Was Sendungsverfolgung mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Bestellungen und Retouren
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/order-tracking.tsx
- Bringt mit
- utilskitbuttoniconproduct-imageinput
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 49 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- Online-ShopShop-Checkout
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle OrderTrackingProps aus components/ui/order-tracking.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalOrderTrackingVarianttimelinemaplookupparcels
orderoptionalTrackingOrderordersoptionalTrackingOrder[]lookup: the orders the form can find. Or pass onLookup.
onLookupoptional(number: string, contact: string) => Promise<TrackingOrder | null> | TrackingOrder | nullliveoptional{ 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| ordered | Englisch Ordered | Deutsch Bestellt |
| packed | Englisch Packed | Deutsch Verpackt |
| shipped | Englisch On its way | Deutsch Unterwegs |
| out | Englisch Out for delivery | Deutsch In Zustellung |
| delivered | Englisch Delivered | Deutsch Zugestellt |
| order | Englisch Order | Deutsch Bestellung |
| tracking | Englisch Tracking number | Deutsch Sendungsnummer |
| copy | Englisch Copy tracking number | Deutsch Sendungsnummer kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| arrives | Englisch Arrives | Deutsch Ankunft |
| arrived | Englisch Delivered | Deutsch Zugestellt |
| between | Englisch between {from} and {to} | Deutsch zwischen {from} und {to} |
| history | Englisch Shipment history | Deutsch Sendungsverlauf |
| showAll | Englisch Show all {n} updates | Deutsch Alle {n} Meldungen zeigen |
| showLess | Englisch Show fewer updates | Deutsch Weniger Meldungen zeigen |
| notify | Englisch Text me before it arrives | Deutsch Per SMS vorher Bescheid geben |
| notifyOn | Englisch We will text you about 30 minutes before | Deutsch Wir schreiben dir etwa 30 Minuten vorher |
| reschedule | Englisch Change delivery | Deutsch Zustellung ändern |
| help | Englisch Get help with this order | Deutsch Hilfe zu dieser Bestellung |
| deliverTo | Englisch Delivering to | Deutsch Lieferung an |
| stopsAway | Englisch {n} stops before yours | Deutsch Noch {n} Stopps vor dir |
| oneStop | Englisch One stop before yours | Deutsch Noch ein Stopp vor dir |
| nextStop | Englisch You are the next stop | Deutsch Du bist der nächste Stopp |
| minutes | Englisch about {n} min | Deutsch etwa {n} Min. |
| liveNote | Englisch Live position, updated every minute | Deutsch Live-Position, jede Minute aktualisiert |
| driver | Englisch Your driver | Deutsch Dein Fahrer |
| message | Englisch Message | Deutsch Nachricht |
| depot | Englisch Depot | Deutsch Depot |
| home | Englisch You | Deutsch Du |
| lookupTitle | Englisch Track your order | Deutsch Bestellung verfolgen |
| lookupText | Englisch 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. |
| number | Englisch Order number | Deutsch Bestellnummer |
| numberHint | Englisch e.g. NH-48210 | Deutsch z. B. NH-48210 |
| contact | Englisch Email or postcode | Deutsch E-Mail oder Postleitzahl |
| find | Englisch Track order | Deutsch Bestellung verfolgen |
| finding | Englisch Searching | Deutsch Wird gesucht |
| eNumber | Englisch Enter your order number, it starts with two letters. | Deutsch Gib deine Bestellnummer ein, sie beginnt mit zwei Buchstaben. |
| eNumberFormat | Englisch Order numbers look like NH-48210. Check the confirmation email. | Deutsch Bestellnummern sehen aus wie NH-48210. Schau in die Bestätigungsmail. |
| eContact | Englisch 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. |
| eNotFound | Englisch 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. |
| another | Englisch Track another order | Deutsch Andere Bestellung verfolgen |
| latest | Englisch Latest update | Deutsch Letzte Meldung |
| parcel | Englisch Parcel | Deutsch Paket |
| parcelsOf | Englisch {n} parcels | Deutsch {n} Pakete |
| inside | Englisch In this parcel | Deutsch In diesem Paket |
| progress | Englisch Progress | Deutsch Fortschritt |
| current | Englisch current stage | Deutsch aktuelle Etappe |
| done | Englisch done | Deutsch erledigt |
| next | Englisch to come | Deutsch 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
'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.