Zum Inhalt springen
UI Kit
Accent

Wunschliste

Gemerkte Produkte als Raster oder Liste, mehrere benannte Listen, eine Liste, aus der Freunde Geschenke reservieren, und Preisalarme mit Preisverlauf.

Eine von 4 Components in Konto und Treue

5 Variants

Variants

Raster

Gemerkte Produkte als Karten mit Preissenkung seit dem Merken, Bestand und „In den Warenkorb“ mit aufsteigendem +1. Das Herz entfernt eine Karte und hinterlässt ein Rückgängig, das abläuft, „Alle“ füllt den Warenkorb auf einmal.

Im Studio öffnen

Liste mit Übersicht

Zeilen mit Optionen, Bestand, Merkdatum und einem Hinweis, sobald Ausverkauftes wieder da ist. Die Sortierung lässt die Zeilen neu einlaufen, die Übersicht daneben zählt Summe und Preissenkungen und legt alles auf einmal in den Warenkorb.

Im Studio öffnen

Listen

Mehrere benannte Listen als Collagen, privat oder geteilt. Die Collage zoomt beim Hover, Karten steigen nacheinander auf, eine neue Liste wird direkt benannt und erscheint vorn.

Im Studio öffnen

Geteilte Geschenkliste

Die Liste, die Freunde über einen Link öffnen. Ein Countdown bis zum Tag, ein Balken, der sich mit jeder Reservierung füllt, Notizen der Person und ein Reservieren-Knopf, der zum Sticker wird. Niemand sieht, wer was reserviert hat.

Im Studio öffnen

Preisalarm

Ein Wunschpreis je Produkt mit einer Preislinie über zwölf Wochen, die sich zeichnet, und dem Wunschpreis als gestrichelter Linie. Plus und Minus verschieben die Linie, ein erreichter Preis lässt die Karte leuchten und macht aus der Aktion „Jetzt kaufen“.

Im Studio öffnen

Details

Was Wunschliste mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/wishlist.tsx
Bringt mit
utilskitbuttoniconrevealmotionsectionselectproduct-image
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 61 eigene Texte
In Sammlungen
Online-Shop

Props

Die Schnittstelle WishlistProps aus components/ui/wishlist.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    WishlistVariant

    gridlistboardssharedprice-watch

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • itemsoptional
    WishItem[]
  • boardsoptional
    WishBoard[]
  • owneroptional
    { name: string; occasion?: string; date?: string; days?: number; avatar?: React.ReactNode; message?: string }

    shared: who made the list and what it is for

  • currencyoptional
    string
  • shareUrloptional
    string

    Link to share the list (grid, boards)

  • onMoveToCartoptional
    (item: WishItem) => void
  • onRemoveoptional
    (item: WishItem) => void
  • onReserveoptional
    (item: WishItem) => void
  • onCreateBoardoptional
    (name: string) => void
  • onAlertoptional
    (item: WishItem, target: number) => void

Eingebaute Texte

Diese Texte schreibt Wunschliste selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 61 Texte anzeigen
savedEnglisch savedDeutsch gemerkt
itemEnglisch itemDeutsch Artikel
itemsEnglisch itemsDeutsch Artikel
shareEnglisch Share listDeutsch Liste teilen
copiedEnglisch Link copiedDeutsch Link kopiert
addAllEnglisch Add all to cartDeutsch Alle in den Warenkorb
addedAllEnglisch All in your cartDeutsch Alles im Warenkorb
moveToCartEnglisch Move to cartDeutsch In den Warenkorb
inCartEnglisch In your cartDeutsch Im Warenkorb
removeEnglisch Remove from wishlistDeutsch Von der Wunschliste entfernen
removeShortEnglisch RemoveDeutsch Entfernen
removedEnglisch RemovedDeutsch Entfernt
undoEnglisch UndoDeutsch Rückgängig
priceDropEnglisch Price droppedDeutsch Preis gesunken
sinceEnglisch since you saved itDeutsch seit du ihn gemerkt hast
inStockEnglisch In stockDeutsch Auf Lager
lowStockEnglisch Only a few leftDeutsch Nur noch wenige da
outOfStockEnglisch Sold outDeutsch Ausverkauft
notifyEnglisch Notify meDeutsch Benachrichtigen
notifyOnEnglisch We will email youDeutsch Wir schreiben dir
sortByEnglisch Sort byDeutsch Sortieren nach
sortAddedEnglisch Recently savedDeutsch Zuletzt gemerkt
sortPriceLowEnglisch Price, low to highDeutsch Preis aufsteigend
sortPriceHighEnglisch Price, high to lowDeutsch Preis absteigend
sortDropEnglisch Biggest price dropDeutsch Größte Preissenkung
totalEnglisch Available itemsDeutsch Verfügbare Artikel
summaryEnglisch Your listDeutsch Deine Liste
availableEnglisch availableDeutsch verfügbar
emptyTitleEnglisch Your wishlist is ready for wishesDeutsch Deine Wunschliste wartet auf Wünsche
emptyTextEnglisch Tap the heart on any product and it waits here, with a note when its price drops.Deutsch Tipp bei einem Produkt auf das Herz, dann wartet es hier, mit Hinweis, sobald der Preis sinkt.
browseEnglisch Browse the storeDeutsch Im Shop stöbern
newListEnglisch New listDeutsch Neue Liste
newListLabelEnglisch Name of the new listDeutsch Name der neuen Liste
newListPlaceholderEnglisch e.g. Kitchen, Gifts for MumDeutsch z. B. Küche, Geschenke für Mama
createEnglisch Create listDeutsch Liste anlegen
cancelEnglisch CancelDeutsch Abbrechen
privateEnglisch PrivateDeutsch Privat
sharedListEnglisch SharedDeutsch Geteilt
openListEnglisch Open listDeutsch Liste öffnen
emptyBoardEnglisch Save products hereDeutsch Merk dir hier Produkte
reserveEnglisch Reserve as a giftDeutsch Als Geschenk reservieren
reservedByYouEnglisch Reserved by youDeutsch Von dir reserviert
reservedOtherEnglisch Already reservedDeutsch Schon reserviert
reservedHintEnglisch Nobody sees who reserved it.Deutsch Niemand sieht, wer reserviert hat.
cancelReserveEnglisch Cancel reservationDeutsch Reservierung aufheben
reservedCountEnglisch reservedDeutsch reserviert
ofEnglisch ofDeutsch von
daysLeftEnglisch days to goDeutsch Tage bis dahin
dayLeftEnglisch day to goDeutsch Tag bis dahin
fromEnglisch A note fromDeutsch Eine Notiz von
wishPriceEnglisch Wished priceDeutsch Wunschpreis
nowEnglisch NowDeutsch Jetzt
lowestEnglisch Lowest in 12 weeksDeutsch Tiefstpreis der letzten 12 Wochen
reachedEnglisch Your price is reachedDeutsch Dein Preis ist erreicht
toGoEnglisch to goDeutsch fehlen noch
alertOnEnglisch Alert onDeutsch Alarm aktiv
setAlertEnglisch Save alertDeutsch Alarm speichern
lowerEnglisch Lower the wished priceDeutsch Wunschpreis senken
raiseEnglisch Raise the wished priceDeutsch Wunschpreis erhöhen
historyEnglisch Price over the last 12 weeksDeutsch Preis der letzten 12 Wochen
buyNowEnglisch Buy nowDeutsch Jetzt kaufen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen img 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

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 7 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.047 Zeilen
components/ui/wishlist.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { NativeSelect } from '@/components/ui/select';
import { FlyUp, Price, ProductMedia, ShopMotion, Swap, TweenPrice, formatNumber, formatPrice } from '@/components/ui/product-image';

