Sparrechner
Was Besucher sparen, wenn sie wechseln, bündeln oder anders bezahlen. Abgehakte Werkzeuge gegen einen Plan, jährliche Abrechnung mit freien Monaten, ein Tarif über Jahre, ein Paket mit Rabattstufen und Provision gegen feste Gebühr.
Eine von 2 Components in Rechner
Variants
Details
Was Sparrechner mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Rechner
- Bereich
- Marketing
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/savings-calculator.tsx
- Bringt mit
- utilskitbuttoniconmotionrevealsectionnumber-tickerslider
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 32 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SavingsCalculatorProps aus components/ui/savings-calculator.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalSavingsCalculatorVariantstackbillingenergybundlecommission
eyebrowoptionalReact.ReactNodetitlePflichtReact.ReactNodedescriptionoptionalReact.ReactNodestackoptional{ items: { name: string; price: number; icon?: IconName; checked?: boolean }[]; plan: { name: string; price: number } }stack: what is paid today per month, and the plan that replaces it
billingoptional{ seats: SavingsRange; monthly: number; yearly: number; plan: string }billing: seats and the price per seat and month, billed monthly or yearly
energyoptional{ usage: SavingsRange; unit: string; current: { label: string; unitPrice: number; base: number }; offer: { label: string; unitPrice: number; base: number }; years?: number }energy: usage per year, unit and two tariffs
bundleoptional{ items: { name: string; price: number; icon?: IconName; text?: string; selected?: boolean }[]; tiers: { count: number; percent: number }[] }bundle: services to combine and the discount steps
commissionoptional{ bookings: SavingsRange; value: SavingsRange; rate: SavingsRange; fee: number; platform: string; own: string }commission: a platform share against a flat monthly fee
currencyoptionalstringISO currency, default EUR
ctaoptional{ label: string; href: string }noteoptionalstring
Eingebaute Texte
Diese Texte schreibt Sparrechner selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 32 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| today | Englisch You pay today | Deutsch Du zahlst heute |
| with | Englisch With {n} | Deutsch Mit {n} |
| save | Englisch You save | Deutsch Du sparst |
| perYear | Englisch per year | Deutsch pro Jahr |
| perMonth | Englisch per month | Deutsch pro Monat |
| perMonthShort | Englisch / month | Deutsch / Monat |
| tick | Englisch Tick what you pay for today | Deutsch Hake ab, wofür du heute zahlst |
| pays | Englisch {x} costs {a} a month | Deutsch {x} kostet {a} im Monat |
| monthly | Englisch Monthly | Deutsch Monatlich |
| yearly | Englisch Yearly | Deutsch Jährlich |
| billing | Englisch Billing | Deutsch Abrechnung |
| free | Englisch free | Deutsch frei |
| months | Englisch Your year in months | Deutsch Dein Jahr in Monaten |
| billedMonthly | Englisch Billed monthly | Deutsch Monatlich bezahlt |
| billedYearly | Englisch Billed yearly | Deutsch Jährlich bezahlt |
| yearCost | Englisch per year | Deutsch pro Jahr |
| years | Englisch Over {n} years you keep {a} | Deutsch In {n} Jahren bleiben dir {a} |
| year | Englisch Year {n} | Deutsch Jahr {n} |
| usage | Englisch Usage | Deutsch Verbrauch |
| base | Englisch Base fee | Deutsch Grundgebühr |
| perUnit | Englisch per {u} | Deutsch pro {u} |
| bundleSum | Englisch Single prices | Deutsch Einzelpreise |
| discount | Englisch Bundle discount | Deutsch Paketrabatt |
| total | Englisch Your bundle | Deutsch Dein Paket |
| next | Englisch Add {n} more to save {p} | Deutsch Noch {n} dazu und du sparst {p} |
| best | Englisch You get the best discount | Deutsch Du bekommst den höchsten Rabatt |
| choose | Englisch Choose services | Deutsch Leistungen wählen |
| pick | Englisch Add {x} to the bundle | Deutsch {x} zum Paket hinzufügen |
| keep | Englisch Of every {a} booking you keep | Deutsch Von jeder Buchung über {a} bleiben dir |
| platformCost | Englisch Platform share | Deutsch Anteil der Plattform |
| ownCost | Englisch Flat fee | Deutsch Feste Gebühr |
| monthNames | Englisch Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec | Deutsch Jan,Feb,Mär,Apr,Mai,Jun,Jul,Aug,Sep,Okt,Nov,Dez |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Sparrechner reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen radio und radiogroup.
- 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).
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 8 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 658 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Stagger, Magnetic } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow, Section } from '@/components/ui/section';
import { Slider } from '@/components/ui/slider';
/*
* What a visitor saves by switching, bundling or paying differently. Each
* variant compares two prices the visitor can recognize from their own
* bills, never an abstract score. Amounts are numbers, formatted per locale.
*
* stack tools paid today, ticked off, against one plan
* billing monthly or yearly with a gliding switch, the free months light up
* energy usage times unit price plus base fee, today against the new tariff, over years
* bundle pick services, the discount steps up, the next step is named
* commission bookings through a platform that takes a share, against a flat fee
*/
export type SavingsCalculatorVariant = 'stack' | 'billing' | 'energy' | 'bundle' | 'commission';
export interface SavingsRange {
label: string;
min: number;
max: number;
step?: number;
value: number;
hint?: string;
}
export interface SavingsCalculatorProps {
variant?: SavingsCalculatorVariant;
eyebrow?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
/** stack: what is paid today per month, and the plan that replaces it */
stack?: { items: { name: string; price: number; icon?: IconName; checked?: boolean }[]; plan: { name: string; price: number } };
/** billing: seats and the price per seat and month, billed monthly or yearly */
billing?: { seats: SavingsRange; monthly: number; yearly: number; plan: string };
/** energy: usage per year, unit and two tariffs */
energy?: { usage: SavingsRange; unit: string; current: { label: string; unitPrice: number; base: number }; offer: { label: string; unitPrice: number; base: number }; years?: number };
/** bundle: services to combine and the discount steps */
bundle?: { items: { name: string; price: number; icon?: IconName; text?: string; selected?: boolean }[]; tiers: { count: number; percent: number }[] };
/** commission: a platform share against a flat monthly fee */
commission?: { bookings: SavingsRange; value: SavingsRange; rate: SavingsRange; fee: number; platform: string; own: string };
/** ISO currency, default EUR */
currency?: string;
cta?: { label: string; href: string };
note?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
today: 'You pay today',
with: 'With {n}',
save: 'You save',
perYear: 'per year',
perMonth: 'per month',
perMonthShort: '/ month',
tick: 'Tick what you pay for today',
pays: '{x} costs {a} a month',
monthly: 'Monthly',
yearly: 'Yearly',
billing: 'Billing',
free: 'free',
months: 'Your year in months',
billedMonthly: 'Billed monthly',
billedYearly: 'Billed yearly',
yearCost: 'per year',
years: 'Over {n} years you keep {a}',
year: 'Year {n}',
usage: 'Usage',
base: 'Base fee',
perUnit: 'per {u}',
bundleSum: 'Single prices',
discount: 'Bundle discount',
total: 'Your bundle',
next: 'Add {n} more to save {p}',
best: 'You get the best discount',
choose: 'Choose services',
pick: 'Add {x} to the bundle',
keep: 'Of every {a} booking you keep',
platformCost: 'Platform share',
ownCost: 'Flat fee',
monthNames: 'Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec',
},
de: {
today: 'Du zahlst heute',
with: 'Mit {n}',
save: 'Du sparst',
perYear: 'pro Jahr',
perMonth: 'pro Monat',
perMonthShort: '/ Monat',
tick: 'Hake ab, wofür du heute zahlst',
pays: '{x} kostet {a} im Monat',
monthly: 'Monatlich',
yearly: 'Jährlich',
billing: 'Abrechnung',
free: 'frei',
months: 'Dein Jahr in Monaten',
billedMonthly: 'Monatlich bezahlt',
billedYearly: 'Jährlich bezahlt',
yearCost: 'pro Jahr',
years: 'In {n} Jahren bleiben dir {a}',
year: 'Jahr {n}',
usage: 'Verbrauch',
base: 'Grundgebühr',
perUnit: 'pro {u}',
bundleSum: 'Einzelpreise',
discount: 'Paketrabatt',
total: 'Dein Paket',
next: 'Noch {n} dazu und du sparst {p}',
best: 'Du bekommst den höchsten Rabatt',
choose: 'Leistungen wählen',
pick: '{x} zum Paket hinzufügen',
keep: 'Von jeder Buchung über {a} bleiben dir',
platformCost: 'Anteil der Plattform',
ownCost: 'Feste Gebühr',
monthNames: 'Jan,Feb,Mär,Apr,Mai,Jun,Jul,Aug,Sep,Okt,Nov,Dez',
},
};
type Texts = (typeof TEXTS)['en'];
type ViewProps = SavingsCalculatorProps & { t: Texts };
const nf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const money = (n: number, l: Locale, currency = 'EUR', digits = 0) => new Intl.NumberFormat(nf(l), { style: 'currency', currency, minimumFractionDigits: digits, maximumFractionDigits: digits }).format(digits ? n : Math.round(n));
const num = (n: number, l: Locale, digits = 0) => new Intl.NumberFormat(nf(l), { maximumFractionDigits: digits }).format(n);
const pctText = (n: number, l: Locale) => `${num(n, l, 1)}${l === 'de' ? ' %' : '%'}`;
const FALLBACK: SavingsRange = { label: '', min: 0, max: 10, value: 1 };
/** A number that counts from its last value to the new one */
function Live({ value, currency, className }: { value: number; currency: string; className?: string }) {
const last = useRef<number | null>(null);
const from = last.current ?? 0;
useEffect(() => {
last.current = value;
}, [value]);
return <NumberTicker value={Math.round(value)} from={Math.round(from)} duration={last.current === null ? 1300 : 450} format={{ style: 'currency', currency, maximumFractionDigits: 0 }} className={className} />;
}
function useRange(r?: SavingsRange) {
const [v, setV] = useState(r?.value ?? 0);
useEffect(() => setV(r?.value ?? 0), [r?.value]);
return [v, setV] as const;
}
function Head({ eyebrow, title, description, center, className }: Pick<SavingsCalculatorProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string }) {
return (
<div className={cn('max-w-[46rem]', center && 'mx-auto max-w-[42rem] text-center', className)}>
{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-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
{description}
</p>
)}
</div>
);
}
/** The saving, large, in the accent of success */
function Saving({ t, value, currency, monthly, className }: { t: Texts; value: number; currency: string; monthly?: number; className?: string }) {
const locale = useLocale();
return (
<div className={className} aria-live="polite">
<p className="text-[14px] font-medium text-muted-foreground">
{t.save} <span className="text-subtle-foreground">{t.perYear}</span>
</p>
<p className="mt-2">
<Live value={Math.max(0, value)} currency={currency} className="text-[clamp(2.5rem,1.9rem+2.6vw,3.75rem)] font-semibold leading-none tracking-[-0.04em] text-success-foreground" />
</p>
{monthly !== undefined && (
<p className="mt-2 text-[13.5px] text-muted-foreground">
{money(Math.max(0, monthly), locale, currency)} {t.perMonth}
</p>
)}
</div>
);
}
function Foot({ cta, note, className }: Pick<SavingsCalculatorProps, 'cta' | 'note'> & { className?: string }) {
if (!cta && !note) return null;
return (
<div className={className}>
{cta && (
<Button href={cta.href} size="lg" icon="arrow-right" className="w-full">
{cta.label}
</Button>
)}
{note && <p className="mt-3 text-pretty text-[12.5px] leading-relaxed text-muted-foreground">{note}</p>}
</div>
);
}
function Field({ range, value, onChange, format }: { range: SavingsRange; value: number; onChange: (v: number) => void; format: (v: number) => string }) {
return (
<div>
<Slider label={range.label} min={range.min} max={range.max} step={range.step ?? 1} value={value} onValueChange={onChange} format={format} />
{range.hint && <p className="mt-0.5 text-[12.5px] text-muted-foreground">{range.hint}</p>}
</div>
);
}
/** Two horizontal bars that grow when they scroll in */
function CompareBars({ rows, max }: { rows: { label: string; value: string; v: number; tone: 'old' | 'new' }[]; max: number }) {
const [ref, inView] = useInView<HTMLDivElement>();
return (
<div ref={ref} className="grid gap-4" aria-hidden>
{rows.map((b, i) => (
<div key={i}>
<div className="mb-1.5 flex items-baseline justify-between gap-3 text-[13px]">
<span className="min-w-0 text-muted-foreground">{b.label}</span>
<span className="shrink-0 font-semibold tabular-nums text-foreground">{b.value}</span>
</div>
<div className="h-2.5 overflow-hidden rounded-full bg-track">
<div className={cn('h-full w-full origin-left rounded-full transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', b.tone === 'new' ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_32%,var(--card))]')} style={{ transform: `scaleX(${inView ? round(Math.max(0.012, b.v / Math.max(1, max)), 4) : 0})`, transitionDelay: inView ? `${i * 120}ms` : '0ms' }} />
</div>
</div>
))}
</div>
);
}
/* ------------------------------------------------------------------ */
/* stack */
/* ------------------------------------------------------------------ */
function Stack(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const cur = p.currency ?? 'EUR';
const items = p.stack?.items ?? [];
const plan = p.stack?.plan ?? { name: '', price: 0 };
const [on, setOn] = useState<boolean[]>(() => items.map((x) => x.checked !== false));
useEffect(() => setOn(items.map((x) => x.checked !== false)), [items.length]); // eslint-disable-line react-hooks/exhaustive-deps
const today = items.reduce((s, x, i) => s + (on[i] ? x.price : 0), 0);
const monthly = today - plan.price;
return (
<Section className={p.className}>
<Head {...p} />
<div className="mt-12 grid grid-cols-[minmax(0,1fr)] gap-4 sm:mt-14 lg:grid-cols-[minmax(0,1fr)_minmax(0,25rem)] lg:gap-6">
<Reveal className="flex flex-col overflow-hidden rounded-[1.5rem] border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]">
<p className="border-b border-border px-5 py-4 text-[14px] font-medium text-foreground sm:px-6">{t.tick}</p>
<Stagger as="ul" step={60} className="grid flex-1 auto-rows-fr grid-cols-[minmax(0,1fr)] sm:grid-cols-2">
{items.map((x, i) => (
<li key={i} className="border-b border-border sm:odd:border-r [&:nth-last-child(-n+2)]:sm:border-b-0 max-sm:last:border-b-0">
<label className="group/tool flex h-full min-h-16 cursor-pointer items-center gap-3.5 px-5 py-3.5 transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_3%,transparent)] sm:px-6">
<input type="checkbox" className="peer sr-only" checked={!!on[i]} aria-label={t.pays.replace('{x}', x.name).replace('{a}', money(x.price, locale, cur))} onChange={() => setOn((prev) => prev.map((v, k) => (k === i ? !v : v)))} />
<span aria-hidden className={cn('grid size-9 shrink-0 place-items-center rounded-lg transition-[background-color,color] duration-300 peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--ring)]', on[i] ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-primary' : 'bg-muted text-subtle-foreground')}>
<Icon name={x.icon ?? 'layers'} size={17} />
</span>
<span className={cn('min-w-0 flex-1 text-[14.5px] leading-snug transition-colors duration-300', on[i] ? 'text-foreground' : 'text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--foreground)_35%,transparent)]')}>{x.name}</span>
<span className={cn('shrink-0 text-[14px] font-semibold tabular-nums transition-colors duration-300', on[i] ? 'text-foreground' : 'text-subtle-foreground')}>{money(x.price, locale, cur)}</span>
<span aria-hidden className={cn('grid size-5 shrink-0 place-items-center rounded-md border transition-[background-color,border-color] duration-200', on[i] ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background')}>
<Icon name="check" size={12} strokeWidth={3} className={cn('transition-[opacity,transform] duration-200', on[i] ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} />
</span>
</label>
</li>
))}
</Stagger>
</Reveal>
<Reveal effect="scale" delay={100} className="flex flex-col rounded-[1.5rem] border border-border bg-surface p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-7 lg:sticky lg:top-24 lg:self-start">
<Saving t={t} value={monthly * 12} monthly={monthly} currency={cur} />
<div className="mt-7">
<CompareBars
max={Math.max(today, plan.price)}
rows={[
{ label: t.today, value: `${money(today, locale, cur)} ${t.perMonthShort}`, v: today, tone: 'old' },
{ label: t.with.replace('{n}', plan.name), value: `${money(plan.price, locale, cur)} ${t.perMonthShort}`, v: plan.price, tone: 'new' },
]}
/>
</div>
<Foot cta={p.cta} note={p.note} className="mt-8" />
</Reveal>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* billing */
/* ------------------------------------------------------------------ */
function Billing(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const cur = p.currency ?? 'EUR';
const b = p.billing ?? { seats: FALLBACK, monthly: 0, yearly: 0, plan: '' };
const [seats, setSeats] = useRange(b.seats);
const [yearly, setYearly] = useState(true);
const [ref, inView] = useInView<HTMLDivElement>();
const perYearMonthly = b.monthly * seats * 12;
const perYearYearly = b.yearly * seats * 12;
const saving = perYearMonthly - perYearYearly;
// How many of the twelve months the yearly price leaves free
const freeMonths = b.monthly > 0 ? Math.max(0, Math.min(12, Math.round(12 - (b.yearly * 12) / b.monthly))) : 0;
const id = useId();
return (
<Section className={p.className}>
<Head {...p} center />
<Reveal effect="scale" className="mx-auto mt-12 max-w-[52rem] sm:mt-14">
<div className="rounded-[1.5rem] border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
<div className="grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:items-end md:gap-10">
<Field range={b.seats} value={seats} onChange={setSeats} format={(v) => num(v, locale)} />
<div>
<p id={`${id}-b`} className="mb-2 text-sm font-medium text-foreground">
{t.billing}
</p>
<div role="radiogroup" aria-labelledby={`${id}-b`} className="relative grid h-[3.375rem] grid-cols-2 rounded-xl border border-border bg-surface p-1 sm:h-12">
<span aria-hidden className={cn('absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', yearly && 'translate-x-full')} />
{[false, true].map((y) => (
<button
key={String(y)}
type="button"
role="radio"
aria-checked={yearly === y}
tabIndex={yearly === y ? 0 : -1}
onClick={() => setYearly(y)}
onKeyDown={(e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
setYearly(!yearly);
((e.currentTarget.parentElement?.querySelectorAll('[role="radio"]')[yearly ? 0 : 1]) as HTMLElement | undefined)?.focus();
}
}}
className={cn('relative z-10 flex items-center justify-center gap-2 rounded-lg text-[14px] font-medium transition-colors duration-300', yearly === y ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
>
{y ? t.yearly : t.monthly}
{y && freeMonths > 0 && <span className="rounded-full bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] px-1.5 py-px text-[11.5px] font-semibold text-success-foreground">−{Math.round((1 - b.yearly / b.monthly) * 100)}{locale === 'de' ? ' %' : '%'}</span>}
</button>
))}
</div>
</div>
</div>
<div ref={ref} className="mt-8">
<p className="mb-3 text-[12.5px] font-medium text-muted-foreground">{t.months}</p>
<ol className="grid grid-cols-6 gap-1.5 sm:grid-cols-12" aria-label={t.months}>
{Array.from({ length: 12 }, (_, i) => {
const free = yearly && i >= 12 - freeMonths;
return (
<li
key={i}
className={cn(
'relative flex h-14 flex-col items-center justify-center overflow-hidden rounded-lg border text-[12px] font-medium tabular-nums transition-[background-color,border-color,color,transform,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none',
free ? 'border-[color-mix(in_oklab,var(--success)_45%,var(--border))] bg-[color-mix(in_oklab,var(--success)_12%,var(--card))] text-success-foreground' : 'border-border bg-surface text-muted-foreground',
inView ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
)}
style={{ transitionDelay: inView ? `${i * 35}ms` : '0ms' }}
>
<span>{t.monthNames.split(',')[i]}</span>
<span className={cn('text-[10.5px] font-semibold uppercase tracking-[0.04em] transition-opacity duration-300', free ? 'opacity-100' : 'opacity-0')}>{t.free}</span>
</li>
);
})}
</ol>
</div>
<div className="mt-8 grid grid-cols-[minmax(0,1fr)] gap-6 border-t border-border pt-7 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<dl className="grid gap-3 text-[14px]">
{[
{ label: t.billedMonthly, v: perYearMonthly, on: !yearly },
{ label: t.billedYearly, v: perYearYearly, on: yearly },
].map((x) => (
<div key={x.label} className="flex items-baseline justify-between gap-4">
<dt className={cn('transition-colors', x.on ? 'text-foreground' : 'text-muted-foreground')}>{x.label}</dt>
<dd className={cn('font-semibold tabular-nums transition-colors', x.on ? 'text-foreground' : 'text-muted-foreground line-through')}>
{money(x.v, locale, cur)} <span className="font-normal text-muted-foreground">{t.yearCost}</span>
</dd>
</div>
))}
</dl>
<div className={cn('transition-opacity duration-300', yearly ? 'opacity-100' : 'opacity-60')}>
<Saving t={t} value={yearly ? saving : 0} currency={cur} />
</div>
{p.cta && (
<Magnetic className="max-md:flex max-md:w-full">
<Button href={p.cta.href} size="lg" icon="arrow-right" className="max-md:w-full">
{p.cta.label}
</Button>
</Magnetic>
)}
</div>
{p.note && <p className="mt-5 text-[12.5px] text-muted-foreground">{p.note}</p>}
</div>
</Reveal>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* energy */
/* ------------------------------------------------------------------ */
function Energy(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const cur = p.currency ?? 'EUR';
const e = p.energy ?? { usage: FALLBACK, unit: '', current: { label: '', unitPrice: 0, base: 0 }, offer: { label: '', unitPrice: 0, base: 0 } };
const [usage, setUsage] = useRange(e.usage);
const years = e.years ?? 5;
const costNow = usage * e.current.unitPrice + e.current.base * 12;
const costNew = usage * e.offer.unitPrice + e.offer.base * 12;
const saving = costNow - costNew;
const [ref, inView] = useInView<HTMLDivElement>('0px 0px -10% 0px');
const max = costNow * years;
const tariff = (x: { label: string; unitPrice: number; base: number }, fresh: boolean) => (
<div className={cn('rounded-xl border p-4', fresh ? 'border-[color-mix(in_oklab,var(--primary)_35%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'border-border bg-card')}>
<p className={cn('text-[13px] font-medium', fresh ? 'text-foreground' : 'text-muted-foreground')}>{x.label}</p>
<p className="mt-1.5 text-[19px] font-semibold tabular-nums tracking-[-0.015em] text-foreground">
{money(x.unitPrice, locale, cur, 2)} <span className="text-[12.5px] font-normal text-muted-foreground">{t.perUnit.replace('{u}', e.unit)}</span>
</p>
<p className="mt-0.5 text-[12.5px] text-muted-foreground">
{t.base} {money(x.base, locale, cur, 2)} {t.perMonth}
</p>
</div>
);
return (
<Section className={p.className}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-16">
<div>
<Head {...p} />
<div className="mt-8">
<Field range={e.usage} value={usage} onChange={setUsage} format={(v) => `${num(v, locale)} ${e.unit}`} />
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
{tariff(e.current, false)}
{tariff(e.offer, true)}
</div>
</div>
<Reveal effect="scale" className="flex flex-col rounded-[1.5rem] border border-border bg-surface p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
<Saving t={t} value={saving} monthly={saving / 12} currency={cur} />
<div ref={ref} className="mt-8">
<p className="mb-3 text-[12.5px] font-medium text-muted-foreground">{t.years.replace('{n}', String(years)).replace('{a}', money(saving * years, locale, cur))}</p>
<div className="flex h-44 items-end gap-2.5 sm:gap-4" aria-hidden>
{Array.from({ length: years }, (_, i) => {
const a = (costNow * (i + 1)) / Math.max(1, max);
const b = (costNew * (i + 1)) / Math.max(1, max);
return (
<div key={i} className="flex h-full min-w-0 flex-1 flex-col justify-end">
<div className="relative flex h-full items-end justify-center gap-1">
<span className="block h-full w-full max-w-5 origin-bottom rounded-t-md bg-[color-mix(in_oklab,var(--foreground)_28%,var(--card))] transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleY(${inView ? round(a, 4) : 0})`, transitionDelay: `${i * 90}ms` }} />
<span className="block h-full w-full max-w-5 origin-bottom rounded-t-md bg-primary transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleY(${inView ? round(b, 4) : 0})`, transitionDelay: `${i * 90 + 60}ms` }} />
</div>
<span className="mt-2 text-center text-[11.5px] tabular-nums text-subtle-foreground">{i + 1}</span>
</div>
);
})}
</div>
<p className="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-[12.5px] text-muted-foreground">
<span className="flex items-center gap-2">
<span className="size-2.5 rounded-sm bg-[color-mix(in_oklab,var(--foreground)_28%,var(--card))]" />
{e.current.label}, {money(costNow, locale, cur)} {t.perYear}
</span>
<span className="flex items-center gap-2">
<span className="size-2.5 rounded-sm bg-primary" />
{e.offer.label}, {money(costNew, locale, cur)} {t.perYear}
</span>
</p>
</div>
<Foot cta={p.cta} note={p.note} className="mt-8 lg:mt-auto lg:pt-8" />
</Reveal>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* bundle */
/* ------------------------------------------------------------------ */
function Bundle(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const cur = p.currency ?? 'EUR';
const items = p.bundle?.items ?? [];
const tiers = [...(p.bundle?.tiers ?? [])].sort((a, b) => a.count - b.count);
const [on, setOn] = useState<boolean[]>(() => items.map((x) => !!x.selected));
useEffect(() => setOn(items.map((x) => !!x.selected)), [items.length]); // eslint-disable-line react-hooks/exhaustive-deps
const count = on.filter(Boolean).length;
const sum = items.reduce((s, x, i) => s + (on[i] ? x.price : 0), 0);
const tier = [...tiers].reverse().find((x) => count >= x.count);
const next = tiers.find((x) => count < x.count);
const pct = tier?.percent ?? 0;
const off = (sum * pct) / 100;
const total = sum - off;
const lastTier = tiers[tiers.length - 1];
const progress = lastTier ? Math.min(1, count / lastTier.count) : 0;
return (
<Section className={p.className}>
<Head {...p} />
<div className="mt-12 grid grid-cols-[minmax(0,1fr)] gap-4 sm:mt-14 lg:grid-cols-[minmax(0,1fr)_minmax(0,23rem)] lg:gap-6">
<fieldset className="min-w-0">
<legend className="sr-only">{t.choose}</legend>
<Stagger step={70} className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2">
{items.map((x, i) => (
<label key={i} className="group/item block h-full cursor-pointer">
<input type="checkbox" className="peer sr-only" checked={!!on[i]} aria-label={t.pick.replace('{x}', x.name)} onChange={() => setOn((prev) => prev.map((v, k) => (k === i ? !v : v)))} />
<span className={cn('flex h-full flex-col rounded-2xl border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow,transform] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--ring)] motion-reduce:hover:translate-y-0', on[i] ? 'border-primary shadow-[0_0_0_3px_var(--glow),var(--shadow-md)]' : 'border-border hover:border-border-strong')}>
<span className="flex items-start justify-between gap-3">
<span className={cn('grid size-10 place-items-center rounded-xl transition-colors duration-300', on[i] ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground')}>
<Icon name={x.icon ?? 'package'} size={18} />
</span>
<span aria-hidden className={cn('grid size-6 place-items-center rounded-full border-2 transition-colors duration-300', on[i] ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>
<Icon name="check" size={12} strokeWidth={3} className={cn('transition-[opacity,transform] duration-300', on[i] ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} />
</span>
</span>
<span className="mt-4 text-[15.5px] font-semibold text-foreground">{x.name}</span>
{x.text && <span className="mt-1 flex-1 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{x.text}</span>}
<span className="mt-4 text-[15px] font-semibold tabular-nums text-foreground">
{money(x.price, locale, cur)} <span className="text-[12.5px] font-normal text-muted-foreground">{t.perMonthShort}</span>
</span>
</span>
</label>
))}
</Stagger>
<div className="sticky bottom-3 z-10 mt-3 flex items-center justify-between gap-3 rounded-full border border-border bg-[color-mix(in_oklab,var(--card)_90%,transparent)] py-2 pl-5 pr-2 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-md lg:hidden" aria-hidden>
<span className="text-[13.5px] text-muted-foreground">
<span className="font-semibold tabular-nums text-foreground">{money(total, locale, cur)}</span> {t.perMonthShort}
{pct > 0 && <span className="ml-2 font-medium text-success-foreground">−{pctText(pct, locale)}</span>}
</span>
<span className="rounded-full bg-muted px-3 py-1.5 text-[12.5px] font-medium tabular-nums text-foreground">{count} / {items.length}</span>
</div>
</fieldset>
<Reveal effect="scale" delay={100} className="flex flex-col rounded-[1.5rem] border border-border bg-surface p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-7 lg:sticky lg:top-24 lg:self-start">
<div className="relative" aria-hidden>
<div className="relative h-1.5 overflow-hidden rounded-full bg-track">
<span 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(${round(progress, 3)})` }} />
</div>
{tiers.map((x) => (
<span key={x.count} className={cn('absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-colors duration-300', count >= x.count ? 'border-primary bg-primary' : 'border-border-strong bg-surface')} style={{ left: `${round((x.count / (lastTier?.count ?? 1)) * 100, 3)}%` }} />
))}
</div>
<div className="relative mt-2.5 h-4 text-[11.5px] tabular-nums text-subtle-foreground" aria-hidden>
{tiers.map((x, i) => (
<span key={x.count} className={cn('absolute top-0 whitespace-nowrap transition-colors', i === tiers.length - 1 ? '-translate-x-full' : '-translate-x-1/2', count >= x.count && 'font-semibold text-foreground')} style={{ left: `${round((x.count / (lastTier?.count ?? 1)) * 100, 3)}%` }}>
−{x.percent}
{locale === 'de' ? ' %' : '%'}
</span>
))}
</div>
<p key={`${count}`} className="kit-rise mt-4 min-h-10 text-pretty text-[13.5px] leading-snug text-foreground" aria-live="polite">
{next ? t.next.replace('{n}', String(next.count - count)).replace('{p}', pctText(next.percent, locale)) : t.best}
</p>
<dl className="mt-4 grid gap-2.5 border-t border-border pt-5 text-[14px]">
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">{t.bundleSum}</dt>
<dd className="tabular-nums text-foreground">{money(sum, locale, cur)}</dd>
</div>
<div className="flex items-baseline justify-between gap-4">
<dt className="text-muted-foreground">
{t.discount} {pct > 0 && <span className="text-success-foreground">({pctText(pct, locale)})</span>}
</dt>
<dd className="tabular-nums text-success-foreground">−{money(off, locale, cur)}</dd>
</div>
<div className="mt-1 flex items-baseline justify-between gap-4 border-t border-border pt-3">
<dt className="font-medium text-foreground">{t.total}</dt>
<dd className="flex items-baseline gap-1.5">
<Live value={total} currency={cur} className="text-[28px] font-semibold tracking-[-0.025em] text-foreground" />
<span className="text-[12.5px] text-muted-foreground">{t.perMonthShort}</span>
</dd>
</div>
</dl>
<p className="mt-2 text-right text-[12.5px] text-muted-foreground">
{t.save} {money(off * 12, locale, cur)} {t.perYear}
</p>
<Foot cta={p.cta} note={p.note} className="mt-6" />
</Reveal>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* commission */
/* ------------------------------------------------------------------ */
function Commission(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const cur = p.currency ?? 'EUR';
const c = p.commission ?? { bookings: FALLBACK, value: FALLBACK, rate: FALLBACK, fee: 0, platform: '', own: '' };
const [bookings, setBookings] = useRange(c.bookings);
const [value, setValue] = useRange(c.value);
const [rate, setRate] = useRange(c.rate);
const platformMonth = (bookings * value * rate) / 100;
const monthly = platformMonth - c.fee;
const keepPlatform = value * (1 - rate / 100);
const keepOwn = bookings > 0 ? value - c.fee / bookings : value;
const [ref, inView] = useInView<HTMLDivElement>();
const share = (x: number) => round(Math.max(0, Math.min(1, x / Math.max(1, value))), 4);
return (
<Section className={p.className}>
<Head {...p} />
<div className="mt-12 grid grid-cols-[minmax(0,1fr)] gap-4 sm:mt-14 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:gap-6">
<Reveal className="grid content-start gap-6 rounded-[1.5rem] border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-md)] sm:p-8 lg:self-start">
<Field range={c.bookings} value={bookings} onChange={setBookings} format={(v) => num(v, locale)} />
<Field range={c.value} value={value} onChange={setValue} format={(v) => money(v, locale, cur)} />
<Field range={c.rate} value={rate} onChange={setRate} format={(v) => pctText(v, locale)} />
</Reveal>
<Reveal effect="scale" delay={100} className="flex flex-col rounded-[1.5rem] border border-border bg-surface p-6 shadow-[var(--highlight),var(--shadow-lg)] sm:p-8">
<Saving t={t} value={monthly * 12} monthly={monthly} currency={cur} />
<div ref={ref} className="mt-8">
<p className="text-[13px] font-medium text-foreground">{t.keep.replace('{a}', money(value, locale, cur))}</p>
<div className="mt-4 grid gap-4">
{[
{ label: c.platform, keep: keepPlatform, cost: value - keepPlatform, costLabel: t.platformCost, tone: 'old' as const },
{ label: c.own, keep: keepOwn, cost: value - keepOwn, costLabel: t.ownCost, tone: 'new' as const },
].map((r, i) => (
<div key={i}>
<div className="mb-1.5 flex items-baseline justify-between gap-3 text-[13px]">
<span className="min-w-0 text-muted-foreground">{r.label}</span>
<span className="shrink-0 font-semibold tabular-nums text-foreground">{money(r.keep, locale, cur, 2)}</span>
</div>
<div className="relative h-7 overflow-hidden rounded-lg bg-[color-mix(in_oklab,var(--destructive)_16%,var(--card))]" aria-hidden>
<span className={cn('absolute inset-0 origin-left rounded-lg transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', r.tone === 'new' ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--foreground)_32%,var(--card))]')} style={{ transform: `scaleX(${inView ? share(r.keep) : 0})`, transitionDelay: `${i * 120}ms` }} />
</div>
<p className="mt-1 text-right text-[12px] tabular-nums text-muted-foreground">
{r.costLabel} {money(r.cost, locale, cur, 2)}
</p>
</div>
))}
</div>
</div>
<Foot cta={p.cta} note={p.note} className="mt-8 lg:mt-auto lg:pt-8" />
</Reveal>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* SavingsCalculator */
/* ------------------------------------------------------------------ */
const VARIANTS: Record<SavingsCalculatorVariant, (p: ViewProps) => React.ReactNode> = {
stack: Stack,
billing: Billing,
energy: Energy,
bundle: Bundle,
commission: Commission,
};
/**
* What a visitor saves by switching, bundling or paying differently.
* <SavingsCalculator variant="stack" title="…" stack={{ items: [{ name, price, icon }], plan: { name, price } }} cta={{ label, href }} />
*/
export function SavingsCalculator({ variant = 'stack', labels, ...props }: SavingsCalculatorProps) {
const t = useTexts(TEXTS, labels);
const View = VARIANTS[variant] ?? Stack;
return <View {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Sparrechner?
Mit aiw add @ai-website/savings-calculator. Der Befehl legt die Datei components/ui/savings-calculator.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion, reveal, section, number-ticker und slider. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Sparrechner?
5 Varianten, Werkzeugkasten, Jährliche Abrechnung, Tarif über Jahre, Paket zusammenstellen und Provision oder Festpreis. Du wählst sie mit der Prop variant, zum Beispiel variant="billing".
Kann ich die Texte von Sparrechner übersetzen?
Ja. Sparrechner bringt 32 eigene Texte auf Deutsch und Englisch mit, etwa „Du zahlst heute“, „Du sparst“ und „pro Jahr“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Sparrechner einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Sparrechner mit aiw_components und installieren sie mit aiw_add @ai-website/savings-calculator. Nenn im Prompt die Variante, etwa variant="stack", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Sparrechner ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SavingsCalculator-Code zum Kopieren.