{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "trial-banner",
  "type": "registry:ui",
  "title": "Trial Banner",
  "description": "Trial countdown with one cell per day that turns urgent in the final days.",
  "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/trial-banner.tsx",
      "content": "\"use client\";\nimport { Meter } from \"@base-ui/react/meter\";\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 { Loader, X } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type TrialTone = \"calm\" | \"urgent\" | \"ended\";\n\n/** Whole days from `now` until `end`, counting today as 0. Negative once the trial is over. Call it in an effect or on the server, never during render with Date.now(). */\nexport function daysUntil(end: Date, now: Date) {\n  const day = 86_400_000;\n  const a = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());\n  const b = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate());\n  return Math.round((b - a) / day);\n}\n\nexport type TrialBannerProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** Days until the trial ends: 0 is today, below 0 is over. */\n  daysLeft: number;\n  /** Length of the trial in days. */\n  totalDays: number;\n  /** The plan being trialled, named in the message. */\n  plan?: string;\n  /** The end date, already formatted: \"Sep 27\". Shown beside the countdown. */\n  endsOn?: string;\n  /** One line under the message: what they keep by upgrading. */\n  description?: React.ReactNode;\n  /** Days left at which the banner turns urgent. */\n  urgentAt?: number;\n  /** \"bar\" spans the top of a page; \"card\" sits in a sidebar. */\n  variant?: \"bar\" | \"card\";\n  upgradeLabel?: string;\n  /** Return a promise to show progress on the button. */\n  onUpgrade?: () => void | Promise<unknown>;\n  /** Visible, controlled. Dismissing is only offered while the trial is calm. */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  dismissible?: boolean;\n  /** Where keyboard focus goes after dismissing, since the dismiss button disappears with the banner. */\n  returnFocus?: React.RefObject<HTMLElement | null>;\n};\n\nexport function TrialBanner({\n  daysLeft,\n  totalDays,\n  plan,\n  endsOn,\n  description,\n  urgentAt = 2,\n  variant = \"bar\",\n  upgradeLabel,\n  onUpgrade,\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  dismissible = true,\n  returnFocus,\n  className,\n  ...rest\n}: TrialBannerProps) {\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const reduce = useReducedMotion();\n  const titleId = useId();\n  const [busy, setBusy] = useState(false);\n  const alive = useRef(true);\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  const tone: TrialTone = daysLeft < 0 ? \"ended\" : daysLeft <= urgentAt ? \"urgent\" : \"calm\";\n  const card = variant === \"card\";\n  const name = plan ? `${plan} trial` : \"trial\";\n  const used = Math.min(totalDays, Math.max(0, totalDays - Math.max(0, daysLeft)));\n  // A reminder put away while calm comes back by itself once the trial turns urgent.\n  const shown = open || tone !== \"calm\";\n  const cta = upgradeLabel ?? (tone === \"ended\" ? \"Choose a plan\" : \"Upgrade\");\n\n  async function upgrade() {\n    if (busy) return;\n    const result = onUpgrade?.();\n    if (!(result instanceof Promise)) return;\n    setBusy(true);\n    try {\n      await result;\n    } finally {\n      if (alive.current) setBusy(false);\n    }\n  }\n\n  // The countdown keeps one sentence shape so the number can roll in place.\n  const message =\n    tone === \"ended\" ? (\n      <>Your {name} has ended</>\n    ) : daysLeft === 0 ? (\n      <>Your {name} ends today</>\n    ) : daysLeft === 1 ? (\n      <>Your {name} ends tomorrow</>\n    ) : (\n      <>\n        Your {name} ends in <NumberFlow value={daysLeft} className=\"tabular\" /> days\n      </>\n    );\n\n  return (\n    <AnimatePresence initial={false}>\n      {shown && (\n        <motion.section\n          key=\"trial\"\n          aria-labelledby={titleId}\n          data-tone={tone}\n          data-variant={variant}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n          animate={{ opacity: 1, height: \"auto\" }}\n          exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, height: 0, transition: { duration: 0.2, ease: ease.inOut, opacity: { duration: 0.12 } } }}\n          transition={{ duration: reduce ? 0.15 : 0.26, ease: ease.out }}\n          className={cn(\"@container w-full min-w-0 overflow-hidden\", className)}\n          {...(rest as React.ComponentProps<typeof motion.section>)}\n        >\n          <div\n            className={cn(\n              \"border transition-[background-color,border-color] duration-300 ease-out\",\n              card ? \"flex flex-col gap-3 rounded-xl p-3\" : \"flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg py-2 pl-3 pr-2\",\n              tone === \"calm\" && \"border-line-2 bg-raised\",\n              tone === \"urgent\" && \"border-warning/25 bg-warning-soft\",\n              tone === \"ended\" && \"border-danger/25 bg-danger-soft\",\n            )}\n          >\n            <div className={cn(\"flex min-w-0 flex-1 gap-2.5\", card ? \"items-start\" : \"items-center\")}>\n              <ClockGlyph tone={tone} reduce={!!reduce} />\n              <div className=\"min-w-0 flex-1\">\n                <h3 id={titleId} className=\"text-[13px] font-medium leading-[18px] tracking-[-0.005em] text-fg text-balance\">\n                  {message}\n                  {endsOn && tone !== \"ended\" && !card && <span className=\"font-normal text-fg-3\"> · {endsOn}</span>}\n                </h3>\n                {(description != null || (card && endsOn && tone !== \"ended\")) && (\n                  <p className=\"text-[12px] leading-4 text-fg-3 text-pretty\">\n                    {description ?? `Ends ${endsOn}`}\n                  </p>\n                )}\n              </div>\n            </div>\n\n            <DayMeter used={used} total={totalDays} urgentAt={urgentAt} tone={tone} card={card} />\n\n            <div className={cn(\"flex items-center gap-1\", card ? \"\" : \"ml-auto shrink-0\")}>\n              <button\n                type=\"button\"\n                onClick={upgrade}\n                aria-busy={busy || undefined}\n                className={cn(\n                  \"relative inline-flex h-7 select-none items-center justify-center rounded-md px-2.5 text-[12.5px] font-medium tracking-[-0.005em]\",\n                  card && \"w-full\",\n                  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                  \"bg-fg text-frame transition-[background-color,scale] duration-150 ease-out hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n                  busy && \"pointer-events-none\",\n                )}\n              >\n                <span className={cn(\"transition-opacity duration-150\", busy && \"opacity-0\")}>{cta}</span>\n                {busy && (\n                  <span className=\"absolute inset-0 grid place-items-center\">\n                    <Loader size={14} className=\"animate-spin motion-reduce:animate-none\" aria-hidden />\n                    <span className=\"sr-only\">Opening checkout</span>\n                  </span>\n                )}\n              </button>\n              {/* Only a calm trial can be put away; an ending one should stay in view. */}\n              {dismissible && tone === \"calm\" && !card && (\n                <button\n                  type=\"button\"\n                  aria-label=\"Dismiss trial reminder\"\n                  onClick={() => {\n                    setOpen(false);\n                    returnFocus?.current?.focus({ preventScroll: true });\n                  }}\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            </div>\n          </div>\n        </motion.section>\n      )}\n    </AnimatePresence>\n  );\n}\n\n// One segment per trial day, so \"5 days left\" is five empty cells rather than a percentage.\n// Past a month it becomes a plain bar.\nfunction DayMeter({ used, total, urgentAt, tone, card }: { used: number; total: number; urgentAt: number; tone: TrialTone; card: boolean }) {\n  const segmented = total <= 31;\n  const fill = tone === \"calm\" ? \"bg-fg\" : tone === \"urgent\" ? \"bg-warning\" : \"bg-danger\";\n  return (\n    <Meter.Root\n      value={used}\n      max={total}\n      aria-label=\"Trial days used\"\n      getAriaValueText={() => `${used} of ${total} days used`}\n      // In a narrow bar the meter drops to its own full-width row under the message; from 36rem it sits inline.\n      className={cn(\"min-w-0\", card ? \"w-full\" : \"order-last basis-full pb-1 pl-[26px] pr-1 @xl:order-none @xl:w-32 @xl:shrink-0 @xl:basis-auto @xl:p-0\")}\n    >\n      <Meter.Track className={cn(\"flex h-1 w-full gap-[2px] overflow-hidden\", !segmented && \"rounded-full bg-fg/10\")}>\n        {segmented ? (\n          Array.from({ length: total }, (_, i) => {\n            const on = i < used;\n            // The last urgentAt + 1 days are marked so the deadline is visible before it arrives.\n            const late = i >= total - (urgentAt + 1);\n            return (\n              <span\n                key={i}\n                aria-hidden\n                className={cn(\n                  \"h-full min-w-0 flex-1 rounded-full transition-colors duration-300 ease-out\",\n                  on ? fill : late ? \"bg-fg/20\" : \"bg-fg/10\",\n                )}\n              />\n            );\n          })\n        ) : (\n          <Meter.Indicator className={cn(\"rounded-full transition-[width,background-color] duration-300 ease-out\", fill)} />\n        )}\n      </Meter.Track>\n    </Meter.Root>\n  );\n}\n\n// A clock whose hand sweeps once when the trial turns urgent: the moment worth noticing.\nfunction ClockGlyph({ tone, reduce }: { tone: TrialTone; reduce: boolean }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"grid h-[18px] shrink-0 place-items-center transition-colors duration-300\",\n        tone === \"calm\" ? \"text-fg-2\" : tone === \"urgent\" ? \"text-warning\" : \"text-danger\",\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        <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n        <motion.path\n          d=\"M8 5v3\"\n          style={{ originX: \"50%\", originY: \"100%\" }}\n          initial={false}\n          animate={{ rotate: tone === \"calm\" || reduce ? 0 : 360 }}\n          transition={{ duration: 0.7, ease: ease.inOut }}\n        />\n        <path d=\"M8 8l2 1.25\" />\n      </svg>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/trial-banner"
}
