{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "calculator-1",
  "title": "Calculator 1: pricing estimator",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A pricing estimator: sliders for seats, requests and storage, monthly or yearly, add-ons, and a dark summary that picks the cheapest plan that fits, rolls the total as you move, itemizes the sum and offers Talk to us beyond the plans.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/animated-number-flow.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/checkbox.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/slider.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/calculator-1/calculator-1.tsx",
      "content": "// Ballmac UI: Calculator 1. https://ui.ballmac.com/blocks/calculator-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowRight, Check, Sparkles } from \"lucide-react\"\n\nimport { AnimatedNumberFlow } from \"@/components/ballmac/animated-number-flow\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { Checkbox } from \"@/components/ballmac/checkbox\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Slider } from \"@/components/ballmac/slider\"\nimport { cn } from \"@/lib/utils\"\n\ntype Calculator1Plan = {\n  id: string\n  name: string\n  /** Per month, before seats. */\n  base: number\n  /** Per seat per month. */\n  perSeat: number\n  maxSeats: number\n  includedRequests: number\n  /** Price of each extra thousand requests. */\n  perThousand: number\n  includedStorage: number\n  /** Price of each extra GB. */\n  perGb: number\n}\n\ntype Calculator1Addon = { id: string; name: string; description: string; price: number; /** Per seat, or once per month for the whole account. */ per: \"seat\" | \"account\" }\n\ntype Calculator1Estimate = { plan: string; seats: number; requests: number; storage: number; yearly: boolean; addons: string[]; monthly: number }\n\ntype Calculator1Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onSelect\"> & {\n  title?: string\n  description?: string\n  plans?: Calculator1Plan[]\n  addons?: Calculator1Addon[]\n  /** How much cheaper a year paid up front is, from 0 to 1. */\n  yearlyDiscount?: number\n  currency?: string\n  locale?: string\n  /** Called with the estimate when the person continues. */\n  onContinue?: (estimate: Calculator1Estimate) => void\n  /** Called when the needs are beyond every plan. */\n  onTalk?: (estimate: Omit<Calculator1Estimate, \"plan\" | \"monthly\">) => void\n}\n\nconst samplePlans: Calculator1Plan[] = [\n  { id: \"starter\", name: \"Starter\", base: 0, perSeat: 0, maxSeats: 3, includedRequests: 10_000, perThousand: 0, includedStorage: 5, perGb: 0 },\n  { id: \"team\", name: \"Team\", base: 0, perSeat: 12, maxSeats: 50, includedRequests: 250_000, perThousand: 0.4, includedStorage: 100, perGb: 0.12 },\n  { id: \"business\", name: \"Business\", base: 199, perSeat: 9, maxSeats: 250, includedRequests: 2_000_000, perThousand: 0.25, includedStorage: 1000, perGb: 0.08 },\n]\nconst sampleAddons: Calculator1Addon[] = [\n  { id: \"sso\", name: \"Single sign-on\", description: \"SAML and SCIM for your identity provider\", price: 99, per: \"account\" },\n  { id: \"support\", name: \"Priority support\", description: \"A reply within one hour, on any day\", price: 4, per: \"seat\" },\n  { id: \"audit\", name: \"Audit log export\", description: \"Keep every event for as long as you need\", price: 49, per: \"account\" },\n]\nconst REQUESTS = [10_000, 50_000, 100_000, 250_000, 500_000, 1_000_000, 2_000_000, 5_000_000, 10_000_000]\nconst STORAGE = [5, 50, 100, 250, 500, 1000, 2500, 5000]\n\nconst compact = (n: number, locale: string) => new Intl.NumberFormat(locale, { notation: \"compact\", maximumFractionDigits: 1 }).format(n)\n\nfunction cost(p: Calculator1Plan, seats: number, requests: number, storage: number) {\n  const extraReq = Math.max(0, requests - p.includedRequests) / 1000\n  const extraGb = Math.max(0, storage - p.includedStorage)\n  return { base: p.base, seats: p.perSeat * seats, requests: extraReq * p.perThousand, storage: extraGb * p.perGb }\n}\n\n/**\n * A pricing estimator: three sliders (seats, requests, storage), monthly or yearly, and add-ons. The cheapest plan that fits is chosen for the\n * person and the total rolls to the new price as they move; each line of the sum is shown, with what the other plans would cost. Needs beyond\n * every plan turn the button into \"Talk to us\" instead of showing a made-up number.\n */\nfunction Calculator1({\n  title = \"Estimate your cost\",\n  description = \"Move the sliders to match your team. The price updates as you go, and nothing here is a quote you have to accept.\",\n  plans = samplePlans,\n  addons = sampleAddons,\n  yearlyDiscount = 0.2,\n  currency = \"USD\",\n  locale = \"en\",\n  onContinue,\n  onTalk,\n  className,\n  ...props\n}: Calculator1Props) {\n  const id = React.useId()\n  const [seats, setSeats] = React.useState(12)\n  const [ri, setRi] = React.useState(3)\n  const [si, setSi] = React.useState(2)\n  const [yearly, setYearly] = React.useState(true)\n  const [picked, setPicked] = React.useState<Set<string>>(new Set())\n\n  const requests = REQUESTS[ri]!\n  const storage = STORAGE[si]!\n  const money = (n: number, digits = 0) => new Intl.NumberFormat(locale, { style: \"currency\", currency, minimumFractionDigits: digits, maximumFractionDigits: digits }).format(n)\n  const factor = yearly ? 1 - yearlyDiscount : 1\n\n  const fits = plans.filter((p) => p.maxSeats >= seats)\n  const addonCost = addons.filter((a) => picked.has(a.id)).reduce((n, a) => n + (a.per === \"seat\" ? a.price * seats : a.price), 0)\n  const totals = fits.map((p) => ({ plan: p, parts: cost(p, seats, requests, storage) })).map((x) => ({ ...x, monthly: (x.parts.base + x.parts.seats + x.parts.requests + x.parts.storage) * factor }))\n  const best = totals.reduce<(typeof totals)[number] | undefined>((b, x) => (!b || x.monthly < b.monthly ? x : b), undefined)\n  const free = best && best.monthly === 0 && !picked.size\n  const monthly = best ? best.monthly + addonCost * factor : 0\n  const talk = !best\n\n  const toggle = (aid: string, on: boolean) =>\n    setPicked((s) => {\n      const n = new Set(s)\n      if (on) n.add(aid)\n      else n.delete(aid)\n      return n\n    })\n\n  const lines = best\n    ? [\n        best.parts.base ? { label: `${best.plan.name} base`, value: best.parts.base * factor } : null,\n        best.parts.seats ? { label: `${seats} ${seats === 1 ? \"seat\" : \"seats\"} × ${money(best.plan.perSeat * factor, 2)}`, value: best.parts.seats * factor } : null,\n        best.parts.requests ? { label: `${compact(requests - best.plan.includedRequests, locale)} extra requests`, value: best.parts.requests * factor } : null,\n        best.parts.storage ? { label: `${compact(storage - best.plan.includedStorage, locale)} GB extra storage`, value: best.parts.storage * factor } : null,\n        ...addons.filter((a) => picked.has(a.id)).map((a) => ({ label: a.name, value: (a.per === \"seat\" ? a.price * seats : a.price) * factor })),\n      ].filter((l): l is { label: string; value: number } => !!l)\n    : []\n\n  const others = totals.filter((x) => x !== best)\n\n  return (\n    <section data-slot=\"calculator-1\" aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 sm:py-24\", className)} {...props}>\n      <header className=\"mx-auto max-w-2xl text-center\">\n        <h2 id={`${id}-title`} className=\"text-4xl font-semibold tracking-[-0.04em] text-balance sm:text-5xl\">\n          {title}\n        </h2>\n        <p className=\"text-muted-foreground mt-4 text-lg text-pretty\">{description}</p>\n      </header>\n\n      <div className=\"mt-12 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_24rem]\">\n        <div className=\"bg-card grid gap-8 rounded-3xl border p-6 sm:p-8\">\n          <div className=\"flex flex-wrap items-center justify-between gap-3\">\n            <p id={`${id}-bill`} className=\"text-sm font-medium\">\n              Billing\n            </p>\n            <SegmentedControl value={yearly ? \"yearly\" : \"monthly\"} onValueChange={(v) => setYearly(v === \"yearly\")} aria-labelledby={`${id}-bill`} size=\"sm\">\n              <SegmentedControlItem value=\"monthly\">Monthly</SegmentedControlItem>\n              <SegmentedControlItem value=\"yearly\">Yearly · save {Math.round(yearlyDiscount * 100)}%</SegmentedControlItem>\n            </SegmentedControl>\n          </div>\n\n          {[\n            { key: \"seats\", label: \"Team members\", value: String(seats), min: 1, max: 300, v: seats, set: (n: number) => setSeats(n), hint: \"People who sign in\" },\n            { key: \"req\", label: \"API requests a month\", value: compact(requests, locale), min: 0, max: REQUESTS.length - 1, v: ri, set: setRi, hint: \"Everything your apps and automations call\" },\n            { key: \"gb\", label: \"Storage\", value: `${storage >= 1000 ? `${storage / 1000} TB` : `${storage} GB`}`, min: 0, max: STORAGE.length - 1, v: si, set: setSi, hint: \"Files, backups and exports\" },\n          ].map((s) => (\n            <div key={s.key}>\n              <div className=\"mb-3 flex items-baseline justify-between gap-4\">\n                <label id={`${id}-${s.key}`} className=\"text-sm font-medium\">\n                  {s.label}\n                  <span className=\"text-muted-foreground ms-2 hidden text-xs font-normal sm:inline\">{s.hint}</span>\n                </label>\n                <output htmlFor={`${id}-${s.key}`} className=\"text-2xl font-semibold tracking-tight tabular-nums\">\n                  {s.value}\n                </output>\n              </div>\n              <Slider aria-labelledby={`${id}-${s.key}`} min={s.min} max={s.max} step={1} value={[s.v]} onValueChange={([n]) => s.set(n!)} formatValue={() => s.value} />\n            </div>\n          ))}\n\n          <fieldset>\n            <legend className=\"mb-3 text-sm font-medium\">Add-ons</legend>\n            <div className=\"grid gap-2.5\">\n              {addons.map((a) => (\n                <label key={a.id} className={cn(\"hover:bg-accent/40 flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition-colors motion-reduce:transition-none\", picked.has(a.id) && \"border-foreground/40 bg-accent/30\")}>\n                  <Checkbox checked={picked.has(a.id)} onCheckedChange={(v) => toggle(a.id, v === true)} className=\"mt-0.5\" />\n                  <span className=\"min-w-0 flex-1\">\n                    <span className=\"block text-sm font-medium\">{a.name}</span>\n                    <span className=\"text-muted-foreground block text-sm\">{a.description}</span>\n                  </span>\n                  <span className=\"text-sm font-medium tabular-nums\">\n                    {money(a.price * factor)}\n                    <span className=\"text-muted-foreground font-normal\"> /{a.per === \"seat\" ? \"seat\" : \"mo\"}</span>\n                  </span>\n                </label>\n              ))}\n            </div>\n          </fieldset>\n        </div>\n\n        <aside aria-labelledby={`${id}-sum`} className=\"bg-foreground text-background relative overflow-hidden rounded-3xl p-6 sm:p-8 lg:sticky lg:top-6\">\n          <div aria-hidden=\"true\" className=\"from-background/10 pointer-events-none absolute -top-24 -end-24 size-64 rounded-full bg-gradient-to-br to-transparent blur-2xl\" />\n          <h3 id={`${id}-sum`} className=\"text-background/70 flex items-center gap-2 text-sm font-medium\">\n            <Sparkles aria-hidden=\"true\" className=\"size-4\" />\n            {talk ? \"Beyond our plans\" : \"We would suggest\"}\n          </h3>\n          {talk ? (\n            <>\n              <p className=\"mt-2 text-3xl font-semibold tracking-tight text-balance\">Let us build this one with you</p>\n              <p className=\"text-background/70 mt-3 text-sm text-pretty\">{seats} team members is more than our plans cover. We will put together a price that fits.</p>\n            </>\n          ) : (\n            <>\n              <p className=\"mt-2 text-3xl font-semibold tracking-tight\">{best!.plan.name}</p>\n              <p className=\"mt-6 flex items-baseline gap-2\">\n                <AnimatedNumberFlow value={monthly} locale={locale} format={{ style: \"currency\", currency, maximumFractionDigits: 0 }} className=\"text-6xl font-semibold tracking-[-0.05em]\" />\n                <span className=\"text-background/70 text-base\">/month</span>\n              </p>\n              <p className=\"text-background/70 mt-1 text-sm\">{free ? \"Free, with room for what you described.\" : yearly ? `Billed ${money(monthly * 12)} a year` : \"Billed monthly\"}</p>\n              {lines.length ? (\n                <dl className=\"border-background/15 mt-6 grid gap-2.5 border-t pt-5 text-sm\">\n                  {lines.map((l) => (\n                    <div key={l.label} className=\"flex justify-between gap-4\">\n                      <dt className=\"text-background/70\">{l.label}</dt>\n                      <dd className=\"font-medium tabular-nums\">{money(l.value)}</dd>\n                    </div>\n                  ))}\n                </dl>\n              ) : null}\n              {others.length ? (\n                <p className=\"text-background/60 mt-5 text-xs text-pretty\">\n                  The other plans that fit would cost {others.map((o) => `${o.plan.name} ${money(o.monthly + addonCost * factor)}`).join(\" and \")} a month.\n                </p>\n              ) : null}\n            </>\n          )}\n          <Button\n            variant=\"secondary\"\n            size=\"lg\"\n            className=\"mt-7 w-full\"\n            onClick={() => {\n              const base = { seats, requests, storage, yearly, addons: [...picked] }\n              if (talk) onTalk?.(base)\n              else onContinue?.({ ...base, plan: best!.plan.id, monthly })\n            }}\n          >\n            {talk ? \"Talk to us\" : `Continue with ${best!.plan.name}`}\n            <ArrowRight aria-hidden=\"true\" className=\"rtl:rotate-180\" />\n          </Button>\n          {!talk ? (\n            <ul className=\"text-background/70 mt-5 grid gap-1.5 text-xs\">\n              {[`Up to ${compact(best!.plan.includedRequests, locale)} requests included`, `${best!.plan.includedStorage} GB storage included`, \"Change or cancel any time\"].map((t) => (\n                <li key={t} className=\"flex items-center gap-2\">\n                  <Check aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n                  {t}\n                </li>\n              ))}\n            </ul>\n          ) : null}\n        </aside>\n      </div>\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {talk ? \"Beyond our plans. Talk to us.\" : `${best!.plan.name}, ${money(monthly)} a month`}\n      </p>\n    </section>\n  )\n}\n\nexport { Calculator1, type Calculator1Props, type Calculator1Plan, type Calculator1Addon, type Calculator1Estimate }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/calculator-1/calculator-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "calculator",
      "pricing",
      "estimator",
      "usage",
      "sliders",
      "cost",
      "plans",
      "quote",
      "seats",
      "pay as you go"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A usage-based pricing calculator. <Calculator1 plans={[{ id, name, base, perSeat, maxSeats, includedRequests, perThousand, includedStorage, perGb }]} addons={[{ id, name, description, price, per: 'seat' | 'account' }]} yearlyDiscount={0.2} currency='USD' locale onContinue={(estimate) => ...} onTalk />. It picks the cheapest plan whose maxSeats fits, shows each line of the sum and the cost on the other plans, and shows Talk to us when no plan fits. Prices are numbers per month.",
      "whenToUse": [
        "A pricing page for per-seat or usage-based products",
        "Helping a buyer size a plan before they talk to sales"
      ],
      "whenNotToUse": [
        "Fixed tiers with a feature list (use pricing-1 to pricing-4)",
        "A checkout form"
      ],
      "composesWith": [
        "pricing-1",
        "faq-1",
        "cta-1",
        "comparison-1"
      ],
      "a11y": [
        {
          "keys": "Tab, then arrow keys",
          "action": "Each slider is a labelled slider; arrows move one step, Home and End jump to the ends, and the value is read aloud with its unit"
        },
        {
          "keys": "Result",
          "action": "The suggested plan and monthly price are announced politely as the sliders move"
        },
        {
          "keys": "Add-ons",
          "action": "Checkboxes inside a labelled group, each with its price in text"
        }
      ],
      "customization": [
        "Plans and add-ons are plain data, so any pricing model that is a base plus seats plus overage fits",
        "Requests and storage move along fixed steps, so the slider is never at an awkward number",
        "The total's digits roll; with reduced motion it changes at once"
      ]
    },
    "statesNone": "Static content: nothing to load, submit or fail.",
    "examples": [
      "calculator-1-demo",
      "calculator-1-enterprise"
    ],
    "url": "https://ui.ballmac.com/components/calculator-1"
  },
  "categories": [
    "blocks",
    "calculator"
  ],
  "type": "registry:block"
}