/*
 * Saved products: the wishlist page, lists people sort their wishes into,
 * a list shared with friends who reserve gifts, and price alerts.
 *
 *   grid         saved products as cards, move to cart, remove with undo, price drops
 *   list         rows with stock, back-in-stock alerts and a summary to add everything
 *   boards       several named lists as collages, with a new list made in place
 *   shared       the list a friend opens: reserve a gift, progress, the date it is for
 *   price-watch  wished prices per product, a price history and an alert when it is reached
 *
 * Images come in as nodes (`image`), so any <img> works.
 */

export type WishlistVariant = 'grid' | 'list' | 'boards' | 'shared' | 'price-watch';

export interface WishItem {
  id: string;
  name: string;
  href?: string;
  /** Picked options, e.g. "Sage · 350 ml" */
  variant?: string;
  price: number;
  compareAt?: number;
  /** The price on the day it was saved. Lower prices now show as a drop. */
  savedPrice?: number;
  image?: React.ReactNode;
  stock?: 'in' | 'low' | 'out';
  /** e.g. "Only 3 left" or "Back on Nov 12" */
  stockNote?: string;
  /** Already formatted, e.g. "Saved Oct 2" */
  added?: string;
  /** shared: already reserved by someone else */
  reserved?: boolean;
  /** shared: a note from the list owner, e.g. "Size M please" */
  note?: string;
  /** price-watch: the wished price */
  target?: number;
  /** price-watch: prices of the last weeks, oldest first */
  history?: number[];
}

export interface WishBoard {
  id: string;
  name: string;
  count: number;
  /** Up to four images for the collage */
  images?: React.ReactNode[];
  shared?: boolean;
  /** Already formatted, e.g. "Updated 2 days ago" */
  updated?: string;
  href?: string;
}

export interface WishlistProps {
  variant?: WishlistVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  items?: WishItem[];
  boards?: WishBoard[];
  /** shared: who made the list and what it is for */
  owner?: { name: string; occasion?: string; date?: string; days?: number; avatar?: React.ReactNode; message?: string };
  currency?: string;
  /** Link to share the list (grid, boards) */
  shareUrl?: string;
  onMoveToCart?: (item: WishItem) => void;
  onRemove?: (item: WishItem) => void;
  onReserve?: (item: WishItem) => void;
  onCreateBoard?: (name: string) => void;
  onAlert?: (item: WishItem, target: number) => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    saved: 'saved',
    item: 'item',
    items: 'items',
    share: 'Share list',
    copied: 'Link copied',
    addAll: 'Add all to cart',
    addedAll: 'All in your cart',
    moveToCart: 'Move to cart',
    inCart: 'In your cart',
    remove: 'Remove from wishlist',
    removeShort: 'Remove',
    removed: 'Removed',
    undo: 'Undo',
    priceDrop: 'Price dropped',
    since: 'since you saved it',
    inStock: 'In stock',
    lowStock: 'Only a few left',
    outOfStock: 'Sold out',
    notify: 'Notify me',
    notifyOn: 'We will email you',
    sortBy: 'Sort by',
    sortAdded: 'Recently saved',
    sortPriceLow: 'Price, low to high',
    sortPriceHigh: 'Price, high to low',
    sortDrop: 'Biggest price drop',
    total: 'Available items',
    summary: 'Your list',
    available: 'available',
    emptyTitle: 'Your wishlist is ready for wishes',
    emptyText: 'Tap the heart on any product and it waits here, with a note when its price drops.',
    browse: 'Browse the store',
    newList: 'New list',
    newListLabel: 'Name of the new list',
    newListPlaceholder: 'e.g. Kitchen, Gifts for Mum',
    create: 'Create list',
    cancel: 'Cancel',
    private: 'Private',
    sharedList: 'Shared',
    openList: 'Open list',
    emptyBoard: 'Save products here',
    reserve: 'Reserve as a gift',
    reservedByYou: 'Reserved by you',
    reservedOther: 'Already reserved',
    reservedHint: 'Nobody sees who reserved it.',
    cancelReserve: 'Cancel reservation',
    reservedCount: 'reserved',
    of: 'of',
    daysLeft: 'days to go',
    dayLeft: 'day to go',
    from: 'A note from',
    wishPrice: 'Wished price',
    now: 'Now',
    lowest: 'Lowest in 12 weeks',
    reached: 'Your price is reached',
    toGo: 'to go',
    alertOn: 'Alert on',
    setAlert: 'Save alert',
    lower: 'Lower the wished price',
    raise: 'Raise the wished price',
    history: 'Price over the last 12 weeks',
    buyNow: 'Buy now',
  },
  de: {
    saved: 'gemerkt',
    item: 'Artikel',
    items: 'Artikel',
    share: 'Liste teilen',
    copied: 'Link kopiert',
    addAll: 'Alle in den Warenkorb',
    addedAll: 'Alles im Warenkorb',
    moveToCart: 'In den Warenkorb',
    inCart: 'Im Warenkorb',
    remove: 'Von der Wunschliste entfernen',
    removeShort: 'Entfernen',
    removed: 'Entfernt',
    undo: 'Rückgängig',
    priceDrop: 'Preis gesunken',
    since: 'seit du ihn gemerkt hast',
    inStock: 'Auf Lager',
    lowStock: 'Nur noch wenige da',
    outOfStock: 'Ausverkauft',
    notify: 'Benachrichtigen',
    notifyOn: 'Wir schreiben dir',
    sortBy: 'Sortieren nach',
    sortAdded: 'Zuletzt gemerkt',
    sortPriceLow: 'Preis aufsteigend',
    sortPriceHigh: 'Preis absteigend',
    sortDrop: 'Größte Preissenkung',
    total: 'Verfügbare Artikel',
    summary: 'Deine Liste',
    available: 'verfügbar',
    emptyTitle: 'Deine Wunschliste wartet auf Wünsche',
    emptyText: 'Tipp bei einem Produkt auf das Herz, dann wartet es hier, mit Hinweis, sobald der Preis sinkt.',
    browse: 'Im Shop stöbern',
    newList: 'Neue Liste',
    newListLabel: 'Name der neuen Liste',
    newListPlaceholder: 'z. B. Küche, Geschenke für Mama',
    create: 'Liste anlegen',
    cancel: 'Abbrechen',
    private: 'Privat',
    sharedList: 'Geteilt',
    openList: 'Liste öffnen',
    emptyBoard: 'Merk dir hier Produkte',
    reserve: 'Als Geschenk reservieren',
    reservedByYou: 'Von dir reserviert',
    reservedOther: 'Schon reserviert',
    reservedHint: 'Niemand sieht, wer reserviert hat.',
    cancelReserve: 'Reservierung aufheben',
    reservedCount: 'reserviert',
    of: 'von',
    daysLeft: 'Tage bis dahin',
    dayLeft: 'Tag bis dahin',
    from: 'Eine Notiz von',
    wishPrice: 'Wunschpreis',
    now: 'Jetzt',
    lowest: 'Tiefstpreis der letzten 12 Wochen',
    reached: 'Dein Preis ist erreicht',
    toGo: 'fehlen noch',
    alertOn: 'Alarm aktiv',
    setAlert: 'Alarm speichern',
    lower: 'Wunschpreis senken',
    raise: 'Wunschpreis erhöhen',
    history: 'Preis der letzten 12 Wochen',
    buyNow: 'Jetzt kaufen',
  },
};

