Zum Inhalt springen
UI Kit
Accent

Warenkorb-Seitenleiste

Der Warenkorb als Seitenleiste aus dem Shop-Header. Am Telefon ein Sheet, sonst ein Panel, mit gehaltenem Fokus und Escape.

4 VariantsEine von 4 Components in Cart und Checkout

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

EtappenNeu in 1.2

Gratisversand und ein Geschenk als Etappen auf einem Balken. Jede Etappe springt beim Erreichen auf, die Zeile sagt, was fehlt, und der günstigste Vorschlag, der die Lücke schließt, ist markiert.

Mit VorschlägenNeu in 1.2

Auf dem Desktop wird die Leiste breiter und die Vorschläge stehen als Spalte links darin, auf Tablet und Telefon werden sie zu einer Reihe mit Bildern zum Wischen. Hinzufügen schiebt das Produkt in den Warenkorb.

HinzugefügtNeu in 1.2

Der Moment nach „In den Warenkorb“. Ein Haken springt auf, der neue Artikel steht groß da, der Rest des Warenkorbs ist eingeklappt und darunter warten drei passende Produkte.

Mit ExtrasNeu in 1.2

Jede Position lässt sich für später merken und zurückholen. Die Geschenkverpackung zählt in die Summe, die Anmerkung zählt ihre Zeichen herunter und das Lieferdatum steht über dem Knopf.

Details

Was Warenkorb-Seitenleiste mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle MiniCartDrawerProps aus components/ui/mini-cart-drawer.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    MiniCartDrawerVariant

    progressrecommendationsaddedextras

  • itemsoptional
    LineItem[]
  • recommendationsoptional
    ShopProduct[]

    Suggestions with images, added straight into the cart

  • milestonesoptional
    MiniCartMilestone[]

    progress: stops on the bar, smallest first. Without it the bar shows free shipping only.

  • freeShippingFromoptional
    number

    Free shipping from this subtotal on

  • shippingCostoptional
    number
  • addedIdoptional
    string

    added: id of the line that was just added

  • giftWrapoptional
    { price: number }

    extras: gift wrapping price, 0 for free

  • deliveryoptional
    string

    extras: the expected delivery, e.g. "Thu, 30 Oct"

  • noteMaxoptional
    number

    extras: longest order note

  • headeroptional
    { name: React.ReactNode; links?: { label: string; href: string }[] }

    The store header the cart button sits in

  • defaultOpenoptional
    boolean

    Open on load, for previews

  • childrenoptional
    React.ReactNode

    The page behind the drawer

  • checkoutHrefoptional
    string
  • cartHrefoptional
    string
  • currencyoptional
    string

    ISO 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
titleEnglisch Your cartDeutsch Dein Warenkorb
openEnglisch Open cartDeutsch Warenkorb öffnen
closeEnglisch Close cartDeutsch Warenkorb schließen
itemEnglisch itemDeutsch Artikel
itemsEnglisch itemsDeutsch Artikel
subtotalEnglisch SubtotalDeutsch Zwischensumme
taxNoteEnglisch Incl. VAT, shipping calculated at checkoutDeutsch inkl. MwSt., Versand wird an der Kasse berechnet
checkoutEnglisch Check outDeutsch Zur Kasse
viewCartEnglisch View cartDeutsch Warenkorb ansehen
continueEnglisch Keep shoppingDeutsch Weiter einkaufen
freeShippingEnglisch Free shippingDeutsch Gratisversand
leftForEnglisch {goal} with {amount} moreDeutsch Noch {amount}, dann {goal}
allDoneEnglisch Everything unlocked, enjoyDeutsch Alles freigeschaltet, viel Freude
reachedEnglisch UnlockedDeutsch Erreicht
closesGapEnglisch Closes the gapDeutsch Schließt die Lücke
addEnglisch AddDeutsch Hinzufügen
addedEnglisch AddedDeutsch Im Korb
addNamedEnglisch Add {name}Deutsch {name} hinzufügen
youMayLikeEnglisch Goes well withDeutsch Passt dazu
previousEnglisch Previous suggestionsDeutsch Vorherige Vorschläge
nextEnglisch Next suggestionsDeutsch Nächste Vorschläge
addedTitleEnglisch Added to your cartDeutsch Im Warenkorb
alsoInCartEnglisch Also in your cartDeutsch Außerdem im Warenkorb
removedEnglisch removed.Deutsch entfernt.
undoEnglisch UndoDeutsch Rückgängig
saveLaterEnglisch Save for laterDeutsch Für später merken
savedEnglisch Saved for laterDeutsch Für später gemerkt
moveToCartEnglisch Move to cartDeutsch In den Warenkorb
giftWrapEnglisch Wrap it as a giftDeutsch Als Geschenk verpacken
giftWrapNoteEnglisch Recycled paper and a handwritten cardDeutsch Recyclingpapier und eine handgeschriebene Karte
freeEnglisch FreeDeutsch Gratis
noteEnglisch Note for the orderDeutsch Anmerkung zur Bestellung
notePlaceholderEnglisch A message for us or the gift cardDeutsch Eine Nachricht an uns oder für die Geschenkkarte
noteLeftEnglisch {n} characters leftDeutsch Noch {n} Zeichen
noteOverEnglisch {n} characters too many, please shorten the noteDeutsch {n} Zeichen zu viel, bitte kürze die Anmerkung
deliveryByEnglisch Arrives by {date}Deutsch Ankunft bis {date}
emptyTitleEnglisch Your cart is emptyDeutsch Dein Warenkorb ist leer
emptyTextEnglisch 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
components/ui/mini-cart-drawer.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert