Abos
Verkaufen, was wiederkommt. Einmal kaufen oder abonnieren auf der Produktseite, Boxgrößen als Pläne, ein Baukasten in drei Schritten, der Plan der nächsten Lieferungen und wie ein Abo funktioniert.
Eine von 2 Components in Bundles und Abos
Variants
Details
Was Abos mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Bundles und Abos
- Bereich
- E-Commerce
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/subscription-box.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 40 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SubscriptionBoxProps aus components/ui/subscription-box.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalSubscriptionBoxVariantpurchase-toggleplansbuilderschedulehow-it-works
eyebrowoptionalstringtitleoptionalstringdescriptionoptionalstringproductoptional{ name: string; subtitle?: string; price: number; image?: React.ReactNode }purchase-toggle: the product
discountoptionalnumberDiscount in percent for subscribers
rhythmsoptionalnumber[]Delivery rhythms in weeks
defaultRhythmoptionalnumberplansoptionalSubPlan[]unitOneoptionalstringplans: one piece, e.g. "bag", shown as "€12.45 per bag"
yearlyoptionalnumberplans: yearly discount in percent on top
questionsoptional[string, string, string]builder: the question of each step, e.g. ["Which coffee?", "How much?", "How often?"]
choicesoptionalSubOption[]builder: the choices of step one, two and three
sizesoptional(SubOption & { price: number })[]deliveriesoptionalSubDelivery[]stepsoptional{ icon?: IconName; title: string; text: string }[]perksoptionalstring[]currencyoptionalstringonSubscribeoptional(detail: Record<string, string | number>) => void
Eingebaute Texte
Diese Texte schreibt Abos selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 40 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| oneTime | Englisch One-time purchase | Deutsch Einmal kaufen |
| subscribe | Englisch Subscribe and save | Deutsch Abonnieren und sparen |
| every | Englisch Every | Deutsch Alle |
| everyWeek | Englisch Every week | Deutsch Jede Woche |
| week | Englisch week | Deutsch Woche |
| weeks | Englisch weeks | Deutsch Wochen |
| deliverEvery | Englisch Deliver every | Deutsch Liefern alle |
| perDelivery | Englisch per delivery | Deutsch pro Lieferung |
| perPiece | Englisch per piece | Deutsch pro Stück |
| per | Englisch per | Deutsch pro |
| save | Englisch Save | Deutsch Spare |
| addToCart | Englisch Add to cart | Deutsch In den Warenkorb |
| startSub | Englisch Start subscription | Deutsch Abo starten |
| started | Englisch Subscription started | Deutsch Abo gestartet |
| cancelAny | Englisch Skip, pause or cancel any time in your account. | Deutsch Jederzeit im Konto aussetzen, pausieren oder kündigen. |
| monthly | Englisch Monthly | Deutsch Monatlich |
| yearly | Englisch Yearly | Deutsch Jährlich |
| billed | Englisch billed yearly | Deutsch jährlich abgerechnet |
| choose | Englisch Choose | Deutsch Auswählen |
| chosen | Englisch Chosen | Deutsch Ausgewählt |
| step | Englisch Step | Deutsch Schritt |
| of | Englisch of | Deutsch von |
| next | Englisch Next | Deutsch Weiter |
| back | Englisch Back | Deutsch Zurück |
| summary | Englisch Your box | Deutsch Deine Box |
| what | Englisch What | Deutsch Was |
| howMuch | Englisch How much | Deutsch Wie viel |
| howOften | Englisch How often | Deutsch Wie oft |
| then | Englisch then | Deutsch danach |
| shipped | Englisch Shipped | Deutsch Versendet |
| upNext | Englisch Up next | Deutsch Als Nächstes |
| planned | Englisch Planned | Deutsch Geplant |
| skipped | Englisch Skipped | Deutsch Ausgesetzt |
| skip | Englisch Skip | Deutsch Aussetzen |
| undo | Englisch Undo skip | Deutsch Doch liefern |
| move | Englisch Move a week | Deutsch Eine Woche später |
| moved | Englisch Moved | Deutsch Verschoben |
| pause | Englisch Pause subscription | Deutsch Abo pausieren |
| resume | Englisch Resume subscription | Deutsch Abo fortsetzen |
| paused | Englisch Your subscription is paused. Nothing ships until you resume. | Deutsch Dein Abo ist pausiert. Bis du es fortsetzt, wird nichts verschickt. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-checked, aria-current, aria-label und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen radio, radiogroup 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. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 649 Zeilen
'use client';
import { useId, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollProgress } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { ProductMedia, ShopMotion, Swap, TweenPrice, formatNumber, formatPercent, formatPrice } from '@/components/ui/product-image';
/*
* Selling something that comes again: coffee, tea, flowers, razors, pet food.
*
* purchase-toggle one-time or subscribe and save on the product page, with the rhythm
* plans box sizes as plans, monthly or yearly, the price per delivery and per piece
* builder three steps: what, how much, how often, with a summary that follows along
* schedule the next deliveries on a timeline: skip, move, pause
* how-it-works how a subscription works, in steps that light up while scrolling
*/
export type SubscriptionBoxVariant = 'purchase-toggle' | 'plans' | 'builder' | 'schedule' | 'how-it-works';
export interface SubPlan {
id: string;
name: string;
/** e.g. "2 bags of 250 g" */
amount: string;
/** Price per delivery */
price: number;
/** Pieces per delivery, for the price per piece */
pieces?: number;
perks?: string[];
tag?: string;
}
export interface SubOption {
id: string;
label: string;
note?: string;
icon?: IconName;
image?: React.ReactNode;
}
export interface SubDelivery {
id: string;
/** Already formatted, e.g. "Tue, Nov 4" */
date: string;
/** What comes, e.g. "Nº 7 espresso · 2 × 250 g" */
items: string;
status?: 'shipped' | 'next' | 'planned' | 'skipped';
}
export interface SubscriptionBoxProps {
variant?: SubscriptionBoxVariant;
eyebrow?: string;
title?: string;
description?: string;
/** purchase-toggle: the product */
product?: { name: string; subtitle?: string; price: number; image?: React.ReactNode };
/** Discount in percent for subscribers */
discount?: number;
/** Delivery rhythms in weeks */
rhythms?: number[];
defaultRhythm?: number;
plans?: SubPlan[];
/** plans: one piece, e.g. "bag", shown as "€12.45 per bag" */
unitOne?: string;
/** plans: yearly discount in percent on top */
yearly?: number;
/** builder: the question of each step, e.g. ["Which coffee?", "How much?", "How often?"] */
questions?: [string, string, string];
/** builder: the choices of step one, two and three */
choices?: SubOption[];
sizes?: (SubOption & { price: number })[];
deliveries?: SubDelivery[];
steps?: { icon?: IconName; title: string; text: string }[];
perks?: string[];
currency?: string;
onSubscribe?: (detail: Record<string, string | number>) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
oneTime: 'One-time purchase',
subscribe: 'Subscribe and save',
every: 'Every',
everyWeek: 'Every week',
week: 'week',
weeks: 'weeks',
deliverEvery: 'Deliver every',
perDelivery: 'per delivery',
perPiece: 'per piece',
per: 'per',
save: 'Save',
addToCart: 'Add to cart',
startSub: 'Start subscription',
started: 'Subscription started',
cancelAny: 'Skip, pause or cancel any time in your account.',
monthly: 'Monthly',
yearly: 'Yearly',
billed: 'billed yearly',
choose: 'Choose',
chosen: 'Chosen',
step: 'Step',
of: 'of',
next: 'Next',
back: 'Back',
summary: 'Your box',
what: 'What',
howMuch: 'How much',
howOften: 'How often',
then: 'then',
shipped: 'Shipped',
upNext: 'Up next',
planned: 'Planned',
skipped: 'Skipped',
skip: 'Skip',
undo: 'Undo skip',
move: 'Move a week',
moved: 'Moved',
pause: 'Pause subscription',
resume: 'Resume subscription',
paused: 'Your subscription is paused. Nothing ships until you resume.',
},
de: {
oneTime: 'Einmal kaufen',
subscribe: 'Abonnieren und sparen',
every: 'Alle',
everyWeek: 'Jede Woche',
week: 'Woche',
weeks: 'Wochen',
deliverEvery: 'Liefern alle',
perDelivery: 'pro Lieferung',
perPiece: 'pro Stück',
per: 'pro',
save: 'Spare',
addToCart: 'In den Warenkorb',
startSub: 'Abo starten',
started: 'Abo gestartet',
cancelAny: 'Jederzeit im Konto aussetzen, pausieren oder kündigen.',
monthly: 'Monatlich',
yearly: 'Jährlich',
billed: 'jährlich abgerechnet',
choose: 'Auswählen',
chosen: 'Ausgewählt',
step: 'Schritt',
of: 'von',
next: 'Weiter',
back: 'Zurück',
summary: 'Deine Box',
what: 'Was',
howMuch: 'Wie viel',
howOften: 'Wie oft',
then: 'danach',
shipped: 'Versendet',
upNext: 'Als Nächstes',
planned: 'Geplant',
skipped: 'Ausgesetzt',
skip: 'Aussetzen',
undo: 'Doch liefern',
move: 'Eine Woche später',
moved: 'Verschoben',
pause: 'Abo pausieren',
resume: 'Abo fortsetzen',
paused: 'Dein Abo ist pausiert. Bis du es fortsetzt, wird nichts verschickt.',
},
};
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 ACCENT_TEXT = 'text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]';
const ACCENT_SOFT = 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)]';
const SAVE = 'text-success-foreground';
function Head({ eyebrow, title, description, center, aside }: { eyebrow?: string; title?: string; description?: string; center?: boolean; aside?: React.ReactNode }) {
if (!title) return null;
return (
<div className={cn('mb-10 flex flex-col gap-6 sm:mb-12', center ? 'items-center text-center' : 'sm:flex-row sm:items-end sm:justify-between')}>
<div className={cn('max-w-[40rem]', center && 'mx-auto')}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className={cn('kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
{aside}
</div>
);
}
const weeksLabel = (t: Texts, n: number) => (n === 1 ? t.everyWeek : `${t.every} ${n} ${t.weeks}`);
/** Rhythm chips with a sliding thumb */
function Rhythm({ rhythms, value, onChange, t, name }: { rhythms: number[]; value: number; onChange: (n: number) => void; t: Texts; name: string }) {
const i = Math.max(0, rhythms.indexOf(value));
return (
<div role="radiogroup" aria-label={t.deliverEvery} className="relative grid rounded-lg border border-border-strong bg-surface p-1" style={{ gridTemplateColumns: `repeat(${rhythms.length}, minmax(0, 1fr))` }}>
<span aria-hidden className="absolute inset-y-1 left-1 rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: `calc((100% - 0.5rem) / ${rhythms.length})`, transform: `translateX(${i * 100}%)` }} />
{rhythms.map((n) => (
<label key={n} className={cn('relative z-10 grid min-h-11 cursor-pointer place-items-center rounded-md px-1 text-center text-[13px] font-medium transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-[var(--ring)]', n === value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
<input type="radio" name={name} checked={n === value} onChange={() => onChange(n)} className="sr-only" />
{n} {n === 1 ? t.week : t.weeks}
</label>
))}
</div>
);
}
/* ------------------------------------------------------------------ */
/* purchase-toggle */
/* ------------------------------------------------------------------ */
function PurchaseToggle(p: SubscriptionBoxProps & { t: Texts }) {
const { t, currency, product } = p;
const locale = useLocale();
const rhythms = p.rhythms ?? [2, 4, 6];
const [mode, setMode] = useState<'once' | 'sub'>('sub');
const [rhythm, setRhythm] = useState(p.defaultRhythm ?? rhythms[0]);
const [done, setDone] = useState(false);
const name = useId();
const price = product?.price ?? 0;
const subPrice = round(price * (1 - (p.discount ?? 0) / 100));
const shown = mode === 'sub' ? subPrice : price;
return (
<Section className={p.className}>
<ShopMotion />
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-center lg:gap-16">
<Reveal effect="scale">
<ProductMedia className="aspect-[4/3] lg:aspect-square">{product?.image}</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>
{product?.subtitle && <p className="kit-rise mt-2 text-[15px] text-muted-foreground">{product.subtitle}</p>}
<div className="mt-5 flex items-baseline gap-3">
<TweenPrice value={shown} currency={currency} className="text-[28px] font-semibold tracking-[-0.02em] text-foreground" />
{mode === 'sub' && (
<s key="was" className="animate-[kitshop-fade_0.3s] text-[15px] tabular-nums text-muted-foreground">
{formatPrice(locale, price, currency)}
</s>
)}
</div>
<fieldset className="mt-7 space-y-2">
<legend className="sr-only">{t.subscribe}</legend>
{(['sub', 'once'] as const).map((m) => {
const on = mode === m;
return (
<div key={m} className={cn('rounded-xl border transition-[border-color,box-shadow] duration-300', on ? 'border-foreground bg-card shadow-[0_0_0_1px_var(--foreground)]' : 'border-border bg-card hover:border-border-strong')}>
<label className="flex min-h-14 cursor-pointer items-center gap-3 px-4 py-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)]">
<input type="radio" name={name} checked={on} onChange={() => setMode(m)} className="sr-only" />
<span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-full border-2 transition-colors', on ? 'border-foreground' : 'border-border-strong')}>
<span className={cn('size-2.5 rounded-full bg-foreground transition-transform duration-300 ease-[var(--ease-kit)]', on ? 'scale-100' : 'scale-0')} />
</span>
<span className="flex-1 text-[15px] font-medium text-foreground">{m === 'sub' ? t.subscribe : t.oneTime}</span>
{m === 'sub' && p.discount ? <span className={cn('rounded-full px-2 py-0.5 text-[12.5px] font-semibold', 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)]', SAVE)}>-{formatPercent(locale, p.discount)}</span> : null}
<span className="text-[14.5px] font-semibold tabular-nums text-foreground">{formatPrice(locale, m === 'sub' ? subPrice : price, currency)}</span>
</label>
{m === 'sub' && (
<div className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
<div className="overflow-hidden" inert={!on}>
<div className="space-y-3 px-4 pb-4">
<p className="text-[13px] text-muted-foreground">{t.deliverEvery}</p>
<Rhythm rhythms={rhythms} value={rhythm} onChange={setRhythm} t={t} name={`${name}-r`} />
{p.perks?.length ? (
<ul className="grid grid-cols-1 gap-1.5 pt-1 sm:grid-cols-2">
{p.perks.map((perk) => (
<li key={perk} className="flex items-start gap-2 text-[13px] text-foreground">
<Icon name="check" size={14} className={cn('mt-0.5 shrink-0', SAVE)} />
{perk}
</li>
))}
</ul>
) : null}
</div>
</div>
</div>
)}
</div>
);
})}
</fieldset>
<Button
size="lg"
className="mt-6 w-full"
iconLeft={done ? 'check' : mode === 'sub' ? 'repeat' : 'bag'}
onClick={() => {
setDone(true);
p.onSubscribe?.({ mode, rhythm, price: shown });
}}
>
<Swap value={`${mode}-${done}`}>{done ? (mode === 'sub' ? t.started : t.addToCart) : mode === 'sub' ? `${t.startSub} · ${weeksLabel(t, rhythm)}` : t.addToCart}</Swap>
</Button>
{mode === 'sub' && <p className="mt-3 animate-[kitshop-fade_0.3s] text-center text-[13px] text-muted-foreground">{t.cancelAny}</p>}
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* plans */
/* ------------------------------------------------------------------ */
function Plans(p: SubscriptionBoxProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const [period, setPeriod] = useState<'month' | 'year'>('month');
const [picked, setPicked] = useState<string | null>(null);
const factor = period === 'year' ? 1 - (p.yearly ?? 0) / 100 : 1;
return (
<Section className={p.className}>
<ShopMotion />
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
center
aside={
p.yearly ? (
<div role="radiogroup" aria-label={`${t.monthly}, ${t.yearly}`} className="relative grid w-[19rem] grid-cols-2 rounded-full border border-border-strong bg-surface p-1">
<span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-foreground transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `translateX(${period === 'year' ? 100 : 0}%)` }} />
{(['month', 'year'] as const).map((k) => (
<button key={k} type="button" role="radio" aria-checked={period === k} onClick={() => setPeriod(k)} className={cn('relative z-10 inline-flex h-10 items-center justify-center gap-1.5 rounded-full text-[13.5px] font-medium transition-colors', period === k ? 'text-background' : 'text-muted-foreground hover:text-foreground', FOCUS)}>
{k === 'month' ? t.monthly : t.yearly}
{k === 'year' && <span className={cn('text-[11.5px] font-semibold', period === k ? 'opacity-80' : SAVE)}>-{formatPercent(locale, p.yearly!)}</span>}
</button>
))}
</div>
) : null
}
/>
<Stagger step={90} as="ul" className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:gap-6">
{(p.plans ?? []).map((plan) => {
const price = round(plan.price * factor);
const on = picked === plan.id;
const hero = !!plan.tag;
return (
<li key={plan.id}>
<article className={cn(CARD, 'relative flex h-full flex-col p-6 transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 motion-reduce:transition-none', hero && 'shadow-[var(--highlight),0_0_0_1.5px_var(--primary),var(--shadow-md)]')}>
{plan.tag && <span className="absolute -top-3 left-6 rounded-full bg-primary px-2.5 py-1 text-[12px] font-medium text-primary-foreground shadow-[var(--shadow-sm)]">{plan.tag}</span>}
<h3 className="text-[19px] font-semibold tracking-[-0.015em] text-foreground">{plan.name}</h3>
<p className="mt-1 text-[14px] text-muted-foreground">{plan.amount}</p>
<TweenPrice value={price} currency={currency} className="mt-6 block text-[32px] font-semibold tracking-[-0.03em] text-foreground" />
<p className="mt-1 text-[13px] text-muted-foreground">
{t.perDelivery}
{plan.pieces ? (
<>
{' · '}
<span className="tabular-nums">{formatPrice(locale, price / plan.pieces, currency)}</span> {p.unitOne ? `${t.per} ${p.unitOne}` : t.perPiece}
</>
) : null}
</p>
<p className={cn('h-5 text-[12.5px] transition-opacity duration-300', SAVE, period === 'year' ? 'opacity-100' : 'opacity-0')} aria-hidden={period !== 'year'}>
{t.billed}
</p>
{plan.perks?.length ? (
<ul className="mt-6 space-y-2.5 border-t border-border pt-6">
{plan.perks.map((perk) => (
<li key={perk} className="flex items-start gap-2.5 text-[14px] text-foreground">
<Icon name="check" size={16} className={cn('mt-px shrink-0', ACCENT_TEXT)} />
{perk}
</li>
))}
</ul>
) : null}
<div className="mt-auto pt-7">
<Button className="w-full" variant={hero || on ? 'primary' : 'outline'} iconLeft={on ? 'check' : undefined} onClick={() => { setPicked(plan.id); p.onSubscribe?.({ plan: plan.id, period, price }); }}>
<Swap value={on}>{on ? t.chosen : `${t.choose} · ${plan.name}`}</Swap>
</Button>
</div>
</article>
</li>
);
})}
</Stagger>
<p className="mt-8 text-center text-[13.5px] text-muted-foreground">{t.cancelAny}</p>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* builder */
/* ------------------------------------------------------------------ */
function Builder(p: SubscriptionBoxProps & { t: Texts }) {
const { t, currency } = p;
const locale = useLocale();
const choices = p.choices ?? [];
const sizes = p.sizes ?? [];
const rhythms = p.rhythms ?? [2, 4];
const [step, setStep] = useState(0);
const [choice, setChoice] = useState(choices[0]?.id ?? '');
const [size, setSize] = useState(sizes[1]?.id ?? sizes[0]?.id ?? '');
const [rhythm, setRhythm] = useState(p.defaultRhythm ?? rhythms[0]);
const [done, setDone] = useState(false);
const [dir, setDir] = useState<1 | -1>(1);
const name = useId();
const c = choices.find((x) => x.id === choice);
const s = sizes.find((x) => x.id === size);
const price = s ? round(s.price * (1 - (p.discount ?? 0) / 100)) : 0;
const titles = [t.what, t.howMuch, t.howOften];
const go = (n: number) => {
setDir(n > step ? 1 : -1);
setStep(n);
};
const tile = (on: boolean) => cn('relative flex h-full cursor-pointer flex-col rounded-xl border p-4 max-sm:min-h-16 max-sm:justify-center max-sm:pe-10 transition-[border-color,box-shadow,transform] duration-300 hover:-translate-y-0.5 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');
const checkMark = <span aria-hidden className="absolute right-3 top-3 grid size-5 animate-[kitshop-pop_0.35s_var(--ease-kit)] place-items-center rounded-full bg-foreground text-background"><Icon name="check" size={12} strokeWidth={2.4} /></span>;
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)_20rem] lg:gap-12">
<div className="min-w-0">
{/* Steps as a bar that fills, each step a button */}
<ol className="grid grid-cols-3 gap-2">
{titles.map((x, i) => (
<li key={x}>
<button type="button" onClick={() => go(i)} aria-current={step === i ? 'step' : undefined} className={cn('group/st relative block w-full after:absolute after:inset-x-0 after:-inset-y-2 after:content-[""] text-left', FOCUS)}>
<span className="relative block h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
<span className="absolute inset-0 origin-left rounded-full bg-primary transition-transform duration-500 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${i <= step ? 1 : 0})` }} />
</span>
<span className={cn('mt-2 block text-[12.5px]', i === step ? 'font-semibold text-foreground' : 'text-muted-foreground group-hover/st:text-foreground')}>
<span className="tabular-nums">{i + 1}</span> · {x}
</span>
</button>
</li>
))}
</ol>
<div key={step} className={cn('mt-8', dir === 1 ? 'animate-[kitshop-next_0.45s_var(--ease-kit)]' : 'animate-[kitshop-prev_0.45s_var(--ease-kit)]')}>
<h3 className="text-[18px] font-semibold text-foreground">{p.questions?.[step] ?? titles[step]}</h3>
{step === 0 && (
<div role="radiogroup" aria-label={t.what} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
{choices.map((x) => (
<label key={x.id} className={tile(x.id === choice)}>
<input type="radio" name={`${name}-c`} checked={x.id === choice} onChange={() => setChoice(x.id)} className="sr-only" />
{x.image ? <ProductMedia className="mb-3 aspect-[4/3] rounded-lg max-sm:absolute max-sm:left-3 max-sm:top-1/2 max-sm:mb-0 max-sm:size-14 max-sm:-translate-y-1/2 max-sm:aspect-square">{x.image}</ProductMedia> : <Icon name={x.icon ?? 'coffee'} size={22} className="mb-3 text-foreground" />}
<span className={cn('text-[15px] font-semibold text-foreground', x.image && 'max-sm:ps-[4.25rem]')}>{x.label}</span>
{x.note && <span className={cn('mt-0.5 text-[13px] leading-snug text-muted-foreground', x.image && 'max-sm:ps-[4.25rem]')}>{x.note}</span>}
{x.id === choice && checkMark}
</label>
))}
</div>
)}
{step === 1 && (
<div role="radiogroup" aria-label={t.howMuch} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
{sizes.map((x) => (
<label key={x.id} className={tile(x.id === size)}>
<input type="radio" name={`${name}-s`} checked={x.id === size} onChange={() => setSize(x.id)} className="sr-only" />
<span className="text-[15px] font-semibold text-foreground">{x.label}</span>
{x.note && <span className="mt-0.5 text-[13px] text-muted-foreground">{x.note}</span>}
<span className="mt-4 text-[17px] font-semibold tabular-nums text-foreground">{formatPrice(locale, round(x.price * (1 - (p.discount ?? 0) / 100)), currency)}</span>
{x.id === size && checkMark}
</label>
))}
</div>
)}
{step === 2 && (
<div className="mt-4 max-w-[30rem]">
<Rhythm rhythms={rhythms} value={rhythm} onChange={setRhythm} t={t} name={`${name}-r`} />
<p className="mt-3 text-[13.5px] text-muted-foreground">{t.cancelAny}</p>
</div>
)}
</div>
<div className="mt-8 flex gap-2">
{step > 0 && (
<Button variant="ghost" iconLeft="arrow-left" onClick={() => go(step - 1)}>
{t.back}
</Button>
)}
{step < 2 && (
<Button variant="outline" icon="arrow-right" onClick={() => go(step + 1)}>
{t.next}
</Button>
)}
</div>
</div>
<aside className="lg:sticky lg:top-8 lg:self-start" aria-label={t.summary}>
<div className={cn(CARD, 'p-5 sm:p-6')}>
<p className="text-[13px] font-medium text-muted-foreground">{t.summary}</p>
<dl className="mt-3 divide-y divide-border text-[14px]">
{[
[t.what, c?.label],
[t.howMuch, s?.label],
[t.howOften, weeksLabel(t, rhythm)],
].map(([k, v], i) => (
<div key={k} className="flex items-baseline justify-between gap-3 py-2.5">
<dt className="text-muted-foreground">{k}</dt>
<dd className={cn('text-right font-medium text-foreground', i === step && 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
<Swap value={v ?? ''}>{v}</Swap>
</dd>
</div>
))}
</dl>
<div className="mt-2 flex items-baseline justify-between gap-3 border-t border-border pt-4">
<span className="text-[14px] text-muted-foreground">{t.perDelivery}</span>
<TweenPrice value={price} currency={currency} className="text-[22px] font-semibold tracking-[-0.02em] text-foreground" />
</div>
<Button size="lg" className="mt-5 w-full" iconLeft={done ? 'check' : 'repeat'} onClick={() => { setDone(true); p.onSubscribe?.({ choice, size, rhythm, price }); }}>
<Swap value={done}>{done ? t.started : t.startSub}</Swap>
</Button>
<p className="mt-2 text-center text-[12.5px] text-muted-foreground">{t.cancelAny}</p>
</div>
</aside>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* schedule */
/* ------------------------------------------------------------------ */
function Schedule(p: SubscriptionBoxProps & { t: Texts }) {
const { t } = p;
const [list, setList] = useState<SubDelivery[]>(p.deliveries ?? []);
const [moved, setMoved] = useState<Record<string, boolean>>({});
const [paused, setPaused] = useState(false);
const line = useScrollProgress<HTMLOListElement>({ start: 0.1, end: 0.6 });
const toggleSkip = (id: string) => setList((l) => l.map((d) => (d.id === id ? { ...d, status: d.status === 'skipped' ? 'planned' : 'skipped' } : d)));
const STATE: Record<NonNullable<SubDelivery['status']>, { label: string; cls: string; icon: IconName }> = {
shipped: { label: t.shipped, cls: 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground', icon: 'truck' },
next: { label: t.upNext, cls: cn(ACCENT_SOFT, ACCENT_TEXT), icon: 'package' },
planned: { label: t.planned, cls: 'bg-muted text-muted-foreground', icon: 'calendar' },
skipped: { label: t.skipped, cls: 'bg-muted text-subtle-foreground', icon: 'ban' },
};
return (
<Section className={p.className}>
<ShopMotion />
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
aside={
<Button variant="outline" iconLeft={paused ? 'play' : 'pause'} aria-pressed={paused} onClick={() => setPaused(!paused)}>
<Swap value={paused}>{paused ? t.resume : t.pause}</Swap>
</Button>
}
/>
{paused && (
<p className="mb-6 flex animate-[kitshop-in_0.35s_var(--ease-kit)] items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--warning)_45%,transparent)] bg-[color-mix(in_oklab,var(--warning)_14%,transparent)] px-4 py-3 text-[14px] font-medium text-warning-foreground" role="status">
<Icon name="pause" size={16} />
{t.paused}
</p>
)}
<ol ref={line} className={cn('relative transition-opacity duration-500', paused && 'opacity-50')}>
<span aria-hidden className="absolute bottom-6 left-[21px] top-6 w-0.5 rounded-full bg-border sm:left-[23px]" />
<span aria-hidden className="absolute left-[21px] top-6 h-[calc(100%-3rem)] w-0.5 origin-top rounded-full bg-primary sm:left-[23px]" style={{ transform: 'scaleY(var(--kit-progress, 1))' }} />
{list.map((d, i) => {
const st = STATE[d.status ?? 'planned'];
const editable = d.status !== 'shipped';
return (
<li key={d.id} className="relative flex gap-4 pb-6 last:pb-0 sm:gap-6">
<span className={cn('relative z-10 grid size-11 shrink-0 place-items-center rounded-full border-4 border-background sm:size-12', d.status === 'next' ? 'bg-primary text-primary-foreground' : d.status === 'shipped' ? 'bg-success text-[oklch(0.99_0_0)]' : 'bg-muted text-muted-foreground')}>
<Icon name={st.icon} size={18} />
</span>
<Reveal effect="rise" delay={i * 70} className={cn(CARD, 'min-w-0 flex-1 p-4 sm:p-5', d.status === 'skipped' && 'bg-surface shadow-none')}>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div className="min-w-0">
<p className={cn('text-[16px] font-semibold text-foreground', d.status === 'skipped' && 'text-muted-foreground line-through decoration-1')}>
<Swap value={`${d.date}-${moved[d.id] ? 1 : 0}`}>{d.date}</Swap>
</p>
<p className="text-[13.5px] text-muted-foreground">{d.items}</p>
</div>
<span className={cn('inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[12px] font-medium', st.cls)}>{moved[d.id] && d.status !== 'skipped' ? t.moved : st.label}</span>
</div>
{editable && (
<div className="mt-3 flex flex-wrap gap-1 border-t border-border pt-3">
<Button size="sm" variant="ghost" className="h-11 sm:h-9" iconLeft={d.status === 'skipped' ? 'undo' : 'skip-forward'} onClick={() => toggleSkip(d.id)} disabled={paused}>
{d.status === 'skipped' ? t.undo : t.skip}
</Button>
{d.status !== 'skipped' && (
<Button size="sm" variant="ghost" className="h-11 sm:h-9" iconLeft="calendar" disabled={paused || moved[d.id]} onClick={() => setMoved((m) => ({ ...m, [d.id]: true }))}>
{t.move}
</Button>
)}
</div>
)}
</Reveal>
</li>
);
})}
</ol>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* how-it-works */
/* ------------------------------------------------------------------ */
function HowItWorks(p: SubscriptionBoxProps & { t: Texts }) {
const steps = p.steps ?? [];
const line = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.6 });
return (
<Section className={p.className}>
<ShopMotion />
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} center />
<div ref={line} className="relative">
{/* The line that joins the steps fills while scrolling: across on desktop, down on phones */}
<span aria-hidden className="absolute hidden h-0.5 rounded-full bg-border lg:block" style={{ top: 31, left: `${round(50 / Math.max(1, steps.length), 2)}%`, right: `${round(50 / Math.max(1, steps.length), 2)}%` }} />
<span aria-hidden className="absolute hidden h-0.5 origin-left rounded-full bg-primary lg:block" style={{ top: 31, left: `${round(50 / Math.max(1, steps.length), 2)}%`, right: `${round(50 / Math.max(1, steps.length), 2)}%`, transform: 'scaleX(var(--kit-progress, 1))' }} />
<span aria-hidden className="absolute bottom-8 left-[31px] top-8 w-0.5 rounded-full bg-border sm:hidden" />
<span aria-hidden className="absolute left-[31px] top-8 h-[calc(100%-4rem)] w-0.5 origin-top rounded-full bg-primary sm:hidden" style={{ transform: 'scaleY(var(--kit-progress, 1))' }} />
<ol className={cn('grid grid-cols-1 gap-8 sm:grid-cols-2 lg:gap-6', steps.length >= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3')}>
{steps.map((s, i) => (
<Reveal as="li" key={s.title} effect="rise" delay={i * 110} className="relative flex gap-4 lg:flex-col lg:items-center lg:text-center">
<span className="relative z-10 grid size-16 shrink-0 place-items-center rounded-2xl border-4 border-background bg-card text-foreground shadow-[var(--highlight),0_0_0_1px_var(--border),var(--shadow-sm)]">
<Icon name={s.icon ?? 'package'} size={24} />
<span className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-primary text-[11.5px] font-semibold tabular-nums text-primary-foreground">{i + 1}</span>
</span>
<span className="lg:mt-5">
<span className="block text-[17px] font-semibold text-foreground">{s.title}</span>
<span className="mt-1.5 block text-pretty text-[14.5px] leading-relaxed text-muted-foreground lg:mx-auto lg:max-w-[16rem]">{s.text}</span>
</span>
</Reveal>
))}
</ol>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* The block */
/* ------------------------------------------------------------------ */
/**
* Subscriptions in five layouts. purchase-toggle: one-time or subscribe on
* the product page. plans: box sizes. builder: three steps to a box.
* schedule: the next deliveries. how-it-works: the steps.
*/
export function SubscriptionBox(props: SubscriptionBoxProps) {
const { variant = 'purchase-toggle' } = props;
const t = useTexts(TEXTS, props.labels);
if (variant === 'plans') return <Plans {...props} t={t} />;
if (variant === 'builder') return <Builder {...props} t={t} />;
if (variant === 'schedule') return <Schedule {...props} t={t} />;
if (variant === 'how-it-works') return <HowItWorks {...props} t={t} />;
return <PurchaseToggle {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Abos?
Mit aiw add @ai-website/subscription-box. Der Befehl legt die Datei components/ui/subscription-box.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 Abos?
5 Varianten, Abonnieren und sparen, Box-Pläne, Box zusammenstellen, Lieferplan und So funktioniert es. Du wählst sie mit der Prop variant, zum Beispiel variant="plans".
Kann ich die Texte von Abos übersetzen?
Ja. Abos bringt 40 eigene Texte auf Deutsch und Englisch mit, etwa „Einmal kaufen“, „Abonnieren und sparen“ und „Alle“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Abos einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Abos mit aiw_components und installieren sie mit aiw_add @ai-website/subscription-box. Nenn im Prompt die Variante, etwa variant="purchase-toggle", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Abos ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SubscriptionBox-Code zum Kopieren.