type Texts = (typeof TEXTS)['en'];

const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const SUCCESS_TEXT = 'text-success-foreground';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

function Head({ eyebrow, title, description, aside }: { eyebrow?: string; title?: React.ReactNode; description?: string; aside?: React.ReactNode }) {
  if (!title && !aside) return null;
  return (
    <div className="mb-10 flex flex-col gap-6 sm:mb-12 sm:flex-row sm:items-end sm:justify-between sm:gap-10">
      <div className="max-w-[40rem]">
        {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
        {title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>}
        {description && (
          <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
            {description}
          </p>
        )}
      </div>
      {aside && <div className="flex shrink-0 flex-wrap items-center gap-3">{aside}</div>}
    </div>
  );
}

/** Copies the share link and says so for a moment */
function ShareButton({ url, t }: { url?: string; t: Texts }) {
  const [done, setDone] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  useEffect(() => () => clearTimeout(timer.current), []);
  return (
    <Button
      variant="outline"
      onClick={() => {
        const link = url ?? (typeof window !== 'undefined' ? window.location.href : '');
        navigator.clipboard?.writeText(link).catch(() => {});
        setDone(true);
        clearTimeout(timer.current);
        timer.current = setTimeout(() => setDone(false), 1800);
      }}
    >
      <Swap value={done} className="gap-2">
        <Icon name={done ? 'check' : 'share'} size={16} />
        {done ? t.copied : t.share}
      </Swap>
    </Button>
  );
}

function Stock({ item, t }: { item: WishItem; t: Texts }) {
  const s = item.stock ?? 'in';
  const text = item.stockNote ?? (s === 'out' ? t.outOfStock : s === 'low' ? t.lowStock : t.inStock);
  return (
    <span className={cn('inline-flex items-center gap-1.5 text-[12.5px]', s === 'out' ? 'text-muted-foreground' : s === 'low' ? 'text-warning-foreground' : SUCCESS_TEXT)}>
      <span aria-hidden className={cn('size-1.5 rounded-full', s === 'out' ? 'bg-subtle-foreground' : s === 'low' ? 'bg-warning' : 'bg-success')} />
      {text}
    </span>
  );
}

/** "Price dropped €8 since you saved it" */
function Drop({ item, currency, t, compact }: { item: WishItem; currency?: string; t: Texts; compact?: boolean }) {
  const locale = useLocale();
  if (!item.savedPrice || item.savedPrice <= item.price) return null;
  const diff = item.savedPrice - item.price;
  return (
    <span className="inline-flex items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-2 py-0.5 text-[12px] font-medium text-success-foreground">
      <Icon name="trending-down" size={13} />
      {compact ? `-${formatPrice(locale, diff, currency)}` : `${t.priceDrop} ${formatPrice(locale, diff, currency)}`}
    </span>
  );
}

/** A heart that fills, used as the remove button on saved items */
function HeartButton({ onClick, label, className }: { onClick: () => void; label: string; className?: string }) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={label}
      className={cn('group/heart grid size-11 place-items-center rounded-full bg-background/90 text-foreground shadow-[var(--shadow-sm)] backdrop-blur transition-transform duration-200 hover:scale-105 active:scale-90', FOCUS, className)}
    >
      <svg viewBox="0 0 24 24" className="size-[18px] text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))] transition-transform duration-300 group-hover/heart:scale-90" aria-hidden>
        <path d="M12 20.3 4.6 13a4.7 4.7 0 0 1 6.6-6.7l.8.8.8-.8a4.7 4.7 0 0 1 6.6 6.7Z" fill="currentColor" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
      </svg>
    </button>
  );
}

/** Items that can be removed with an undo, and moved to the cart */
function useSaved(initial: WishItem[], onRemove?: (i: WishItem) => void) {
  const [items, setItems] = useState(initial);
  const [ghosts, setGhosts] = useState<Record<string, boolean>>({});
  const [inCart, setInCart] = useState<Record<string, boolean>>({});
  const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
  useEffect(() => () => Object.values(timers.current).forEach(clearTimeout), []);
  const remove = (item: WishItem) => {
    setGhosts((g) => ({ ...g, [item.id]: true }));
    timers.current[item.id] = setTimeout(() => {
      setItems((list) => list.filter((x) => x.id !== item.id));
      setGhosts((g) => {
        const { [item.id]: _, ...rest } = g;
        return rest;
      });
      onRemove?.(item);
    }, 5000);
  };
  const undo = (id: string) => {
    clearTimeout(timers.current[id]);
    setGhosts((g) => {
      const { [id]: _, ...rest } = g;
      return rest;
    });
  };
  return { items, ghosts, inCart, setInCart, remove, undo };
}

