{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "billing-2",
  "title": "Billing 2: change plan with proration",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A change-plan flow: billing interval, plan cards as radios, a seat stepper that respects the current team, and a sticky summary that works out the prorated charge, the credit and what is due today.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/badge.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/billing-2/billing-2.tsx",
      "content": "// Ballmac UI: Billing 2. https://ui.ballmac.com/blocks/billing-2\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Minus, Plus } from \"lucide-react\"\n\nimport { Badge } from \"@/components/ballmac/badge\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { cn } from \"@/lib/utils\"\nimport { useLocale } from \"@/lib/ballmac/i18n\"\n\ntype Billing2Plan = {\n  id: string\n  name: string\n  /** Price per seat per month when billed monthly. */\n  monthly: number\n  /** Price per seat per month when billed yearly. */\n  yearly: number\n  description: string\n  features: string[]\n}\n\ntype Billing2Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onSubmit\"> & {\n  /** Page heading. */\n  title?: string\n  /** One line under the heading. */\n  description?: string\n  /** Plans to choose from. */\n  plans?: Billing2Plan[]\n  /** Id of the plan the account is on now. */\n  currentPlan?: string\n  /** Plan selected when the page opens. Defaults to the plan after the current one, so the summary shows an upgrade. */\n  defaultPlan?: string\n  /** Seats the account has now. */\n  currentSeats?: number\n  /** How it is billed now. */\n  currentInterval?: \"monthly\" | \"yearly\"\n  /** Today, as an ISO date. Passed in so the proration is the same on the server and in the browser. */\n  today?: string\n  /** The date the current period ends and the next one starts, as an ISO date. */\n  renewsOn?: string\n  /** Seats can't go below this (people already on the team). */\n  minSeats?: number\n  /** ISO currency code. */\n  currency?: string\n  /** Locale for numbers and dates. Fixed by default so server and browser match. */\n  locale?: string\n  /** Called with the choice when it is confirmed. Throw to show an error; resolve to show the confirmation. */\n  onConfirm?: (change: { plan: string; seats: number; interval: \"monthly\" | \"yearly\" }) => void | Promise<void>\n}\n\nconst defaultPlans: Billing2Plan[] = [\n  { id: \"starter\", name: \"Starter\", monthly: 12, yearly: 9, description: \"For small teams getting started.\", features: [\"3 projects\", \"10 GB storage\", \"Community support\"] },\n  { id: \"pro\", name: \"Pro\", monthly: 32, yearly: 25, description: \"For teams that ship every week.\", features: [\"Unlimited projects\", \"1 TB storage\", \"Roles and SSO\", \"Priority support\"] },\n  { id: \"scale\", name: \"Scale\", monthly: 79, yearly: 63, description: \"For security and volume needs.\", features: [\"Audit log and SCIM\", \"99.99% uptime SLA\", \"Dedicated manager\", \"Custom retention\"] },\n]\n\nconst DAY = 86400000\nconst utc = (iso: string) => Date.parse(`${iso}T00:00:00Z`)\n\nfunction Billing2({\n  title = \"Change your plan\",\n  description = \"Pick a plan and seats. You only pay the difference for the days left in this period.\",\n  plans = defaultPlans,\n  currentPlan = \"pro\",\n  defaultPlan,\n  currentSeats = 7,\n  currentInterval = \"monthly\",\n  today = \"2026-10-01\",\n  renewsOn = \"2026-10-28\",\n  minSeats = 7,\n  currency = \"USD\",\n  locale,\n  onConfirm,\n  className,\n  ...props\n}: Billing2Props) {\n  const defaultLocale = useLocale()\n  locale ??= defaultLocale\n  const [planId, setPlanId] = React.useState(defaultPlan ?? plans[plans.findIndex((p) => p.id === currentPlan) + 1]?.id ?? currentPlan)\n  const [seats, setSeats] = React.useState(currentSeats)\n  const [period, setPeriod] = React.useState<\"monthly\" | \"yearly\">(currentInterval)\n  const [state, setState] = React.useState<\"idle\" | \"saving\" | \"error\" | \"done\">(\"idle\")\n  const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"currency\", currency, minimumFractionDigits: 2 }), [locale, currency])\n  const whole = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: 0 }), [locale, currency])\n  const date = React.useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: \"medium\", timeZone: \"UTC\" }), [locale])\n\n  const current = plans.find((p) => p.id === currentPlan) ?? plans[0]\n  const next = plans.find((p) => p.id === planId) ?? plans[0]\n  if (!current || !next) return null\n\n  const lengthOf = (i: \"monthly\" | \"yearly\") => (i === \"yearly\" ? 365 : 30)\n  const daysLeft = Math.max(0, Math.min(lengthOf(currentInterval), Math.round((utc(renewsOn) - utc(today)) / DAY)))\n  const perSeat = (p: Billing2Plan, i: \"monthly\" | \"yearly\") => (i === \"yearly\" ? p.yearly * 12 : p.monthly)\n  const currentCost = perSeat(current, currentInterval) * currentSeats\n  const nextCost = perSeat(next, period) * seats\n  // Both sides are charged by the day for the days left in the current period.\n  const credit = (currentCost / lengthOf(currentInterval)) * daysLeft\n  const charge = (nextCost / lengthOf(period)) * daysLeft\n  const dueToday = Math.max(0, charge - credit)\n  const unchanged = planId === currentPlan && seats === currentSeats && period === currentInterval\n  const direction = next === current ? (seats > currentSeats ? \"Add seats\" : \"Update seats\") : plans.indexOf(next) > plans.indexOf(current) ? `Upgrade to ${next.name}` : `Switch to ${next.name}`\n\n  async function confirm() {\n    setState(\"saving\")\n    try {\n      if (onConfirm) await onConfirm({ plan: planId, seats, interval: period })\n      else await new Promise((r) => setTimeout(r, 900))\n      setState(\"done\")\n    } catch {\n      setState(\"error\")\n    }\n  }\n\n  return (\n    <section data-slot=\"billing-2\" className={cn(\"mx-auto w-full max-w-5xl px-4 py-8 sm:px-6\", className)} {...props}>\n      <h2 className=\"text-2xl font-semibold tracking-[-0.03em]\">{title}</h2>\n      <p className=\"text-muted-foreground mt-1 text-sm\">{description}</p>\n\n      {state === \"done\" ? (\n        <div role=\"status\" className=\"bg-card mt-8 flex flex-col items-center rounded-3xl border px-6 py-16 text-center\">\n          <span aria-hidden=\"true\" className=\"bg-chart-2/15 flex size-14 items-center justify-center rounded-full\"><Check className=\"size-7\" /></span>\n          <h3 className=\"mt-6 text-2xl font-semibold tracking-[-0.03em]\">You’re on {next.name}</h3>\n          <p className=\"text-muted-foreground mt-2 max-w-sm text-pretty\">{seats} seats, billed {period}. {dueToday > 0 ? `We charged ${money.format(dueToday)} today.` : \"There is nothing to pay today.\"} Your next invoice is on {date.format(new Date(utc(renewsOn)))}.</p>\n          <Button variant=\"outline\" shape=\"pill\" className=\"mt-8\" onClick={() => setState(\"idle\")}>Make another change</Button>\n        </div>\n      ) : (\n        <div className=\"mt-8 grid gap-6 lg:grid-cols-[1fr_21rem]\">\n          <div className=\"min-w-0 space-y-6\">\n            <div>\n              <p className=\"mb-2 text-sm font-medium\">Billing</p>\n              <SegmentedControl aria-label=\"Billing interval\" value={period} onValueChange={(v) => setPeriod(v as \"monthly\" | \"yearly\")}>\n                <SegmentedControlItem value=\"monthly\">Monthly</SegmentedControlItem>\n                <SegmentedControlItem value=\"yearly\">Yearly · save 20%</SegmentedControlItem>\n              </SegmentedControl>\n            </div>\n\n            <fieldset>\n              <legend className=\"mb-2 text-sm font-medium\">Plan</legend>\n              <div className=\"grid gap-3\">\n                {plans.map((p) => {\n                  const on = planId === p.id\n                  const price = period === \"yearly\" ? p.yearly : p.monthly\n                  return (\n                    <label key={p.id} className=\"relative cursor-pointer\">\n                      <input type=\"radio\" name=\"billing-2-plan\" value={p.id} checked={on} onChange={() => setPlanId(p.id)} className=\"peer sr-only\" />\n                      <span className=\"peer-focus-visible:ring-ring/50 peer-checked:border-foreground peer-checked:bg-accent/50 hover:bg-accent/30 bg-card flex flex-col gap-4 rounded-2xl border p-5 transition-colors peer-focus-visible:ring-[3px] sm:flex-row sm:items-center\">\n                        <span className=\"flex flex-1 items-start gap-3\">\n                          <span aria-hidden=\"true\" className={cn(\"mt-1 flex size-5 shrink-0 items-center justify-center rounded-full border\", on && \"bg-foreground text-background border-transparent\")}>{on && <Check className=\"size-3\" />}</span>\n                          <span>\n                            <span className=\"flex items-center gap-2 font-semibold\">{p.name}{p.id === currentPlan && <Badge variant=\"outline\">Current</Badge>}</span>\n                            <span className=\"text-muted-foreground mt-0.5 block text-sm\">{p.description}</span>\n                            <span className=\"text-muted-foreground mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs\">{p.features.map((f) => <span key={f} className=\"flex items-center gap-1\"><Check className=\"size-3\" aria-hidden=\"true\" />{f}</span>)}</span>\n                          </span>\n                        </span>\n                        <span className=\"sm:text-end\">\n                          <span className=\"text-2xl font-semibold tracking-[-0.03em] tabular-nums\">{whole.format(price)}</span>\n                          <span className=\"text-muted-foreground block text-xs\">per seat / month</span>\n                        </span>\n                      </span>\n                    </label>\n                  )\n                })}\n              </div>\n            </fieldset>\n\n            <div>\n              <p id=\"billing-2-seats\" className=\"mb-2 text-sm font-medium\">Seats</p>\n              <div role=\"group\" aria-labelledby=\"billing-2-seats\" className=\"inline-flex items-center rounded-xl border\">\n                <Button variant=\"ghost\" size=\"icon\" aria-label=\"Remove a seat\" disabled={seats <= minSeats} onClick={() => setSeats((s) => Math.max(minSeats, s - 1))}><Minus /></Button>\n                <span role=\"status\" aria-label={`${seats} seats`} className=\"w-14 text-center text-sm font-semibold tabular-nums\">{seats}</span>\n                <Button variant=\"ghost\" size=\"icon\" aria-label=\"Add a seat\" onClick={() => setSeats((s) => s + 1)}><Plus /></Button>\n              </div>\n              <p className=\"text-muted-foreground mt-2 text-xs\">{minSeats} people are on the team now, so you can’t go below {minSeats}.</p>\n            </div>\n          </div>\n\n          <aside aria-label=\"Order summary\" className=\"bg-card h-fit rounded-2xl border p-5 lg:sticky lg:top-6\">\n            <p className=\"text-sm font-medium\">Summary</p>\n            <dl className=\"mt-4 space-y-3 text-sm\">\n              <div className=\"flex justify-between gap-3\"><dt className=\"text-muted-foreground\">{next.name} × {seats} seats</dt><dd className=\"tabular-nums\">{money.format(nextCost)} / {period === \"yearly\" ? \"yr\" : \"mo\"}</dd></div>\n              <div className=\"flex justify-between gap-3\"><dt className=\"text-muted-foreground\">New cost for {daysLeft} remaining days</dt><dd className=\"tabular-nums\">{money.format(charge)}</dd></div>\n              <div className=\"flex justify-between gap-3\"><dt className=\"text-muted-foreground\">Credit for your current plan</dt><dd className=\"tabular-nums\">−{money.format(credit)}</dd></div>\n              <div className=\"flex justify-between gap-3 border-t pt-3 text-base font-semibold\"><dt>Due today</dt><dd className=\"tabular-nums\">{money.format(dueToday)}</dd></div>\n            </dl>\n            <p className=\"text-muted-foreground mt-3 text-xs\">From {date.format(new Date(utc(renewsOn)))} you’ll pay {money.format(nextCost)} per {period === \"yearly\" ? \"year\" : \"month\"}. Cancel any time.</p>\n            {state === \"error\" && <p role=\"alert\" className=\"text-destructive mt-3 text-sm\">We couldn’t update your plan. Please try again.</p>}\n            <Button size=\"lg\" shape=\"pill\" className=\"mt-5 w-full\" disabled={unchanged} loading={state === \"saving\"} onClick={confirm}>{unchanged ? \"Choose a change\" : direction}</Button>\n          </aside>\n        </div>\n      )}\n    </section>\n  )\n}\n\nexport { Billing2, type Billing2Props, type Billing2Plan }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/billing-2/billing-2.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "billing",
      "upgrade",
      "downgrade",
      "proration",
      "seats",
      "plans",
      "checkout"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "Upgrade and downgrade flow. Pass plans=[{ id, name, monthly, yearly, description, features }], currentPlan, currentSeats, today and renewsOn (ISO dates, so server and browser agree) and onConfirm({ plan, seats, interval }) (throw to show an error).",
      "whenToUse": [
        "In-app plan changes",
        "Seat-based products with prorated billing"
      ],
      "whenNotToUse": [
        "Public pricing pages (use pricing-2 or pricing-3)"
      ],
      "composesWith": [
        "billing-1",
        "app-shell-1",
        "settings-3"
      ],
      "a11y": [
        {
          "keys": "Arrow keys on plans",
          "action": "Move between plan radios; the chosen plan is marked and named 'Current' when it is the active one"
        },
        {
          "keys": "Seat buttons",
          "action": "Named 'Add a seat' and 'Remove a seat'; the count is announced politely and can't go below the team size"
        },
        {
          "keys": "Summary",
          "action": "A labelled complementary region; every line is plain text"
        }
      ],
      "customization": [
        "plans, currentPlan, currentSeats, currentInterval, minSeats",
        "today and renewsOn drive the proration",
        "onConfirm(change) async"
      ]
    },
    "examples": [
      "billing-2-demo",
      "billing-2-yearly"
    ],
    "url": "https://ui.ballmac.com/components/billing-2"
  },
  "categories": [
    "blocks",
    "billing"
  ],
  "type": "registry:block"
}