Bundles und Sets
Mehrere Dinge zusammen verkaufen. Häufig zusammen gekauft mit einer Summe, ein Set zum Zusammenstellen mit Staffelrabatt, fertige Sets und Packungen mit Stückpreis.
Eine von 2 Components in Bundles und Abos
Variants
Details
Was Bundles und Sets mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Bundles und Abos
- Bereich
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/bundles.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 26 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle BundlesProps aus components/ui/bundles.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalBundlesVariantfrequently-boughtbuild-a-setset-cardsvolume
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringitemsoptionalBundleItem[]frequently-bought: the first is the product on the page
discountoptionalnumberfrequently-bought: discount in percent when all are taken
tiersoptionalBundleTier[]build-a-set: tiers, smallest first
slotsoptionalnumberbuild-a-set: number of slots
defaultPickedoptionalstring[]build-a-set: ids picked on load
setsoptionalBundleSet[]productoptionalBundleItem & { unit?: string; /** One piece, e.g. "bag", shown as "€13.40 per bag" */ unitOne?: string }volume: the product and its packs
packsoptionalBundlePack[]currencyoptionalstringonAddoptional(items: BundleItem[], total: number) => void
Eingebaute Texte
Diese Texte schreibt Bundles und Sets selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 26 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| thisItem | Englisch This item | Deutsch Dieser Artikel |
| total | Englisch Total | Deutsch Summe |
| save | Englisch You save | Deutsch Du sparst |
| saveShort | Englisch Save | Deutsch Spare |
| addSelected | Englisch Add to cart | Deutsch In den Warenkorb |
| added | Englisch Added to cart | Deutsch Im Warenkorb |
| items | Englisch items | Deutsch Artikel |
| item | Englisch item | Deutsch Artikel |
| together | Englisch together | Deutsch zusammen |
| pick | Englisch Add to set | Deutsch Zum Set |
| picked | Englisch In your set | Deutsch Im Set |
| remove | Englisch Remove | Deutsch Entfernen |
| emptySlot | Englisch Empty slot | Deutsch Freier Platz |
| unlock | Englisch more for | Deutsch mehr für |
| off | Englisch off | Deutsch Rabatt |
| setFull | Englisch Your set is complete | Deutsch Dein Set ist komplett |
| addSet | Englisch Add set to cart | Deutsch Set in den Warenkorb |
| inside | Englisch Inside | Deutsch Darin |
| viewSet | Englisch Add set to cart | Deutsch Set in den Warenkorb |
| instead | Englisch instead of | Deutsch statt |
| perPiece | Englisch per piece | Deutsch pro Stück |
| per | Englisch per | Deutsch pro |
| pack | Englisch Pack of | Deutsch Packung mit |
| one | Englisch Single | Deutsch Einzeln |
| choosePack | Englisch Choose your pack | Deutsch Wähle deine Packung |
| bestValue | Englisch Best value | Deutsch Bester Preis |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- 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, 625 Zeilen
'use client';
import { useId, 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 { FlyUp, Price, ProductMedia, ShopMotion, Swap, TweenPrice, formatNumber, formatPercent, formatPrice } from '@/components/ui/product-image';
/*
* Selling several things together.
*
* frequently-bought the product and what people add, tick what you want, one total
* build-a-set pick pieces into empty slots, every tier unlocks a bigger discount
* set-cards ready-made sets with what is inside and what you save
* volume one product in packs of 1, 3 or 6, with the price per piece
*
* Prices are gross. Images come in as nodes.
*/
export type BundlesVariant = 'frequently-bought' | 'build-a-set' | 'set-cards' | 'volume';
export interface BundleItem {
id: string;
name: string;
/** e.g. "Clay · 350 ml" */
variant?: string;
price: number;
image?: React.ReactNode;
href?: string;
}
export interface BundleSet {
id: string;
name: string;
description?: string;
/** What is inside, shown as chips */
items: BundleItem[];
/** Set price; the sum of the items is the compare price */
price: number;
tag?: string;
href?: string;
}
export interface BundleTier {
/** Pieces needed */
count: number;
/** Discount in percent */
percent: number;
}
export interface BundlePack {
id: string;
/** Pieces in the pack */
count: number;
/** Price of the pack */
price: number;
tag?: string;
}
export interface BundlesProps {
variant?: BundlesVariant;
eyebrow?: string;
title?: string;
description?: string;
/** frequently-bought: the first is the product on the page */
items?: BundleItem[];
/** frequently-bought: discount in percent when all are taken */
discount?: number;
/** build-a-set: tiers, smallest first */
tiers?: BundleTier[];
/** build-a-set: number of slots */
slots?: number;
/** build-a-set: ids picked on load */
defaultPicked?: string[];
sets?: BundleSet[];
/** volume: the product and its packs */
product?: BundleItem & { unit?: string; /** One piece, e.g. "bag", shown as "€13.40 per bag" */ unitOne?: string };
packs?: BundlePack[];
currency?: string;
onAdd?: (items: BundleItem[], total: number) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
thisItem: 'This item',
total: 'Total',
save: 'You save',
saveShort: 'Save',
addSelected: 'Add to cart',
added: 'Added to cart',
items: 'items',
item: 'item',
together: 'together',
pick: 'Add to set',
picked: 'In your set',
remove: 'Remove',
emptySlot: 'Empty slot',
unlock: 'more for',
off: 'off',
setFull: 'Your set is complete',
addSet: 'Add set to cart',
inside: 'Inside',
viewSet: 'Add set to cart',
instead: 'instead of',
perPiece: 'per piece',
per: 'per',
pack: 'Pack of',
one: 'Single',
choosePack: 'Choose your pack',
bestValue: 'Best value',
},
de: {
thisItem: 'Dieser Artikel',
total: 'Summe',
save: 'Du sparst',
saveShort: 'Spare',
addSelected: 'In den Warenkorb',
added: 'Im Warenkorb',
items: 'Artikel',
item: 'Artikel',
together: 'zusammen',
pick: 'Zum Set',
picked: 'Im Set',
remove: 'Entfernen',
emptySlot: 'Freier Platz',
unlock: 'mehr für',
off: 'Rabatt',
setFull: 'Dein Set ist komplett',
addSet: 'Set in den Warenkorb',
inside: 'Darin',
viewSet: 'Set in den Warenkorb',
instead: 'statt',
perPiece: 'pro Stück',
per: 'pro',
pack: 'Packung mit',
one: 'Einzeln',
choosePack: 'Wähle deine Packung',
bestValue: 'Bester Preis',
},
};
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 CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const SAVE = 'text-success-foreground';
function Head({ eyebrow, title, description }: { eyebrow?: string; title?: string; description?: string }) {
if (!title) return null;
return (
<div className="mb-10 max-w-[40rem] sm:mb-12">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<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>
);
}
/** A tick box drawn in the kit style, the real input stays for keyboards */
function Tick({ on }: { on: boolean }) {
return (
<span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-[5px] border transition-colors duration-200', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
{on && <Icon name="check" size={13} strokeWidth={2.4} className="animate-[kitshop-pop_0.3s_var(--ease-kit)]" />}
</span>
);
}
/** The add button with "+1" and a swap to "Added" */
function AddButton({ label, done, count, onClick, disabled, className, size = 'lg' }: { label: string; done: string; count: number; onClick: () => void; disabled?: boolean; className?: string; size?: 'md' | 'lg' }) {
const [state, setState] = useState(0);
return (
<span className={cn('relative block', className)}>
<FlyUp count={state} label={`+${count}`} />
<Button
size={size}
className="w-full"
disabled={disabled}
iconLeft={state ? 'check' : 'bag'}
onClick={() => {
setState((s) => s + 1);
onClick();
}}
>
<Swap value={state > 0}>{state ? done : label}</Swap>
</Button>
</span>
);
}
/* ------------------------------------------------------------------ */
/* frequently-bought */
/* ------------------------------------------------------------------ */
function FrequentlyBought(p: BundlesProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const items = p.items ?? [];
const [on, setOn] = useState<Record<string, boolean>>(() => Object.fromEntries(items.map((i) => [i.id, true])));
const picked = items.filter((i) => on[i.id]);
const all = picked.length === items.length && items.length > 1;
const sum = picked.reduce((s, i) => s + i.price, 0);
const discount = all ? round((sum * (p.discount ?? 0)) / 100) : 0;
const total = sum - discount;
const name = useId();
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-center lg:gap-12">
<Stagger step={90} as="ul" className="flex items-center gap-8 sm:gap-14">
{items.map((it, i) => (
<li key={it.id} className="relative min-w-0 flex-1">
{i > 0 && (
<span aria-hidden className={cn('absolute -left-4 top-1/2 z-10 grid size-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-border bg-background text-muted-foreground transition-opacity duration-300 sm:-left-7 sm:size-9', !on[it.id] && 'opacity-40')}>
<Icon name="plus" size={15} />
</span>
)}
<label className={cn('group/tile relative block min-w-0 flex-1 cursor-pointer rounded-xl has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]')}>
<input type="checkbox" name={name} checked={!!on[it.id]} disabled={i === 0} onChange={(e) => setOn((o) => ({ ...o, [it.id]: e.target.checked }))} className="sr-only" />
<ProductMedia className={cn('aspect-square transition-[opacity,filter,transform] duration-500 ease-[var(--ease-kit)]', !on[it.id] && 'scale-[0.96] opacity-45 grayscale')}>
<span className="block size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/tile:scale-[1.05] motion-reduce:transition-none [&>*]:size-full">{it.image}</span>
</ProductMedia>
<span className="absolute left-2 top-2">{i === 0 ? <span className="rounded-full bg-background/90 px-2 py-0.5 text-[11.5px] font-medium text-foreground backdrop-blur max-sm:hidden">{t.thisItem}</span> : <Tick on={!!on[it.id]} />}</span>
<span className="sr-only">{it.name}</span>
</label>
</li>
))}
</Stagger>
<Reveal effect="rise" className={cn(CARD, 'p-5 sm:p-6')}>
<ul className="space-y-1">
{items.map((it, i) => (
<li key={it.id}>
<label className={cn('flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5 transition-colors', i === 0 ? 'cursor-default' : 'cursor-pointer hover:bg-surface')}>
<input type="checkbox" checked={!!on[it.id]} disabled={i === 0} onChange={(e) => setOn((o) => ({ ...o, [it.id]: e.target.checked }))} className="peer sr-only" />
<span className="peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--ring)] rounded-[5px]">
<Tick on={!!on[it.id]} />
</span>
<span className={cn('min-w-0 flex-1 transition-opacity', !on[it.id] && 'opacity-55')}>
<span className="block truncate text-[14px] font-medium text-foreground">
{i === 0 && <span className="text-muted-foreground">{t.thisItem} · </span>}
{it.name}
</span>
{it.variant && <span className="block truncate text-[12.5px] text-muted-foreground">{it.variant}</span>}
</span>
<span className={cn('text-[14px] tabular-nums text-foreground transition-opacity', !on[it.id] && 'opacity-55')}>{formatPrice(locale, it.price, currency)}</span>
</label>
</li>
))}
</ul>
<div className="mt-4 border-t border-border pt-4">
<div className="flex items-baseline justify-between gap-3">
<span className="text-[14px] text-muted-foreground">
{t.total} · {formatNumber(locale, picked.length)} {picked.length === 1 ? t.item : t.items}
</span>
<span className="text-right">
<TweenPrice value={total} currency={currency} className="block text-[22px] font-semibold tracking-[-0.02em] text-foreground" />
{discount > 0 && <s className="text-[13px] tabular-nums text-muted-foreground">{formatPrice(locale, sum, currency)}</s>}
</span>
</div>
<p className={cn('mt-1 flex h-5 items-center gap-1.5 text-[13px] font-medium transition-opacity duration-300', SAVE, discount > 0 ? 'opacity-100' : 'opacity-0')} aria-live="polite">
<Icon name="badge-percent" size={14} />
{t.save} {formatPrice(locale, discount, currency)} {t.together}
</p>
<AddButton className="mt-4" label={`${t.addSelected} · ${picked.length}`} done={t.added} count={picked.length} onClick={() => p.onAdd?.(picked, total)} />
</div>
</Reveal>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* build-a-set */
/* ------------------------------------------------------------------ */
function BuildASet(p: BundlesProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const items = p.items ?? [];
const tiers = p.tiers ?? [];
const slots = p.slots ?? tiers[tiers.length - 1]?.count ?? 3;
const [picked, setPicked] = useState<string[]>(p.defaultPicked ?? []);
const list = picked.map((id) => items.find((i) => i.id === id)!).filter(Boolean);
const sum = list.reduce((s, i) => s + i.price, 0);
const tier = [...tiers].reverse().find((x) => list.length >= x.count);
const next = tiers.find((x) => list.length < x.count);
const discount = tier ? round((sum * tier.percent) / 100) : 0;
const total = sum - discount;
const full = list.length >= slots;
const toggle = (id: string) => setPicked((l) => (l.includes(id) ? l.filter((x) => x !== id) : l.length >= slots ? l : [...l, id]));
const [ref, inView] = useInView<HTMLDivElement>();
const summary = (
<>
<div className="flex items-baseline justify-between gap-3">
<span className="text-[14px] text-muted-foreground">{t.total}</span>
<span className="text-right">
<TweenPrice value={total} currency={currency} className="block text-[22px] font-semibold tracking-[-0.02em] text-foreground" />
{discount > 0 && <s className="text-[13px] tabular-nums text-muted-foreground">{formatPrice(locale, sum, currency)}</s>}
</span>
</div>
<p className={cn('mt-1 text-[13px] font-medium', discount > 0 ? SAVE : 'text-muted-foreground')} aria-live="polite">
{full ? (
<span className="inline-flex items-center gap-1.5">
<Icon name="check-circle" size={14} />
{t.setFull} · {t.save} {formatPrice(locale, discount, currency)}
</span>
) : next ? (
<>
{formatNumber(locale, next.count - list.length)} {t.unlock} {formatPercent(locale, next.percent)} {t.off}
</>
) : null}
</p>
</>
);
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div ref={ref} className={cn(CARD, 'p-5 sm:p-6')}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-center lg:gap-10">
<div>
<ol className="grid max-w-[40rem] gap-2 sm:gap-3" style={{ gridTemplateColumns: `repeat(${slots}, minmax(0, 1fr))` }} aria-label={t.picked}>
{Array.from({ length: slots }, (_, i) => {
const it = list[i];
return (
<li key={i} className="relative aspect-square">
{it ? (
<button type="button" onClick={() => toggle(it.id)} aria-label={`${t.remove}, ${it.name}`} className={cn('group/slot relative block size-full animate-[kitshop-pop_0.45s_var(--ease-kit)] overflow-hidden rounded-xl bg-surface-2', FOCUS)}>
<span className="block size-full [&>*]:size-full">{it.image}</span>
<span className="absolute right-1.5 top-1.5 grid size-7 place-items-center rounded-full bg-background/90 text-foreground opacity-100 shadow-[var(--shadow-sm)] backdrop-blur transition-opacity sm:opacity-0 sm:group-hover/slot:opacity-100 sm:group-focus-visible/slot:opacity-100">
<Icon name="x" size={13} />
</span>
</button>
) : (
<span className="grid size-full place-items-center rounded-xl border border-dashed border-border-strong text-[12px] text-subtle-foreground">
<span className="grid justify-items-center gap-1">
<Icon name="plus" size={16} />
<span className="tabular-nums">{i + 1}</span>
</span>
<span className="sr-only">{t.emptySlot}</span>
</span>
)}
</li>
);
})}
</ol>
{tiers.length > 0 && (
<div className="mt-5 max-w-[40rem]">
<div className="relative h-1.5 rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
<span aria-hidden className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(list.length / slots, 3) : 0})` }} />
{tiers.map((x) => (
<span key={x.count} aria-hidden className={cn('absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card transition-colors duration-300', list.length >= x.count ? 'bg-primary' : 'bg-border-strong')} style={{ left: `${round((x.count / slots) * 100, 2)}%` }} />
))}
</div>
<div className="relative mt-3 h-9 text-[12.5px]">
{tiers.map((x) => (
<span key={x.count} className={cn('absolute -translate-x-1/2 whitespace-nowrap text-center leading-tight transition-colors last:-translate-x-full last:text-right', list.length >= x.count ? SAVE : 'text-muted-foreground')} style={{ left: `${round((x.count / slots) * 100, 2)}%` }}>
<span className="block font-semibold">-{formatPercent(locale, x.percent)}</span>
<span className="block text-[11.5px] opacity-80">
{formatNumber(locale, x.count)} {t.items}
</span>
</span>
))}
</div>
</div>
)}
</div>
<div className="border-t border-border pt-5 lg:border-l lg:border-t-0 lg:ps-10 lg:pt-0">
{summary}
<AddButton className="mt-4" label={t.addSet} done={t.added} count={list.length} disabled={!list.length} onClick={() => p.onAdd?.(list, total)} />
</div>
</div>
</div>
<Stagger step={60} as="ul" className="mt-8 grid grid-cols-2 gap-x-3 gap-y-6 sm:grid-cols-3 sm:gap-x-5 lg:grid-cols-6">
{items.map((it) => {
const on = picked.includes(it.id);
return (
<li key={it.id} className="flex flex-col">
<button
type="button"
aria-pressed={on}
disabled={!on && full}
onClick={() => toggle(it.id)}
className={cn('group/card relative block rounded-xl text-left disabled:cursor-not-allowed', FOCUS)}
>
<ProductMedia className={cn('aspect-square transition-[box-shadow,opacity] duration-300', on && 'shadow-[0_0_0_2px_var(--primary)]', !on && full && 'opacity-50')}>
<span className="block size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/card:scale-[1.05] motion-reduce:transition-none [&>*]:size-full">{it.image}</span>
</ProductMedia>
<span className={cn('absolute right-2 top-2 grid size-8 place-items-center rounded-full shadow-[var(--shadow-sm)] transition-colors duration-300', on ? 'bg-primary text-primary-foreground' : 'bg-background/90 text-foreground backdrop-blur')}>
<Icon name={on ? 'check' : 'plus'} size={15} className={cn(on && 'animate-[kitshop-pop_0.35s_var(--ease-kit)]')} />
</span>
<span className="mt-2.5 block text-[14px] font-medium leading-snug text-foreground">{it.name}</span>
{it.variant && <span className="block text-[12.5px] text-muted-foreground">{it.variant}</span>}
<span className="mt-1 block text-[14px] tabular-nums text-foreground">{formatPrice(locale, it.price, currency)}</span>
</button>
</li>
);
})}
</Stagger>
{/* Phones and tablets: a bar that stays at the bottom while picking */}
<div className="sticky bottom-3 z-10 mt-6 lg:hidden">
<div className="flex items-center gap-3 rounded-2xl border border-border bg-background/92 p-2.5 ps-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-md">
<div className="min-w-0 flex-1">
<p className="text-[12.5px] tabular-nums text-muted-foreground">
{formatNumber(locale, list.length)} / {formatNumber(locale, slots)}
</p>
<TweenPrice value={total} currency={currency} className="text-[17px] font-semibold tracking-[-0.02em] text-foreground" />
</div>
<Button size="md" iconLeft="bag" disabled={!list.length} onClick={() => p.onAdd?.(list, total)}>
{t.addSet}
</Button>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* set-cards */
/* ------------------------------------------------------------------ */
function SetCards(p: BundlesProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const sets = p.sets ?? [];
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<Stagger step={90} as="ul" className="grid grid-cols-1 gap-5 lg:grid-cols-3 lg:gap-6">
{sets.map((set) => {
const sum = set.items.reduce((s, i) => s + i.price, 0);
const save = Math.max(0, sum - set.price);
const pct = sum ? Math.round((save / sum) * 100) : 0;
const shown = set.items.slice(0, 3);
return (
<li key={set.id}>
<article className={cn(CARD, 'group/set flex h-full flex-col p-2.5 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:gap-2 lg:flex lg:gap-0 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')}>
{/* The pieces stand side by side and fan out on hover */}
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-surface-2 sm:aspect-auto sm:min-h-[17rem] lg:aspect-[4/3] lg:min-h-0">
<div aria-hidden className="absolute inset-0 bg-[radial-gradient(70%_60%_at_50%_30%,rgb(255_255_255/0.7),transparent_70%)] dark:bg-[radial-gradient(70%_60%_at_50%_30%,rgb(255_255_255/0.07),transparent_70%)]" />
<div className="absolute inset-0 flex items-center justify-center">
{shown.map((it, i) => {
const mid = (shown.length - 1) / 2;
const off = i - mid;
return (
<span
key={it.id}
className="relative -mx-[1.5%] block w-[34%] transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none [&>*]:aspect-square [&>*]:w-full"
style={{ zIndex: 10 - Math.abs(Math.round(off * 2)), transform: `translateY(${round(Math.abs(off) * 6)}%) rotate(${round(off * 4)}deg)` }}
>
<span className="block transition-transform duration-700 ease-[var(--ease-kit)] group-hover/set:[transform:var(--fan)] motion-reduce:!transform-none [&>*]:aspect-square [&>*]:w-full" style={{ '--fan': `translateX(${round(off * 14)}%) rotate(${round(off * 4)}deg)` } as React.CSSProperties}>
{it.image}
</span>
</span>
);
})}
</div>
{pct > 0 && (
<span className="absolute right-3 top-3 grid size-14 rotate-[-8deg] place-items-center rounded-full bg-primary text-center text-[13px] font-semibold leading-tight text-primary-foreground shadow-[var(--shadow-md)] transition-transform duration-500 ease-[var(--ease-kit)] group-hover/set:rotate-0 group-hover/set:scale-105">
-{formatPercent(locale, pct)}
</span>
)}
{set.tag && <span className="absolute left-3 top-3 rounded-full bg-background/90 px-2.5 py-1 text-[12px] font-medium text-foreground backdrop-blur">{set.tag}</span>}
</div>
<div className="flex flex-1 flex-col px-2.5 pb-2.5 pt-4">
<h3 className="text-[18px] font-semibold tracking-[-0.015em] text-foreground">{set.name}</h3>
{set.description && <p className="mt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">{set.description}</p>}
<p className="mt-4 text-[12px] font-medium text-subtle-foreground">{t.inside}</p>
<ul className="mt-1.5 flex flex-wrap gap-1.5">
{set.items.map((it) => (
<li key={it.id} className="rounded-md bg-muted px-2 py-1 text-[12.5px] text-foreground">
{it.name}
</li>
))}
</ul>
<div className="mt-auto pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<Price value={set.price} compareAt={sum} currency={currency} size="lg" />
{save > 0 && (
<span className={cn('text-[13px] font-medium', SAVE)}>
{t.saveShort} {formatPrice(locale, save, currency)}
</span>
)}
</div>
<AddButton size="md" className="mt-4" label={t.viewSet} done={t.added} count={set.items.length} onClick={() => p.onAdd?.(set.items, set.price)} />
</div>
</div>
</article>
</li>
);
})}
</Stagger>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* volume */
/* ------------------------------------------------------------------ */
function Volume(p: BundlesProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const packs = p.packs ?? [];
const product = p.product;
const single = product?.price ?? packs[0]?.price ?? 0;
const best = packs.reduce((b, x) => (x.price / x.count < b.price / b.count ? x : b), packs[0] ?? { id: '', count: 1, price: single });
const [packId, setPackId] = useState(packs.find((x) => x.tag)?.id ?? packs[0]?.id ?? '');
const pack = packs.find((x) => x.id === packId) ?? packs[0];
const name = useId();
return (
<Section className={p.className}>
<ShopMotion />
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-center lg:gap-16">
<Reveal effect="scale" className="relative">
<ProductMedia className="aspect-[4/3] lg:aspect-square">
{/* The picked pack shows as that many pieces, spread like on a counter */}
<span aria-hidden className="absolute inset-0 bg-[radial-gradient(60%_55%_at_50%_35%,rgb(255_255_255/0.75),transparent_70%)] dark:bg-[radial-gradient(60%_55%_at_50%_35%,rgb(255_255_255/0.08),transparent_70%)]" />
<span className="absolute inset-0 flex items-center justify-center">
{Array.from({ length: Math.min(pack?.count ?? 1, 6) }, (_, i) => {
const n = Math.min(pack?.count ?? 1, 6);
const off = i - (n - 1) / 2;
return (
<span
key={`${pack?.id}-${i}`}
className="relative block w-[50%] animate-[kitshop-zoom_0.5s_var(--ease-kit)_both] [&>*]:aspect-square [&>*]:w-full"
style={{ marginInline: n > 1 ? `-${round(8 + n * 2.6, 1)}%` : 0, transform: `translateY(${round(Math.abs(off) * -3, 1)}%) scale(${round(1 - Math.abs(off) * 0.06, 3)})`, zIndex: 10 - Math.round(Math.abs(off) * 2), animationDelay: `${i * 60}ms` }}
>
{product?.image}
</span>
);
})}
</span>
</ProductMedia>
</Reveal>
<div>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title ?? product?.name}</h2>
{p.description && (
<p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{p.description}
</p>
)}
<fieldset className="mt-8">
<legend className="mb-3 text-[14px] font-medium text-foreground">{t.choosePack}</legend>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 sm:gap-2">
{packs.map((x) => {
const on = x.id === packId;
const unit = x.price / x.count;
const pct = single ? Math.round((1 - unit / single) * 100) : 0;
return (
<label
key={x.id}
className={cn(
'relative flex cursor-pointer items-center justify-between gap-3 rounded-xl border p-4 transition-[border-color,box-shadow] sm:flex-col sm:items-start sm:justify-start has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]',
on ? 'border-foreground bg-card shadow-[0_0_0_1px_var(--foreground)]' : 'border-border bg-card hover:border-border-strong',
)}
>
<input type="radio" name={name} checked={on} onChange={() => setPackId(x.id)} className="sr-only" />
{(x.tag || x.id === best.id) && x.count > 1 && (
<span className="absolute -top-2.5 left-4 rounded-full bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground shadow-[var(--shadow-sm)]">{x.tag ?? t.bestValue}</span>
)}
<span>
<span className="block text-[15px] font-semibold text-foreground">{x.count === 1 ? t.one : `${formatNumber(locale, x.count)} ${product?.unit ?? t.items}`}</span>
<span className="block text-[12.5px] tabular-nums text-muted-foreground">
{formatPrice(locale, unit, currency)} {product?.unitOne ? `${t.per} ${product.unitOne}` : t.perPiece}
</span>
</span>
<span className="text-right sm:mt-3 sm:text-left">
<span className="block text-[17px] font-semibold tabular-nums text-foreground">{formatPrice(locale, x.price, currency)}</span>
<span className={cn('block text-[12.5px] font-medium', pct > 0 ? SAVE : 'text-transparent')}>{pct > 0 ? `-${formatPercent(locale, pct)}` : '·'}</span>
</span>
</label>
);
})}
</div>
</fieldset>
{pack && (
<div className="mt-6 flex items-center justify-between gap-4">
<div>
<TweenPrice value={pack.price} currency={currency} className="text-[26px] font-semibold tracking-[-0.02em] text-foreground" />
{pack.count > 1 && (
<p className="text-[13px] text-muted-foreground">
{t.instead} <s className="tabular-nums">{formatPrice(locale, single * pack.count, currency)}</s>
</p>
)}
</div>
<AddButton className="w-44 sm:w-56" label={t.addSelected} done={t.added} count={pack.count} onClick={() => product && p.onAdd?.([product], pack.price)} />
</div>
)}
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* Bundles in four layouts. frequently-bought: the product and its add-ons.
* build-a-set: pick pieces for a tiered discount. set-cards: ready sets.
* volume: packs with a price per piece.
*/
export function Bundles(props: BundlesProps) {
const { variant = 'frequently-bought' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'build-a-set') return <BuildASet {...props} t={t} />;
if (variant === 'set-cards') return <SetCards {...props} t={t} />;
if (variant === 'volume') return <Volume {...props} t={t} />;
return <FrequentlyBought {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Bundles und Sets?
Mit aiw add @ai-website/bundles. Der Befehl legt die Datei components/ui/bundles.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, motion, section und product-image. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Bundles und Sets?
4 Varianten, Häufig zusammen gekauft, Set zusammenstellen, Fertige Sets und Packungen und Staffelpreise. Du wählst sie mit der Prop variant, zum Beispiel variant="build-a-set".
Kann ich die Texte von Bundles und Sets übersetzen?
Ja. Bundles und Sets bringt 26 eigene Texte auf Deutsch und Englisch mit, etwa „Dieser Artikel“, „Summe“ und „Du sparst“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Bundles und Sets einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Bundles und Sets mit aiw_components und installieren sie mit aiw_add @ai-website/bundles. Nenn im Prompt die Variante, etwa variant="frequently-bought", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Bundles und Sets ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Bundles-Code zum Kopieren.