function Ghost({ name, onUndo, t, className }: { name: string; onUndo: () => void; t: Texts; className?: string }) {
  return (
    <div className={cn('flex animate-[kitshop-fade_0.35s_var(--ease-kit)] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border-strong p-6 text-center', className)} role="status">
      <p className="text-[13.5px] text-muted-foreground">
        <span className="font-medium text-foreground">{name}</span> · {t.removed}
      </p>
      <Button variant="outline" size="sm" iconLeft="undo" onClick={onUndo}>
        {t.undo}
      </Button>
      <span aria-hidden className="relative h-0.5 w-24 overflow-hidden rounded-full bg-border">
        <span className="absolute inset-0 origin-left animate-kit-progress rounded-full bg-foreground [animation-direction:reverse] [--kit-duration:5s] [animation-duration:5s] [animation-timing-function:linear]" />
      </span>
    </div>
  );
}

function Empty({ t }: { t: Texts }) {
  return (
    <div className="mx-auto flex max-w-[26rem] flex-col items-center py-16 text-center">
      <span className="grid size-14 animate-[kitshop-pop_0.5s_var(--ease-kit)] place-items-center rounded-full bg-muted text-muted-foreground">
        <Icon name="heart" size={22} />
      </span>
      <h3 className="mt-5 text-[18px] font-semibold text-foreground">{t.emptyTitle}</h3>
      <p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{t.emptyText}</p>
      <Button href="#" variant="outline" className="mt-6" icon="arrow-right">
        {t.browse}
      </Button>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* grid                                                                */
/* ------------------------------------------------------------------ */

function Grid(p: WishlistProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const saved = useSaved(p.items ?? [], p.onRemove);
  const [flyCount, setFlyCount] = useState<Record<string, number>>({});
  const live = saved.items.filter((i) => !saved.ghosts[i.id]);
  const allIn = live.filter((i) => i.stock !== 'out').every((i) => saved.inCart[i.id]);
  const move = (item: WishItem) => {
    saved.setInCart((c) => ({ ...c, [item.id]: true }));
    setFlyCount((f) => ({ ...f, [item.id]: (f[item.id] ?? 0) + 1 }));
    p.onMoveToCart?.(item);
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head
        eyebrow={p.eyebrow}
        title={
          <>
            {p.title} <span className="font-normal text-subtle-foreground tabular-nums">{formatNumber(locale, live.length)}</span>
          </>
        }
        description={p.description}
        aside={
          live.length > 0 && (
            <>
              <ShareButton url={p.shareUrl} t={t} />
              <Button
                iconLeft={allIn ? 'check' : 'bag'}
                disabled={allIn}
                onClick={() => live.filter((i) => i.stock !== 'out' && !saved.inCart[i.id]).forEach(move)}
              >
                <Swap value={allIn}>{allIn ? t.addedAll : t.addAll}</Swap>
              </Button>
            </>
          )
        }
      />
      {saved.items.length === 0 ? (
        <Empty t={t} />
      ) : (
        <Stagger step={70} as="ul" className="grid grid-cols-2 gap-x-3 gap-y-8 sm:gap-x-5 lg:grid-cols-4 lg:gap-x-6">
          {saved.items.map((item) => (
            <li key={item.id} className="flex">
              {saved.ghosts[item.id] ? (
                <Ghost name={item.name} onUndo={() => saved.undo(item.id)} t={t} className="w-full" />
              ) : (
                <article className="group/card flex w-full flex-col">
                  <div className="relative">
                    <a href={item.href ?? '#'} tabIndex={-1} aria-hidden className="block">
                      <ProductMedia className={cn('aspect-[4/5] transition-[filter] duration-500', item.stock === 'out' && 'grayscale-[0.6]')}>
                        <span className="block size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/card:scale-[1.04] motion-reduce:transition-none [&>*]:size-full [&>*]:object-cover">{item.image}</span>
                      </ProductMedia>
                    </a>
                    <HeartButton onClick={() => saved.remove(item)} label={`${t.remove}, ${item.name}`} className="absolute right-2 top-2" />
                    {item.savedPrice && item.savedPrice > item.price && (
                      <span className="absolute bottom-2 left-2">
                        <Drop item={item} currency={currency} t={t} compact />
                      </span>
                    )}
                  </div>
                  <div className="flex flex-1 flex-col pt-3.5">
                    <h3 className="text-[14.5px] font-medium leading-snug text-foreground">
                      <a href={item.href ?? '#'} className={cn('rounded-sm hover:underline hover:decoration-border-strong hover:underline-offset-4', FOCUS)}>
                        {item.name}
                      </a>
                    </h3>
                    {item.variant && <p className="mt-0.5 text-[12.5px] text-muted-foreground">{item.variant}</p>}
                    <Price value={item.price} compareAt={item.compareAt ?? (item.savedPrice && item.savedPrice > item.price ? item.savedPrice : undefined)} currency={currency} className="mt-2" />
                    <div className="mt-1.5">
                      <Stock item={item} t={t} />
                    </div>
                    <div className="relative mt-auto pt-4">
                      <FlyUp count={flyCount[item.id] ?? 0} />
                      {item.stock === 'out' ? (
                        <NotifyButton t={t} />
                      ) : (
                        <Button variant={saved.inCart[item.id] ? 'secondary' : 'outline'} size="sm" className="h-11 w-full sm:h-10" onClick={() => move(item)} disabled={saved.inCart[item.id]}>
                          <Swap value={!!saved.inCart[item.id]} className="gap-1.5">
                            <Icon name={saved.inCart[item.id] ? 'check' : 'bag'} size={15} />
                            {saved.inCart[item.id] ? t.inCart : t.moveToCart}
                          </Swap>
                        </Button>
                      )}
                    </div>
                  </div>
                </article>
              )}
            </li>
          ))}
        </Stagger>
      )}
    </Section>
  );
}

function NotifyButton({ t, className }: { t: Texts; className?: string }) {
  const [on, setOn] = useState(false);
  return (
    <Button variant={on ? 'secondary' : 'outline'} size="sm" aria-pressed={on} className={cn('h-11 w-full sm:h-10', className)} onClick={() => setOn(!on)}>
      <Swap value={on} className="gap-1.5">
        <Icon name={on ? 'check' : 'bell'} size={15} />
        {on ? t.notifyOn : t.notify}
      </Swap>
    </Button>
  );
}

/* ------------------------------------------------------------------ */
/* list                                                                */
/* ------------------------------------------------------------------ */

type ListSort = 'added' | 'low' | 'high' | 'drop';

function List(p: WishlistProps & { t: Texts }) {
  const { t, currency } = p;
  const locale = useLocale();
  const saved = useSaved(p.items ?? [], p.onRemove);
  const [sort, setSort] = useState<ListSort>('added');
  const order = [...saved.items];
  const drop = (i: WishItem) => (i.savedPrice ?? i.price) - i.price;
  if (sort === 'low') order.sort((a, b) => a.price - b.price);
  if (sort === 'high') order.sort((a, b) => b.price - a.price);
  if (sort === 'drop') order.sort((a, b) => drop(b) - drop(a));
  const live = saved.items.filter((i) => !saved.ghosts[i.id]);
  const open = live.filter((i) => i.stock !== 'out' && !saved.inCart[i.id]);
  const total = open.reduce((s, i) => s + i.price, 0);
  const savings = open.reduce((s, i) => s + Math.max(0, drop(i)), 0);
  const addAll = () => {
    saved.setInCart((c) => ({ ...c, ...Object.fromEntries(open.map((i) => [i.id, true])) }));
    open.forEach((i) => p.onMoveToCart?.(i));
  };
  const summary = (
    <div className={cn(CARD, 'p-6')}>
      <h3 className="text-[15px] font-semibold text-foreground">{t.summary}</h3>
      <dl className="mt-4 space-y-2.5 text-[14px]">
        <div className="flex justify-between gap-4">
          <dt className="text-muted-foreground">
            {formatNumber(locale, live.length)} {live.length === 1 ? t.item : t.items}
          </dt>
          <dd className="tabular-nums text-foreground">
            {formatNumber(locale, live.filter((i) => i.stock !== 'out').length)} {t.available}
          </dd>
        </div>
        {savings > 0 && (
          <div className="flex justify-between gap-4">
            <dt className="text-muted-foreground">{t.priceDrop}</dt>
            <dd className="tabular-nums text-success-foreground">-<TweenPrice value={savings} currency={currency} /></dd>
          </div>
        )}
      </dl>
      <div className="mt-4 flex items-baseline justify-between gap-4 border-t border-border pt-4">
        <span className="text-[14px] text-muted-foreground">{t.total}</span>
        <TweenPrice value={total} currency={currency} className="text-[20px] font-semibold tracking-[-0.02em] text-foreground" />
      </div>
      <Button className="mt-5 w-full" size="lg" iconLeft={open.length ? 'bag' : 'check'} disabled={!open.length} onClick={addAll}>
        <Swap value={open.length === 0}>{open.length ? t.addAll : t.addedAll}</Swap>
      </Button>
      <div className="mt-3 [&>*]:w-full">
        <ShareButton url={p.shareUrl} t={t} />
      </div>
    </div>
  );
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-12">
        <div className="min-w-0">
          <div className="flex items-center justify-between gap-4 border-b border-border pb-4">
            <p className="text-[13.5px] text-muted-foreground" aria-live="polite">
              <span className="font-medium tabular-nums text-foreground">{formatNumber(locale, live.length)}</span> {live.length === 1 ? t.item : t.items} {t.saved}
            </p>
            <NativeSelect
              aria-label={t.sortBy}
              value={sort}
              onChange={(e) => setSort(e.target.value as ListSort)}
              className="w-[12.5rem]"
              options={[
                { value: 'added', label: t.sortAdded },
                { value: 'low', label: t.sortPriceLow },
                { value: 'high', label: t.sortPriceHigh },
                { value: 'drop', label: t.sortDrop },
              ]}
            />
          </div>
          {saved.items.length === 0 ? (
            <Empty t={t} />
          ) : (
            <ul key={sort}>
              {order.map((item, n) => (
                <li key={item.id} className="border-b border-border py-5 animate-[kitshop-in_0.5s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(n, 6) * 60}ms` }}>
                  {saved.ghosts[item.id] ? (
                    <div className="flex min-h-24 animate-[kitshop-fade_0.35s] items-center justify-between gap-4 rounded-xl bg-surface px-4" role="status">
                      <p className="text-[13.5px] text-muted-foreground">
                        <span className="font-medium text-foreground">{item.name}</span> · {t.removed}
                      </p>
                      <Button variant="outline" size="sm" iconLeft="undo" onClick={() => saved.undo(item.id)}>
                        {t.undo}
                      </Button>
                    </div>
                  ) : (
                    <div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-4 sm:grid-cols-[7rem_minmax(0,1fr)_auto] sm:items-center sm:gap-6">
                      <ProductMedia className={cn('aspect-square sm:row-span-1', item.stock === 'out' && 'grayscale-[0.6]')}>{item.image}</ProductMedia>
                      <div className="min-w-0">
                        <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
                          <h3 className="text-[15px] font-medium text-foreground">
                            <a href={item.href ?? '#'} className={cn('rounded-sm hover:underline hover:decoration-border-strong hover:underline-offset-4', FOCUS)}>
                              {item.name}
                            </a>
                          </h3>
                          <Drop item={item} currency={currency} t={t} compact />
                        </div>
                        {item.variant && <p className="mt-0.5 text-[13px] text-muted-foreground">{item.variant}</p>}
                        <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
                          <Price value={item.price} compareAt={item.savedPrice && item.savedPrice > item.price ? item.savedPrice : item.compareAt} currency={currency} />
                          <Stock item={item} t={t} />
                        </div>
                        {item.added && <p className="mt-1 text-[12.5px] text-subtle-foreground">{item.added}</p>}
                      </div>
                      <div className="col-span-2 flex items-center gap-2 sm:col-span-1 sm:flex-col sm:items-stretch">
                        {item.stock === 'out' ? (
                          <NotifyButton t={t} className="flex-1 sm:w-44" />
                        ) : (
                          <Button variant={saved.inCart[item.id] ? 'secondary' : 'outline'} size="sm" className="h-11 flex-1 sm:h-10 sm:w-44" disabled={saved.inCart[item.id]} onClick={() => { saved.setInCart((c) => ({ ...c, [item.id]: true })); p.onMoveToCart?.(item); }}>
                            <Swap value={!!saved.inCart[item.id]} className="gap-1.5">
                              <Icon name={saved.inCart[item.id] ? 'check' : 'bag'} size={15} />
                              {saved.inCart[item.id] ? t.inCart : t.moveToCart}
                            </Swap>
                          </Button>
                        )}
                        <button type="button" onClick={() => saved.remove(item)} aria-label={`${t.remove}, ${item.name}`} className={cn('inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-lg px-3 text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:h-9', FOCUS)}>
                          <Icon name="trash" size={15} />
                          <span className="max-sm:sr-only">{t.removeShort}</span>
                        </button>
                      </div>
                    </div>
                  )}
                </li>
              ))}
            </ul>
          )}
        </div>
        <Reveal effect="rise" className="hidden lg:sticky lg:top-8 lg:block lg:self-start">
          {summary}
        </Reveal>
        {/* Phones and tablets: the summary becomes a bar that stays at the bottom while the list scrolls */}
        <div className="sticky bottom-3 z-10 -mx-2 lg:hidden">
          <div className="flex items-center gap-4 rounded-2xl border border-border bg-background/92 p-3 ps-5 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-md">
            <div className="min-w-0 flex-1">
              <p className="truncate text-[12.5px] text-muted-foreground">{t.total}</p>
              <TweenPrice value={total} currency={currency} className="text-[18px] font-semibold tracking-[-0.02em] text-foreground" />
            </div>
            <Button iconLeft={open.length ? 'bag' : 'check'} disabled={!open.length} onClick={addAll}>
              <Swap value={open.length === 0}>{open.length ? t.addAll : t.addedAll}</Swap>
            </Button>
          </div>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* boards                                                              */
/* ------------------------------------------------------------------ */

function Collage({ images, t }: { images?: React.ReactNode[]; t: Texts }) {
  const list = (images ?? []).slice(0, 4);
  if (!list.length)
    return (
      <div className="grid aspect-[4/3] place-items-center rounded-xl bg-surface-2 text-center text-[13px] text-muted-foreground">
        <span className="grid justify-items-center gap-2">
          <Icon name="heart" size={20} />
          {t.emptyBoard}
        </span>
      </div>
    );
  const big = list[0];
  const small = list.slice(1);
  return (
    <div className="grid aspect-[4/3] grid-cols-[minmax(0,2fr)_minmax(0,1fr)] grid-rows-3 gap-1 overflow-hidden rounded-xl">
      <div className={cn('relative overflow-hidden bg-surface-2', small.length ? 'row-span-3' : 'col-span-2 row-span-3')}>
        <span className="absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover/board:scale-[1.05] motion-reduce:transition-none [&>*]:size-full [&>*]:object-cover">{big}</span>
      </div>
      {small.map((img, i) => (
        <div key={i} className={cn('relative overflow-hidden bg-surface-2', small.length === 1 && 'row-span-3', small.length === 2 && (i === 0 ? 'row-span-2' : ''))}>
          <span
            className="absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover/board:scale-[1.08] motion-reduce:transition-none [&>*]:size-full [&>*]:object-cover"
            style={{ transitionDelay: `${(i + 1) * 60}ms` }}
          >
            {img}
          </span>
        </div>
      ))}
    </div>
  );
}

function Boards(p: WishlistProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const [boards, setBoards] = useState<WishBoard[]>(p.boards ?? []);
  const [creating, setCreating] = useState(false);
  const [name, setName] = useState('');
  const [fresh, setFresh] = useState<string | null>(null);
  const inputId = useId();
  const create = (e: React.FormEvent) => {
    e.preventDefault();
    const n = name.trim();
    if (!n) return;
    const id = `new-${boards.length}`;
    setBoards((b) => [{ id, name: n, count: 0, images: [] }, ...b]);
    setFresh(id);
    setName('');
    setCreating(false);
    p.onCreateBoard?.(n);
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
        <li>
          {creating ? (
            <form onSubmit={create} className={cn(CARD, 'flex h-full animate-[kitshop-zoom_0.4s_var(--ease-kit)] flex-col justify-center gap-4 p-5 sm:min-h-[18rem] sm:p-6')}>
              <label htmlFor={inputId} className="text-[15px] font-semibold text-foreground">
                {t.newListLabel}
              </label>
              <input
                id={inputId}
                autoFocus
                value={name}
                onChange={(e) => setName(e.target.value)}
                placeholder={t.newListPlaceholder}
                maxLength={40}
                className="h-11 w-full rounded-lg border border-input bg-background px-3.5 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none placeholder:text-subtle-foreground focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]"
              />
              <div className="flex gap-2">
                <Button type="submit" disabled={!name.trim()} iconLeft="plus">
                  {t.create}
                </Button>
                <Button type="button" variant="ghost" onClick={() => setCreating(false)}>
                  {t.cancel}
                </Button>
              </div>
            </form>
          ) : (
            <button
              type="button"
              onClick={() => setCreating(true)}
              className={cn('group/new flex h-full min-h-[4.5rem] w-full items-center justify-center gap-3 rounded-2xl border border-dashed sm:min-h-[18rem] sm:flex-col border-border-strong text-muted-foreground transition-[background-color,border-color,color] duration-300 hover:border-foreground hover:bg-surface hover:text-foreground', FOCUS)}
            >
              <span className="grid size-10 place-items-center rounded-full bg-muted transition-transform sm:size-12 duration-500 ease-[var(--ease-kit)] group-hover/new:rotate-90 group-hover/new:scale-110">
                <Icon name="plus" size={20} />
              </span>
              <span className="text-[15px] font-medium">{t.newList}</span>
            </button>
          )}
        </li>
        {boards.map((b, i) => (
          <Reveal as="li" key={b.id} effect="rise" delay={Math.min(i, 5) * 80}>
            <a
              href={b.href ?? '#'}
              className={cn('group/board block h-full rounded-2xl border border-border bg-card p-2.5 shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:transition-none motion-reduce:hover:translate-y-0', fresh === b.id && 'animate-[kitshop-pop_0.5s_var(--ease-kit)]', FOCUS)}
              aria-label={`${t.openList}, ${b.name}, ${formatNumber(locale, b.count)} ${b.count === 1 ? t.item : t.items}`}
            >
              <Collage images={b.images} t={t} />
              <div className="flex items-start justify-between gap-3 px-2 pb-1.5 pt-4">
                <div className="min-w-0">
                  <h3 className="truncate text-[16px] font-semibold tracking-[-0.01em] text-foreground">{b.name}</h3>
                  <p className="mt-0.5 text-[13px] text-muted-foreground">
                    {formatNumber(locale, b.count)} {b.count === 1 ? t.item : t.items}
                    {b.updated && ` · ${b.updated}`}
                  </p>
                </div>
                <span className={cn('inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium', b.shared ? 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'bg-muted text-muted-foreground')}>
                  <Icon name={b.shared ? 'users' : 'lock'} size={13} />
                  {b.shared ? t.sharedList : t.private}
                </span>
              </div>
            </a>
          </Reveal>
        ))}
      </ul>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* shared                                                              */
/* ------------------------------------------------------------------ */

function Shared(p: WishlistProps & { t: Texts }) {
  const { t, currency, owner } = p;
  const locale = useLocale();
  const items = p.items ?? [];
  const [mine, setMine] = useState<Record<string, boolean>>({});
  const reservedCount = items.filter((i) => i.reserved || mine[i.id]).length;
  const [ref, inView] = useInView<HTMLDivElement>();
  const share = items.length ? reservedCount / items.length : 0;
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-end lg:gap-16">
        <div>
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title}</h2>
          {owner?.message && (
            <figure className="kit-rise mt-6 flex max-w-[36rem] gap-4" style={{ '--kit-delay': '100ms' } as React.CSSProperties}>
              {owner.avatar && <span className="size-11 shrink-0 overflow-hidden rounded-full bg-muted [&>*]:size-full [&>*]:object-cover">{owner.avatar}</span>}
              <div>
                <figcaption className="text-[13px] text-muted-foreground">
                  {t.from} {owner.name}
                </figcaption>
                <blockquote className="mt-1 text-pretty text-[16px] leading-relaxed text-foreground">{owner.message}</blockquote>
              </div>
            </figure>
          )}
        </div>
        <div ref={ref} className={cn(CARD, 'p-6 sm:grid sm:grid-cols-2 sm:items-end sm:gap-8 lg:block')}>
          <div>
          {owner?.days !== undefined && (
            <p className="flex items-baseline gap-2">
              <span className="text-[40px] font-semibold leading-none tracking-[-0.04em] text-foreground tabular-nums">{formatNumber(locale, owner.days)}</span>
              <span className="text-[14px] text-muted-foreground">{owner.days === 1 ? t.dayLeft : t.daysLeft}</span>
            </p>
          )}
          {(owner?.occasion || owner?.date) && <p className="mt-1.5 text-[14px] text-foreground">{[owner?.occasion, owner?.date].filter(Boolean).join(' · ')}</p>}
          </div>
          <div>
          <div className="mt-5 sm:mt-0 lg:mt-5">
            <div className="flex justify-between text-[13px] text-muted-foreground">
              <span>
                <Swap value={reservedCount}>
                  <span className="font-medium tabular-nums text-foreground">{formatNumber(locale, reservedCount)}</span>
                </Swap>{' '}
                {t.of} {formatNumber(locale, items.length)} {t.reservedCount}
              </span>
            </div>
            <span className="relative mt-2 block h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" aria-hidden>
              <span className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(share, 3) : 0})` }} />
            </span>
          </div>
          <p className="mt-4 flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
            <Icon name="eye-off" size={14} />
            {t.reservedHint}
          </p>
          </div>
        </div>
      </div>

      <Stagger step={70} as="ul" className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
        {items.map((item) => {
          const other = !!item.reserved;
          const me = !!mine[item.id];
          return (
            <li key={item.id}>
              <article className={cn(CARD, 'flex h-full gap-4 p-3 pe-4 sm:flex-col sm:p-2.5 sm:pe-2.5')}>
                <div className="relative w-28 shrink-0 sm:w-auto">
                  <ProductMedia className={cn('aspect-square transition-[filter,opacity] duration-500 sm:aspect-[4/3]', other && 'opacity-60 grayscale')}>{item.image}</ProductMedia>
                  {me && (
                    <span className="absolute left-2 top-2 grid size-8 animate-[kitshop-pop_0.4s_var(--ease-kit)] place-items-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-sm)] sm:inline-flex sm:h-7 sm:w-auto sm:gap-1 sm:px-2.5 sm:text-[12px] sm:font-medium">
                      <Icon name="gift" size={13} />
                      <span className="max-sm:sr-only">{t.reservedByYou}</span>
                    </span>
                  )}
                </div>
                <div className="flex min-w-0 flex-1 flex-col sm:px-2.5 sm:pb-2.5 sm:pt-1.5">
                  <h3 className="text-[15px] font-medium text-foreground">
                    <a href={item.href ?? '#'} className={cn('rounded-sm hover:underline hover:decoration-border-strong hover:underline-offset-4', FOCUS)}>
                      {item.name}
                    </a>
                  </h3>
                  {item.variant && <p className="mt-0.5 text-[13px] text-muted-foreground">{item.variant}</p>}
                  <Price value={item.price} currency={currency} className="mt-1.5" />
                  {item.note && (
                    <p className="mt-2.5 flex items-start gap-1.5 text-pretty text-[13px] text-muted-foreground">
                      <Icon name="quote" size={13} className="mt-0.5 shrink-0" />
                      {item.note}
                    </p>
                  )}
                  <div className="mt-auto pt-4">
                    {other ? (
                      <p className="flex h-11 items-center justify-center gap-1.5 rounded-lg bg-muted text-[13px] text-muted-foreground sm:h-10">
                        <Icon name="check" size={14} />
                        {t.reservedOther}
                      </p>
                    ) : (
                      <Button
                        variant={me ? 'secondary' : 'outline'}
                        size="sm"
                        aria-pressed={me}
                        className="h-11 w-full sm:h-10"
                        onClick={() => {
                          setMine((m) => ({ ...m, [item.id]: !me }));
                          if (!me) p.onReserve?.(item);
                        }}
                      >
                        <Swap value={me} className="gap-1.5">
                          <Icon name={me ? 'x' : 'gift'} size={15} />
                          {me ? t.cancelReserve : t.reserve}
                        </Swap>
                      </Button>
                    )}
                  </div>
                </div>
              </article>
            </li>
          );
        })}
      </Stagger>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* price-watch                                                         */
/* ------------------------------------------------------------------ */

/** Price line over the weeks with the wished price as a dashed line, drawn when it scrolls in */
function PriceChart({ history, target, label }: { history: number[]; target: number; label: string }) {
  const [ref, inView] = useInView<HTMLDivElement>();
  const clip = 'pw' + useId().replace(/[^a-zA-Z0-9]/g, '');
  const W = 240;
  const H = 64;
  const lo = Math.min(...history, target) * 0.96;
  const hi = Math.max(...history, target) * 1.02;
  const x = (i: number) => round((i / Math.max(1, history.length - 1)) * W);
  const y = (v: number) => round(H - ((v - lo) / Math.max(0.01, hi - lo)) * H);
  const d = history.map((v, i) => `${i ? 'L' : 'M'}${x(i)} ${y(v)}`).join(' ');
  const last = history[history.length - 1];
  return (
    <div ref={ref} className="relative" role="img" aria-label={label}>
      <svg viewBox={`-4 -6 ${W + 8} ${H + 12}`} className="h-16 w-full overflow-visible" preserveAspectRatio="none">
        <defs>
          <clipPath id={clip}>
            <rect x="-4" y="-6" height={H + 12} width={W + 8} className="origin-left transition-transform duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? 1 : 0})` }} />
          </clipPath>
        </defs>
        <line x1="0" x2={W} y1={y(target)} y2={y(target)} strokeDasharray="4 4" strokeWidth="1.2" vectorEffect="non-scaling-stroke" className="stroke-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]" />
        <path d={d} fill="none" strokeWidth="1.8" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" className="stroke-foreground" clipPath={`url(#${clip})`} />
      </svg>
      <span
        aria-hidden
        className={cn('absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card bg-primary transition-opacity delay-[1200ms] duration-300', inView ? 'opacity-100' : 'opacity-0')}
        style={{ left: `${round(((x(history.length - 1) + 4) / (W + 8)) * 100, 2)}%`, top: `${round(((y(last) + 6) / (H + 12)) * 100, 2)}%` }}
      />
    </div>
  );
}

