Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Häufig zusammen gekauft

Das Produkt und zwei Ergänzungen mit Pluszeichen dazwischen. Abwählen blendet Kachel und Plus ab, die Summe zählt zum neuen Wert, der Rabatt für alle drei blendet ein und der Knopf sagt, wie viele in den Warenkorb gehen.

Im Studio öffnen

Set zusammenstellen

Freie Plätze oben füllen sich mit jeder Wahl, jedes Stück springt hinein und verlässt den Platz per Tippen. Ein Balken mit Stufenmarken füllt sich bis zum nächsten Rabatt und sagt, wie viele fehlen, die Summe zählt mit.

Im Studio öffnen

Fertige Sets

Kuratierte Sets als Karten. Die Stücke stehen nebeneinander und fächern sich beim Hover auf, ein Sticker mit der Ersparnis richtet sich auf, Chips zeigen den Inhalt und der Setpreis steht gegen die Einzelpreise.

Im Studio öffnen

Packungen und Staffelpreise

Ein Produkt in Packungen zu 1, 3 und 6 als Karten mit Stückpreis und Ersparnis. Das Bild zeigt die gewählte Packung als so viele Stücke, die nacheinander aufsteigen, die Summe zählt zum neuen Preis.

Im Studio öffnen

Details

Was Bundles und Sets mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/bundles.tsx
Bringt mit
utilskitbuttoniconrevealmotionsectionproduct-image
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 26 eigene Texte

Props

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

  • variantoptional
    BundlesVariant

    frequently-boughtbuild-a-setset-cardsvolume

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • itemsoptional
    BundleItem[]

    frequently-bought: the first is the product on the page

  • discountoptional
    number

    frequently-bought: discount in percent when all are taken

  • tiersoptional
    BundleTier[]

    build-a-set: tiers, smallest first

  • slotsoptional
    number

    build-a-set: number of slots

  • defaultPickedoptional
    string[]

    build-a-set: ids picked on load

  • setsoptional
    BundleSet[]
  • productoptional
    BundleItem & { unit?: string; /** One piece, e.g. "bag", shown as "€13.40 per bag" */ unitOne?: string }

    volume: the product and its packs

  • packsoptional
    BundlePack[]
  • currencyoptional
    string
  • onAddoptional
    (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
thisItemEnglisch This itemDeutsch Dieser Artikel
totalEnglisch TotalDeutsch Summe
saveEnglisch You saveDeutsch Du sparst
saveShortEnglisch SaveDeutsch Spare
addSelectedEnglisch Add to cartDeutsch In den Warenkorb
addedEnglisch Added to cartDeutsch Im Warenkorb
itemsEnglisch itemsDeutsch Artikel
itemEnglisch itemDeutsch Artikel
togetherEnglisch togetherDeutsch zusammen
pickEnglisch Add to setDeutsch Zum Set
pickedEnglisch In your setDeutsch Im Set
removeEnglisch RemoveDeutsch Entfernen
emptySlotEnglisch Empty slotDeutsch Freier Platz
unlockEnglisch more forDeutsch mehr für
offEnglisch offDeutsch Rabatt
setFullEnglisch Your set is completeDeutsch Dein Set ist komplett
addSetEnglisch Add set to cartDeutsch Set in den Warenkorb
insideEnglisch InsideDeutsch Darin
viewSetEnglisch Add set to cartDeutsch Set in den Warenkorb
insteadEnglisch instead ofDeutsch statt
perPieceEnglisch per pieceDeutsch pro Stück
perEnglisch perDeutsch pro
packEnglisch Pack ofDeutsch Packung mit
oneEnglisch SingleDeutsch Einzeln
choosePackEnglisch Choose your packDeutsch Wähle deine Packung
bestValueEnglisch Best valueDeutsch 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
components/ui/bundles.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert