Lieferzeit und Versand
Wann es ankommt und was es kostet. Optionen für eine Postleitzahl, der Bestellschluss von heute mit dem Weg zur Tür, Preise je Region und ein Lieferfenster zum Buchen.
Eine von 4 Components in Versand und Filialen
Variants
Details
Was Lieferzeit und Versand mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Versand und Filialen
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/shipping-estimator.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 31 eigene Texte
- In Sammlungen
- Online-Shop
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ShippingEstimatorProps aus components/ui/shipping-estimator.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalShippingEstimatorVariantpostcodecountdownrates-tabledelivery-slots
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringcountriesoptional{ code: string; label: string; pattern?: string; example?: string }[]postcode: countries to pick from
defaultCountryoptionalstringdefaultPostcodeoptionalstringdefaultCheckedoptionalbooleanpostcode: show the options right away (previews)
placeoptionalstringpostcode: the place the postcode resolves to, e.g. "Hamburg"
optionsoptionalShippingOption[]minutesLeftoptionalnumbercountdown: minutes until the cut-off of today
arrivaloptional{ standard: string; fast?: string; fastName?: string; fastPrice?: number; standardName?: string }countdown: arrival with the standard and the fast method
stepsoptionalShippingStep[]zonesoptionalShippingZone[]notesoptionalstring[]rates-table: lines below the table, e.g. customs and islands
daysoptionalDeliveryDay[]defaultDayoptionalstringdefaultSlotoptionalstringfreeFromoptionalnumberFree shipping from this order value on
currencyoptionalstringonCheckoptional(country: string, postcode: string) => voidonSelectoptional(day: DeliveryDay, slotId: string) => void
Eingebaute Texte
Diese Texte schreibt Lieferzeit und Versand selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 31 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| country | Englisch Country | Deutsch Land |
| postcode | Englisch Postcode | Deutsch Postleitzahl |
| check | Englisch Show delivery dates | Deutsch Lieferzeiten anzeigen |
| checking | Englisch Checking | Deutsch Wird geprüft |
| change | Englisch Change | Deutsch Ändern |
| deliveringTo | Englisch Delivering to | Deutsch Lieferung nach |
| invalid | Englisch Enter a postcode like | Deutsch Gib eine Postleitzahl ein wie |
| free | Englisch Free | Deutsch Kostenlos |
| arrives | Englisch Arrives | Deutsch Ankunft |
| orderWithin | Englisch Order within | Deutsch Bestell in den nächsten |
| andGet | Englisch and it arrives | Deutsch und es kommt |
| h | Englisch h | Deutsch Std. |
| min | Englisch min | Deutsch Min. |
| cutoffPassed | Englisch Ordered now, it ships tomorrow and arrives | Deutsch Heute bestellt, geht es morgen raus und kommt |
| standard | Englisch Standard | Deutsch Standard |
| faster | Englisch Faster | Deutsch Schneller |
| method | Englisch Shipping method | Deutsch Versandart |
| time | Englisch Delivery time | Deutsch Lieferzeit |
| cost | Englisch Cost | Deutsch Kosten |
| freeFrom | Englisch Free from | Deutsch Kostenlos ab |
| region | Englisch Region | Deutsch Region |
| pickDay | Englisch Pick a day | Deutsch Wähle einen Tag |
| pickSlot | Englisch Pick a time window | Deutsch Wähle ein Zeitfenster |
| full | Englisch Full | Deutsch Voll |
| yourSlot | Englisch Your delivery | Deutsch Deine Lieferung |
| confirm | Englisch Book this window | Deutsch Zeitfenster buchen |
| confirmed | Englisch Window booked | Deutsch Zeitfenster gebucht |
| earlier | Englisch Earlier days | Deutsch Frühere Tage |
| later | Englisch Later days | Deutsch Spätere Tage |
| options | Englisch Delivery options | Deutsch Lieferoptionen |
| datePending | Englisch Date after the check | Deutsch Datum nach der Prüfung |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Lieferzeit und Versand reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-busy, aria-checked, aria-describedby, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, radio und radiogroup.
- 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. 3 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 742 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollProgress } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { Tabs } from '@/components/ui/tabs';
import { NativeSelect } from '@/components/ui/select';
import { ShopMotion, Swap, formatPrice } from '@/components/ui/product-image';
/*
* When will it arrive and what does it cost. For product pages, the cart and
* the shipping page.
*
* postcode country and postcode in, delivery options with dates and prices out
* countdown "order within 2 h 14 min, it arrives Thursday", with the way there as a timeline
* rates-table shipping rates per region in tabs, as a table on wide screens and cards on phones
* delivery-slots pick a day and a time window, with prices per window and full windows marked
*/
export type ShippingEstimatorVariant = 'postcode' | 'countdown' | 'rates-table' | 'delivery-slots';
export interface ShippingOption {
id: string;
name: string;
/** e.g. "Delivered by Parcel North" or "Pick up at Nordhaus Hamburg" */
note?: string;
/** Already formatted arrival, e.g. "Thu, Oct 24" */
eta: string;
/** Short line above the date, e.g. "Tomorrow" */
etaNote?: string;
/** 0 shows "Free" */
price: number;
icon?: IconName;
/** A small label, e.g. "Fastest" */
tag?: string;
}
export interface ShippingStep {
label: string;
/** Already formatted, e.g. "Today" or "Thu, Oct 24" */
date: string;
/** Date with the faster method, for countdown */
fastDate?: string;
icon?: IconName;
}
export interface ShippingZone {
id: string;
label: string;
rows: { method: string; time: string; price: number; freeFrom?: number; note?: string }[];
note?: string;
}
export interface DeliveryDay {
id: string;
/** e.g. "Thu" */
weekday: string;
/** e.g. "24" */
day: string;
/** e.g. "Oct" */
month?: string;
/** Full date for screen readers and the summary, e.g. "Thursday, October 24" */
label: string;
slots: { id: string; label: string; price: number; full?: boolean; tag?: string }[];
}
export interface ShippingEstimatorProps {
variant?: ShippingEstimatorVariant;
eyebrow?: string;
title?: string;
description?: string;
/** postcode: countries to pick from */
countries?: { code: string; label: string; pattern?: string; example?: string }[];
defaultCountry?: string;
defaultPostcode?: string;
/** postcode: show the options right away (previews) */
defaultChecked?: boolean;
/** postcode: the place the postcode resolves to, e.g. "Hamburg" */
place?: string;
options?: ShippingOption[];
/** countdown: minutes until the cut-off of today */
minutesLeft?: number;
/** countdown: arrival with the standard and the fast method */
arrival?: { standard: string; fast?: string; fastName?: string; fastPrice?: number; standardName?: string };
steps?: ShippingStep[];
zones?: ShippingZone[];
/** rates-table: lines below the table, e.g. customs and islands */
notes?: string[];
days?: DeliveryDay[];
defaultDay?: string;
defaultSlot?: string;
/** Free shipping from this order value on */
freeFrom?: number;
currency?: string;
onCheck?: (country: string, postcode: string) => void;
onSelect?: (day: DeliveryDay, slotId: string) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
country: 'Country',
postcode: 'Postcode',
check: 'Show delivery dates',
checking: 'Checking',
change: 'Change',
deliveringTo: 'Delivering to',
invalid: 'Enter a postcode like',
free: 'Free',
arrives: 'Arrives',
orderWithin: 'Order within',
andGet: 'and it arrives',
h: 'h',
min: 'min',
cutoffPassed: 'Ordered now, it ships tomorrow and arrives',
standard: 'Standard',
faster: 'Faster',
method: 'Shipping method',
time: 'Delivery time',
cost: 'Cost',
freeFrom: 'Free from',
region: 'Region',
pickDay: 'Pick a day',
pickSlot: 'Pick a time window',
full: 'Full',
yourSlot: 'Your delivery',
confirm: 'Book this window',
confirmed: 'Window booked',
earlier: 'Earlier days',
later: 'Later days',
options: 'Delivery options',
datePending: 'Date after the check',
},
de: {
country: 'Land',
postcode: 'Postleitzahl',
check: 'Lieferzeiten anzeigen',
checking: 'Wird geprüft',
change: 'Ändern',
deliveringTo: 'Lieferung nach',
invalid: 'Gib eine Postleitzahl ein wie',
free: 'Kostenlos',
arrives: 'Ankunft',
orderWithin: 'Bestell in den nächsten',
andGet: 'und es kommt',
h: 'Std.',
min: 'Min.',
cutoffPassed: 'Heute bestellt, geht es morgen raus und kommt',
standard: 'Standard',
faster: 'Schneller',
method: 'Versandart',
time: 'Lieferzeit',
cost: 'Kosten',
freeFrom: 'Kostenlos ab',
region: 'Region',
pickDay: 'Wähle einen Tag',
pickSlot: 'Wähle ein Zeitfenster',
full: 'Voll',
yourSlot: 'Deine Lieferung',
confirm: 'Zeitfenster buchen',
confirmed: 'Zeitfenster gebucht',
earlier: 'Frühere Tage',
later: 'Spätere Tage',
options: 'Lieferoptionen',
datePending: 'Datum nach der Prüfung',
},
};
type Texts = (typeof TEXTS)['en'];
const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const ACCENT_SOFT = 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]';
function Head({ eyebrow, title, description, center }: { eyebrow?: string; title?: string; description?: string; center?: boolean }) {
if (!title) return null;
return (
<div className={cn('mb-10 max-w-[40rem] sm:mb-12', center && 'mx-auto text-center')}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className={cn('kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
);
}
function PriceTag({ value, currency, t, className }: { value: number; currency?: string; t: Texts; className?: string }) {
const locale = useLocale();
return value === 0 ? <span className={cn('font-semibold text-success-foreground', className)}>{t.free}</span> : <span className={cn('font-semibold tabular-nums text-foreground', className)}>{formatPrice(locale, value, currency)}</span>;
}
/* ------------------------------------------------------------------ */
/* postcode */
/* ------------------------------------------------------------------ */
function Postcode(p: ShippingEstimatorProps & { t: Texts }) {
const { t, currency } = p;
const countries = p.countries ?? [];
const [country, setCountry] = useState(p.defaultCountry ?? countries[0]?.code ?? '');
const [code, setCode] = useState(p.defaultPostcode ?? '');
const [state, setState] = useState<'idle' | 'checking' | 'done'>(p.defaultChecked ? 'done' : 'idle');
const [error, setError] = useState(false);
const [shake, setShake] = useState(0);
const id = useId();
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const c = countries.find((x) => x.code === country);
const valid = (v: string) => (c?.pattern ? new RegExp(`^${c.pattern}$`).test(v.trim()) : v.trim().length > 2);
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!valid(code)) {
setError(true);
setShake((s) => s + 1);
return;
}
setError(false);
setState('checking');
p.onCheck?.(country, code.trim());
clearTimeout(timer.current);
timer.current = setTimeout(() => setState('done'), 700);
};
const options = p.options ?? [];
return (
<Section className={p.className}>
<ShopMotion />
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-16">
<div>
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
{state === 'done' ? (
<div className={cn(CARD, 'flex items-center gap-4 p-4 ps-5')}>
<span className={cn('grid size-11 shrink-0 place-items-center rounded-full', ACCENT_SOFT)}>
<Icon name="map-pin" size={19} />
</span>
<p className="min-w-0 flex-1 text-[14px] text-muted-foreground">
{t.deliveringTo} {c?.label}
<span className="block text-[16px] font-semibold tabular-nums text-foreground">
{code.trim()} {p.place}
</span>
</p>
<Button variant="ghost" size="sm" onClick={() => setState('idle')}>
{t.change}
</Button>
</div>
) : (
<form noValidate onSubmit={submit} className={cn(CARD, 'p-5 sm:p-6')}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div>
<label htmlFor={`${id}-c`} className="mb-2 block text-[14px] font-medium text-foreground">
{t.country}
</label>
<NativeSelect id={`${id}-c`} value={country} onChange={(e) => { setCountry(e.target.value); setError(false); }} options={countries.map((x) => ({ value: x.code, label: x.label }))} />
</div>
<div>
<label htmlFor={`${id}-p`} className="mb-2 block text-[14px] font-medium text-foreground">
{t.postcode}
</label>
<input
id={`${id}-p`}
key={error ? `e${shake}` : 'ok'}
value={code}
onChange={(e) => setCode(e.target.value)}
inputMode={c?.pattern?.includes('\\d') ? 'numeric' : undefined}
autoComplete="postal-code"
placeholder={c?.example}
aria-invalid={error || undefined}
aria-describedby={error ? `${id}-e` : undefined}
className={cn(
'h-11 w-full rounded-lg border bg-background px-3.5 text-[15px] tabular-nums text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]',
error ? 'animate-[kitshop-shake_0.4s_var(--ease-kit)] border-destructive' : 'border-input hover:border-border-strong focus:border-border-strong',
)}
/>
</div>
</div>
{error && (
<p id={`${id}-e`} role="alert" className="mt-2 flex items-center gap-1.5 text-[13px] text-destructive">
<Icon name="alert-circle" size={14} />
{t.invalid} {c?.example}
</p>
)}
<Button type="submit" className="mt-5 w-full sm:w-auto" iconLeft={state === 'checking' ? 'loader' : 'truck'} disabled={state === 'checking'}>
<Swap value={state}>{state === 'checking' ? t.checking : t.check}</Swap>
</Button>
</form>
)}
{p.freeFrom !== undefined && <FreeNote freeFrom={p.freeFrom} currency={currency} t={t} />}
</div>
<div className="min-w-0 lg:pt-2">
<h3 className="sr-only">{t.options}</h3>
<ul className={cn('grid grid-cols-1 gap-3 transition-opacity duration-300', state === 'checking' && 'opacity-40')} aria-busy={state === 'checking'} aria-live="polite">
{options.map((o, i) => (
<li
key={`${o.id}-${state}`}
className={cn(CARD, 'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-4 p-4 sm:p-5', state === 'done' && 'animate-[kitshop-in_0.5s_var(--ease-kit)_both]')}
style={state === 'done' ? { animationDelay: `${i * 90}ms` } : undefined}
>
<span className="grid size-11 place-items-center rounded-xl bg-muted text-foreground">
<Icon name={o.icon ?? 'truck'} size={19} />
</span>
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-2">
<span className="text-[15px] font-semibold text-foreground">{o.name}</span>
{o.tag && <span className={cn('rounded-full px-2 py-0.5 text-[11.5px] font-medium', ACCENT_SOFT)}>{o.tag}</span>}
</span>
{o.note && <span className="mt-0.5 block text-[13px] text-muted-foreground sm:truncate">{o.note}</span>}
</span>
<span className="text-right">
{state === 'done' ? (
<span className="block text-[14.5px] font-medium text-foreground">
{o.etaNote ? `${o.etaNote}, ` : ''}
{o.eta}
</span>
) : (
<span className="block text-[13px] text-subtle-foreground">{t.datePending}</span>
)}
<PriceTag value={o.price} currency={currency} t={t} className="mt-0.5 block text-[13.5px]" />
</span>
</li>
))}
</ul>
</div>
</div>
</Section>
);
}
function FreeNote({ freeFrom, currency, t }: { freeFrom: number; currency?: string; t: Texts }) {
const locale = useLocale();
return (
<p className="mt-4 flex items-center gap-2 text-[13.5px] text-muted-foreground">
<Icon name="gift" size={15} className="text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" />
{t.freeFrom} <span className="font-medium tabular-nums text-foreground">{formatPrice(locale, freeFrom, currency)}</span>
</p>
);
}
/* ------------------------------------------------------------------ */
/* countdown */
/* ------------------------------------------------------------------ */
/** Minutes that count down once a minute after hydration, starting from the given value */
function useMinutes(start: number) {
const [left, setLeft] = useState(start);
useEffect(() => {
setLeft(start);
const t = window.setInterval(() => setLeft((m) => Math.max(0, m - 1)), 60_000);
return () => window.clearInterval(t);
}, [start]);
return left;
}
function Countdown(p: ShippingEstimatorProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const total = p.minutesLeft ?? 0;
const left = useMinutes(total);
const [fast, setFast] = useState(false);
const steps = p.steps ?? [];
const arrival = p.arrival ?? { standard: '' };
const h = Math.floor(left / 60);
const m = left % 60;
const ring = round(2 * Math.PI * 26);
// The ring empties over the last 8 hours before the cut-off
const share = Math.min(1, left / 480);
const line = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.55 });
const [ref, inView] = useInView<HTMLDivElement>();
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div ref={ref} className={cn(CARD, 'overflow-hidden')}>
<div className="grid grid-cols-1 gap-6 p-6 sm:p-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:gap-10">
<div className="flex items-center gap-5">
<span className="relative grid size-16 shrink-0 place-items-center" aria-hidden>
<svg viewBox="0 0 60 60" className="absolute inset-0 -rotate-90">
<circle cx="30" cy="30" r="26" fill="none" strokeWidth="4" className="stroke-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" />
<circle
cx="30"
cy="30"
r="26"
fill="none"
strokeWidth="4"
strokeLinecap="round"
className="stroke-primary transition-[stroke-dashoffset] duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none"
strokeDasharray={ring}
strokeDashoffset={round(ring * (1 - (inView ? share : 0)))}
/>
</svg>
<Icon name="clock" size={20} className="text-foreground" />
</span>
<p className="text-pretty text-[17px] leading-snug text-muted-foreground sm:text-[19px]">
{left > 0 ? (
<>
{t.orderWithin}{' '}
<span className="font-semibold tabular-nums text-foreground">
{h > 0 && `${h} ${t.h} `}
{`${m} ${t.min}`}
</span>{' '}
{t.andGet}{' '}
</>
) : (
<>{t.cutoffPassed} </>
)}
<Swap value={fast}>
<span className="font-semibold text-foreground">{fast && arrival.fast ? arrival.fast : arrival.standard}</span>
</Swap>
</p>
</div>
{arrival.fast && (
<div role="radiogroup" aria-label={t.method} className="relative grid grid-cols-2 rounded-xl border border-border-strong bg-surface p-1 lg:w-[22rem]">
<span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-lg bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `translateX(${fast ? 100 : 0}%)` }} />
{[false, true].map((f) => (
<button
key={String(f)}
type="button"
role="radio"
aria-checked={fast === f}
onClick={() => setFast(f)}
className={cn('relative z-10 flex min-h-12 flex-col items-center justify-center rounded-lg px-3 py-1.5 text-center transition-colors', fast === f ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', FOCUS)}
>
<span className="text-[13.5px] font-medium">{f ? arrival.fastName ?? t.faster : arrival.standardName ?? t.standard}</span>
<span className="text-[12px] tabular-nums opacity-80">{f ? (arrival.fastPrice ? `+${formatPrice(locale, arrival.fastPrice, currency)}` : t.free) : t.free}</span>
</button>
))}
</div>
)}
</div>
{steps.length > 0 && (
<div className="border-t border-border bg-surface px-6 py-7 sm:px-8">
<div ref={line} className="relative">
{/* The line between the steps fills while scrolling: down on phones, across from tablet on */}
<span aria-hidden className="absolute bottom-5 left-[21px] top-5 w-0.5 rounded-full bg-border sm:hidden" />
<span aria-hidden className="absolute left-[21px] top-5 h-[calc(100%-2.5rem)] w-0.5 origin-top rounded-full bg-primary sm:hidden" style={{ transform: 'scaleY(var(--kit-progress, 1))' }} />
<div className="relative hidden sm:block">
<span aria-hidden className="absolute top-[21px] h-0.5 rounded-full bg-border" style={{ left: `${round(50 / steps.length, 2)}%`, right: `${round(50 / steps.length, 2)}%` }} />
<span
aria-hidden
className="absolute top-[21px] h-0.5 origin-left rounded-full bg-primary"
style={{ left: `${round(50 / steps.length, 2)}%`, right: `${round(50 / steps.length, 2)}%`, transform: 'scaleX(var(--kit-progress, 1))' }}
/>
<ol className="relative grid" style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}>
{steps.map((s, i) => (
<StepItem key={s.label} step={s} i={i} fast={fast} />
))}
</ol>
</div>
<ol className="relative grid gap-6 sm:hidden">
{steps.map((s, i) => (
<StepItem key={s.label} step={s} i={i} fast={fast} row />
))}
</ol>
</div>
</div>
)}
</div>
</Section>
);
}
function StepItem({ step, i, fast, row }: { step: ShippingStep; i: number; fast: boolean; row?: boolean }) {
const date = fast && step.fastDate ? step.fastDate : step.date;
return (
<li className={cn('relative', row ? 'flex items-center gap-4' : 'flex flex-col items-center text-center')}>
<span className={cn('relative z-10 grid size-11 shrink-0 place-items-center rounded-full border-2 border-surface', i === 0 ? 'bg-primary text-primary-foreground' : 'bg-card text-foreground shadow-[0_0_0_1px_var(--border)]')}>
<Icon name={step.icon ?? 'package'} size={18} />
</span>
<span className={cn(!row && 'mt-3')}>
<span className="block text-[14px] font-medium text-foreground">{step.label}</span>
<Swap value={date}>
<span className="block text-[13px] tabular-nums text-muted-foreground">{date}</span>
</Swap>
</span>
</li>
);
}
/* ------------------------------------------------------------------ */
/* rates-table */
/* ------------------------------------------------------------------ */
function RatesTable(p: ShippingEstimatorProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const zones = p.zones ?? [];
const table = (z: ShippingZone) => (
<div>
<div className={cn(CARD, 'hidden overflow-hidden sm:block')}>
<table className="w-full text-left text-[14px]">
<thead className="bg-surface text-[12.5px] text-muted-foreground">
<tr>
<th scope="col" className="px-5 py-3 font-medium">{t.method}</th>
<th scope="col" className="px-5 py-3 font-medium">{t.time}</th>
<th scope="col" className="px-5 py-3 text-right font-medium">{t.cost}</th>
<th scope="col" className="px-5 py-3 text-right font-medium">{t.freeFrom}</th>
</tr>
</thead>
<tbody className="divide-y divide-border border-t border-border">
{z.rows.map((r, i) => (
<tr key={r.method} className="animate-[kitshop-in_0.45s_var(--ease-kit)_both] transition-colors hover:bg-surface" style={{ animationDelay: `${i * 60}ms` }}>
<th scope="row" className="px-5 py-4 font-medium text-foreground">
{r.method}
{r.note && <span className="mt-0.5 block text-[12.5px] font-normal text-muted-foreground">{r.note}</span>}
</th>
<td className="px-5 py-4 text-muted-foreground">{r.time}</td>
<td className="px-5 py-4 text-right">
<PriceTag value={r.price} currency={currency} t={t} />
</td>
<td className="px-5 py-4 text-right tabular-nums text-muted-foreground">{r.freeFrom !== undefined ? formatPrice(locale, r.freeFrom, currency) : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="grid grid-cols-1 gap-3 sm:hidden">
{z.rows.map((r, i) => (
<li key={r.method} className={cn(CARD, 'animate-[kitshop-in_0.45s_var(--ease-kit)_both] p-4')} style={{ animationDelay: `${i * 60}ms` }}>
<div className="flex items-baseline justify-between gap-3">
<p className="text-[15px] font-semibold text-foreground">{r.method}</p>
<PriceTag value={r.price} currency={currency} t={t} className="text-[15px]" />
</div>
<p className="mt-1 text-[13.5px] text-muted-foreground">{r.time}</p>
{(r.freeFrom !== undefined || r.note) && (
<p className="mt-2 border-t border-border pt-2 text-[12.5px] text-muted-foreground">
{r.freeFrom !== undefined && (
<>
{t.freeFrom} <span className="tabular-nums text-foreground">{formatPrice(locale, r.freeFrom, currency)}</span>
</>
)}
{r.freeFrom !== undefined && r.note && ' · '}
{r.note}
</p>
)}
</li>
))}
</ul>
{z.note && (
<p className="mt-4 flex items-start gap-2 text-[13px] text-muted-foreground">
<Icon name="info" size={15} className="mt-px shrink-0" />
{z.note}
</p>
)}
</div>
);
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<Reveal effect="rise">
<Tabs label={t.region} variant="segmented" items={zones.map((z) => ({ value: z.id, label: z.label, content: table(z) }))} panelClassName="pt-6" />
</Reveal>
{p.notes?.length ? (
<Stagger step={60} as="ul" className="mt-10 grid grid-cols-1 gap-3 border-t border-border pt-8 sm:grid-cols-2 lg:grid-cols-3">
{p.notes.map((n) => (
<li key={n} className="flex items-start gap-3 text-[14px] leading-relaxed text-muted-foreground">
<Icon name="check-circle" size={16} className="mt-0.5 shrink-0 text-success-foreground" />
{n}
</li>
))}
</Stagger>
) : null}
</Section>
);
}
/* ------------------------------------------------------------------ */
/* delivery-slots */
/* ------------------------------------------------------------------ */
function DeliverySlots(p: ShippingEstimatorProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const days = p.days ?? [];
const [dayId, setDayId] = useState(p.defaultDay ?? days[0]?.id ?? '');
const day = days.find((d) => d.id === dayId) ?? days[0];
const firstOpen = (d?: DeliveryDay) => d?.slots.find((s) => !s.full)?.id ?? '';
const [slotId, setSlotId] = useState(p.defaultSlot ?? firstOpen(day));
const [booked, setBooked] = useState(false);
const slot = day?.slots.find((s) => s.id === slotId);
const strip = useRef<HTMLDivElement>(null);
const [mark, setMark] = useState<{ x: number; w: number; ready: boolean } | null>(null);
const index = days.findIndex((d) => d.id === dayId);
useEffect(() => {
const el = strip.current;
if (!el) return;
const update = () => {
const b = el.querySelectorAll<HTMLElement>('[data-day]')[index];
if (b) setMark((m) => ({ x: b.offsetLeft, w: b.offsetWidth, ready: !!m }));
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [index]);
const pickDay = (d: DeliveryDay) => {
setDayId(d.id);
setBooked(false);
if (!d.slots.some((s) => s.id === slotId && !s.full)) setSlotId(firstOpen(d));
};
const scroll = (dir: 1 | -1) => strip.current?.scrollBy({ left: dir * 240, behavior: 'smooth' });
const onKey = (e: React.KeyboardEvent) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
e.preventDefault();
const next = days[Math.max(0, Math.min(days.length - 1, index + (e.key === 'ArrowRight' ? 1 : -1)))];
pickDay(next);
requestAnimationFrame(() => strip.current?.querySelectorAll<HTMLElement>('[data-day]')[days.indexOf(next)]?.focus());
};
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-12">
<div className="min-w-0">
<div className="flex items-center justify-between gap-4">
<h3 className="text-[15px] font-semibold text-foreground">{t.pickDay}</h3>
<div className="flex gap-2 max-sm:hidden">
<button type="button" onClick={() => scroll(-1)} aria-label={t.earlier} className={cn('grid size-10 place-items-center rounded-full border border-border-strong text-foreground hover:bg-accent', FOCUS)}>
<Icon name="chevron-left" size={16} />
</button>
<button type="button" onClick={() => scroll(1)} aria-label={t.later} className={cn('grid size-10 place-items-center rounded-full border border-border-strong text-foreground hover:bg-accent', FOCUS)}>
<Icon name="chevron-right" size={16} />
</button>
</div>
</div>
<div ref={strip} role="radiogroup" aria-label={t.pickDay} onKeyDown={onKey} className="kit-no-scrollbar kit-fade-end relative -mx-6 mt-4 flex gap-2 overflow-x-auto px-6 py-1 sm:mx-0 sm:px-0">
{mark && (
<span
aria-hidden
className={cn('absolute bottom-1 left-0 top-1 rounded-xl bg-foreground', mark.ready && 'transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')}
style={{ width: mark.w, transform: `translateX(${mark.x}px)` }}
/>
)}
{days.map((d) => {
const on = d.id === dayId;
const open = d.slots.some((s) => !s.full);
const from = Math.min(...d.slots.filter((s) => !s.full).map((s) => s.price));
return (
<button
key={d.id}
data-day
type="button"
role="radio"
aria-checked={on}
aria-label={d.label}
tabIndex={on ? 0 : -1}
disabled={!open}
onClick={() => pickDay(d)}
className={cn(
'relative z-10 flex w-[4.75rem] shrink-0 flex-col items-center rounded-xl border py-3 transition-colors duration-300 disabled:cursor-not-allowed disabled:opacity-40',
on ? (mark ? 'border-transparent text-background' : 'border-foreground bg-foreground text-background') : 'border-border bg-card text-foreground hover:border-border-strong',
FOCUS,
)}
>
<span className={cn('text-[12px]', on ? 'opacity-80' : 'text-muted-foreground')}>{d.weekday}</span>
<span className="text-[22px] font-semibold leading-tight tracking-[-0.02em] tabular-nums">{d.day}</span>
<span className={cn('text-[11.5px] tabular-nums', on ? 'opacity-80' : 'text-muted-foreground')}>{open ? (from === 0 ? t.free : formatPrice(locale, from, currency)) : t.full}</span>
</button>
);
})}
</div>
<h3 className="mt-8 text-[15px] font-semibold text-foreground">{t.pickSlot}</h3>
<div key={dayId} role="radiogroup" aria-label={t.pickSlot} className={cn('mt-4 grid grid-cols-2 gap-2', (day?.slots.length ?? 0) === 4 ? 'sm:grid-cols-4' : (day?.slots.length ?? 0) === 2 ? 'sm:grid-cols-2' : 'sm:grid-cols-3')}>
{day?.slots.map((s, i) => {
const on = s.id === slotId;
return (
<label
key={s.id}
className={cn(
'relative flex min-h-16 cursor-pointer flex-col justify-center rounded-xl border px-4 py-3 animate-[kitshop-in_0.4s_var(--ease-kit)_both] transition-[border-color,background-color,box-shadow] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]',
s.full ? 'cursor-not-allowed border-border bg-surface text-subtle-foreground' : on ? 'border-foreground bg-card shadow-[0_0_0_1px_var(--foreground)]' : 'border-border bg-card hover:border-border-strong',
)}
style={{ animationDelay: `${i * 50}ms` }}
>
<input type="radio" name="kit-slot" value={s.id} checked={on} disabled={s.full} onChange={() => { setSlotId(s.id); setBooked(false); }} className="sr-only" />
<span className={cn('text-[14.5px] font-medium tabular-nums', !s.full && 'text-foreground', s.full && 'line-through decoration-1')}>{s.label}</span>
<span className="mt-0.5 flex items-center gap-2 text-[12.5px]">
{s.full ? <span>{t.full}</span> : <PriceTag value={s.price} currency={currency} t={t} className="font-normal text-muted-foreground" />}
{s.tag && !s.full && <span className={cn('rounded-full px-1.5 py-px text-[11px] font-medium', ACCENT_SOFT)}>{s.tag}</span>}
</span>
{on && <Icon name="check-circle" size={16} className="absolute right-3 top-3 animate-[kitshop-pop_0.35s_var(--ease-kit)] text-foreground" />}
</label>
);
})}
</div>
</div>
<Reveal effect="rise" className="lg:sticky lg:top-8 lg:self-start">
<div className={cn(CARD, 'p-6')}>
<p className="text-[13px] text-muted-foreground">{t.yourSlot}</p>
<Swap value={`${dayId}-${slotId}`}>
<span className="mt-1 block text-[18px] font-semibold tracking-[-0.01em] text-foreground">{day?.label}</span>
</Swap>
<p className="mt-0.5 flex items-center justify-between gap-3 text-[14.5px] text-muted-foreground">
<span className="tabular-nums">{slot?.label ?? '·'}</span>
{slot && <PriceTag value={slot.price} currency={currency} t={t} />}
</p>
<Button
className="mt-5 w-full"
size="lg"
disabled={!slot || booked}
iconLeft={booked ? 'check' : 'calendar-check'}
onClick={() => {
if (!day || !slot) return;
setBooked(true);
p.onSelect?.(day, slot.id);
}}
>
<Swap value={booked}>{booked ? t.confirmed : t.confirm}</Swap>
</Button>
{p.freeFrom !== undefined && <FreeNote freeFrom={p.freeFrom} currency={currency} t={t} />}
</div>
</Reveal>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* Delivery dates and shipping costs in four layouts. postcode: options for
* an address. countdown: the cut-off of today and the way to the door.
* rates-table: rates per region. delivery-slots: a day and a time window.
*/
export function ShippingEstimator(props: ShippingEstimatorProps) {
const { variant = 'postcode' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'countdown') return <Countdown {...props} t={t} />;
if (variant === 'rates-table') return <RatesTable {...props} t={t} />;
if (variant === 'delivery-slots') return <DeliverySlots {...props} t={t} />;
return <Postcode {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Lieferzeit und Versand?
Mit aiw add @ai-website/shipping-estimator. Der Befehl legt die Datei components/ui/shipping-estimator.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, motion, section, tabs, select und product-image. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Lieferzeit und Versand?
4 Varianten, Postleitzahl prüfen, Bestellschluss, Preise je Region und Lieferfenster. Du wählst sie mit der Prop variant, zum Beispiel variant="countdown".
Kann ich die Texte von Lieferzeit und Versand übersetzen?
Ja. Lieferzeit und Versand bringt 31 eigene Texte auf Deutsch und Englisch mit, etwa „Land“, „Postleitzahl“ und „Lieferzeiten anzeigen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Lieferzeit und Versand einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Lieferzeit und Versand mit aiw_components und installieren sie mit aiw_add @ai-website/shipping-estimator. Nenn im Prompt die Variante, etwa variant="postcode", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Lieferzeit und Versand ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ShippingEstimator-Code zum Kopieren.