function WatchCard({ item, currency, t, onAlert, delay }: { item: WishItem; currency?: string; t: Texts; onAlert?: WishlistProps['onAlert']; delay: number }) {
  const locale = useLocale();
  const step = item.price >= 100 ? 5 : 1;
  const [target, setTarget] = useState(item.target ?? Math.floor((item.price * 0.85) / step) * step);
  const [saved, setSaved] = useState(item.target !== undefined);
  const history = item.history?.length ? item.history : [item.price];
  const low = Math.min(...history);
  const reached = item.price <= target;
  const gap = Math.max(0, item.price - target);
  const set = (v: number) => {
    setTarget(Math.max(step, v));
    setSaved(false);
  };
  return (
    <Reveal as="li" effect="rise" delay={delay}>
      <article className={cn(CARD, 'flex h-full flex-col p-5 transition-shadow duration-500', reached && 'shadow-[var(--highlight),0_0_0_1px_color-mix(in_oklab,var(--success)_45%,transparent),var(--shadow-md)]')}>
        <div className="flex items-start gap-4">
          <ProductMedia className="size-16 shrink-0 rounded-lg">{item.image}</ProductMedia>
          <div className="min-w-0 flex-1">
            <h3 className="truncate text-[15px] font-medium text-foreground">
              <a href={item.href ?? '#'} className={cn('rounded-sm hover:underline hover:decoration-border-strong hover:underline-offset-4', FOCUS)}>
                {item.name}
              </a>
            </h3>
            {item.variant && <p className="truncate text-[13px] text-muted-foreground">{item.variant}</p>}
            <p className="mt-1 text-[13px] text-muted-foreground">
              {t.now} <span className="font-semibold tabular-nums text-foreground">{formatPrice(locale, item.price, currency)}</span>
            </p>
          </div>
        </div>
        <div className="mt-5">
          <PriceChart history={history} target={target} label={`${t.history}, ${t.lowest} ${formatPrice(locale, low, currency)}`} />
          <p className="mt-2 flex justify-between gap-3 text-[12px] text-muted-foreground">
            <span>{t.lowest}</span>
            <span className="tabular-nums">{formatPrice(locale, low, currency)}</span>
          </p>
        </div>
        <div className="mt-5 border-t border-border pt-5">
          <p className="text-[13px] text-muted-foreground">{t.wishPrice}</p>
          <div className="mt-2 flex items-center gap-3">
            <div className="inline-flex items-center overflow-hidden rounded-lg border border-border-strong bg-background shadow-[var(--shadow-sm)]">
              <button type="button" onClick={() => set(target - step)} aria-label={t.lower} className="relative grid size-11 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground">
                <Icon name="minus" size={15} />
              </button>
              <output aria-live="polite" className="min-w-[5.5rem] text-center text-[15px] font-semibold tabular-nums text-foreground">
                {formatPrice(locale, target, currency)}
              </output>
              <button type="button" onClick={() => set(target + step)} aria-label={t.raise} className="relative grid size-11 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground">
                <Icon name="plus" size={15} />
              </button>
            </div>
            <p className={cn('text-[13px]', reached ? 'font-medium text-success-foreground' : 'text-muted-foreground')} aria-live="polite">
              {reached ? (
                <span className="inline-flex items-center gap-1">
                  <Icon name="check-circle" size={14} />
                  {t.reached}
                </span>
              ) : (
                <>
                  <span className="tabular-nums">{formatPrice(locale, gap, currency)}</span> {t.toGo}
                </>
              )}
            </p>
          </div>
        </div>
        <div className="mt-auto pt-5">
          {reached ? (
            <Button href={item.href ?? '#'} className="w-full" iconLeft="bag">
              {t.buyNow}
            </Button>
          ) : (
            <Button
              variant={saved ? 'secondary' : 'outline'}
              className="w-full"
              aria-pressed={saved}
              onClick={() => {
                setSaved(!saved);
                if (!saved) onAlert?.(item, target);
              }}
            >
              <Swap value={saved} className="gap-1.5">
                <Icon name={saved ? 'check' : 'bell'} size={15} />
                {saved ? t.alertOn : t.setAlert}
              </Swap>
            </Button>
          )}
        </div>
      </article>
    </Reveal>
  );
}

