{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pricing-4",
  "title": "Pricing 4: one-time license vs subscription",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A pricing section for Mac apps that sell both ways: a Buy once / Subscribe switch, license size cards and a cumulative-cost chart that computes the break-even year.",
  "dependencies": [
    "lucide-react",
    "recharts@^3"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/chart.json",
    "https://ui.ballmac.com/r/label.json",
    "https://ui.ballmac.com/r/radio-group.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/switch.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/pricing-4/pricing-4.tsx",
      "content": "// Ballmac UI: Pricing 4. https://ui.ballmac.com/blocks/pricing-4\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ShieldCheck } from \"lucide-react\"\nimport { CartesianGrid, Line, LineChart, XAxis, YAxis } from \"recharts\"\n\nimport { buttonVariants } from \"@/components/ballmac/button\"\nimport { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig } from \"@/components/ballmac/chart\"\nimport { Label } from \"@/components/ballmac/label\"\nimport { RadioGroup, RadioGroupOption } from \"@/components/ballmac/radio-group\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Switch } from \"@/components/ballmac/switch\"\nimport { cn } from \"@/lib/utils\"\n\ntype Pricing4Tier = {\n  /** Stable key, e.g. \"solo\". */\n  id: string\n  /** Tier name, e.g. \"1 Mac\". */\n  name: string\n  /** One short line, e.g. \"For your own Mac\". */\n  description?: string\n  /** One-time price for this tier, in whole currency units. */\n  once: number\n  /** Subscription price per year for this tier. */\n  yearly: number\n}\n\ntype Pricing4Mode = \"once\" | \"subscribe\"\n\ntype Pricing4Props = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** Section heading. */\n  title?: string\n  /** One sentence under the heading. */\n  description?: string\n  /** License sizes to choose from. */\n  tiers?: Pricing4Tier[]\n  /** Which tier starts selected (an `id`). Defaults to the second tier. */\n  defaultTier?: string\n  /** Which way to pay starts selected. */\n  defaultMode?: Pricing4Mode\n  /** Yearly price of optional updates after the first year of a one-time license, per tier id. A single number applies to every tier. */\n  renewal?: number | Record<string, number>\n  /** Number of years the cost chart covers (2 to 8). */\n  years?: number\n  /** ISO 4217 currency code. */\n  currency?: string\n  /** What the one-time license includes. */\n  onceIncludes?: string[]\n  /** What the subscription includes. */\n  subscribeIncludes?: string[]\n  /** Where the buy button goes. Receives the tier id and the way to pay. */\n  href?: (tierId: string, mode: Pricing4Mode) => string\n  /** Money-back line under the button. Pass null to hide it. */\n  guarantee?: string | null\n}\n\nconst defaultTiers: Pricing4Tier[] = [\n  { id: \"solo\", name: \"1 Mac\", description: \"Just for you\", once: 49, yearly: 39 },\n  { id: \"trio\", name: \"3 Macs\", description: \"Desk, laptop and studio\", once: 99, yearly: 79 },\n  { id: \"five\", name: \"5 Macs\", description: \"A small team\", once: 149, yearly: 119 },\n]\n\nconst defaultOnce = [\"Yours to keep, no account needed\", \"A full year of updates\", \"Every Mac you license, forever\"]\nconst defaultSubscribe = [\"Every update, always current\", \"Priority email support\", \"Cancel any time, keep your data\"]\n\nconst MODE_COPY: Record<Pricing4Mode, { label: string; cta: string; period: string }> = {\n  once: { label: \"Buy once\", cta: \"Buy a license\", period: \"one payment\" },\n  subscribe: { label: \"Subscribe\", cta: \"Start subscription\", period: \"per year\" },\n}\n\nfunction Pricing4({\n  title = \"Pay once, or stay current.\",\n  description = \"Own it outright with a one-time license, or subscribe for every update. See exactly when each one pays off.\",\n  tiers = defaultTiers,\n  defaultTier,\n  defaultMode = \"once\",\n  renewal = 19,\n  years: yearsProp = 5,\n  currency = \"USD\",\n  onceIncludes = defaultOnce,\n  subscribeIncludes = defaultSubscribe,\n  href = () => \"#\",\n  guarantee = \"30-day money-back guarantee\",\n  className,\n  ...props\n}: Pricing4Props) {\n  const years = Math.min(8, Math.max(2, Math.round(yearsProp)))\n  const [mode, setMode] = React.useState<Pricing4Mode>(defaultMode)\n  const [tierId, setTierId] = React.useState(defaultTier ?? tiers[1]?.id ?? tiers[0]?.id ?? \"\")\n  const [keepUpdates, setKeepUpdates] = React.useState(false)\n  const baseId = React.useId()\n\n  const tier = tiers.find((t) => t.id === tierId) ?? tiers[0]\n  const money = React.useMemo(() => new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency, maximumFractionDigits: 0 }), [currency])\n\n  if (!tier) return null\n\n  const renew = typeof renewal === \"number\" ? renewal : (renewal[tier.id] ?? 0)\n  const onceAt = (y: number) => tier.once + (keepUpdates ? renew * (y - 1) : 0)\n  const subAt = (y: number) => tier.yearly * y\n  const data = Array.from({ length: years }, (_, i) => ({ year: `Year ${i + 1}`, once: onceAt(i + 1), subscribe: subAt(i + 1) }))\n  const breakEven = data.findIndex((_, i) => subAt(i + 1) >= onceAt(i + 1))\n  const breakYear = breakEven === -1 ? null : breakEven + 1\n  const saved = subAt(years) - onceAt(years)\n\n  const headline =\n    breakYear === null\n      ? `Subscribing costs less than buying over ${years} years`\n      : breakYear === 1\n        ? \"A one-time license is cheaper from day one\"\n        : `A one-time license pays for itself in year ${breakYear}`\n  const detail =\n    breakYear === null\n      ? `The subscription totals ${money.format(subAt(years))} against ${money.format(onceAt(years))} for the license, so it is the cheaper way to pay.`\n      : `By year ${years} you will have paid ${money.format(onceAt(years))} instead of ${money.format(subAt(years))}${saved > 0 ? `, saving ${money.format(saved)}` : \"\"}.`\n\n  const config = {\n    once: { label: keepUpdates ? \"Buy once, with updates\" : \"Buy once\", color: \"var(--chart-1)\" },\n    subscribe: { label: \"Subscribe\", color: \"var(--chart-3)\" },\n  } satisfies ChartConfig\n\n  const price = mode === \"once\" ? tier.once : tier.yearly\n  const includes = mode === \"once\" ? onceIncludes : subscribeIncludes\n  const modeLabelId = `${baseId}-mode`\n  const tierLabelId = `${baseId}-tier`\n\n  return (\n    <section data-slot=\"pricing-4\" className={cn(\"mx-auto max-w-6xl px-4 py-20 sm:px-6 md:py-28\", className)} {...props}>\n      <div className=\"mx-auto max-w-2xl text-center\">\n        <h2 className=\"text-3xl font-semibold tracking-[-0.035em] text-balance sm:text-4xl lg:text-5xl\">{title}</h2>\n        <p className=\"text-muted-foreground mt-4 text-lg text-pretty\">{description}</p>\n      </div>\n\n      <div className=\"mt-12 grid items-start gap-5 lg:grid-cols-[minmax(0,26rem)_minmax(0,1fr)]\">\n        <div className=\"bg-card rounded-3xl border p-5 shadow-sm sm:p-6\">\n          <p id={modeLabelId} className=\"sr-only\">Way to pay</p>\n          <SegmentedControl aria-labelledby={modeLabelId} value={mode} onValueChange={(v) => setMode(v as Pricing4Mode)} fullWidth>\n            <SegmentedControlItem value=\"once\">{MODE_COPY.once.label}</SegmentedControlItem>\n            <SegmentedControlItem value=\"subscribe\">{MODE_COPY.subscribe.label}</SegmentedControlItem>\n          </SegmentedControl>\n\n          <p id={tierLabelId} className=\"text-muted-foreground mt-6 text-xs font-semibold tracking-wide uppercase\">License size</p>\n          <RadioGroup aria-labelledby={tierLabelId} value={tier.id} onValueChange={setTierId} className=\"mt-2 grid gap-2\">\n            {tiers.map((t) => (\n              <RadioGroupOption key={t.id} value={t.id} title={t.name} description={t.description}>\n                <span className=\"text-sm font-semibold tabular-nums\">{money.format(mode === \"once\" ? t.once : t.yearly)}</span>\n              </RadioGroupOption>\n            ))}\n          </RadioGroup>\n\n          <div className=\"mt-6 flex items-baseline gap-2\" aria-live=\"polite\">\n            <span className=\"text-5xl font-semibold tracking-[-0.045em] tabular-nums\">{money.format(price)}</span>\n            <span className=\"text-muted-foreground text-sm\">{MODE_COPY[mode].period}</span>\n          </div>\n          <ul className=\"mt-4 grid gap-2\">\n            {includes.map((item) => (\n              <li key={item} className=\"flex items-start gap-2 text-sm\">\n                <Check className=\"text-chart-2 mt-0.5 size-4 shrink-0\" aria-hidden=\"true\" />\n                {item}\n              </li>\n            ))}\n          </ul>\n          <a href={href(tier.id, mode)} className={buttonVariants({ shape: \"pill\", size: \"lg\", className: \"mt-6 w-full\" })}>\n            {MODE_COPY[mode].cta}\n          </a>\n          {guarantee && (\n            <p className=\"text-muted-foreground mt-3 flex items-center justify-center gap-1.5 text-xs\">\n              <ShieldCheck className=\"size-3.5\" aria-hidden=\"true\" />\n              {guarantee}\n            </p>\n          )}\n        </div>\n\n        <div className=\"bg-card rounded-3xl border p-5 sm:p-6\">\n          <p className=\"text-muted-foreground text-xs font-semibold tracking-wide uppercase\">What you pay over time · {tier.name}</p>\n          <h3 className=\"mt-2 text-xl font-semibold tracking-tight text-balance sm:text-2xl\" aria-live=\"polite\">{headline}</h3>\n          <p className=\"text-muted-foreground mt-1.5 text-sm text-pretty\">{detail}</p>\n\n          <ChartContainer\n            config={config}\n            label={`Total cost over ${years} years: one-time license against subscription`}\n            summary={`${headline}. ${detail}`}\n            className=\"mt-5 aspect-[16/10] w-full\"\n          >\n            <LineChart data={data} margin={{ left: 0, right: 12, top: 8 }} accessibilityLayer>\n              <CartesianGrid vertical={false} />\n              <XAxis dataKey=\"year\" tickLine={false} axisLine={false} tickMargin={8} />\n              <YAxis tickLine={false} axisLine={false} width={44} tickFormatter={(v: number) => money.format(v)} />\n              <ChartTooltip cursor={false} content={<ChartTooltipContent indicator=\"line\" formatter={(v) => money.format(Number(v))} />} />\n              <Line dataKey=\"subscribe\" type=\"monotone\" stroke=\"var(--color-subscribe)\" strokeWidth={2} dot={{ r: 3 }} isAnimationActive={false} />\n              <Line dataKey=\"once\" type=\"monotone\" stroke=\"var(--color-once)\" strokeWidth={2} dot={{ r: 3 }} isAnimationActive={false} />\n              <ChartLegend content={<ChartLegendContent />} />\n            </LineChart>\n          </ChartContainer>\n\n          {renew > 0 && (\n            <div className=\"mt-4 flex items-center justify-between gap-4 rounded-xl border p-3\">\n              <Label htmlFor={`${baseId}-keep`} className=\"text-sm leading-snug\">\n                Keep updates after year 1\n                <span className=\"text-muted-foreground block text-xs font-normal\">{money.format(renew)} per year, optional</span>\n              </Label>\n              <Switch id={`${baseId}-keep`} checked={keepUpdates} onCheckedChange={setKeepUpdates} />\n            </div>\n          )}\n        </div>\n      </div>\n    </section>\n  )\n}\n\nexport { Pricing4, type Pricing4Props, type Pricing4Tier, type Pricing4Mode }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/pricing-4/pricing-4.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "pricing",
      "license",
      "subscription",
      "one-time",
      "mac",
      "chart",
      "break-even"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "Pricing for apps with a one-time license and a subscription. Pass tiers=[{ id, name, description?, once, yearly }], optional renewal (price of paid updates after year 1), years, currency and href(tierId, mode). The chart and the 'pays for itself in year N' message are computed from the numbers.",
      "whenToUse": [
        "Mac and desktop apps that offer a perpetual license next to a subscription",
        "When you want to show buyers the long-run cost honestly"
      ],
      "whenNotToUse": [
        "Monthly SaaS plans with feature tiers (pricing-1 or pricing-3)"
      ],
      "composesWith": [
        "download-1",
        "faq-2",
        "cta-1",
        "showcase-1"
      ],
      "a11y": [
        {
          "keys": "Arrow Left / Right on the way-to-pay switch",
          "action": "Switches between Buy once and Subscribe"
        },
        {
          "keys": "Arrow Up / Down on license size",
          "action": "Selects another size and updates the price and chart"
        },
        {
          "keys": "Space on the updates switch",
          "action": "Includes optional update renewals in the one-time cost"
        }
      ],
      "customization": [
        "renewal: a number, or { [tierId]: number }; 0 hides the updates switch",
        "href: build the checkout link from the tier and mode",
        "onceIncludes / subscribeIncludes: the checklist for each way to pay"
      ]
    },
    "examples": [
      "pricing-4-demo",
      "pricing-4-custom"
    ],
    "url": "https://ui.ballmac.com/components/pricing-4"
  },
  "categories": [
    "blocks",
    "pricing"
  ],
  "type": "registry:block"
}