Warenkorb-Seitenleiste
Der Warenkorb als Seitenleiste aus dem Shop-Header. Am Telefon ein Sheet, sonst ein Panel, mit gehaltenem Fokus und Escape.
Der Warenkorb, einen Klick von jeder Seite
Eine Warenkorb-Seitenleiste öffnet sich aus dem Shop-Header, ohne die Seite zu verlassen, das Einkaufen geht nach dem Hinzufügen also direkt weiter. Am Handy ist sie ein Sheet, auf größeren Bildschirmen ein Panel, der Fokus bleibt darin und Escape schließt.
„Etappen“ setzt Gratisversand und ein Geschenk als Stationen auf einen Balken, jede springt beim Erreichen auf, die Zeile sagt, was fehlt, und der günstigste Vorschlag, der die Lücke schließt, ist markiert. „Mit Vorschlägen“ verbreitert die Leiste am Desktop um eine Spalte mit Vorschlägen, auf Tablet und Handy werden sie zu einer Reihe zum Wischen. „Hinzugefügt“ ist der Moment nach dem Hinzufügen, ein Haken springt auf, der neue Artikel steht groß da und darunter warten drei passende Produkte. „Mit Extras“ lässt Positionen für später merken, rechnet die Geschenkverpackung ein, zählt die Zeichen der Anmerkung herunter und zeigt das Lieferdatum über dem Knopf.
Zeig die Grenze für Gratisversand in der Leiste, genau dort hebt sie den Warenkorbwert. Halte Vorschläge bei drei, passend zum Warenkorb. Nenn das Lieferdatum vor der Kasse, es beantwortet die letzte Frage vor dem Bezahlen.
Variants
Details
Was Warenkorb-Seitenleiste mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Cart und Checkout
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/mini-cart-drawer.tsx
- Bringt mit
- utilskitbuttoniconproduct-imagecart
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 38 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 MiniCartDrawerProps aus components/ui/mini-cart-drawer.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalMiniCartDrawerVariantprogressrecommendationsaddedextras
itemsoptionalLineItem[]recommendationsoptionalShopProduct[]Suggestions with images, added straight into the cart
milestonesoptionalMiniCartMilestone[]progress: stops on the bar, smallest first. Without it the bar shows free shipping only.
freeShippingFromoptionalnumberFree shipping from this subtotal on
shippingCostoptionalnumberaddedIdoptionalstringadded: id of the line that was just added
giftWrapoptional{ price: number }extras: gift wrapping price, 0 for free
deliveryoptionalstringextras: the expected delivery, e.g. "Thu, 30 Oct"
noteMaxoptionalnumberextras: longest order note
headeroptional{ name: React.ReactNode; links?: { label: string; href: string }[] }The store header the cart button sits in
defaultOpenoptionalbooleanOpen on load, for previews
childrenoptionalReact.ReactNodeThe page behind the drawer
checkoutHrefoptionalstringcartHrefoptionalstringcurrencyoptionalstringISO code, default USD in English and EUR in German
onCheckoutoptional(items: LineItem[]) => void
Eingebaute Texte
Diese Texte schreibt Warenkorb-Seitenleiste selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 38 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| title | Englisch Your cart | Deutsch Dein Warenkorb |
| open | Englisch Open cart | Deutsch Warenkorb öffnen |
| close | Englisch Close cart | Deutsch Warenkorb schließen |
| item | Englisch item | Deutsch Artikel |
| items | Englisch items | Deutsch Artikel |
| subtotal | Englisch Subtotal | Deutsch Zwischensumme |
| taxNote | Englisch Incl. VAT, shipping calculated at checkout | Deutsch inkl. MwSt., Versand wird an der Kasse berechnet |
| checkout | Englisch Check out | Deutsch Zur Kasse |
| viewCart | Englisch View cart | Deutsch Warenkorb ansehen |
| continue | Englisch Keep shopping | Deutsch Weiter einkaufen |
| freeShipping | Englisch Free shipping | Deutsch Gratisversand |
| leftFor | Englisch {goal} with {amount} more | Deutsch Noch {amount}, dann {goal} |
| allDone | Englisch Everything unlocked, enjoy | Deutsch Alles freigeschaltet, viel Freude |
| reached | Englisch Unlocked | Deutsch Erreicht |
| closesGap | Englisch Closes the gap | Deutsch Schließt die Lücke |
| add | Englisch Add | Deutsch Hinzufügen |
| added | Englisch Added | Deutsch Im Korb |
| addNamed | Englisch Add {name} | Deutsch {name} hinzufügen |
| youMayLike | Englisch Goes well with | Deutsch Passt dazu |
| previous | Englisch Previous suggestions | Deutsch Vorherige Vorschläge |
| next | Englisch Next suggestions | Deutsch Nächste Vorschläge |
| addedTitle | Englisch Added to your cart | Deutsch Im Warenkorb |
| alsoInCart | Englisch Also in your cart | Deutsch Außerdem im Warenkorb |
| removed | Englisch removed. | Deutsch entfernt. |
| undo | Englisch Undo | Deutsch Rückgängig |
| saveLater | Englisch Save for later | Deutsch Für später merken |
| saved | Englisch Saved for later | Deutsch Für später gemerkt |
| moveToCart | Englisch Move to cart | Deutsch In den Warenkorb |
| giftWrap | Englisch Wrap it as a gift | Deutsch Als Geschenk verpacken |
| giftWrapNote | Englisch Recycled paper and a handwritten card | Deutsch Recyclingpapier und eine handgeschriebene Karte |
| free | Englisch Free | Deutsch Gratis |
| note | Englisch Note for the order | Deutsch Anmerkung zur Bestellung |
| notePlaceholder | Englisch A message for us or the gift card | Deutsch Eine Nachricht an uns oder für die Geschenkkarte |
| noteLeft | Englisch {n} characters left | Deutsch Noch {n} Zeichen |
| noteOver | Englisch {n} characters too many, please shorten the note | Deutsch {n} Zeichen zu viel, bitte kürze die Anmerkung |
| deliveryBy | Englisch Arrives by {date} | Deutsch Ankunft bis {date} |
| emptyTitle | Englisch Your cart is empty | Deutsch Dein Warenkorb ist leer |
| emptyText | Englisch Have a look around, the new season is in. | Deutsch Schau dich um, die neue Saison ist da. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Warenkorb-Seitenleiste reagiert selbst auf die Tasten Escape und Tab.
- Setzt aria-describedby, aria-expanded, aria-haspopup, aria-invalid, aria-label, aria-labelledby, aria-live, aria-modal, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen dialog, progressbar und status.
- 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
- 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, 907 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { CART_TEXTS, CartLine, computeTotals } from '@/components/ui/cart';
import { FlyUp, ProductMedia, ShopMotion, Swap, TweenPrice, formatPrice, type LineItem, type ShopProduct } from '@/components/ui/product-image';
/*
* The cart as a drawer that opens from the cart button in the store header.
* On phones it is a sheet from the bottom with a handle, from 640 px a panel
* from the right. Focus stays inside while it is open, Escape and the scrim
* close it, focus goes back to the cart button.
*
* progress milestones on one bar (free shipping, a gift), the product that closes the gap is marked
* recommendations a rail of suggestions with pictures, a column inside the wider drawer on desktop
* added the confirmation after "Add to cart", the new item large, the rest folded
* extras save for later, gift wrapping, a note with a counter and the delivery date
*/
export type MiniCartDrawerVariant = 'progress' | 'recommendations' | 'added' | 'extras';
export interface MiniCartMilestone {
/** Subtotal from which the milestone is reached */
at: number;
label: string;
icon?: IconName;
}
export interface MiniCartDrawerProps {
variant?: MiniCartDrawerVariant;
items?: LineItem[];
/** Suggestions with images, added straight into the cart */
recommendations?: ShopProduct[];
/** progress: stops on the bar, smallest first. Without it the bar shows free shipping only. */
milestones?: MiniCartMilestone[];
/** Free shipping from this subtotal on */
freeShippingFrom?: number;
shippingCost?: number;
/** added: id of the line that was just added */
addedId?: string;
/** extras: gift wrapping price, 0 for free */
giftWrap?: { price: number };
/** extras: the expected delivery, e.g. "Thu, 30 Oct" */
delivery?: string;
/** extras: longest order note */
noteMax?: number;
/** The store header the cart button sits in */
header?: { name: React.ReactNode; links?: { label: string; href: string }[] };
/** Open on load, for previews */
defaultOpen?: boolean;
/** The page behind the drawer */
children?: React.ReactNode;
checkoutHref?: string;
cartHref?: string;
/** ISO code, default USD in English and EUR in German */
currency?: string;
onCheckout?: (items: LineItem[]) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
title: 'Your cart',
open: 'Open cart',
close: 'Close cart',
item: 'item',
items: 'items',
subtotal: 'Subtotal',
taxNote: 'Incl. VAT, shipping calculated at checkout',
checkout: 'Check out',
viewCart: 'View cart',
continue: 'Keep shopping',
freeShipping: 'Free shipping',
leftFor: '{goal} with {amount} more',
allDone: 'Everything unlocked, enjoy',
reached: 'Unlocked',
closesGap: 'Closes the gap',
add: 'Add',
added: 'Added',
addNamed: 'Add {name}',
youMayLike: 'Goes well with',
previous: 'Previous suggestions',
next: 'Next suggestions',
addedTitle: 'Added to your cart',
alsoInCart: 'Also in your cart',
removed: 'removed.',
undo: 'Undo',
saveLater: 'Save for later',
saved: 'Saved for later',
moveToCart: 'Move to cart',
giftWrap: 'Wrap it as a gift',
giftWrapNote: 'Recycled paper and a handwritten card',
free: 'Free',
note: 'Note for the order',
notePlaceholder: 'A message for us or the gift card',
noteLeft: '{n} characters left',
noteOver: '{n} characters too many, please shorten the note',
deliveryBy: 'Arrives by {date}',
emptyTitle: 'Your cart is empty',
emptyText: 'Have a look around, the new season is in.',
},
de: {
title: 'Dein Warenkorb',
open: 'Warenkorb öffnen',
close: 'Warenkorb schließen',
item: 'Artikel',
items: 'Artikel',
subtotal: 'Zwischensumme',
taxNote: 'inkl. MwSt., Versand wird an der Kasse berechnet',
checkout: 'Zur Kasse',
viewCart: 'Warenkorb ansehen',
continue: 'Weiter einkaufen',
freeShipping: 'Gratisversand',
leftFor: 'Noch {amount}, dann {goal}',
allDone: 'Alles freigeschaltet, viel Freude',
reached: 'Erreicht',
closesGap: 'Schließt die Lücke',
add: 'Hinzufügen',
added: 'Im Korb',
addNamed: '{name} hinzufügen',
youMayLike: 'Passt dazu',
previous: 'Vorherige Vorschläge',
next: 'Nächste Vorschläge',
addedTitle: 'Im Warenkorb',
alsoInCart: 'Außerdem im Warenkorb',
removed: 'entfernt.',
undo: 'Rückgängig',
saveLater: 'Für später merken',
saved: 'Für später gemerkt',
moveToCart: 'In den Warenkorb',
giftWrap: 'Als Geschenk verpacken',
giftWrapNote: 'Recyclingpapier und eine handgeschriebene Karte',
free: 'Gratis',
note: 'Anmerkung zur Bestellung',
notePlaceholder: 'Eine Nachricht an uns oder für die Geschenkkarte',
noteLeft: 'Noch {n} Zeichen',
noteOver: '{n} Zeichen zu viel, bitte kürze die Anmerkung',
deliveryBy: 'Ankunft bis {date}',
emptyTitle: 'Dein Warenkorb ist leer',
emptyText: 'Schau dich um, die neue Saison ist da.',
},
};
type Texts = (typeof TEXTS)['en'];
type T = Texts & (typeof CART_TEXTS)['en'];
const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const currencyOf = (locale: Locale, currency?: string) => currency ?? (locale === 'de' ? 'EUR' : 'USD');
const toLine = (p: ShopProduct): LineItem => ({ id: p.id, name: p.name, href: p.href, variant: p.subtitle, price: p.price, quantity: 1, image: p.image });
/* ------------------------------------------------------------------ */
/* State */
/* ------------------------------------------------------------------ */
function useLines(initial: LineItem[]) {
const [items, setItems] = useState(initial);
const [removed, setRemoved] = useState<{ item: LineItem; index: number } | null>(null);
const first = useRef(new Set(initial.map((i) => i.id)));
return {
items,
removed,
isNew: (id: string) => !first.current.has(id),
setQty: (id: string, q: number) => setItems((xs) => xs.map((x) => (x.id === id ? { ...x, quantity: q } : x))),
remove: (id: string) =>
setItems((xs) => {
const index = xs.findIndex((x) => x.id === id);
if (index >= 0) setRemoved({ item: xs[index], index });
return xs.filter((x) => x.id !== id);
}),
undo: () => {
if (!removed) return;
setItems((xs) => [...xs.slice(0, removed.index), removed.item, ...xs.slice(removed.index)]);
setRemoved(null);
},
add: (line: LineItem) => {
setRemoved(null);
setItems((xs) => (xs.some((x) => x.id === line.id) ? xs.map((x) => (x.id === line.id ? { ...x, quantity: x.quantity + 1 } : x)) : [...xs, line]));
},
take: (id: string) => setItems((xs) => xs.filter((x) => x.id !== id)),
};
}
/** Open state, Escape, a focus trap and focus back to the trigger on close */
function useDrawer(defaultOpen?: boolean) {
const [open, setOpen] = useState(!!defaultOpen);
const panel = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const was = useRef(open);
useEffect(() => {
// The panel turns visible with its slide, focus moves in a moment later
if (open && !was.current) setTimeout(() => panel.current?.querySelector<HTMLElement>('[data-autofocus]')?.focus(), 60);
if (!open && was.current) trigger.current?.focus();
was.current = open;
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
setOpen(false);
return;
}
if (e.key !== 'Tab' || !panel.current) return;
const list = [...panel.current.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])')].filter((el) => el.offsetParent !== null);
if (!list.length) return;
const firstEl = list[0];
const lastEl = list[list.length - 1];
if (!panel.current.contains(document.activeElement)) {
e.preventDefault();
firstEl.focus();
} else if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
return { open, setOpen, panel, trigger };
}
/* ------------------------------------------------------------------ */
/* Frame: header, scrim and the drawer itself */
/* ------------------------------------------------------------------ */
function Header({ header, count, onOpen, open, trigger, t }: { header?: MiniCartDrawerProps['header']; count: number; onOpen: () => void; open: boolean; trigger: React.RefObject<HTMLButtonElement | null>; t: T }) {
const firstCount = useRef(count);
return (
<header className="sticky top-0 z-30 border-b border-border bg-background/85 backdrop-blur-xl">
<div className="kit-container flex h-16 items-center gap-8">
<span className="text-[17px] font-semibold tracking-[-0.02em] text-foreground">{header?.name}</span>
<nav className="flex gap-6 text-[14px] text-muted-foreground max-md:hidden">
{header?.links?.map((l) => (
<a key={l.label} href={l.href} className="hover:text-foreground">
{l.label}
</a>
))}
</nav>
<button
ref={trigger}
type="button"
onClick={onOpen}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={`${t.open}, ${count} ${count === 1 ? t.item : t.items}`}
className="relative ml-auto grid size-11 place-items-center rounded-lg text-foreground transition-colors hover:bg-accent"
>
<Icon name="bag" size={20} />
{count > 0 && (
<span
key={count}
className={cn(
'absolute -right-0.5 -top-0.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-primary px-1 text-[10px] font-semibold tabular-nums text-primary-foreground ring-2 ring-background',
count !== firstCount.current && 'animate-[kitshop-pop_0.45s_var(--ease-kit)]',
)}
>
{count}
</span>
)}
</button>
</div>
</header>
);
}
function Drawer({
open,
onClose,
panel,
title,
titleExtra,
wide,
children,
footer,
t,
}: {
open: boolean;
onClose: () => void;
panel: React.RefObject<HTMLDivElement | null>;
title: React.ReactNode;
titleExtra?: React.ReactNode;
wide?: boolean;
children: React.ReactNode;
footer: React.ReactNode;
t: T;
}) {
const titleId = useId();
return (
<>
<div onClick={onClose} className={cn('fixed inset-0 z-40 bg-scrim backdrop-blur-[2px] transition-opacity duration-300 motion-reduce:transition-none', open ? 'opacity-100' : 'pointer-events-none opacity-0')} aria-hidden />
<div
ref={panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
inert={!open}
className={cn(
'fixed z-50 flex flex-col bg-background shadow-[var(--shadow-lg)] transition-[transform,visibility] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none',
// Phone: a sheet from the bottom. From 640 px: a panel from the right.
'inset-x-0 bottom-0 max-h-[90dvh] rounded-t-2xl border-t border-border',
'sm:inset-x-auto sm:inset-y-0 sm:right-0 sm:max-h-none sm:w-[27rem] sm:rounded-none sm:border-l sm:border-t-0',
wide && 'lg:w-[46rem]',
open ? 'visible translate-y-0 sm:translate-x-0' : 'invisible translate-y-full sm:translate-x-full sm:translate-y-0',
)}
>
<div className="mx-auto mt-2.5 h-1 w-10 shrink-0 rounded-full bg-border-strong sm:hidden" aria-hidden />
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-2.5 sm:px-6 sm:py-3">
<h2 id={titleId} className="flex min-w-0 items-center gap-2 text-[17px] font-semibold text-foreground">
{title}
{titleExtra}
</h2>
<button type="button" data-autofocus onClick={onClose} aria-label={t.close} className="-mr-2 grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
<Icon name="x" size={19} />
</button>
</div>
{children}
<div className="shrink-0 border-t border-border bg-background px-5 pb-[max(1rem,env(safe-area-inset-bottom))] pt-4 sm:px-6 sm:pb-6 sm:pt-5">{footer}</div>
</div>
</>
);
}
function Count({ n, t }: { n: number; t: T }) {
return (
<span className="font-normal tabular-nums text-muted-foreground">
({n}
<span className="sr-only"> {n === 1 ? t.item : t.items}</span>)
</span>
);
}
function Footer({ subtotal, t, currency, checkoutHref, cartHref, onCheckout, onClose, children, compact, label }: { label?: string; subtotal: number; t: T; currency: string; checkoutHref?: string; cartHref?: string; onCheckout?: () => void; onClose: () => void; children?: React.ReactNode; compact?: boolean }) {
return (
<>
{children}
<div className="flex items-baseline justify-between gap-4">
<span className="text-[15px] font-semibold text-foreground">{label ?? t.subtotal}</span>
<TweenPrice value={subtotal} currency={currency} className="text-[19px] font-semibold tracking-[-0.02em] text-foreground" />
</div>
<p className="mt-0.5 text-[12px] text-muted-foreground">{t.taxNote}</p>
<div className={cn('mt-4 grid gap-2', compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-[auto_minmax(0,1fr)]')}>
{!compact && (
<Button href={cartHref ?? '#'} variant="outline" size="lg" className="max-sm:hidden">
{t.viewCart}
</Button>
)}
<Button href={checkoutHref ?? '#'} size="lg" icon="arrow-right" onClick={onCheckout} className="w-full">
{t.checkout}
</Button>
</div>
<button type="button" onClick={onClose} className="mt-1 flex h-11 w-full items-center justify-center text-[13px] font-medium text-muted-foreground transition-colors hover:text-foreground">
{t.continue}
</button>
</>
);
}
function Removed({ name, onUndo, t }: { name: string; onUndo: () => void; t: T }) {
return (
<div role="status" className="mt-4 flex animate-[kitshop-in_0.4s_var(--ease-kit)] items-center justify-between gap-3 rounded-lg bg-surface-2 px-4 py-2.5 text-[13px] text-muted-foreground">
<span className="min-w-0 truncate">
<span className="font-medium text-foreground">{name}</span> {t.removed}
</span>
<button type="button" onClick={onUndo} className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-md px-2 font-medium text-foreground hover:bg-accent">
<Icon name="undo" size={14} />
{t.undo}
</button>
</div>
);
}
function Empty({ t }: { t: T }) {
return (
<div className="grid flex-1 place-items-center px-6 py-14 text-center">
<div>
<span className="mx-auto grid size-14 place-items-center rounded-2xl border border-border bg-card text-muted-foreground shadow-[var(--highlight),var(--shadow-sm)]">
<Icon name="bag" size={24} />
</span>
<p className="mt-4 text-[15px] font-semibold text-foreground">{t.emptyTitle}</p>
<p className="mt-1 text-[13px] text-muted-foreground">{t.emptyText}</p>
</div>
</div>
);
}
/** Lines of the cart, the ones that arrive later slide in */
function Lines({ lines, t, currency, open, extra }: { lines: ReturnType<typeof useLines>; t: T; currency: string; open: boolean; extra?: (item: LineItem) => React.ReactNode }) {
return (
<>
{lines.removed && <Removed name={lines.removed.item.name} onUndo={lines.undo} t={t} />}
<ul className="divide-y divide-border">
{lines.items.map((i, n) => (
<li
key={i.id}
className={cn('transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'translate-x-0 opacity-100' : 'translate-x-6 opacity-0', lines.isNew(i.id) && 'animate-[kitshop-in_0.5s_var(--ease-kit)_both]')}
style={{ transitionDelay: open ? `${120 + Math.min(n, 5) * 60}ms` : '0ms' }}
>
<CartLine item={i} size="sm" currency={currency} t={t} onQuantity={(q) => lines.setQty(i.id, q)} onRemove={() => lines.remove(i.id)} />
{extra?.(i)}
</li>
))}
</ul>
</>
);
}
/** A small product with an add button, for rails and lists */
function RecCard({ p, onAdd, inCart, t, currency, badge, layout = 'card' }: { p: ShopProduct; onAdd: () => void; inCart: boolean; t: T; currency: string; badge?: string; layout?: 'card' | 'row' | 'icon' }) {
const locale = useLocale();
const [fly, setFly] = useState(0);
const button = (
<span className="relative">
<Button
size="sm"
variant={inCart ? 'secondary' : 'outline'}
disabled={inCart}
onClick={() => { onAdd(); setFly((f) => f + 1); }}
aria-label={inCart ? `${t.added}, ${p.name}` : fill(t.addNamed, { name: p.name })}
className={cn('overflow-hidden', layout === 'card' && 'h-10 w-full max-sm:h-11', layout === 'icon' && 'size-10 rounded-full px-0')}
>
<Swap value={inCart} className="gap-1.5">
<Icon name={inCart ? 'check' : 'plus'} size={15} />
{layout !== 'icon' && (inCart ? t.added : t.add)}
</Swap>
</Button>
<FlyUp count={fly} className="-top-5" />
</span>
);
if (layout === 'row' || layout === 'icon')
return (
<div className="flex items-center gap-3">
<ProductMedia className="size-14 shrink-0 rounded-lg">{p.image}</ProductMedia>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-foreground">{p.name}</p>
<p className="text-[12px] tabular-nums text-muted-foreground">{formatPrice(locale, p.price, currency)}</p>
{badge && <p className="mt-0.5 truncate text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{badge}</p>}
</div>
{button}
</div>
);
return (
<div className="group/rec flex h-full flex-col rounded-xl border border-border bg-card p-2 shadow-[var(--shadow-sm)]">
<ProductMedia className="aspect-square rounded-lg [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/rec:[&>*]:scale-[1.05] motion-reduce:[&>*]:transition-none">{p.image}</ProductMedia>
<p className="mt-2 line-clamp-1 px-1 text-[13px] font-medium text-foreground">{p.name}</p>
<p className="mb-2 px-1 text-[12px] tabular-nums text-muted-foreground">{formatPrice(locale, p.price, currency)}</p>
<div className="mt-auto">{button}</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* progress */
/* ------------------------------------------------------------------ */
function Milestones({ subtotal, stops, currency, t, open }: { subtotal: number; stops: MiniCartMilestone[]; currency: string; t: T; open: boolean }) {
const locale = useLocale();
const max = stops[stops.length - 1].at;
const pct = Math.min(100, round((subtotal / max) * 100, 1));
const next = stops.find((s) => subtotal < s.at);
// The bar grows when the drawer opens, then glides with every change
const shown = open ? pct : 0;
return (
<div>
<p className="flex min-h-5 items-center gap-2 text-[13px] text-foreground" aria-live="polite">
<Icon name={next ? (next.icon ?? 'truck') : 'check-circle'} size={16} className={next ? 'shrink-0 text-muted-foreground' : 'shrink-0 text-success'} />
<span>{next ? fill(t.leftFor, { amount: formatPrice(locale, round(next.at - subtotal), currency), goal: next.label }) : t.allDone}</span>
</p>
<div className="relative mt-3 pb-9">
<div className="h-1.5 overflow-hidden rounded-full bg-track" role="progressbar" aria-label={t.subtotal} aria-valuemin={0} aria-valuemax={max} aria-valuenow={Math.min(subtotal, max)} aria-valuetext={formatPrice(locale, subtotal, currency)}>
<div className="h-full origin-left rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${shown / 100})` }} />
</div>
{stops.map((s, i) => {
const at = round((s.at / max) * 100, 1);
const done = subtotal >= s.at;
const last = i === stops.length - 1;
return (
<div key={s.label} className="absolute top-[3px] flex flex-col items-center" style={last ? { right: 0, transform: 'translateY(-50%)' } : { left: `${at}%`, transform: 'translate(-50%,-50%)' }}>
<span
key={String(done)}
className={cn(
'grid size-6 place-items-center rounded-full border-2 transition-colors duration-500',
done ? 'animate-[kitshop-pop_0.45s_var(--ease-kit)] border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background text-muted-foreground',
)}
>
<Icon name={done ? 'check' : (s.icon ?? 'gift')} size={12} strokeWidth={done ? 2.4 : 1.8} />
</span>
<span className={cn('absolute top-7 whitespace-nowrap text-[11px] font-medium', last ? 'right-0' : 'left-1/2 -translate-x-1/2', done ? 'text-foreground' : 'text-muted-foreground')}>
{s.label}
<span className="sr-only">{done ? `, ${t.reached}` : ''}</span>
</span>
</div>
);
})}
</div>
</div>
);
}
function Progress(props: MiniCartDrawerProps & { t: T; currency: string }) {
const { t, currency } = props;
const d = useDrawer(props.defaultOpen);
const lines = useLines(props.items ?? []);
const totals = computeTotals(lines.items, { freeShippingFrom: props.freeShippingFrom, shippingCost: props.shippingCost });
const stops = props.milestones?.length ? props.milestones : props.freeShippingFrom ? [{ at: props.freeShippingFrom, label: t.freeShipping, icon: 'truck' as IconName }] : [];
const next = stops.find((s) => totals.subtotal < s.at);
const gap = next ? next.at - totals.subtotal : 0;
const recs = (props.recommendations ?? []).filter((r) => !lines.items.some((i) => i.id === r.id));
// The cheapest suggestion that reaches the next milestone gets a mark
const closer = next ? recs.filter((r) => r.price >= gap).sort((a, b) => a.price - b.price)[0] : undefined;
return (
<div className={cn('relative min-h-[100svh]', props.className)}>
<ShopMotion />
<Header header={props.header} count={totals.count} onOpen={() => d.setOpen(true)} open={d.open} trigger={d.trigger} t={t} />
<div aria-hidden={d.open || undefined}>{props.children}</div>
<Drawer
open={d.open}
onClose={() => d.setOpen(false)}
panel={d.panel}
title={t.title}
titleExtra={<Count n={totals.count} t={t} />}
t={t}
footer={<Footer subtotal={totals.subtotal} t={t} currency={currency} checkoutHref={props.checkoutHref} cartHref={props.cartHref} onCheckout={() => props.onCheckout?.(lines.items)} onClose={() => d.setOpen(false)} />}
>
{stops.length > 0 && (
<div className="shrink-0 border-b border-border bg-surface px-5 pt-4 sm:px-6">
<Milestones subtotal={totals.subtotal} stops={stops} currency={currency} t={t} open={d.open} />
</div>
)}
<div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 sm:px-6">
{lines.items.length ? <Lines lines={lines} t={t} currency={currency} open={d.open} /> : <Empty t={t} />}
{recs.length > 0 && (
<section className="mb-5 mt-1 rounded-xl bg-surface-2 p-4" aria-label={t.youMayLike}>
<h3 className="text-[13px] font-medium text-foreground">{t.youMayLike}</h3>
<ul className="mt-3 space-y-3">
{recs.slice(0, 3).map((r) => (
<li key={r.id}>
<RecCard p={r} layout="row" inCart={false} onAdd={() => lines.add(toLine(r))} t={t} currency={currency} badge={closer?.id === r.id ? t.closesGap : undefined} />
</li>
))}
</ul>
</section>
)}
</div>
</Drawer>
</div>
);
}
/* ------------------------------------------------------------------ */
/* recommendations */
/* ------------------------------------------------------------------ */
function Rail({ recs, isIn, onAdd, t, currency }: { recs: ShopProduct[]; isIn: (id: string) => boolean; onAdd: (p: ShopProduct) => void; t: T; currency: string }) {
const row = useRef<HTMLUListElement>(null);
const [edge, setEdge] = useState<'start' | 'mid' | 'end' | 'none'>('start');
const update = () => {
const el = row.current;
if (!el) return;
if (el.scrollWidth <= el.clientWidth + 2) return setEdge('none');
setEdge(el.scrollLeft < 4 ? 'start' : el.scrollLeft + el.clientWidth >= el.scrollWidth - 4 ? 'end' : 'mid');
};
useEffect(update, [recs.length]);
const step = (dir: 1 | -1) => row.current?.scrollBy({ left: dir * row.current.clientWidth * 0.8, behavior: 'smooth' });
return (
<section aria-label={t.youMayLike} className="lg:hidden">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-medium text-foreground">{t.youMayLike}</h3>
<div className="flex gap-1 max-sm:hidden">
<button type="button" onClick={() => step(-1)} disabled={edge === 'start' || edge === 'none'} aria-label={t.previous} className="grid size-9 place-items-center rounded-full border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-35">
<Icon name="chevron-left" size={16} />
</button>
<button type="button" onClick={() => step(1)} disabled={edge === 'end' || edge === 'none'} aria-label={t.next} className="grid size-9 place-items-center rounded-full border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-35">
<Icon name="chevron-right" size={16} />
</button>
</div>
</div>
<ul
ref={row}
onScroll={update}
tabIndex={0}
aria-label={t.youMayLike}
className={cn(
'kit-no-scrollbar -mx-5 flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-px-5 px-5 pb-1 sm:-mx-6 sm:scroll-px-6 sm:px-6',
edge === 'start' && 'kit-fade-end',
edge === 'mid' && 'kit-fade-x',
edge === 'end' && 'kit-fade-start',
)}
>
{recs.map((r) => (
<li key={r.id} className="w-[9.5rem] shrink-0 snap-start">
<RecCard p={r} inCart={isIn(r.id)} onAdd={() => onAdd(r)} t={t} currency={currency} />
</li>
))}
</ul>
</section>
);
}
function Recommendations(props: MiniCartDrawerProps & { t: T; currency: string }) {
const { t, currency } = props;
const d = useDrawer(props.defaultOpen);
const lines = useLines(props.items ?? []);
const totals = computeTotals(lines.items, {});
const recs = props.recommendations ?? [];
const isIn = (id: string) => lines.items.some((i) => i.id === id);
return (
<div className={cn('relative min-h-[100svh]', props.className)}>
<ShopMotion />
<Header header={props.header} count={totals.count} onOpen={() => d.setOpen(true)} open={d.open} trigger={d.trigger} t={t} />
<div aria-hidden={d.open || undefined}>{props.children}</div>
<Drawer
open={d.open}
onClose={() => d.setOpen(false)}
panel={d.panel}
wide
title={t.title}
titleExtra={<Count n={totals.count} t={t} />}
t={t}
footer={<Footer subtotal={totals.subtotal} t={t} currency={currency} checkoutHref={props.checkoutHref} cartHref={props.cartHref} onCheckout={() => props.onCheckout?.(lines.items)} onClose={() => d.setOpen(false)} />}
>
<div className="flex min-h-0 flex-1">
{/* Desktop: suggestions as a column on the left of the wide drawer */}
<aside aria-label={t.youMayLike} className="kit-scrollbar relative w-[16rem] shrink-0 overflow-y-auto border-r border-border bg-surface p-5 max-lg:hidden">
<h3 className="text-[13px] font-medium text-foreground">{t.youMayLike}</h3>
<ul className="mt-4 grid grid-cols-1 gap-3">
{recs.map((r, n) => (
<li key={r.id} className={cn('transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', d.open ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0')} style={{ transitionDelay: d.open ? `${260 + n * 70}ms` : '0ms' }}>
<RecCard p={r} layout="icon" inCart={isIn(r.id)} onAdd={() => lines.add(toLine(r))} t={t} currency={currency} />
</li>
))}
</ul>
</aside>
<div className="kit-scrollbar relative min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain px-5 sm:px-6">
{lines.items.length ? <Lines lines={lines} t={t} currency={currency} open={d.open} /> : <Empty t={t} />}
{recs.length > 0 && (
<div className="mb-5 mt-2">
<Rail recs={recs} isIn={isIn} onAdd={(p) => lines.add(toLine(p))} t={t} currency={currency} />
</div>
)}
</div>
</div>
</Drawer>
</div>
);
}
/* ------------------------------------------------------------------ */
/* added */
/* ------------------------------------------------------------------ */
function Added(props: MiniCartDrawerProps & { t: T; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const d = useDrawer(props.defaultOpen);
const lines = useLines(props.items ?? []);
const totals = computeTotals(lines.items, { freeShippingFrom: props.freeShippingFrom, shippingCost: props.shippingCost });
const fresh = lines.items.find((i) => i.id === props.addedId) ?? lines.items[lines.items.length - 1];
const rest = lines.items.filter((i) => i.id !== fresh?.id);
const recs = (props.recommendations ?? []).slice(0, 3);
const isIn = (id: string) => lines.items.some((i) => i.id === id);
const left = props.freeShippingFrom !== undefined ? round(Math.max(0, props.freeShippingFrom - totals.subtotal)) : 0;
return (
<div className={cn('relative min-h-[100svh]', props.className)}>
<ShopMotion />
<Header header={props.header} count={totals.count} onOpen={() => d.setOpen(true)} open={d.open} trigger={d.trigger} t={t} />
<div aria-hidden={d.open || undefined}>{props.children}</div>
<Drawer
open={d.open}
onClose={() => d.setOpen(false)}
panel={d.panel}
title={
<>
<span key={String(d.open)} className="grid size-6 animate-[kitshop-pop_0.5s_var(--ease-kit)_0.15s_both] place-items-center rounded-full bg-success text-[var(--background)]">
<Icon name="check" size={14} strokeWidth={2.6} />
</span>
{t.addedTitle}
</>
}
t={t}
footer={<Footer compact subtotal={totals.subtotal} t={t} currency={currency} checkoutHref={props.checkoutHref} cartHref={props.cartHref} onCheckout={() => props.onCheckout?.(lines.items)} onClose={() => d.setOpen(false)} />}
>
<div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-5 sm:px-6">
{fresh && (
<div className="mt-5 flex gap-4 rounded-2xl border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)]">
<ProductMedia className="size-24 shrink-0 rounded-xl sm:size-28">{fresh.image}</ProductMedia>
<div className="flex min-w-0 flex-1 flex-col py-1">
<p className="text-[15px] font-semibold text-foreground">{fresh.name}</p>
{fresh.variant && <p className="mt-0.5 text-[13px] text-muted-foreground">{fresh.variant}</p>}
<p className="mt-auto pt-2 text-[15px] font-semibold tabular-nums text-foreground">{formatPrice(locale, fresh.price * fresh.quantity, currency)}</p>
</div>
</div>
)}
{props.freeShippingFrom !== undefined && (
<p className="mt-4 flex items-center gap-2 rounded-lg bg-surface-2 px-3.5 py-2.5 text-[13px] text-foreground">
<Icon name={left ? 'truck' : 'check-circle'} size={16} className={left ? 'shrink-0 text-muted-foreground' : 'shrink-0 text-success'} />
{left ? fill(t.leftFor, { amount: formatPrice(locale, left, currency), goal: t.freeShipping }) : t.allDone}
</p>
)}
{rest.length > 0 && (
<details className="group/rest mt-4 rounded-xl border border-border">
<summary className="flex min-h-12 cursor-pointer list-none items-center gap-3 px-4 text-[13px] font-medium text-foreground [&::-webkit-details-marker]:hidden">
<span className="flex -space-x-2">
{rest.slice(0, 3).map((i) => (
<ProductMedia key={i.id} className="size-7 rounded-full ring-2 ring-background">
{i.image}
</ProductMedia>
))}
</span>
{t.alsoInCart} ({rest.reduce((a, i) => a + i.quantity, 0)})
<Icon name="chevron-down" size={15} className="ml-auto text-muted-foreground transition-transform group-open/rest:rotate-180 motion-reduce:transition-none" />
</summary>
<ul className="divide-y divide-border border-t border-border px-4">
{rest.map((i) => (
<li key={i.id}>
<CartLine item={i} size="sm" currency={currency} t={t} onQuantity={(q) => lines.setQty(i.id, q)} onRemove={() => lines.remove(i.id)} />
</li>
))}
</ul>
</details>
)}
{lines.removed && <Removed name={lines.removed.item.name} onUndo={lines.undo} t={t} />}
{recs.length > 0 && (
<section className="mt-6" aria-label={t.youMayLike}>
<h3 className="mb-3 text-[13px] font-medium text-foreground">{t.youMayLike}</h3>
{/* Phone: rows, from 640 px: three cards */}
<ul className="space-y-3 sm:hidden">
{recs.map((r) => (
<li key={r.id}>
<RecCard p={r} layout="row" inCart={isIn(r.id)} onAdd={() => lines.add(toLine(r))} t={t} currency={currency} />
</li>
))}
</ul>
<ul className="grid grid-cols-3 gap-3 max-sm:hidden">
{recs.map((r, n) => (
<li key={r.id} className={cn('transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', d.open ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0')} style={{ transitionDelay: d.open ? `${300 + n * 80}ms` : '0ms' }}>
<RecCard p={r} inCart={isIn(r.id)} onAdd={() => lines.add(toLine(r))} t={t} currency={currency} />
</li>
))}
</ul>
</section>
)}
</div>
</Drawer>
</div>
);
}
/* ------------------------------------------------------------------ */
/* extras */
/* ------------------------------------------------------------------ */
function Extras(props: MiniCartDrawerProps & { t: T; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const d = useDrawer(props.defaultOpen);
const lines = useLines(props.items ?? []);
const [saved, setSaved] = useState<LineItem[]>([]);
const [wrap, setWrap] = useState(false);
const [note, setNote] = useState('');
const max = props.noteMax ?? 140;
const noteId = useId();
const wrapPrice = props.giftWrap?.price ?? 0;
const totals = computeTotals(lines.items, { extras: wrap ? wrapPrice : 0 });
const over = note.length - max;
const save = (item: LineItem) => {
lines.take(item.id);
setSaved((s) => [item, ...s.filter((x) => x.id !== item.id)]);
};
const back = (item: LineItem) => {
setSaved((s) => s.filter((x) => x.id !== item.id));
lines.add(item);
};
return (
<div className={cn('relative min-h-[100svh]', props.className)}>
<ShopMotion />
<Header header={props.header} count={totals.count} onOpen={() => d.setOpen(true)} open={d.open} trigger={d.trigger} t={t} />
<div aria-hidden={d.open || undefined}>{props.children}</div>
<Drawer
open={d.open}
onClose={() => d.setOpen(false)}
panel={d.panel}
title={t.title}
titleExtra={<Count n={totals.count} t={t} />}
t={t}
footer={
<Footer
label={t.total}
subtotal={totals.total}
t={t}
currency={currency}
checkoutHref={props.checkoutHref}
cartHref={props.cartHref}
onCheckout={() => props.onCheckout?.(lines.items)}
onClose={() => d.setOpen(false)}
>
{props.delivery && (
<p className="mb-3 flex items-center gap-2 text-[13px] text-foreground">
<Icon name="calendar-check" size={16} className="shrink-0 text-success" />
{fill(t.deliveryBy, { date: props.delivery })}
</p>
)}
</Footer>
}
>
<div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-5 sm:px-6">
{lines.items.length ? (
<Lines
lines={lines}
t={t}
currency={currency}
open={d.open}
extra={(i) => (
<button type="button" onClick={() => save(i)} className="-mt-3.5 mb-2 ml-20 inline-flex h-9 items-center max-sm:h-11 gap-1.5 rounded-md px-2 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
<Icon name="bookmark" size={14} />
{t.saveLater}
</button>
)}
/>
) : (
<Empty t={t} />
)}
{props.giftWrap && (
<label className={cn('mt-2 flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', wrap ? 'border-foreground bg-surface' : 'border-border-strong hover:border-foreground/40')}>
<input type="checkbox" checked={wrap} onChange={(e) => setWrap(e.target.checked)} className="sr-only" />
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
<Icon name="gift" size={18} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="text-[14px] font-medium text-foreground">{t.giftWrap}</span>
<span className="shrink-0 text-[13px] tabular-nums text-muted-foreground">{wrapPrice ? `+ ${formatPrice(locale, wrapPrice, currency)}` : t.free}</span>
</span>
<span className="mt-0.5 block text-[12px] text-muted-foreground">{t.giftWrapNote}</span>
</span>
<span aria-hidden className={cn('mt-0.5 grid size-5 shrink-0 place-items-center rounded-[6px] border transition-colors', wrap ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>
{wrap && <Icon name="check" size={13} strokeWidth={2.4} className="animate-[kitshop-pop_0.35s_var(--ease-kit)]" />}
</span>
</label>
)}
<div className="mt-4">
<label htmlFor={noteId} className="mb-1.5 block text-[13px] font-medium text-foreground">
{t.note}
</label>
<textarea
id={noteId}
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t.notePlaceholder}
aria-invalid={over > 0}
aria-describedby={`${noteId}-count`}
className="block w-full resize-none rounded-xl border border-input bg-background px-3.5 py-2.5 text-base text-foreground shadow-[var(--shadow-sm)] outline-none placeholder:text-subtle-foreground focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-[color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-destructive sm:text-[14px]"
/>
<p id={`${noteId}-count`} aria-live="polite" className={cn('mt-1.5 text-right text-[12px] tabular-nums', over > 0 ? 'text-destructive' : 'text-muted-foreground')}>
{over > 0 ? fill(t.noteOver, { n: over }) : fill(t.noteLeft, { n: max - note.length })}
</p>
</div>
{saved.length > 0 && (
<section className="mt-5 border-t border-border pt-4" aria-label={t.saved}>
<h3 className="text-[13px] font-medium text-foreground">
{t.saved} <span className="font-normal text-muted-foreground">({saved.length})</span>
</h3>
<ul className="mt-3 space-y-3">
{saved.map((i) => (
<li key={i.id} className="flex animate-[kitshop-in_0.45s_var(--ease-kit)] items-center gap-3">
<ProductMedia className="size-12 shrink-0 rounded-lg opacity-80">{i.image}</ProductMedia>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-foreground">{i.name}</p>
<p className="text-[12px] tabular-nums text-muted-foreground">{formatPrice(locale, i.price, currency)}</p>
</div>
<Button size="sm" variant="outline" onClick={() => back(i)}>
{t.moveToCart}
</Button>
</li>
))}
</ul>
</section>
)}
</div>
</Drawer>
</div>
);
}
/**
* A cart drawer from the store header: a sheet from the bottom on phones,
* a panel from the right from 640 px. Four layouts for the moments a shop
* needs it, with free shipping milestones, suggestions, a confirmation
* after adding and extras like gift wrapping.
*/
export function MiniCartDrawer(props: MiniCartDrawerProps) {
const locale = useLocale();
const t = { ...useTexts(CART_TEXTS), ...useTexts(TEXTS, props.labels) } as T;
const currency = currencyOf(locale, props.currency);
const { variant = 'progress' } = props;
if (variant === 'recommendations') return <Recommendations {...props} t={t} currency={currency} />;
if (variant === 'added') return <Added {...props} t={t} currency={currency} />;
if (variant === 'extras') return <Extras {...props} t={t} currency={currency} />;
return <Progress {...props} t={t} currency={currency} />;
}Häufige Fragen
Wie installiere ich Warenkorb-Seitenleiste?
Mit aiw add @ai-website/mini-cart-drawer. Der Befehl legt die Datei components/ui/mini-cart-drawer.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, product-image und cart. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Warenkorb-Seitenleiste?
4 Varianten, Etappen, Mit Vorschlägen, Hinzugefügt und Mit Extras. Du wählst sie mit der Prop variant, zum Beispiel variant="recommendations".
Kann ich die Texte von Warenkorb-Seitenleiste übersetzen?
Ja. Warenkorb-Seitenleiste bringt 38 eigene Texte auf Deutsch und Englisch mit, etwa „Dein Warenkorb“, „Warenkorb öffnen“ und „Warenkorb schließen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Warenkorb-Seitenleiste einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Warenkorb-Seitenleiste mit aiw_components und installieren sie mit aiw_add @ai-website/mini-cart-drawer. Nenn im Prompt die Variante, etwa variant="progress", dann schreibt der Assistent deine Inhalte direkt in die Props.