function PriceWatch(p: WishlistProps & { t: Texts }) {
  const { t, currency } = p;
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
        {(p.items ?? []).map((item, i) => (
          <WatchCard key={item.id} item={item} currency={currency} t={t} onAlert={p.onAlert} delay={Math.min(i, 5) * 80} />
        ))}
      </ul>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* The block                                                           */
/* ------------------------------------------------------------------ */

/**
 * Saved products in five layouts. grid: cards with move to cart and undo.
 * list: rows with stock alerts and a summary. boards: several named lists.
 * shared: a list friends reserve gifts from. price-watch: wished prices with alerts.
 */
export function Wishlist(props: WishlistProps) {
  const { variant = 'grid' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'list') return <List {...props} t={t} />;
  if (variant === 'boards') return <Boards {...props} t={t} />;
  if (variant === 'shared') return <Shared {...props} t={t} />;
  if (variant === 'price-watch') return <PriceWatch {...props} t={t} />;
  return <Grid {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Wunschliste?

Mit aiw add @ai-website/wishlist. Der Befehl legt die Datei components/ui/wishlist.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, motion, section, select und product-image. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Wunschliste?

5 Varianten, Raster, Liste mit Übersicht, Listen, Geteilte Geschenkliste und Preisalarm. Du wählst sie mit der Prop variant, zum Beispiel variant="list".

Kann ich die Texte von Wunschliste übersetzen?

Ja. Wunschliste bringt 61 eigene Texte auf Deutsch und Englisch mit, etwa „gemerkt“, „Artikel“ und „Artikel“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Wunschliste einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Wunschliste mit aiw_components und installieren sie mit aiw_add @ai-website/wishlist. Nenn im Prompt die Variante, etwa variant="grid", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Wunschliste ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Wishlist-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert