Skip to content
UI Kit
Accent color

Wishlist

Saved products as a grid or list, several named lists, a list friends reserve gifts from and price alerts with a price history.

One of 4 components in Account and loyalty

5 variants

Variants

Grid

Saved products as cards with price drops since saving, stock and move to cart with a rising +1. The heart removes a card and leaves an undo that runs out, "Add all" fills the cart in one go.

Open in Studio

List with summary

Rows with options, stock, the date saved and a back-in-stock alert for sold out items. Sorting lets the rows run in again, the summary beside them counts the total and the price drops and adds everything at once.

Open in Studio

Lists

Several named lists as collages, private or shared. The collage zooms on hover, cards rise in turn, and a new list is named in place and pops in first.

Open in Studio

Shared gift list

The list a friend opens from a link. A countdown to the day, a bar that fills with every reservation, notes from the owner and a reserve button that turns into a sticker. Nobody sees who reserved what.

Open in Studio

Price alerts

A wished price per product with a twelve week price line that draws itself and the wished price as a dashed line. Plus and minus move the line, a reached price lights the card and turns the action into "Buy now".

Open in Studio

Details

What Wishlist brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/wishlist.tsx
Brings along
utilskitbuttoniconrevealmotionsectionselectproduct-image
npm packages
None
Languages
English and German, 61 texts of its own
In collections
Online shop

Props

The WishlistProps interface from components/ui/wishlist.tsx, with the comments from the code.

  • 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

Built-in texts

Wishlist writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 61 texts
savedEnglish savedGerman gemerkt
itemEnglish itemGerman Artikel
itemsEnglish itemsGerman Artikel
shareEnglish Share listGerman Liste teilen
copiedEnglish Link copiedGerman Link kopiert
addAllEnglish Add all to cartGerman Alle in den Warenkorb
addedAllEnglish All in your cartGerman Alles im Warenkorb
moveToCartEnglish Move to cartGerman In den Warenkorb
inCartEnglish In your cartGerman Im Warenkorb
removeEnglish Remove from wishlistGerman Von der Wunschliste entfernen
removeShortEnglish RemoveGerman Entfernen
removedEnglish RemovedGerman Entfernt
undoEnglish UndoGerman Rückgängig
priceDropEnglish Price droppedGerman Preis gesunken
sinceEnglish since you saved itGerman seit du ihn gemerkt hast
inStockEnglish In stockGerman Auf Lager
lowStockEnglish Only a few leftGerman Nur noch wenige da
outOfStockEnglish Sold outGerman Ausverkauft
notifyEnglish Notify meGerman Benachrichtigen
notifyOnEnglish We will email youGerman Wir schreiben dir
sortByEnglish Sort byGerman Sortieren nach
sortAddedEnglish Recently savedGerman Zuletzt gemerkt
sortPriceLowEnglish Price, low to highGerman Preis aufsteigend
sortPriceHighEnglish Price, high to lowGerman Preis absteigend
sortDropEnglish Biggest price dropGerman Größte Preissenkung
totalEnglish Available itemsGerman Verfügbare Artikel
summaryEnglish Your listGerman Deine Liste
availableEnglish availableGerman verfügbar
emptyTitleEnglish Your wishlist is ready for wishesGerman Deine Wunschliste wartet auf Wünsche
emptyTextEnglish Tap the heart on any product and it waits here, with a note when its price drops.German Tipp bei einem Produkt auf das Herz, dann wartet es hier, mit Hinweis, sobald der Preis sinkt.
browseEnglish Browse the storeGerman Im Shop stöbern
newListEnglish New listGerman Neue Liste
newListLabelEnglish Name of the new listGerman Name der neuen Liste
newListPlaceholderEnglish e.g. Kitchen, Gifts for MumGerman z. B. Küche, Geschenke für Mama
createEnglish Create listGerman Liste anlegen
cancelEnglish CancelGerman Abbrechen
privateEnglish PrivateGerman Privat
sharedListEnglish SharedGerman Geteilt
openListEnglish Open listGerman Liste öffnen
emptyBoardEnglish Save products hereGerman Merk dir hier Produkte
reserveEnglish Reserve as a giftGerman Als Geschenk reservieren
reservedByYouEnglish Reserved by youGerman Von dir reserviert
reservedOtherEnglish Already reservedGerman Schon reserviert
reservedHintEnglish Nobody sees who reserved it.German Niemand sieht, wer reserviert hat.
cancelReserveEnglish Cancel reservationGerman Reservierung aufheben
reservedCountEnglish reservedGerman reserviert
ofEnglish ofGerman von
daysLeftEnglish days to goGerman Tage bis dahin
dayLeftEnglish day to goGerman Tag bis dahin
fromEnglish A note fromGerman Eine Notiz von
wishPriceEnglish Wished priceGerman Wunschpreis
nowEnglish NowGerman Jetzt
lowestEnglish Lowest in 12 weeksGerman Tiefstpreis der letzten 12 Wochen
reachedEnglish Your price is reachedGerman Dein Preis ist erreicht
toGoEnglish to goGerman fehlen noch
alertOnEnglish Alert onGerman Alarm aktiv
setAlertEnglish Save alertGerman Alarm speichern
lowerEnglish Lower the wished priceGerman Wunschpreis senken
raiseEnglish Raise the wished priceGerman Wunschpreis erhöhen
historyEnglish Price over the last 12 weeksGerman Preis der letzten 12 Wochen
buyNowEnglish Buy nowGerman Jetzt kaufen

Accessibility

Read from the source of the component.

  • Sets aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles img and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 7 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,047 lines
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} />;
}

Frequent questions

How do I install Wishlist?

With aiw add @ai-website/wishlist. The command puts components/ui/wishlist.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, motion, section, select and product-image. It needs no further npm packages.

Which variants does Wishlist have?

5 variants, Grid, List with summary, Lists, Shared gift list and Price alerts. You pick one with the variant prop, for example variant="list".

Can I translate the texts of Wishlist?

Yes. Wishlist carries 61 texts of its own in English and German, such as “saved”, “item” and “items”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Wishlist?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Wishlist with aiw_components and install it with aiw_add @ai-website/wishlist. Name the variant in your prompt, for example variant="grid", and the assistant writes your content straight into the props.

Can I adjust Wishlist without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Wishlist code to copy.

Often searched

Jump to

Copied to the clipboard