Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Werkzeugkasten

Besucher haken die Werkzeuge ab, für die sie heute zahlen. Die Ersparnis zählt zum neuen Betrag, zwei Balken wiegen heute gegen den einen Plan. Das Ergebnis bleibt beim Scrollen stehen.

Im Studio öffnen

Jährliche Abrechnung

Plätze als Regler und ein gleitender Schalter zwischen monatlich und jährlich. Zwölf Monate steigen auf, die freien leuchten, die Ersparnis folgt.

Im Studio öffnen

Tarif über Jahre

Verbrauch als Regler, zwei Tarife nebeneinander. Balken für jedes Jahr wachsen beim Hereinscrollen vom Boden auf, die Ersparnis pro Jahr zählt mit.

Im Studio öffnen

Paket zusammenstellen

Leistungen als Karten zum Wählen. Ein Balken steigt durch die Rabattstufen und nennt die nächste, der Paketpreis zählt zum neuen Betrag.

Im Studio öffnen

Provision oder Festpreis

Buchungen, Durchschnittswert und Anteil der Plattform als Regler. Zwei Balken zeigen, was von jeder Buchung bleibt, die Ersparnis pro Jahr zählt mit.

Im Studio öffnen

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

Props

Die Schnittstelle SavingsCalculatorProps aus components/ui/savings-calculator.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    SavingsCalculatorVariant

    stackbillingenergybundlecommission

  • eyebrowoptional
    React.ReactNode
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • stackoptional
    { 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

  • currencyoptional
    string

    ISO currency, default EUR

  • ctaoptional
    { label: string; href: string }
  • noteoptional
    string

Eingebaute Texte

Diese Texte schreibt Sparrechner selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 32 Texte anzeigen
todayEnglisch You pay todayDeutsch Du zahlst heute
withEnglisch With {n}Deutsch Mit {n}
saveEnglisch You saveDeutsch Du sparst
perYearEnglisch per yearDeutsch pro Jahr
perMonthEnglisch per monthDeutsch pro Monat
perMonthShortEnglisch / monthDeutsch / Monat
tickEnglisch Tick what you pay for todayDeutsch Hake ab, wofür du heute zahlst
paysEnglisch {x} costs {a} a monthDeutsch {x} kostet {a} im Monat
monthlyEnglisch MonthlyDeutsch Monatlich
yearlyEnglisch YearlyDeutsch Jährlich
billingEnglisch BillingDeutsch Abrechnung
freeEnglisch freeDeutsch frei
monthsEnglisch Your year in monthsDeutsch Dein Jahr in Monaten
billedMonthlyEnglisch Billed monthlyDeutsch Monatlich bezahlt
billedYearlyEnglisch Billed yearlyDeutsch Jährlich bezahlt
yearCostEnglisch per yearDeutsch pro Jahr
yearsEnglisch Over {n} years you keep {a}Deutsch In {n} Jahren bleiben dir {a}
yearEnglisch Year {n}Deutsch Jahr {n}
usageEnglisch UsageDeutsch Verbrauch
baseEnglisch Base feeDeutsch Grundgebühr
perUnitEnglisch per {u}Deutsch pro {u}
bundleSumEnglisch Single pricesDeutsch Einzelpreise
discountEnglisch Bundle discountDeutsch Paketrabatt
totalEnglisch Your bundleDeutsch Dein Paket
nextEnglisch Add {n} more to save {p}Deutsch Noch {n} dazu und du sparst {p}
bestEnglisch You get the best discountDeutsch Du bekommst den höchsten Rabatt
chooseEnglisch Choose servicesDeutsch Leistungen wählen
pickEnglisch Add {x} to the bundleDeutsch {x} zum Paket hinzufügen
keepEnglisch Of every {a} booking you keepDeutsch Von jeder Buchung über {a} bleiben dir
platformCostEnglisch Platform shareDeutsch Anteil der Plattform
ownCostEnglisch Flat feeDeutsch Feste Gebühr
monthNamesEnglisch Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,DecDeutsch 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
components/ui/savings-calculator.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert