{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "billing-1",
  "title": "Billing 1: plan, usage and invoices",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A billing page: current plan and monthly cost, usage meters that turn amber near the limit, the card on file with an update dialog (formatted, validated), the billing contact and an invoice list with status and downloads.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/badge.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/dialog.json",
    "https://ui.ballmac.com/r/field.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/billing-1/billing-1.tsx",
      "content": "// Ballmac UI: Billing 1. https://ui.ballmac.com/blocks/billing-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { AlertTriangle, CreditCard, Download } from \"lucide-react\"\n\nimport { Badge } from \"@/components/ballmac/badge\"\nimport { Button, buttonVariants } from \"@/components/ballmac/button\"\nimport { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from \"@/components/ballmac/dialog\"\nimport { Field, FieldError, FieldGroup, FieldLabel, useFieldControl } from \"@/components/ballmac/field\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { cn } from \"@/lib/utils\"\nimport { useLocale } from \"@/lib/ballmac/i18n\"\n\ntype Billing1Usage = {\n  label: string\n  used: number\n  /** The plan limit. Leave out for unlimited. */\n  limit?: number\n  /** Unit shown after the numbers, e.g. \"GB\". */\n  unit?: string\n}\n\ntype Billing1Invoice = {\n  id: string\n  /** ISO date (YYYY-MM-DD), shown in UTC. */\n  date: string\n  amount: number\n  status: \"paid\" | \"open\" | \"failed\"\n  href?: string\n}\n\ntype Billing1Props = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** Page heading. */\n  title?: string\n  /** One line under the heading. */\n  description?: string\n  /** The current plan. */\n  plan?: { name: string; price: number; per: string; seats: number; seatLimit: number; renews: string }\n  /** Usage against plan limits. A meter turns amber from 80%. */\n  usage?: Billing1Usage[]\n  /** The card on file. */\n  card?: { brand: string; last4: string; expires: string; holder: string }\n  /** Billing contact. */\n  contact?: { email: string; address: string }\n  /** Past invoices, newest first. */\n  invoices?: Billing1Invoice[]\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 by \"Change plan\". */\n  onChangePlan?: () => void\n  /** Called with the new card details. Throw to show an error. Never send these anywhere except your payment provider. */\n  onUpdateCard?: (card: { number: string; expiry: string; cvc: string }) => void | Promise<void>\n}\n\nconst defaultUsage: Billing1Usage[] = [\n  { label: \"Seats\", used: 7, limit: 10 },\n  { label: \"Storage\", used: 640, limit: 1000, unit: \"GB\" },\n  { label: \"API requests this month\", used: 82400, limit: 100000 },\n  { label: \"Projects\", used: 18 },\n]\n\nconst defaultInvoices: Billing1Invoice[] = [\n  { id: \"INV-1042\", date: \"2026-09-28\", amount: 224, status: \"paid\" },\n  { id: \"INV-1031\", date: \"2026-08-28\", amount: 224, status: \"paid\" },\n  { id: \"INV-1019\", date: \"2026-07-28\", amount: 192, status: \"paid\" },\n  { id: \"INV-1007\", date: \"2026-06-28\", amount: 192, status: \"paid\" },\n  { id: \"INV-0995\", date: \"2026-05-28\", amount: 160, status: \"failed\" },\n]\n\nconst tone = { paid: \"success\", open: \"warning\", failed: \"error\" } as const\nconst label = { paid: \"Paid\", open: \"Open\", failed: \"Failed\" }\n\nfunction FieldInput(props: React.ComponentProps<typeof Input>) {\n  return <Input {...useFieldControl()} {...props} />\n}\n\nfunction Billing1({\n  title = \"Billing\",\n  description = \"Your plan, usage, payment method and invoices.\",\n  plan = { name: \"Pro\", price: 32, per: \"seat / month\", seats: 7, seatLimit: 10, renews: \"2026-10-28\" },\n  usage = defaultUsage,\n  card = { brand: \"Visa\", last4: \"4242\", expires: \"08/28\", holder: \"Jordan Lee\" },\n  contact = { email: \"billing@acme.com\", address: \"Rua das Flores 12, 1200-195 Lisbon, Portugal\" },\n  invoices = defaultInvoices,\n  currency = \"USD\",\n  locale,\n  onChangePlan,\n  onUpdateCard,\n  className,\n  ...props\n}: Billing1Props) {\n  const defaultLocale = useLocale()\n  locale ??= defaultLocale\n  const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: 0 }), [locale, currency])\n  const int = React.useMemo(() => new Intl.NumberFormat(locale), [locale])\n  const date = React.useMemo(() => new Intl.DateTimeFormat(locale, { dateStyle: \"medium\", timeZone: \"UTC\" }), [locale])\n  const fmt = (iso: string) => date.format(new Date(`${iso}T00:00:00Z`))\n\n  const [open, setOpen] = React.useState(false)\n  const [num, setNum] = React.useState(\"\")\n  const [exp, setExp] = React.useState(\"\")\n  const [cvc, setCvc] = React.useState(\"\")\n  const [tried, setTried] = React.useState(false)\n  const [state, setState] = React.useState<\"idle\" | \"saving\" | \"error\">(\"idle\")\n  const [cardNow, setCardNow] = React.useState(card)\n  const [saved, setSaved] = React.useState(false)\n\n  const digits = num.replace(/\\s/g, \"\")\n  const errors = {\n    num: digits.length >= 13 ? undefined : \"Enter the full card number.\",\n    exp: /^(0[1-9]|1[0-2])\\/\\d{2}$/.test(exp) ? undefined : \"Use MM/YY.\",\n    cvc: cvc.length >= 3 ? undefined : \"Enter the 3 or 4 digit code.\",\n  }\n  const show = (k: keyof typeof errors) => (tried ? errors[k] : undefined)\n\n  async function saveCard(e: React.FormEvent) {\n    e.preventDefault()\n    setTried(true)\n    if (Object.values(errors).some(Boolean)) return\n    setState(\"saving\")\n    try {\n      if (onUpdateCard) await onUpdateCard({ number: digits, expiry: exp, cvc })\n      else await new Promise((r) => setTimeout(r, 800))\n      setCardNow({ ...cardNow, last4: digits.slice(-4), expires: exp })\n      setOpen(false)\n      setSaved(true)\n      setNum(\"\")\n      setExp(\"\")\n      setCvc(\"\")\n      setTried(false)\n      setState(\"idle\")\n    } catch {\n      setState(\"error\")\n    }\n  }\n\n  const monthly = plan.price * plan.seats\n\n  return (\n    <section data-slot=\"billing-1\" 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      <p role=\"status\" className=\"sr-only\">{saved ? \"Payment method updated.\" : \"\"}</p>\n\n      <div className=\"mt-6 grid gap-4 lg:grid-cols-[1fr_20rem]\">\n        <div className=\"bg-card rounded-2xl border p-5 sm:p-6\">\n          <div className=\"flex flex-wrap items-start justify-between gap-4\">\n            <div>\n              <p className=\"text-muted-foreground text-sm\">Current plan</p>\n              <p className=\"mt-1 flex items-center gap-2 text-2xl font-semibold tracking-[-0.03em]\">{plan.name} <Badge status=\"success\">Active</Badge></p>\n              <p className=\"text-muted-foreground mt-1 text-sm\">{money.format(plan.price)} per {plan.per} · {plan.seats} seats · renews {fmt(plan.renews)}</p>\n            </div>\n            <div className=\"text-end\">\n              <p className=\"text-3xl font-semibold tracking-[-0.04em] tabular-nums\">{money.format(monthly)}</p>\n              <p className=\"text-muted-foreground text-sm\">per month</p>\n            </div>\n          </div>\n\n          <ul className=\"mt-6 space-y-5 border-t pt-6\">\n            {usage.map((u) => {\n              const pct = u.limit ? Math.min(100, (u.used / u.limit) * 100) : 0\n              const warn = u.limit ? pct >= 80 : false\n              return (\n                <li key={u.label}>\n                  <div className=\"flex flex-wrap items-baseline justify-between gap-2 text-sm\">\n                    <span className=\"font-medium\">{u.label}</span>\n                    <span className=\"text-muted-foreground tabular-nums\">\n                      {int.format(u.used)}{u.unit ? ` ${u.unit}` : \"\"}{u.limit ? ` of ${int.format(u.limit)}${u.unit ? ` ${u.unit}` : \"\"}` : \" · unlimited\"}\n                    </span>\n                  </div>\n                  {u.limit ? (\n                    <div className=\"bg-muted mt-2 h-2 overflow-hidden rounded-full\" role=\"img\" aria-label={`${u.label}: ${Math.round(pct)} percent of the limit used`}>\n                      <div className={cn(\"h-full rounded-full transition-[width] duration-500 motion-reduce:transition-none\", warn ? \"bg-chart-3\" : \"bg-chart-1\")} style={{ width: `${pct}%` }} />\n                    </div>\n                  ) : null}\n                  {warn && <p className=\"mt-1.5 flex items-center gap-1.5 text-xs\"><AlertTriangle className=\"text-chart-3 size-3.5\" aria-hidden=\"true\" />Approaching your limit. Upgrade to avoid interruptions.</p>}\n                </li>\n              )\n            })}\n          </ul>\n\n          <div className=\"mt-6 flex flex-wrap gap-2 border-t pt-6\">\n            <Button shape=\"pill\" onClick={onChangePlan}>Change plan</Button>\n            <Button variant=\"ghost\">Cancel subscription</Button>\n          </div>\n        </div>\n\n        <div className=\"space-y-4\">\n          <div className=\"bg-card rounded-2xl border p-5\">\n            <p className=\"text-sm font-medium\">Payment method</p>\n            <div className=\"mt-4 flex items-center gap-3\">\n              <span aria-hidden=\"true\" className=\"bg-muted flex h-9 w-12 items-center justify-center rounded-md border\"><CreditCard className=\"size-5\" /></span>\n              <div className=\"text-sm leading-tight\">\n                <p className=\"font-medium\">{cardNow.brand} •••• {cardNow.last4}</p>\n                <p className=\"text-muted-foreground text-xs\">Expires {cardNow.expires} · {cardNow.holder}</p>\n              </div>\n            </div>\n            <Dialog open={open} onOpenChange={(o) => { setOpen(o); if (!o) { setTried(false); setState(\"idle\") } }}>\n              <DialogTrigger className={buttonVariants({ variant: \"outline\", size: \"sm\", className: \"mt-4 w-full\" })}>Update card</DialogTrigger>\n              <DialogContent>\n                <form noValidate onSubmit={saveCard}>\n                  <DialogHeader>\n                    <DialogTitle>Update payment method</DialogTitle>\n                    <DialogDescription>Your next invoice will be charged to this card.</DialogDescription>\n                  </DialogHeader>\n                  <FieldGroup className=\"my-5\">\n                    {state === \"error\" && <p role=\"alert\" className=\"border-destructive/40 text-destructive rounded-lg border px-3 py-2.5 text-sm\">We couldn’t verify that card. Check the details and try again.</p>}\n                    <Field invalid={!!show(\"num\")}>\n                      <FieldLabel>Card number</FieldLabel>\n                      <FieldInput inputMode=\"numeric\" autoComplete=\"cc-number\" placeholder=\"4242 4242 4242 4242\" value={num} onChange={(e) => setNum(e.target.value.replace(/\\D/g, \"\").slice(0, 19).replace(/(.{4})/g, \"$1 \").trim())} />\n                      <FieldError errors={[show(\"num\")]} />\n                    </Field>\n                    <div className=\"grid grid-cols-2 gap-4\">\n                      <Field invalid={!!show(\"exp\")}>\n                        <FieldLabel>Expiry</FieldLabel>\n                        <FieldInput inputMode=\"numeric\" autoComplete=\"cc-exp\" placeholder=\"MM/YY\" value={exp} onChange={(e) => { const d = e.target.value.replace(/\\D/g, \"\").slice(0, 4); setExp(d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d) }} />\n                        <FieldError errors={[show(\"exp\")]} />\n                      </Field>\n                      <Field invalid={!!show(\"cvc\")}>\n                        <FieldLabel>Security code</FieldLabel>\n                        <FieldInput inputMode=\"numeric\" autoComplete=\"cc-csc\" placeholder=\"123\" value={cvc} onChange={(e) => setCvc(e.target.value.replace(/\\D/g, \"\").slice(0, 4))} />\n                        <FieldError errors={[show(\"cvc\")]} />\n                      </Field>\n                    </div>\n                  </FieldGroup>\n                  <DialogFooter>\n                    <Button type=\"button\" variant=\"ghost\" onClick={() => setOpen(false)}>Cancel</Button>\n                    <Button type=\"submit\" loading={state === \"saving\"}>Save card</Button>\n                  </DialogFooter>\n                </form>\n              </DialogContent>\n            </Dialog>\n          </div>\n\n          <div className=\"bg-card rounded-2xl border p-5\">\n            <p className=\"text-sm font-medium\">Billing contact</p>\n            <p className=\"mt-3 text-sm break-all\">{contact.email}</p>\n            <p className=\"text-muted-foreground mt-1 text-sm\">{contact.address}</p>\n            <Button variant=\"outline\" size=\"sm\" className=\"mt-4 w-full\">Edit details</Button>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"bg-card mt-4 overflow-hidden rounded-2xl border\">\n        <div className=\"flex items-center justify-between px-5 py-4\">\n          <p className=\"text-sm font-medium\">Invoices</p>\n          <a href=\"#invoices\" className=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded-sm text-sm underline-offset-4 outline-none hover:underline focus-visible:ring-[3px]\">View all</a>\n        </div>\n        <ul className=\"divide-y border-t\">\n          {invoices.map((inv) => (\n            <li key={inv.id} className=\"flex items-center gap-3 px-5 py-3.5 text-sm\">\n              <div className=\"min-w-0 flex-1\">\n                <p className=\"font-mono text-xs\">{inv.id}</p>\n                <p className=\"text-muted-foreground text-xs\">{fmt(inv.date)}</p>\n              </div>\n              <Badge status={tone[inv.status]}>{label[inv.status]}</Badge>\n              <span className=\"w-20 text-end font-medium tabular-nums\">{money.format(inv.amount)}</span>\n              <a href={inv.href ?? \"#\"} aria-label={`Download invoice ${inv.id}`} className=\"hover:bg-accent focus-visible:ring-ring/50 flex size-9 items-center justify-center rounded-lg outline-none focus-visible:ring-[3px]\"><Download className=\"size-4\" aria-hidden=\"true\" /></a>\n            </li>\n          ))}\n        </ul>\n      </div>\n    </section>\n  )\n}\n\nexport { Billing1, type Billing1Props, type Billing1Usage, type Billing1Invoice }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/billing-1/billing-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "billing",
      "subscription",
      "usage",
      "invoices",
      "payment method",
      "plan"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "The billing screen. Pass plan, usage=[{ label, used, limit?, unit? }], card, contact, invoices, currency and handlers onChangePlan / onUpdateCard (throw to show an error). Real card numbers must go to your payment provider's hosted fields, not through this form.",
      "whenToUse": [
        "Subscription management pages",
        "Usage-based or seat-based products"
      ],
      "whenNotToUse": [
        "Choosing between plans (use billing-2 or pricing-2)"
      ],
      "composesWith": [
        "app-shell-1",
        "billing-2",
        "settings-1",
        "settings-3"
      ],
      "a11y": [
        {
          "keys": "Usage meters",
          "action": "Each is an image with a text alternative ('Storage: 64 percent used') and the warning is written out, not only coloured"
        },
        {
          "keys": "Update card",
          "action": "A dialog with labelled fields, inline errors and focus returned to the button on close"
        },
        {
          "keys": "Download",
          "action": "Each icon link is named 'Download invoice INV-…'"
        }
      ],
      "customization": [
        "plan, usage, card, contact, invoices",
        "currency, locale",
        "onChangePlan, onUpdateCard"
      ]
    },
    "examples": [
      "billing-1-demo",
      "billing-1-starter"
    ],
    "url": "https://ui.ballmac.com/components/billing-1"
  },
  "categories": [
    "blocks",
    "billing"
  ],
  "type": "registry:block"
}