Wunschliste
Gemerkte Produkte als Raster oder Liste, mehrere benannte Listen, eine Liste, aus der Freunde Geschenke reservieren, und Preisalarme mit Preisverlauf.
Eine von 4 Components in Konto und Treue
Variants
Details
Was Wunschliste mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Konto und Treue
- Bereich
- E-Commerce
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/wishlist.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 61 eigene Texte
- In Sammlungen
- Online-Shop
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle WishlistProps aus components/ui/wishlist.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Wunschliste selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 61 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| saved | Englisch saved | Deutsch gemerkt |
| item | Englisch item | Deutsch Artikel |
| items | Englisch items | Deutsch Artikel |
| share | Englisch Share list | Deutsch Liste teilen |
| copied | Englisch Link copied | Deutsch Link kopiert |
| addAll | Englisch Add all to cart | Deutsch Alle in den Warenkorb |
| addedAll | Englisch All in your cart | Deutsch Alles im Warenkorb |
| moveToCart | Englisch Move to cart | Deutsch In den Warenkorb |
| inCart | Englisch In your cart | Deutsch Im Warenkorb |
| remove | Englisch Remove from wishlist | Deutsch Von der Wunschliste entfernen |
| removeShort | Englisch Remove | Deutsch Entfernen |
| removed | Englisch Removed | Deutsch Entfernt |
| undo | Englisch Undo | Deutsch Rückgängig |
| priceDrop | Englisch Price dropped | Deutsch Preis gesunken |
| since | Englisch since you saved it | Deutsch seit du ihn gemerkt hast |
| inStock | Englisch In stock | Deutsch Auf Lager |
| lowStock | Englisch Only a few left | Deutsch Nur noch wenige da |
| outOfStock | Englisch Sold out | Deutsch Ausverkauft |
| notify | Englisch Notify me | Deutsch Benachrichtigen |
| notifyOn | Englisch We will email you | Deutsch Wir schreiben dir |
| sortBy | Englisch Sort by | Deutsch Sortieren nach |
| sortAdded | Englisch Recently saved | Deutsch Zuletzt gemerkt |
| sortPriceLow | Englisch Price, low to high | Deutsch Preis aufsteigend |
| sortPriceHigh | Englisch Price, high to low | Deutsch Preis absteigend |
| sortDrop | Englisch Biggest price drop | Deutsch Größte Preissenkung |
| total | Englisch Available items | Deutsch Verfügbare Artikel |
| summary | Englisch Your list | Deutsch Deine Liste |
| available | Englisch available | Deutsch verfügbar |
| emptyTitle | Englisch Your wishlist is ready for wishes | Deutsch Deine Wunschliste wartet auf Wünsche |
| emptyText | Englisch Tap the heart on any product and it waits here, with a note when its price drops. | Deutsch Tipp bei einem Produkt auf das Herz, dann wartet es hier, mit Hinweis, sobald der Preis sinkt. |
| browse | Englisch Browse the store | Deutsch Im Shop stöbern |
| newList | Englisch New list | Deutsch Neue Liste |
| newListLabel | Englisch Name of the new list | Deutsch Name der neuen Liste |
| newListPlaceholder | Englisch e.g. Kitchen, Gifts for Mum | Deutsch z. B. Küche, Geschenke für Mama |
| create | Englisch Create list | Deutsch Liste anlegen |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| private | Englisch Private | Deutsch Privat |
| sharedList | Englisch Shared | Deutsch Geteilt |
| openList | Englisch Open list | Deutsch Liste öffnen |
| emptyBoard | Englisch Save products here | Deutsch Merk dir hier Produkte |
| reserve | Englisch Reserve as a gift | Deutsch Als Geschenk reservieren |
| reservedByYou | Englisch Reserved by you | Deutsch Von dir reserviert |
| reservedOther | Englisch Already reserved | Deutsch Schon reserviert |
| reservedHint | Englisch Nobody sees who reserved it. | Deutsch Niemand sieht, wer reserviert hat. |
| cancelReserve | Englisch Cancel reservation | Deutsch Reservierung aufheben |
| reservedCount | Englisch reserved | Deutsch reserviert |
| of | Englisch of | Deutsch von |
| daysLeft | Englisch days to go | Deutsch Tage bis dahin |
| dayLeft | Englisch day to go | Deutsch Tag bis dahin |
| from | Englisch A note from | Deutsch Eine Notiz von |
| wishPrice | Englisch Wished price | Deutsch Wunschpreis |
| now | Englisch Now | Deutsch Jetzt |
| lowest | Englisch Lowest in 12 weeks | Deutsch Tiefstpreis der letzten 12 Wochen |
| reached | Englisch Your price is reached | Deutsch Dein Preis ist erreicht |
| toGo | Englisch to go | Deutsch fehlen noch |
| alertOn | Englisch Alert on | Deutsch Alarm aktiv |
| setAlert | Englisch Save alert | Deutsch Alarm speichern |
| lower | Englisch Lower the wished price | Deutsch Wunschpreis senken |
| raise | Englisch Raise the wished price | Deutsch Wunschpreis erhöhen |
| history | Englisch Price over the last 12 weeks | Deutsch Preis der letzten 12 Wochen |
| buyNow | Englisch Buy now | Deutsch Jetzt kaufen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen img und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 7 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.047 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { NativeSelect } from '@/components/ui/select';
import { FlyUp, Price, ProductMedia, ShopMotion, Swap, TweenPrice, formatNumber, formatPrice } from '@/components/ui/product-image';
/*
* Saved products: the wishlist page, lists people sort their wishes into,
* a list shared with friends who reserve gifts, and price alerts.
*
* grid saved products as cards, move to cart, remove with undo, price drops
* list rows with stock, back-in-stock alerts and a summary to add everything
* boards several named lists as collages, with a new list made in place
* shared the list a friend opens: reserve a gift, progress, the date it is for
* price-watch wished prices per product, a price history and an alert when it is reached
*
* Images come in as nodes (`image`), so any <img> works.
*/
export type WishlistVariant = 'grid' | 'list' | 'boards' | 'shared' | 'price-watch';
export interface WishItem {
id: string;
name: string;
href?: string;
/** Picked options, e.g. "Sage · 350 ml" */
variant?: string;
price: number;
compareAt?: number;
/** The price on the day it was saved. Lower prices now show as a drop. */
savedPrice?: number;
image?: React.ReactNode;
stock?: 'in' | 'low' | 'out';
/** e.g. "Only 3 left" or "Back on Nov 12" */
stockNote?: string;
/** Already formatted, e.g. "Saved Oct 2" */
added?: string;
/** shared: already reserved by someone else */
reserved?: boolean;
/** shared: a note from the list owner, e.g. "Size M please" */
note?: string;
/** price-watch: the wished price */
target?: number;
/** price-watch: prices of the last weeks, oldest first */
history?: number[];
}
export interface WishBoard {
id: string;
name: string;
count: number;
/** Up to four images for the collage */
images?: React.ReactNode[];
shared?: boolean;
/** Already formatted, e.g. "Updated 2 days ago" */
updated?: string;
href?: string;
}
export interface WishlistProps {
variant?: WishlistVariant;
eyebrow?: string;
title?: string;
description?: string;
items?: WishItem[];
boards?: WishBoard[];
/** shared: who made the list and what it is for */
owner?: { name: string; occasion?: string; date?: string; days?: number; avatar?: React.ReactNode; message?: string };
currency?: string;
/** Link to share the list (grid, boards) */
shareUrl?: string;
onMoveToCart?: (item: WishItem) => void;
onRemove?: (item: WishItem) => void;
onReserve?: (item: WishItem) => void;
onCreateBoard?: (name: string) => void;
onAlert?: (item: WishItem, target: number) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
saved: 'saved',
item: 'item',
items: 'items',
share: 'Share list',
copied: 'Link copied',
addAll: 'Add all to cart',
addedAll: 'All in your cart',
moveToCart: 'Move to cart',
inCart: 'In your cart',
remove: 'Remove from wishlist',
removeShort: 'Remove',
removed: 'Removed',
undo: 'Undo',
priceDrop: 'Price dropped',
since: 'since you saved it',
inStock: 'In stock',
lowStock: 'Only a few left',
outOfStock: 'Sold out',
notify: 'Notify me',
notifyOn: 'We will email you',
sortBy: 'Sort by',
sortAdded: 'Recently saved',
sortPriceLow: 'Price, low to high',
sortPriceHigh: 'Price, high to low',
sortDrop: 'Biggest price drop',
total: 'Available items',
summary: 'Your list',
available: 'available',
emptyTitle: 'Your wishlist is ready for wishes',
emptyText: 'Tap the heart on any product and it waits here, with a note when its price drops.',
browse: 'Browse the store',
newList: 'New list',
newListLabel: 'Name of the new list',
newListPlaceholder: 'e.g. Kitchen, Gifts for Mum',
create: 'Create list',
cancel: 'Cancel',
private: 'Private',
sharedList: 'Shared',
openList: 'Open list',
emptyBoard: 'Save products here',
reserve: 'Reserve as a gift',
reservedByYou: 'Reserved by you',
reservedOther: 'Already reserved',
reservedHint: 'Nobody sees who reserved it.',
cancelReserve: 'Cancel reservation',
reservedCount: 'reserved',
of: 'of',
daysLeft: 'days to go',
dayLeft: 'day to go',
from: 'A note from',
wishPrice: 'Wished price',
now: 'Now',
lowest: 'Lowest in 12 weeks',
reached: 'Your price is reached',
toGo: 'to go',
alertOn: 'Alert on',
setAlert: 'Save alert',
lower: 'Lower the wished price',
raise: 'Raise the wished price',
history: 'Price over the last 12 weeks',
buyNow: 'Buy now',
},
de: {
saved: 'gemerkt',
item: 'Artikel',
items: 'Artikel',
share: 'Liste teilen',
copied: 'Link kopiert',
addAll: 'Alle in den Warenkorb',
addedAll: 'Alles im Warenkorb',
moveToCart: 'In den Warenkorb',
inCart: 'Im Warenkorb',
remove: 'Von der Wunschliste entfernen',
removeShort: 'Entfernen',
removed: 'Entfernt',
undo: 'Rückgängig',
priceDrop: 'Preis gesunken',
since: 'seit du ihn gemerkt hast',
inStock: 'Auf Lager',
lowStock: 'Nur noch wenige da',
outOfStock: 'Ausverkauft',
notify: 'Benachrichtigen',
notifyOn: 'Wir schreiben dir',
sortBy: 'Sortieren nach',
sortAdded: 'Zuletzt gemerkt',
sortPriceLow: 'Preis aufsteigend',
sortPriceHigh: 'Preis absteigend',
sortDrop: 'Größte Preissenkung',
total: 'Verfügbare Artikel',
summary: 'Deine Liste',
available: 'verfügbar',
emptyTitle: 'Deine Wunschliste wartet auf Wünsche',
emptyText: 'Tipp bei einem Produkt auf das Herz, dann wartet es hier, mit Hinweis, sobald der Preis sinkt.',
browse: 'Im Shop stöbern',
newList: 'Neue Liste',
newListLabel: 'Name der neuen Liste',
newListPlaceholder: 'z. B. Küche, Geschenke für Mama',
create: 'Liste anlegen',
cancel: 'Abbrechen',
private: 'Privat',
sharedList: 'Geteilt',
openList: 'Liste öffnen',
emptyBoard: 'Merk dir hier Produkte',
reserve: 'Als Geschenk reservieren',
reservedByYou: 'Von dir reserviert',
reservedOther: 'Schon reserviert',
reservedHint: 'Niemand sieht, wer reserviert hat.',
cancelReserve: 'Reservierung aufheben',
reservedCount: 'reserviert',
of: 'von',
daysLeft: 'Tage bis dahin',
dayLeft: 'Tag bis dahin',
from: 'Eine Notiz von',
wishPrice: 'Wunschpreis',
now: 'Jetzt',
lowest: 'Tiefstpreis der letzten 12 Wochen',
reached: 'Dein Preis ist erreicht',
toGo: 'fehlen noch',
alertOn: 'Alarm aktiv',
setAlert: 'Alarm speichern',
lower: 'Wunschpreis senken',
raise: 'Wunschpreis erhöhen',
history: 'Preis der letzten 12 Wochen',
buyNow: 'Jetzt kaufen',
},
};
type Texts = (typeof TEXTS)['en'];
const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const SUCCESS_TEXT = 'text-success-foreground';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
function Head({ eyebrow, title, description, aside }: { eyebrow?: string; title?: React.ReactNode; description?: string; aside?: React.ReactNode }) {
if (!title && !aside) return null;
return (
<div className="mb-10 flex flex-col gap-6 sm:mb-12 sm:flex-row sm:items-end sm:justify-between sm:gap-10">
<div className="max-w-[40rem]">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
{title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>}
{description && (
<p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
{aside && <div className="flex shrink-0 flex-wrap items-center gap-3">{aside}</div>}
</div>
);
}
/** Copies the share link and says so for a moment */
function ShareButton({ url, t }: { url?: string; t: Texts }) {
const [done, setDone] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
return (
<Button
variant="outline"
onClick={() => {
const link = url ?? (typeof window !== 'undefined' ? window.location.href : '');
navigator.clipboard?.writeText(link).catch(() => {});
setDone(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setDone(false), 1800);
}}
>
<Swap value={done} className="gap-2">
<Icon name={done ? 'check' : 'share'} size={16} />
{done ? t.copied : t.share}
</Swap>
</Button>
);
}
function Stock({ item, t }: { item: WishItem; t: Texts }) {
const s = item.stock ?? 'in';
const text = item.stockNote ?? (s === 'out' ? t.outOfStock : s === 'low' ? t.lowStock : t.inStock);
return (
<span className={cn('inline-flex items-center gap-1.5 text-[12.5px]', s === 'out' ? 'text-muted-foreground' : s === 'low' ? 'text-warning-foreground' : SUCCESS_TEXT)}>
<span aria-hidden className={cn('size-1.5 rounded-full', s === 'out' ? 'bg-subtle-foreground' : s === 'low' ? 'bg-warning' : 'bg-success')} />
{text}
</span>
);
}
/** "Price dropped €8 since you saved it" */
function Drop({ item, currency, t, compact }: { item: WishItem; currency?: string; t: Texts; compact?: boolean }) {
const locale = useLocale();
if (!item.savedPrice || item.savedPrice <= item.price) return null;
const diff = item.savedPrice - item.price;
return (
<span className="inline-flex items-center gap-1 rounded-full bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-2 py-0.5 text-[12px] font-medium text-success-foreground">
<Icon name="trending-down" size={13} />
{compact ? `-${formatPrice(locale, diff, currency)}` : `${t.priceDrop} ${formatPrice(locale, diff, currency)}`}
</span>
);
}
/** A heart that fills, used as the remove button on saved items */
function HeartButton({ onClick, label, className }: { onClick: () => void; label: string; className?: string }) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
className={cn('group/heart grid size-11 place-items-center rounded-full bg-background/90 text-foreground shadow-[var(--shadow-sm)] backdrop-blur transition-transform duration-200 hover:scale-105 active:scale-90', FOCUS, className)}
>
<svg viewBox="0 0 24 24" className="size-[18px] text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))] transition-transform duration-300 group-hover/heart:scale-90" aria-hidden>
<path d="M12 20.3 4.6 13a4.7 4.7 0 0 1 6.6-6.7l.8.8.8-.8a4.7 4.7 0 0 1 6.6 6.7Z" fill="currentColor" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
</button>
);
}
/** Items that can be removed with an undo, and moved to the cart */
function useSaved(initial: WishItem[], onRemove?: (i: WishItem) => void) {
const [items, setItems] = useState(initial);
const [ghosts, setGhosts] = useState<Record<string, boolean>>({});
const [inCart, setInCart] = useState<Record<string, boolean>>({});
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
useEffect(() => () => Object.values(timers.current).forEach(clearTimeout), []);
const remove = (item: WishItem) => {
setGhosts((g) => ({ ...g, [item.id]: true }));
timers.current[item.id] = setTimeout(() => {
setItems((list) => list.filter((x) => x.id !== item.id));
setGhosts((g) => {
const { [item.id]: _, ...rest } = g;
return rest;
});
onRemove?.(item);
}, 5000);
};
const undo = (id: string) => {
clearTimeout(timers.current[id]);
setGhosts((g) => {
const { [id]: _, ...rest } = g;
return rest;
});
};
return { items, ghosts, inCart, setInCart, remove, undo };
}
function Ghost({ name, onUndo, t, className }: { name: string; onUndo: () => void; t: Texts; className?: string }) {
return (
<div className={cn('flex animate-[kitshop-fade_0.35s_var(--ease-kit)] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border-strong p-6 text-center', className)} role="status">
<p className="text-[13.5px] text-muted-foreground">
<span className="font-medium text-foreground">{name}</span> · {t.removed}
</p>
<Button variant="outline" size="sm" iconLeft="undo" onClick={onUndo}>
{t.undo}
</Button>
<span aria-hidden className="relative h-0.5 w-24 overflow-hidden rounded-full bg-border">
<span className="absolute inset-0 origin-left animate-kit-progress rounded-full bg-foreground [animation-direction:reverse] [--kit-duration:5s] [animation-duration:5s] [animation-timing-function:linear]" />
</span>
</div>
);
}
function Empty({ t }: { t: Texts }) {
return (
<div className="mx-auto flex max-w-[26rem] flex-col items-center py-16 text-center">
<span className="grid size-14 animate-[kitshop-pop_0.5s_var(--ease-kit)] place-items-center rounded-full bg-muted text-muted-foreground">
<Icon name="heart" size={22} />
</span>
<h3 className="mt-5 text-[18px] font-semibold text-foreground">{t.emptyTitle}</h3>
<p className="mt-2 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{t.emptyText}</p>
<Button href="#" variant="outline" className="mt-6" icon="arrow-right">
{t.browse}
</Button>
</div>
);
}
/* ------------------------------------------------------------------ */
/* grid */
/* ------------------------------------------------------------------ */
function Grid(p: WishlistProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const saved = useSaved(p.items ?? [], p.onRemove);
const [flyCount, setFlyCount] = useState<Record<string, number>>({});
const live = saved.items.filter((i) => !saved.ghosts[i.id]);
const allIn = live.filter((i) => i.stock !== 'out').every((i) => saved.inCart[i.id]);
const move = (item: WishItem) => {
saved.setInCart((c) => ({ ...c, [item.id]: true }));
setFlyCount((f) => ({ ...f, [item.id]: (f[item.id] ?? 0) + 1 }));
p.onMoveToCart?.(item);
};
return (
<Section className={p.className}>
<ShopMotion />
<Head
eyebrow={p.eyebrow}
title={
<>
{p.title} <span className="font-normal text-subtle-foreground tabular-nums">{formatNumber(locale, live.length)}</span>
</>
}
description={p.description}
aside={
live.length > 0 && (
<>
<ShareButton url={p.shareUrl} t={t} />
<Button
iconLeft={allIn ? 'check' : 'bag'}
disabled={allIn}
onClick={() => live.filter((i) => i.stock !== 'out' && !saved.inCart[i.id]).forEach(move)}
>
<Swap value={allIn}>{allIn ? t.addedAll : t.addAll}</Swap>
</Button>
</>
)
}
/>
{saved.items.length === 0 ? (
<Empty t={t} />
) : (
<Stagger step={70} as="ul" className="grid grid-cols-2 gap-x-3 gap-y-8 sm:gap-x-5 lg:grid-cols-4 lg:gap-x-6">
{saved.items.map((item) => (
<li key={item.id} className="flex">
{saved.ghosts[item.id] ? (
<Ghost name={item.name} onUndo={() => saved.undo(item.id)} t={t} className="w-full" />
) : (
<article className="group/card flex w-full flex-col">
<div className="relative">
<a href={item.href ?? '#'} tabIndex={-1} aria-hidden className="block">
<ProductMedia className={cn('aspect-[4/5] transition-[filter] duration-500', item.stock === 'out' && 'grayscale-[0.6]')}>
<span className="block size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/card:scale-[1.04] motion-reduce:transition-none [&>*]:size-full [&>*]:object-cover">{item.image}</span>
</ProductMedia>
</a>
<HeartButton onClick={() => saved.remove(item)} label={`${t.remove}, ${item.name}`} className="absolute right-2 top-2" />
{item.savedPrice && item.savedPrice > item.price && (
<span className="absolute bottom-2 left-2">
<Drop item={item} currency={currency} t={t} compact />
</span>
)}
</div>
<div className="flex flex-1 flex-col pt-3.5">
<h3 className="text-[14.5px] font-medium leading-snug text-foreground">
<a href={item.href ?? '#'} className={cn('rounded-sm hover:underline hover:decoration-border-strong hover:underline-offset-4', FOCUS)}>
{item.name}
</a>
</h3>
{item.variant && <p className="mt-0.5 text-[12.5px] text-muted-foreground">{item.variant}</p>}
<Price value={item.price} compareAt={item.compareAt ?? (item.savedPrice && item.savedPrice > item.price ? item.savedPrice : undefined)} currency={currency} className="mt-2" />
<div className="mt-1.5">
<Stock item={item} t={t} />
</div>
<div className="relative mt-auto pt-4">
<FlyUp count={flyCount[item.id] ?? 0} />
{item.stock === 'out' ? (
<NotifyButton t={t} />
) : (
<Button variant={saved.inCart[item.id] ? 'secondary' : 'outline'} size="sm" className="h-11 w-full sm:h-10" onClick={() => move(item)} disabled={saved.inCart[item.id]}>
<Swap value={!!saved.inCart[item.id]} className="gap-1.5">
<Icon name={saved.inCart[item.id] ? 'check' : 'bag'} size={15} />
{saved.inCart[item.id] ? t.inCart : t.moveToCart}
</Swap>
</Button>
)}
</div>
</div>
</article>
)}
</li>
))}
</Stagger>
)}
</Section>
);
}
function NotifyButton({ t, className }: { t: Texts; className?: string }) {
const [on, setOn] = useState(false);
return (
<Button variant={on ? 'secondary' : 'outline'} size="sm" aria-pressed={on} className={cn('h-11 w-full sm:h-10', className)} onClick={() => setOn(!on)}>
<Swap value={on} className="gap-1.5">
<Icon name={on ? 'check' : 'bell'} size={15} />
{on ? t.notifyOn : t.notify}
</Swap>
</Button>
);
}
/* ------------------------------------------------------------------ */
/* list */
/* ------------------------------------------------------------------ */
type ListSort = 'added' | 'low' | 'high' | 'drop';
function List(p: WishlistProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const saved = useSaved(p.items ?? [], p.onRemove);
const [sort, setSort] = useState<ListSort>('added');
const order = [...saved.items];
const drop = (i: WishItem) => (i.savedPrice ?? i.price) - i.price;
if (sort === 'low') order.sort((a, b) => a.price - b.price);
if (sort === 'high') order.sort((a, b) => b.price - a.price);
if (sort === 'drop') order.sort((a, b) => drop(b) - drop(a));
const live = saved.items.filter((i) => !saved.ghosts[i.id]);
const open = live.filter((i) => i.stock !== 'out' && !saved.inCart[i.id]);
const total = open.reduce((s, i) => s + i.price, 0);
const savings = open.reduce((s, i) => s + Math.max(0, drop(i)), 0);
const addAll = () => {
saved.setInCart((c) => ({ ...c, ...Object.fromEntries(open.map((i) => [i.id, true])) }));
open.forEach((i) => p.onMoveToCart?.(i));
};
const summary = (
<div className={cn(CARD, 'p-6')}>
<h3 className="text-[15px] font-semibold text-foreground">{t.summary}</h3>
<dl className="mt-4 space-y-2.5 text-[14px]">
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">
{formatNumber(locale, live.length)} {live.length === 1 ? t.item : t.items}
</dt>
<dd className="tabular-nums text-foreground">
{formatNumber(locale, live.filter((i) => i.stock !== 'out').length)} {t.available}
</dd>
</div>
{savings > 0 && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{t.priceDrop}</dt>
<dd className="tabular-nums text-success-foreground">-<TweenPrice value={savings} currency={currency} /></dd>
</div>
)}
</dl>
<div className="mt-4 flex items-baseline justify-between gap-4 border-t border-border pt-4">
<span className="text-[14px] text-muted-foreground">{t.total}</span>
<TweenPrice value={total} currency={currency} className="text-[20px] font-semibold tracking-[-0.02em] text-foreground" />
</div>
<Button className="mt-5 w-full" size="lg" iconLeft={open.length ? 'bag' : 'check'} disabled={!open.length} onClick={addAll}>
<Swap value={open.length === 0}>{open.length ? t.addAll : t.addedAll}</Swap>
</Button>
<div className="mt-3 [&>*]:w-full">
<ShareButton url={p.shareUrl} t={t} />
</div>
</div>
);
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-12">
<div className="min-w-0">
<div className="flex items-center justify-between gap-4 border-b border-border pb-4">
<p className="text-[13.5px] text-muted-foreground" aria-live="polite">
<span className="font-medium tabular-nums text-foreground">{formatNumber(locale, live.length)}</span> {live.length === 1 ? t.item : t.items} {t.saved}
</p>
<NativeSelect
aria-label={t.sortBy}
value={sort}
onChange={(e) => setSort(e.target.value as ListSort)}
className="w-[12.5rem]"
options={[
{ value: 'added', label: t.sortAdded },
{ value: 'low', label: t.sortPriceLow },
{ value: 'high', label: t.sortPriceHigh },
{ value: 'drop', label: t.sortDrop },
]}
/>
</div>
{saved.items.length === 0 ? (
<Empty t={t} />
) : (
<ul key={sort}>
{order.map((item, n) => (
<li key={item.id} className="border-b border-border py-5 animate-[kitshop-in_0.5s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(n, 6) * 60}ms` }}>
{saved.ghosts[item.id] ? (
<div className="flex min-h-24 animate-[kitshop-fade_0.35s] items-center justify-between gap-4 rounded-xl bg-surface px-4" role="status">
<p className="text-[13.5px] text-muted-foreground">
<span className="font-medium text-foreground">{item.name}</span> · {t.removed}
</p>
<Button variant="outline" size="sm" iconLeft="undo" onClick={() => saved.undo(item.id)}>
{t.undo}
</Button>
</div>
) : (
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-4 sm:grid-cols-[7rem_minmax(0,1fr)_auto] sm:items-center sm:gap-6">
<ProductMedia className={cn('aspect-square sm:row-span-1', item.stock === 'out' && 'grayscale-[0.6]')}>{item.image}</ProductMedia>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h3 className="text-[15px] font-medium text-foreground">
<a href={item.href ?? '#'} className={cn('rounded-sm hover:underline hover:decoration-border-strong hover:underline-offset-4', FOCUS)}>
{item.name}
</a>
</h3>
<Drop item={item} currency={currency} t={t} compact />
</div>
{item.variant && <p className="mt-0.5 text-[13px] text-muted-foreground">{item.variant}</p>}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
<Price value={item.price} compareAt={item.savedPrice && item.savedPrice > item.price ? item.savedPrice : item.compareAt} currency={currency} />
<Stock item={item} t={t} />
</div>
{item.added && <p className="mt-1 text-[12.5px] text-subtle-foreground">{item.added}</p>}
</div>
<div className="col-span-2 flex items-center gap-2 sm:col-span-1 sm:flex-col sm:items-stretch">
{item.stock === 'out' ? (
<NotifyButton t={t} className="flex-1 sm:w-44" />
) : (
<Button variant={saved.inCart[item.id] ? 'secondary' : 'outline'} size="sm" className="h-11 flex-1 sm:h-10 sm:w-44" disabled={saved.inCart[item.id]} onClick={() => { saved.setInCart((c) => ({ ...c, [item.id]: true })); p.onMoveToCart?.(item); }}>
<Swap value={!!saved.inCart[item.id]} className="gap-1.5">
<Icon name={saved.inCart[item.id] ? 'check' : 'bag'} size={15} />
{saved.inCart[item.id] ? t.inCart : t.moveToCart}
</Swap>
</Button>
)}
<button type="button" onClick={() => saved.remove(item)} aria-label={`${t.remove}, ${item.name}`} className={cn('inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-lg px-3 text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:h-9', FOCUS)}>
<Icon name="trash" size={15} />
<span className="max-sm:sr-only">{t.removeShort}</span>
</button>
</div>
</div>
)}
</li>
))}
</ul>
)}
</div>
<Reveal effect="rise" className="hidden lg:sticky lg:top-8 lg:block lg:self-start">
{summary}
</Reveal>
{/* Phones and tablets: the summary becomes a bar that stays at the bottom while the list scrolls */}
<div className="sticky bottom-3 z-10 -mx-2 lg:hidden">
<div className="flex items-center gap-4 rounded-2xl border border-border bg-background/92 p-3 ps-5 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-md">
<div className="min-w-0 flex-1">
<p className="truncate text-[12.5px] text-muted-foreground">{t.total}</p>
<TweenPrice value={total} currency={currency} className="text-[18px] font-semibold tracking-[-0.02em] text-foreground" />
</div>
<Button iconLeft={open.length ? 'bag' : 'check'} disabled={!open.length} onClick={addAll}>
<Swap value={open.length === 0}>{open.length ? t.addAll : t.addedAll}</Swap>
</Button>
</div>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* boards */
/* ------------------------------------------------------------------ */
function Collage({ images, t }: { images?: React.ReactNode[]; t: Texts }) {
const list = (images ?? []).slice(0, 4);
if (!list.length)
return (
<div className="grid aspect-[4/3] place-items-center rounded-xl bg-surface-2 text-center text-[13px] text-muted-foreground">
<span className="grid justify-items-center gap-2">
<Icon name="heart" size={20} />
{t.emptyBoard}
</span>
</div>
);
const big = list[0];
const small = list.slice(1);
return (
<div className="grid aspect-[4/3] grid-cols-[minmax(0,2fr)_minmax(0,1fr)] grid-rows-3 gap-1 overflow-hidden rounded-xl">
<div className={cn('relative overflow-hidden bg-surface-2', small.length ? 'row-span-3' : 'col-span-2 row-span-3')}>
<span className="absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover/board:scale-[1.05] motion-reduce:transition-none [&>*]:size-full [&>*]:object-cover">{big}</span>
</div>
{small.map((img, i) => (
<div key={i} className={cn('relative overflow-hidden bg-surface-2', small.length === 1 && 'row-span-3', small.length === 2 && (i === 0 ? 'row-span-2' : ''))}>
<span
className="absolute inset-0 transition-transform duration-700 ease-[var(--ease-kit)] group-hover/board:scale-[1.08] motion-reduce:transition-none [&>*]:size-full [&>*]:object-cover"
style={{ transitionDelay: `${(i + 1) * 60}ms` }}
>
{img}
</span>
</div>
))}
</div>
);
}
function Boards(p: WishlistProps & { t: Texts }) {
const { t } = p;
const locale = useLocale();
const [boards, setBoards] = useState<WishBoard[]>(p.boards ?? []);
const [creating, setCreating] = useState(false);
const [name, setName] = useState('');
const [fresh, setFresh] = useState<string | null>(null);
const inputId = useId();
const create = (e: React.FormEvent) => {
e.preventDefault();
const n = name.trim();
if (!n) return;
const id = `new-${boards.length}`;
setBoards((b) => [{ id, name: n, count: 0, images: [] }, ...b]);
setFresh(id);
setName('');
setCreating(false);
p.onCreateBoard?.(n);
};
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
<li>
{creating ? (
<form onSubmit={create} className={cn(CARD, 'flex h-full animate-[kitshop-zoom_0.4s_var(--ease-kit)] flex-col justify-center gap-4 p-5 sm:min-h-[18rem] sm:p-6')}>
<label htmlFor={inputId} className="text-[15px] font-semibold text-foreground">
{t.newListLabel}
</label>
<input
id={inputId}
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.newListPlaceholder}
maxLength={40}
className="h-11 w-full rounded-lg border border-input bg-background px-3.5 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none placeholder:text-subtle-foreground focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]"
/>
<div className="flex gap-2">
<Button type="submit" disabled={!name.trim()} iconLeft="plus">
{t.create}
</Button>
<Button type="button" variant="ghost" onClick={() => setCreating(false)}>
{t.cancel}
</Button>
</div>
</form>
) : (
<button
type="button"
onClick={() => setCreating(true)}
className={cn('group/new flex h-full min-h-[4.5rem] w-full items-center justify-center gap-3 rounded-2xl border border-dashed sm:min-h-[18rem] sm:flex-col border-border-strong text-muted-foreground transition-[background-color,border-color,color] duration-300 hover:border-foreground hover:bg-surface hover:text-foreground', FOCUS)}
>
<span className="grid size-10 place-items-center rounded-full bg-muted transition-transform sm:size-12 duration-500 ease-[var(--ease-kit)] group-hover/new:rotate-90 group-hover/new:scale-110">
<Icon name="plus" size={20} />
</span>
<span className="text-[15px] font-medium">{t.newList}</span>
</button>
)}
</li>
{boards.map((b, i) => (
<Reveal as="li" key={b.id} effect="rise" delay={Math.min(i, 5) * 80}>
<a
href={b.href ?? '#'}
className={cn('group/board block h-full rounded-2xl border border-border bg-card p-2.5 shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:transition-none motion-reduce:hover:translate-y-0', fresh === b.id && 'animate-[kitshop-pop_0.5s_var(--ease-kit)]', FOCUS)}
aria-label={`${t.openList}, ${b.name}, ${formatNumber(locale, b.count)} ${b.count === 1 ? t.item : t.items}`}
>
<Collage images={b.images} t={t} />
<div className="flex items-start justify-between gap-3 px-2 pb-1.5 pt-4">
<div className="min-w-0">
<h3 className="truncate text-[16px] font-semibold tracking-[-0.01em] text-foreground">{b.name}</h3>
<p className="mt-0.5 text-[13px] text-muted-foreground">
{formatNumber(locale, b.count)} {b.count === 1 ? t.item : t.items}
{b.updated && ` · ${b.updated}`}
</p>
</div>
<span className={cn('inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium', b.shared ? 'bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'bg-muted text-muted-foreground')}>
<Icon name={b.shared ? 'users' : 'lock'} size={13} />
{b.shared ? t.sharedList : t.private}
</span>
</div>
</a>
</Reveal>
))}
</ul>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* shared */
/* ------------------------------------------------------------------ */
function Shared(p: WishlistProps & { t: Texts }) {
const { t, currency, owner } = p;
const locale = useLocale();
const items = p.items ?? [];
const [mine, setMine] = useState<Record<string, boolean>>({});
const reservedCount = items.filter((i) => i.reserved || mine[i.id]).length;
const [ref, inView] = useInView<HTMLDivElement>();
const share = items.length ? reservedCount / items.length : 0;
return (
<Section className={p.className}>
<ShopMotion />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-end lg:gap-16">
<div>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title}</h2>
{owner?.message && (
<figure className="kit-rise mt-6 flex max-w-[36rem] gap-4" style={{ '--kit-delay': '100ms' } as React.CSSProperties}>
{owner.avatar && <span className="size-11 shrink-0 overflow-hidden rounded-full bg-muted [&>*]:size-full [&>*]:object-cover">{owner.avatar}</span>}
<div>
<figcaption className="text-[13px] text-muted-foreground">
{t.from} {owner.name}
</figcaption>
<blockquote className="mt-1 text-pretty text-[16px] leading-relaxed text-foreground">{owner.message}</blockquote>
</div>
</figure>
)}
</div>
<div ref={ref} className={cn(CARD, 'p-6 sm:grid sm:grid-cols-2 sm:items-end sm:gap-8 lg:block')}>
<div>
{owner?.days !== undefined && (
<p className="flex items-baseline gap-2">
<span className="text-[40px] font-semibold leading-none tracking-[-0.04em] text-foreground tabular-nums">{formatNumber(locale, owner.days)}</span>
<span className="text-[14px] text-muted-foreground">{owner.days === 1 ? t.dayLeft : t.daysLeft}</span>
</p>
)}
{(owner?.occasion || owner?.date) && <p className="mt-1.5 text-[14px] text-foreground">{[owner?.occasion, owner?.date].filter(Boolean).join(' · ')}</p>}
</div>
<div>
<div className="mt-5 sm:mt-0 lg:mt-5">
<div className="flex justify-between text-[13px] text-muted-foreground">
<span>
<Swap value={reservedCount}>
<span className="font-medium tabular-nums text-foreground">{formatNumber(locale, reservedCount)}</span>
</Swap>{' '}
{t.of} {formatNumber(locale, items.length)} {t.reservedCount}
</span>
</div>
<span className="relative mt-2 block h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]" aria-hidden>
<span className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(share, 3) : 0})` }} />
</span>
</div>
<p className="mt-4 flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
<Icon name="eye-off" size={14} />
{t.reservedHint}
</p>
</div>
</div>
</div>
<Stagger step={70} as="ul" className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
{items.map((item) => {
const other = !!item.reserved;
const me = !!mine[item.id];
return (
<li key={item.id}>
<article className={cn(CARD, 'flex h-full gap-4 p-3 pe-4 sm:flex-col sm:p-2.5 sm:pe-2.5')}>
<div className="relative w-28 shrink-0 sm:w-auto">
<ProductMedia className={cn('aspect-square transition-[filter,opacity] duration-500 sm:aspect-[4/3]', other && 'opacity-60 grayscale')}>{item.image}</ProductMedia>
{me && (
<span className="absolute left-2 top-2 grid size-8 animate-[kitshop-pop_0.4s_var(--ease-kit)] place-items-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-sm)] sm:inline-flex sm:h-7 sm:w-auto sm:gap-1 sm:px-2.5 sm:text-[12px] sm:font-medium">
<Icon name="gift" size={13} />
<span className="max-sm:sr-only">{t.reservedByYou}</span>
</span>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col sm:px-2.5 sm:pb-2.5 sm:pt-1.5">
<h3 className="text-[15px] font-medium text-foreground">
<a href={item.href ?? '#'} className={cn('rounded-sm hover:underline hover:decoration-border-strong hover:underline-offset-4', FOCUS)}>
{item.name}
</a>
</h3>
{item.variant && <p className="mt-0.5 text-[13px] text-muted-foreground">{item.variant}</p>}
<Price value={item.price} currency={currency} className="mt-1.5" />
{item.note && (
<p className="mt-2.5 flex items-start gap-1.5 text-pretty text-[13px] text-muted-foreground">
<Icon name="quote" size={13} className="mt-0.5 shrink-0" />
{item.note}
</p>
)}
<div className="mt-auto pt-4">
{other ? (
<p className="flex h-11 items-center justify-center gap-1.5 rounded-lg bg-muted text-[13px] text-muted-foreground sm:h-10">
<Icon name="check" size={14} />
{t.reservedOther}
</p>
) : (
<Button
variant={me ? 'secondary' : 'outline'}
size="sm"
aria-pressed={me}
className="h-11 w-full sm:h-10"
onClick={() => {
setMine((m) => ({ ...m, [item.id]: !me }));
if (!me) p.onReserve?.(item);
}}
>
<Swap value={me} className="gap-1.5">
<Icon name={me ? 'x' : 'gift'} size={15} />
{me ? t.cancelReserve : t.reserve}
</Swap>
</Button>
)}
</div>
</div>
</article>
</li>
);
})}
</Stagger>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* price-watch */
/* ------------------------------------------------------------------ */
/** Price line over the weeks with the wished price as a dashed line, drawn when it scrolls in */
function PriceChart({ history, target, label }: { history: number[]; target: number; label: string }) {
const [ref, inView] = useInView<HTMLDivElement>();
const clip = 'pw' + useId().replace(/[^a-zA-Z0-9]/g, '');
const W = 240;
const H = 64;
const lo = Math.min(...history, target) * 0.96;
const hi = Math.max(...history, target) * 1.02;
const x = (i: number) => round((i / Math.max(1, history.length - 1)) * W);
const y = (v: number) => round(H - ((v - lo) / Math.max(0.01, hi - lo)) * H);
const d = history.map((v, i) => `${i ? 'L' : 'M'}${x(i)} ${y(v)}`).join(' ');
const last = history[history.length - 1];
return (
<div ref={ref} className="relative" role="img" aria-label={label}>
<svg viewBox={`-4 -6 ${W + 8} ${H + 12}`} className="h-16 w-full overflow-visible" preserveAspectRatio="none">
<defs>
<clipPath id={clip}>
<rect x="-4" y="-6" height={H + 12} width={W + 8} className="origin-left transition-transform duration-[1400ms] ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? 1 : 0})` }} />
</clipPath>
</defs>
<line x1="0" x2={W} y1={y(target)} y2={y(target)} strokeDasharray="4 4" strokeWidth="1.2" vectorEffect="non-scaling-stroke" className="stroke-[color-mix(in_oklab,var(--success)_70%,var(--foreground))]" />
<path d={d} fill="none" strokeWidth="1.8" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" className="stroke-foreground" clipPath={`url(#${clip})`} />
</svg>
<span
aria-hidden
className={cn('absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card bg-primary transition-opacity delay-[1200ms] duration-300', inView ? 'opacity-100' : 'opacity-0')}
style={{ left: `${round(((x(history.length - 1) + 4) / (W + 8)) * 100, 2)}%`, top: `${round(((y(last) + 6) / (H + 12)) * 100, 2)}%` }}
/>
</div>
);
}
function WatchCard({ item, currency, t, onAlert, delay }: { item: WishItem; currency?: string; t: Texts; onAlert?: WishlistProps['onAlert']; delay: number }) {
const locale = useLocale();
const step = item.price >= 100 ? 5 : 1;
const [target, setTarget] = useState(item.target ?? Math.floor((item.price * 0.85) / step) * step);
const [saved, setSaved] = useState(item.target !== undefined);
const history = item.history?.length ? item.history : [item.price];
const low = Math.min(...history);
const reached = item.price <= target;
const gap = Math.max(0, item.price - target);
const set = (v: number) => {
setTarget(Math.max(step, v));
setSaved(false);
};
return (
<Reveal as="li" effect="rise" delay={delay}>
<article className={cn(CARD, 'flex h-full flex-col p-5 transition-shadow duration-500', reached && 'shadow-[var(--highlight),0_0_0_1px_color-mix(in_oklab,var(--success)_45%,transparent),var(--shadow-md)]')}>
<div className="flex items-start gap-4">
<ProductMedia className="size-16 shrink-0 rounded-lg">{item.image}</ProductMedia>
<div className="min-w-0 flex-1">
<h3 className="truncate text-[15px] font-medium text-foreground">
<a href={item.href ?? '#'} className={cn('rounded-sm hover:underline hover:decoration-border-strong hover:underline-offset-4', FOCUS)}>
{item.name}
</a>
</h3>
{item.variant && <p className="truncate text-[13px] text-muted-foreground">{item.variant}</p>}
<p className="mt-1 text-[13px] text-muted-foreground">
{t.now} <span className="font-semibold tabular-nums text-foreground">{formatPrice(locale, item.price, currency)}</span>
</p>
</div>
</div>
<div className="mt-5">
<PriceChart history={history} target={target} label={`${t.history}, ${t.lowest} ${formatPrice(locale, low, currency)}`} />
<p className="mt-2 flex justify-between gap-3 text-[12px] text-muted-foreground">
<span>{t.lowest}</span>
<span className="tabular-nums">{formatPrice(locale, low, currency)}</span>
</p>
</div>
<div className="mt-5 border-t border-border pt-5">
<p className="text-[13px] text-muted-foreground">{t.wishPrice}</p>
<div className="mt-2 flex items-center gap-3">
<div className="inline-flex items-center overflow-hidden rounded-lg border border-border-strong bg-background shadow-[var(--shadow-sm)]">
<button type="button" onClick={() => set(target - step)} aria-label={t.lower} className="relative grid size-11 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground">
<Icon name="minus" size={15} />
</button>
<output aria-live="polite" className="min-w-[5.5rem] text-center text-[15px] font-semibold tabular-nums text-foreground">
{formatPrice(locale, target, currency)}
</output>
<button type="button" onClick={() => set(target + step)} aria-label={t.raise} className="relative grid size-11 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground">
<Icon name="plus" size={15} />
</button>
</div>
<p className={cn('text-[13px]', reached ? 'font-medium text-success-foreground' : 'text-muted-foreground')} aria-live="polite">
{reached ? (
<span className="inline-flex items-center gap-1">
<Icon name="check-circle" size={14} />
{t.reached}
</span>
) : (
<>
<span className="tabular-nums">{formatPrice(locale, gap, currency)}</span> {t.toGo}
</>
)}
</p>
</div>
</div>
<div className="mt-auto pt-5">
{reached ? (
<Button href={item.href ?? '#'} className="w-full" iconLeft="bag">
{t.buyNow}
</Button>
) : (
<Button
variant={saved ? 'secondary' : 'outline'}
className="w-full"
aria-pressed={saved}
onClick={() => {
setSaved(!saved);
if (!saved) onAlert?.(item, target);
}}
>
<Swap value={saved} className="gap-1.5">
<Icon name={saved ? 'check' : 'bell'} size={15} />
{saved ? t.alertOn : t.setAlert}
</Swap>
</Button>
)}
</div>
</article>
</Reveal>
);
}
function PriceWatch(p: WishlistProps & { t: Texts }) {
const { t, currency } = p;
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
{(p.items ?? []).map((item, i) => (
<WatchCard key={item.id} item={item} currency={currency} t={t} onAlert={p.onAlert} delay={Math.min(i, 5) * 80} />
))}
</ul>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* Saved products in five layouts. grid: cards with move to cart and undo.
* list: rows with stock alerts and a summary. boards: several named lists.
* shared: a list friends reserve gifts from. price-watch: wished prices with alerts.
*/
export function Wishlist(props: WishlistProps) {
const { variant = 'grid' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'list') return <List {...props} t={t} />;
if (variant === 'boards') return <Boards {...props} t={t} />;
if (variant === 'shared') return <Shared {...props} t={t} />;
if (variant === 'price-watch') return <PriceWatch {...props} t={t} />;
return <Grid {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Wunschliste?
Mit aiw add @ai-website/wishlist. Der Befehl legt die Datei components/ui/wishlist.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, motion, section, select und product-image. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Wunschliste?
5 Varianten, Raster, Liste mit Übersicht, Listen, Geteilte Geschenkliste und Preisalarm. Du wählst sie mit der Prop variant, zum Beispiel variant="list".
Kann ich die Texte von Wunschliste übersetzen?
Ja. Wunschliste bringt 61 eigene Texte auf Deutsch und Englisch mit, etwa „gemerkt“, „Artikel“ und „Artikel“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Wunschliste einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Wunschliste mit aiw_components und installieren sie mit aiw_add @ai-website/wishlist. Nenn im Prompt die Variante, etwa variant="grid", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Wunschliste ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Wishlist-Code zum Kopieren.