{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "upgrade-prompt",
  "type": "registry:ui",
  "title": "Upgrade Prompt",
  "description": "Inline paywall at a limit, with a padlock that lifts as you reach Upgrade.",
  "categories": [
    "commerce"
  ],
  "dependencies": [
    "@base-ui/react",
    "Not now",
    "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/upgrade-prompt.tsx",
      "content": "\"use client\";\nimport { Meter } from \"@base-ui/react/meter\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, Loader, X } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type UpgradePromptProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** What happened, in plain words: \"You’ve used all 3 projects on Free\". */\n  title: React.ReactNode;\n  /** Why it matters or what the plan changes, one sentence. */\n  description?: React.ReactNode;\n  /** The limit that was hit, drawn as a full meter. */\n  usage?: { used: number; limit: number; label: React.ReactNode };\n  /** The plan that unlocks it. */\n  plan: { name: string; price?: React.ReactNode; perks?: React.ReactNode[] };\n  /** \"card\" is a block with the plan's perks; \"inline\" is one row for lists, tables and toolbars. */\n  variant?: \"card\" | \"inline\";\n  upgradeLabel?: string;\n  dismissLabel?: string;\n  /** Return a promise to show progress on the button; a rejection is shown beside the actions. */\n  onUpgrade?: () => void | Promise<unknown>;\n  /** Shown instead of the button once onUpgrade resolves. */\n  upgradedLabel?: string;\n  errorMessage?: React.ReactNode;\n  /** Visible. Set false from \"Not now\"; control it to bring the prompt back later. */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Hide \"Not now\" when the limit blocks the task outright. */\n  dismissible?: boolean;\n  /** Where keyboard focus goes after \"Not now\" removes the prompt, usually the control that hit the limit. */\n  returnFocus?: React.RefObject<HTMLElement | null>;\n};\n\nexport function UpgradePrompt({\n  title,\n  description,\n  usage,\n  plan,\n  variant = \"card\",\n  upgradeLabel,\n  dismissLabel = \"Not now\",\n  onUpgrade,\n  upgradedLabel,\n  errorMessage = \"Couldn’t open checkout. Try again.\",\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  dismissible = true,\n  returnFocus,\n  className,\n  ...rest\n}: UpgradePromptProps) {\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const reduce = useReducedMotion();\n  const titleId = useId();\n  const errorId = useId();\n  const [status, setStatus] = useState<\"idle\" | \"busy\" | \"done\" | \"failed\">(\"idle\");\n  // Clip only while the height animates, so the card's shadow isn't cut off at rest.\n  const [clip, setClip] = useState(false);\n  const mounted = useRef(true);\n  useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n    };\n  }, []);\n\n  const label = upgradeLabel ?? `Upgrade to ${plan.name}`;\n  const doneLabel = upgradedLabel ?? `You’re on ${plan.name}`;\n  const inline = variant === \"inline\";\n\n  function dismiss() {\n    setOpen(false);\n    // The button that had focus is about to unmount; hand focus somewhere that still exists.\n    returnFocus?.current?.focus({ preventScroll: true });\n  }\n\n  async function upgrade() {\n    if (status === \"busy\" || status === \"done\") return;\n    setStatus(\"busy\");\n    try {\n      await onUpgrade?.();\n      if (mounted.current) setStatus(\"done\");\n    } catch {\n      if (mounted.current) setStatus(\"failed\");\n    }\n  }\n\n  return (\n    <AnimatePresence initial={false}>\n      {open && (\n        <motion.section\n          key=\"prompt\"\n          aria-labelledby={titleId}\n          data-variant={variant}\n          data-state={status}\n          // Leaving collapses its own height so whatever sits below closes the gap instead of jumping.\n          initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, scale: 0.98 }}\n          animate={{ opacity: 1, height: \"auto\", scale: 1 }}\n          exit={\n            reduce\n              ? { opacity: 0, transition: { duration: 0.12 } }\n              : { opacity: 0, height: 0, scale: 0.98, transition: { duration: 0.2, ease: ease.inOut, opacity: { duration: 0.12 } } }\n          }\n          transition={{ duration: reduce ? 0.15 : 0.26, ease: ease.out }}\n          onAnimationStart={() => setClip(true)}\n          onAnimationComplete={() => setClip(false)}\n          className={cn(\"group/prompt w-full min-w-0 origin-top\", clip && \"overflow-hidden\", className)}\n          {...(rest as React.ComponentProps<typeof motion.section>)}\n        >\n          <div\n            className={cn(\n              \"rounded-xl border border-line-2 bg-raised shadow-[var(--shadow)]\",\n              inline ? \"flex flex-wrap items-center gap-x-3 gap-y-2 py-2 pl-3 pr-2\" : \"flex flex-col gap-4 p-4\",\n            )}\n          >\n            <div className={cn(\"flex min-w-0 gap-3\", inline ? \"min-w-[min(15rem,100%)] flex-1 items-center\" : \"items-start\")}>\n              <LockBadge open={status === \"done\"} small={inline} />\n              <div className=\"min-w-0 flex-1\">\n                <h3\n                  id={titleId}\n                  className={cn(\"font-medium tracking-[-0.01em] text-fg text-balance\", inline ? \"text-[13px] leading-[18px]\" : \"text-[14px] leading-5\")}\n                >\n                  {title}\n                </h3>\n                {description != null && (\n                  <p className={cn(\"text-fg-3 text-pretty\", inline ? \"text-[12px] leading-4\" : \"mt-1 text-[12.5px] leading-[18px]\")}>{description}</p>\n                )}\n              </div>\n            </div>\n\n            {!inline && usage && <Usage {...usage} reduce={!!reduce} />}\n\n            {!inline && (plan.perks?.length || plan.price != null) ? (\n              <div className=\"rounded-lg border border-line bg-frame p-3\">\n                <div className=\"flex items-baseline justify-between gap-3\">\n                  <p className=\"text-[12.5px] font-medium text-fg\">{plan.name} includes</p>\n                  {plan.price != null && <p className=\"shrink-0 text-[12px] text-fg-3 tabular\">{plan.price}</p>}\n                </div>\n                {plan.perks?.length ? (\n                  <ul className=\"mt-2 grid gap-1.5\">\n                    {plan.perks.map((perk, i) => (\n                      <li key={i} className=\"flex items-start gap-2 text-[12.5px] leading-[18px] text-fg-2\">\n                        <span className=\"flex h-[18px] shrink-0 items-center\">\n                          <Check size={14} className=\"text-fg\" />\n                        </span>\n                        <span className=\"min-w-0\">{perk}</span>\n                      </li>\n                    ))}\n                  </ul>\n                ) : null}\n              </div>\n            ) : null}\n\n            <div className={cn(\"flex items-center gap-2\", inline ? \"ml-auto shrink-0\" : \"flex-wrap\")}>\n              <button\n                type=\"button\"\n                data-upgrade\n                onClick={upgrade}\n                aria-busy={status === \"busy\" || undefined}\n                aria-describedby={status === \"failed\" ? errorId : undefined}\n                className={cn(\n                  \"relative inline-flex select-none items-center justify-center rounded-lg font-medium tracking-[-0.005em]\",\n                  inline ? \"h-7 px-2.5 text-[12.5px]\" : \"h-8 px-3 text-[13px]\",\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,color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n                  status === \"done\" ? \"pointer-events-none bg-success-soft text-success\" : \"bg-fg text-frame hover:bg-fg/90\",\n                  status === \"busy\" && \"pointer-events-none\",\n                )}\n              >\n                {/* Every label shares one cell, so the button keeps the width of the longest. */}\n                <span className=\"grid\">\n                  {[label, doneLabel].map((l) => (\n                    <span key={l} aria-hidden className=\"invisible col-start-1 row-start-1 flex items-center gap-1.5 whitespace-nowrap\">\n                      <span className=\"size-3.5\" />\n                      {l}\n                    </span>\n                  ))}\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    <motion.span\n                      key={status === \"done\" ? \"done\" : \"idle\"}\n                      className=\"col-start-1 row-start-1 flex items-center justify-center gap-1.5 whitespace-nowrap\"\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n                      animate={{ opacity: status === \"busy\" ? 0 : 1, y: 0, filter: \"blur(0px)\" }}\n                      exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n                      transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n                    >\n                      {status === \"done\" ? <Check size={14} /> : null}\n                      {status === \"done\" ? doneLabel : label}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n                {status === \"busy\" && (\n                  <span className=\"absolute inset-0 grid place-items-center\">\n                    <Loader size={16} className=\"animate-spin motion-reduce:animate-none\" aria-hidden />\n                    <span className=\"sr-only\">Opening checkout</span>\n                  </span>\n                )}\n              </button>\n\n              {dismissible && status !== \"done\" &&\n                (inline ? (\n                  <button\n                    type=\"button\"\n                    aria-label={dismissLabel}\n                    onClick={dismiss}\n                    className={cn(\n                      \"relative grid size-7 place-items-center rounded-md text-fg-3 outline-none\",\n                      \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n                      \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                      \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n                    )}\n                  >\n                    <X size={14} />\n                  </button>\n                ) : (\n                  <button\n                    type=\"button\"\n                    onClick={dismiss}\n                    className={cn(\n                      \"inline-flex h-8 select-none items-center rounded-lg px-3 text-[13px] font-medium tracking-[-0.005em] text-fg-2 outline-none\",\n                      \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75\",\n                      \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                    )}\n                  >\n                    {dismissLabel}\n                  </button>\n                ))}\n            </div>\n\n            {status === \"failed\" && (\n              <p id={errorId} role=\"alert\" className={cn(\"text-[12px] leading-4 text-danger\", inline ? \"basis-full pb-1 pl-10\" : \"-mt-2\")}>\n                {errorMessage}\n              </p>\n            )}\n          </div>\n        </motion.section>\n      )}\n    </AnimatePresence>\n  );\n}\n\nfunction Usage({ used, limit, label, reduce }: { used: number; limit: number; label: React.ReactNode; reduce: boolean }) {\n  const full = used >= limit;\n  const pct = Math.min(100, (used / Math.max(1, limit)) * 100);\n  return (\n    <Meter.Root value={Math.min(used, limit)} max={limit} className=\"flex flex-col gap-1.5\" aria-valuetext={`${used} of ${limit} used`}>\n      <div className=\"flex items-baseline justify-between gap-3 text-[12px] leading-4\">\n        <Meter.Label className=\"text-fg-2\">{label}</Meter.Label>\n        <span className={cn(\"tabular\", full ? \"text-warning\" : \"text-fg-3\")}>\n          {used} of {limit}\n        </span>\n      </div>\n      <Meter.Track className=\"relative h-1 overflow-hidden rounded-full bg-fg/10\">\n        {/* Fills once on arrival, so the limit reads as reached rather than just stated. */}\n        <motion.div\n          aria-hidden\n          className={cn(\"absolute inset-y-0 left-0 origin-left rounded-full\", full ? \"bg-warning\" : \"bg-fg\")}\n          style={{ width: `${pct}%` }}\n          initial={reduce ? false : { scaleX: 0 }}\n          animate={{ scaleX: 1 }}\n          transition={{ duration: 0.6, ease: ease.out, delay: 0.1 }}\n        />\n      </Meter.Track>\n    </Meter.Root>\n  );\n}\n\n// A padlock whose shackle lifts a pixel when Upgrade is hovered or focused, and opens once the upgrade lands.\nfunction LockBadge({ open, small }: { open: boolean; small: boolean }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"grid shrink-0 place-items-center border border-line-2 bg-frame text-fg-2\",\n        small ? \"size-7 rounded-md\" : \"size-8 rounded-lg\",\n        open && \"text-success\",\n        \"transition-colors duration-200\",\n      )}\n    >\n      <svg width={16} height={16} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <rect x=\"3.5\" y=\"7\" width=\"9\" height=\"6.5\" rx=\"1.5\" />\n        <path\n          d=\"M5.5 7V5.25a2.5 2.5 0 0 1 5 0V7\"\n          className={cn(\n            \"[transform-box:fill-box] origin-[100%_100%] transition-transform duration-200 ease-out-expo motion-reduce:transition-none\",\n            open\n              ? \"-translate-y-[2px] rotate-[18deg]\"\n              : \"group-has-[[data-upgrade]:hover]/prompt:-translate-y-[1.5px] group-has-[[data-upgrade]:focus-visible]/prompt:-translate-y-[1.5px]\",\n          )}\n        />\n        <path d=\"M8 9.75v1.25\" />\n      </svg>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/upgrade-prompt"
}
