{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "plan-picker",
  "type": "registry:ui",
  "title": "Plan Picker",
  "description": "Plan radio cards that price the change, prorated, before anyone confirms it.",
  "categories": [
    "commerce"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/plan-picker.tsx",
      "content": "\"use client\";\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowDown, ArrowUp, Loader } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type PickerPlan = {\n  id: string;\n  name: string;\n  /** Price per billing cycle. Upgrade or downgrade is decided by comparing prices. */\n  price: number;\n  description?: React.ReactNode;\n  disabled?: boolean;\n};\n\nexport type PlanChange = \"current\" | \"upgrade\" | \"downgrade\";\n\nexport type BillingCycle = {\n  /** Days left in the current cycle, including today. */\n  daysLeft: number;\n  /** Length of the cycle in days. */\n  days: number;\n  /** When the next cycle starts, already formatted: \"Oct 14\". */\n  renewsOn: string;\n};\n\n/** Money owed today when moving from one price to another partway through a cycle. Never negative. */\nexport function prorate(from: number, to: number, cycle: Pick<BillingCycle, \"daysLeft\" | \"days\">) {\n  if (to <= from || cycle.days <= 0) return 0;\n  return Math.round(((to - from) * Math.min(cycle.daysLeft, cycle.days) * 100) / cycle.days) / 100;\n}\n\nexport type PlanPickerProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  plans: PickerPlan[];\n  /** The plan the account is on now. */\n  currentPlan: string;\n  /** The chosen plan. Starts on the current plan. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (planId: string) => void;\n  cycle: BillingCycle;\n  currency?: string;\n  locale?: Intl.LocalesArgument;\n  /** Unit after each price. */\n  interval?: string;\n  /** Return a promise to show progress on the button; a rejection is shown under it. */\n  onConfirm?: (planId: string, change: Exclude<PlanChange, \"current\">) => void | Promise<unknown>;\n  errorMessage?: React.ReactNode;\n  disabled?: boolean;\n};\n\nexport function PlanPicker({\n  plans,\n  currentPlan,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  cycle,\n  currency = \"USD\",\n  locale,\n  interval = \"mo\",\n  onConfirm,\n  errorMessage = \"Couldn’t change your plan. You haven’t been charged. Try again.\",\n  disabled,\n  className,\n  ...rest\n}: PlanPickerProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue: defaultValue ?? currentPlan, onChange: onValueChange });\n  const reduce = useReducedMotion();\n  const ring = useId();\n  const noteId = useId();\n  const errorId = useId();\n  const [status, setStatus] = useState<\"idle\" | \"busy\" | \"done\" | \"failed\">(\"idle\");\n  const doneTimer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(doneTimer.current), []);\n\n  const loc = locale ?? \"en-US\";\n  const money = (n: number) =>\n    new Intl.NumberFormat(loc, { style: \"currency\", currency, maximumFractionDigits: n % 1 ? 2 : 0 }).format(n);\n  const current = plans.find((p) => p.id === currentPlan);\n  const selected = plans.find((p) => p.id === value) ?? current;\n  const changeOf = (p: PickerPlan): PlanChange =>\n    !current || p.id === current.id ? \"current\" : p.price > current.price ? \"upgrade\" : \"downgrade\";\n  const change = selected ? changeOf(selected) : \"current\";\n  const due = current && selected ? prorate(current.price, selected.price, cycle) : 0;\n\n  const label =\n    status === \"done\"\n      ? `Now on ${current?.name ?? \"\"}`\n      : change === \"upgrade\"\n        ? `Upgrade to ${selected?.name}`\n        : change === \"downgrade\"\n          ? `Downgrade to ${selected?.name}`\n          : \"Current plan\";\n\n  async function confirm() {\n    if (!selected || change === \"current\" || status === \"busy\") return;\n    setStatus(\"busy\");\n    try {\n      await onConfirm?.(selected.id, change);\n      setStatus(\"done\");\n      window.clearTimeout(doneTimer.current);\n      doneTimer.current = window.setTimeout(() => setStatus(\"idle\"), 1800);\n    } catch {\n      setStatus(\"failed\");\n    }\n  }\n\n  const locked = disabled || status === \"busy\";\n\n  return (\n    <div className={cn(\"flex w-full min-w-0 flex-col gap-3\", className)} {...rest}>\n      <RadioGroup\n        aria-label=\"Plan\"\n        aria-describedby={noteId}\n        value={value}\n        disabled={locked}\n        onValueChange={(next) => {\n          setValue(next as string);\n          if (status !== \"busy\") setStatus(\"idle\");\n        }}\n        className=\"grid gap-2\"\n      >\n        {plans.map((plan) => {\n          const kind = changeOf(plan);\n          const checked = plan.id === value;\n          return (\n            <Radio.Root\n              key={plan.id}\n              value={plan.id}\n              disabled={plan.disabled}\n              render={<div />}\n              data-change={kind}\n              className={cn(\n                \"group/plan relative flex min-w-0 select-none items-start gap-3 rounded-xl border p-3.5 text-left outline-none\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                \"transition-[background-color,border-color,scale,opacity] duration-150 ease-out-expo\",\n                checked ? \"border-transparent bg-hover\" : \"border-line-2 bg-raised\",\n                \"data-disabled:cursor-not-allowed data-disabled:opacity-50\",\n                \"not-data-disabled:motion-safe:active:scale-[0.99] not-data-disabled:active:duration-100\",\n                !checked && \"not-data-disabled:hover:border-fg-4\",\n              )}\n            >\n              {checked && (\n                <motion.span\n                  layoutId={ring}\n                  aria-hidden\n                  className=\"pointer-events-none absolute -inset-px rounded-xl border border-fg\"\n                  transition={reduce ? { duration: 0 } : spring.snappy}\n                />\n              )}\n              <RadioDot checked={checked} reduce={!!reduce} />\n\n              <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                <span className=\"flex min-w-0 items-center gap-2\">\n                  <span className=\"truncate text-[13px] font-medium leading-5 tracking-[-0.005em] text-fg\">{plan.name}</span>\n                  {kind === \"current\" && (\n                    <span className=\"shrink-0 rounded-full border border-line-2 px-1.5 text-[10.5px] leading-4 text-fg-2\">Current</span>\n                  )}\n                </span>\n                {plan.description != null && <span className=\"text-[12.5px] leading-[18px] text-fg-3\">{plan.description}</span>}\n              </span>\n\n              <span className=\"flex shrink-0 flex-col items-end gap-0.5 text-right\">\n                <span className=\"text-[13px] leading-5 text-fg tabular\">\n                  {money(plan.price)}\n                  <span className=\"text-fg-3\">/{interval}</span>\n                </span>\n                {kind !== \"current\" && (\n                  <span\n                    className={cn(\n                      \"flex items-center gap-1 text-[11.5px] leading-[18px] transition-colors duration-150\",\n                      checked ? \"text-fg-2\" : \"text-fg-3\",\n                    )}\n                  >\n                    {kind === \"upgrade\" ? <ArrowUp size={12} /> : <ArrowDown size={12} />}\n                    {kind === \"upgrade\" ? \"Upgrade\" : \"Downgrade\"}\n                  </span>\n                )}\n              </span>\n            </Radio.Root>\n          );\n        })}\n      </RadioGroup>\n\n      {/* What the change costs, in a box that keeps its height as the wording swaps. */}\n      <div\n        id={noteId}\n        aria-live=\"polite\"\n        className=\"relative flex min-h-[78px] items-center gap-3 overflow-hidden rounded-xl border border-line bg-frame px-3.5 py-3\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.div\n            key={change}\n            className=\"flex min-w-0 flex-1 items-center gap-3\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n            transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n          >\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"text-[12.5px] font-medium leading-[18px] text-fg\">\n                {change === \"upgrade\" ? \"Due today\" : change === \"downgrade\" ? \"Nothing due today\" : `You’re on ${current?.name ?? \"this plan\"}`}\n              </p>\n              <p className=\"text-[12px] leading-[17px] text-fg-3 text-pretty\">\n                {change === \"upgrade\"\n                  ? `Prorated for the ${cycle.daysLeft} ${cycle.daysLeft === 1 ? \"day\" : \"days\"} left in this cycle, then ${money(selected!.price)}/${interval} from ${cycle.renewsOn}.`\n                  : change === \"downgrade\"\n                    ? `${current?.name} stays active until ${cycle.renewsOn}, then ${money(selected!.price)}/${interval}.`\n                    : `Renews on ${cycle.renewsOn}. Choose a plan to see what changes.`}\n              </p>\n            </div>\n          </motion.div>\n        </AnimatePresence>\n        {/* The amount stays mounted so moving between two upgrades rolls the number instead of swapping it. */}\n        <span\n          aria-hidden={change !== \"upgrade\"}\n          className={cn(\n            \"shrink-0 text-[17px] font-medium tracking-[-0.02em] text-fg transition-opacity duration-150\",\n            change === \"upgrade\" ? \"opacity-100\" : \"opacity-0\",\n          )}\n        >\n          <NumberFlow value={due} locales={loc} format={{ style: \"currency\", currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }} className=\"tabular\" />\n        </span>\n      </div>\n\n      <div className=\"flex flex-col gap-2\">\n        <button\n          type=\"button\"\n          onClick={confirm}\n          disabled={disabled || (change === \"current\" && status !== \"done\")}\n          aria-busy={status === \"busy\" || undefined}\n          aria-describedby={status === \"failed\" ? errorId : undefined}\n          data-state={status}\n          className={cn(\n            \"relative inline-flex h-9 w-full select-none items-center justify-center overflow-hidden rounded-lg px-3 text-[13px] font-medium tracking-[-0.005em]\",\n            \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            \"transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n            change === \"downgrade\" && status !== \"done\"\n              ? \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\"\n              : \"bg-fg text-frame hover:bg-fg/90\",\n            \"disabled:border disabled:border-line disabled:bg-transparent disabled:text-fg-4 disabled:shadow-none\",\n            (status === \"busy\" || status === \"done\") && \"pointer-events-none\",\n          )}\n        >\n          <span className=\"relative grid w-full place-items-center\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={status === \"busy\" ? \"busy\" : label}\n                className=\"flex items-center gap-1.5 whitespace-nowrap\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: \"blur(2px)\", transition: { duration: 0.12, ease: ease.in } }}\n                transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n              >\n                {status === \"busy\" ? (\n                  <>\n                    <Loader size={16} className=\"animate-spin motion-reduce:animate-none\" aria-hidden />\n                    <span className=\"sr-only\">Changing plan</span>\n                  </>\n                ) : (\n                  <>\n                    {status === \"done\" && <DrawnCheck reduce={!!reduce} />}\n                    {label}\n                  </>\n                )}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        </button>\n        <AnimatePresence initial={false}>\n          {status === \"failed\" && (\n            <motion.div\n              className=\"overflow-hidden\"\n              initial={{ height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={{ height: 0, opacity: 0, transition: { duration: reduce ? 0 : 0.16, ease: ease.inOut } }}\n              transition={{ duration: reduce ? 0 : 0.22, ease: ease.inOut }}\n            >\n              <p id={errorId} role=\"alert\" className=\"text-center text-[12px] leading-4 text-danger text-balance\">\n                {errorMessage}\n              </p>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\nfunction RadioDot({ checked, reduce }: { checked: boolean; reduce: boolean }) {\n  return (\n    <span className=\"flex h-5 shrink-0 items-center\" aria-hidden>\n      <span\n        className={cn(\n          \"grid size-4 place-items-center rounded-full border transition-[background-color,border-color] duration-150\",\n          checked ? \"border-fg bg-fg\" : \"border-fg-4 bg-raised group-hover/plan:border-fg-3\",\n        )}\n      >\n        <motion.span\n          className=\"block size-1.5 rounded-full bg-frame\"\n          initial={false}\n          animate={checked ? { scale: 1, opacity: 1 } : { scale: 0.2, opacity: 0 }}\n          transition={\n            reduce\n              ? { opacity: { duration: 0.12 }, scale: { duration: 0 } }\n              : checked\n                ? { ...spring.bouncy, opacity: { duration: 0.08 } }\n                : { duration: 0.1, ease: ease.in }\n          }\n        />\n      </span>\n    </span>\n  );\n}\n\nfunction DrawnCheck({ reduce }: { reduce: boolean }) {\n  return (\n    <svg width={14} height={14} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.32, ease: ease.out, delay: 0.06 }}\n      />\n    </svg>\n  );\n}\n\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/plan-picker"
}
