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
Variants
Details
What Wishlist brings along, read from the registry and the source.
- Category
- Account and loyalty
- Area
- E-Commerce
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/wishlist.tsx
- npm packages
- None
- Languages
- English and German, 61 texts of its own
- Studio
- All 5 variants editable
- In collections
- Online shop
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The WishlistProps interface from components/ui/wishlist.tsx, with the comments from the code.
variantoptionalWishlistVariantgridlistboardssharedprice-watch
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringitemsoptionalWishItem[]boardsoptionalWishBoard[]owneroptional{ name: string; occasion?: string; date?: string; days?: number; avatar?: React.ReactNode; message?: string }shared: who made the list and what it is for
currencyoptionalstringshareUrloptionalstringLink to share the list (grid, boards)
onMoveToCartoptional(item: WishItem) => voidonRemoveoptional(item: WishItem) => voidonReserveoptional(item: WishItem) => voidonCreateBoardoptional(name: string) => voidonAlertoptional(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
| Key | English | German |
|---|---|---|
| saved | English saved | German gemerkt |
| item | English item | German Artikel |
| items | English items | German Artikel |
| share | English Share list | German Liste teilen |
| copied | English Link copied | German Link kopiert |
| addAll | English Add all to cart | German Alle in den Warenkorb |
| addedAll | English All in your cart | German Alles im Warenkorb |
| moveToCart | English Move to cart | German In den Warenkorb |
| inCart | English In your cart | German Im Warenkorb |
| remove | English Remove from wishlist | German Von der Wunschliste entfernen |
| removeShort | English Remove | German Entfernen |
| removed | English Removed | German Entfernt |
| undo | English Undo | German Rückgängig |
| priceDrop | English Price dropped | German Preis gesunken |
| since | English since you saved it | German seit du ihn gemerkt hast |
| inStock | English In stock | German Auf Lager |
| lowStock | English Only a few left | German Nur noch wenige da |
| outOfStock | English Sold out | German Ausverkauft |
| notify | English Notify me | German Benachrichtigen |
| notifyOn | English We will email you | German Wir schreiben dir |
| sortBy | English Sort by | German Sortieren nach |
| sortAdded | English Recently saved | German Zuletzt gemerkt |
| sortPriceLow | English Price, low to high | German Preis aufsteigend |
| sortPriceHigh | English Price, high to low | German Preis absteigend |
| sortDrop | English Biggest price drop | German Größte Preissenkung |
| total | English Available items | German Verfügbare Artikel |
| summary | English Your list | German Deine Liste |
| available | English available | German verfügbar |
| emptyTitle | English Your wishlist is ready for wishes | German Deine Wunschliste wartet auf Wünsche |
| emptyText | English 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. |
| browse | English Browse the store | German Im Shop stöbern |
| newList | English New list | German Neue Liste |
| newListLabel | English Name of the new list | German Name der neuen Liste |
| newListPlaceholder | English e.g. Kitchen, Gifts for Mum | German z. B. Küche, Geschenke für Mama |
| create | English Create list | German Liste anlegen |
| cancel | English Cancel | German Abbrechen |
| private | English Private | German Privat |
| sharedList | English Shared | German Geteilt |
| openList | English Open list | German Liste öffnen |
| emptyBoard | English Save products here | German Merk dir hier Produkte |
| reserve | English Reserve as a gift | German Als Geschenk reservieren |
| reservedByYou | English Reserved by you | German Von dir reserviert |
| reservedOther | English Already reserved | German Schon reserviert |
| reservedHint | English Nobody sees who reserved it. | German Niemand sieht, wer reserviert hat. |
| cancelReserve | English Cancel reservation | German Reservierung aufheben |
| reservedCount | English reserved | German reserviert |
| of | English of | German von |
| daysLeft | English days to go | German Tage bis dahin |
| dayLeft | English day to go | German Tag bis dahin |
| from | English A note from | German Eine Notiz von |
| wishPrice | English Wished price | German Wunschpreis |
| now | English Now | German Jetzt |
| lowest | English Lowest in 12 weeks | German Tiefstpreis der letzten 12 Wochen |
| reached | English Your price is reached | German Dein Preis ist erreicht |
| toGo | English to go | German fehlen noch |
| alertOn | English Alert on | German Alarm aktiv |
| setAlert | English Save alert | German Alarm speichern |
| lower | English Lower the wished price | German Wunschpreis senken |
| raise | English Raise the wished price | German Wunschpreis erhöhen |
| history | English Price over the last 12 weeks | German Preis der letzten 12 Wochen |
| buyNow | English Buy now | German 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
'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.