{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "promo-code",
  "type": "registry:ui",
  "title": "Promo Code",
  "description": "A code field that opens in place, checks quietly and lands as a chip.",
  "categories": [
    "commerce"
  ],
  "dependencies": [
    "@base-ui/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/promo-code.tsx",
      "content": "\"use client\";\nimport { Collapsible } from \"@base-ui/react/collapsible\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, ChevronDown, Loader, Tag, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type AppliedPromo = {\n  /** The code as the store knows it, e.g. \"SPRING20\". */\n  code: string;\n  /** What it takes off, already formatted: \"−€18\", \"20% off\", \"Free shipping\". */\n  label: string;\n};\n\nexport type PromoCodeProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  /** The applied code, or null. */\n  value?: AppliedPromo | null;\n  defaultValue?: AppliedPromo | null;\n  onValueChange?: (value: AppliedPromo | null) => void;\n  /**\n   * Checks a code. Resolve with the applied promo; reject with an Error whose message says what's\n   * wrong (\"SPRING10 expired on 31 May\"). The code arrives trimmed and uppercased.\n   */\n  onApply: (code: string) => Promise<AppliedPromo>;\n  /** Called after the applied code is removed. */\n  onRemove?: (value: AppliedPromo) => void;\n  /** Start with the field open. */\n  defaultOpen?: boolean;\n  label?: string;\n  placeholder?: string;\n  applyLabel?: string;\n  disabled?: boolean;\n};\n\ntype Phase = \"idle\" | \"busy\" | \"error\";\n\nexport function PromoCode({\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  onApply,\n  onRemove,\n  defaultOpen = false,\n  label = \"Add promo code\",\n  placeholder = \"Enter code\",\n  applyLabel = \"Apply\",\n  disabled = false,\n  className,\n  ...rest\n}: PromoCodeProps) {\n  const [applied, setApplied] = useControllableState<AppliedPromo | null>({ value: valueProp, defaultValue, onChange: onValueChange });\n  const reduce = useReducedMotion();\n  const [open, setOpen] = useState(defaultOpen);\n  const [draft, setDraft] = useState(\"\");\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [error, setError] = useState(\"\");\n  const [spinner, setSpinner] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n  const [moving, setMoving] = useState(false);\n  const id = useId();\n\n  const input = useRef<HTMLInputElement>(null);\n  const trigger = useRef<HTMLButtonElement>(null);\n  const removeButton = useRef<HTMLButtonElement>(null);\n  const entry = useRef<HTMLDivElement>(null);\n  const refocus = useRef<\"input\" | \"trigger\" | \"remove\" | null>(null);\n  const request = useRef(0);\n  const timer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  // Focus follows the control that replaced the one you were using.\n  useEffect(() => {\n    const target = refocus.current;\n    refocus.current = null;\n    if (target === \"input\") input.current?.focus();\n    if (target === \"trigger\") trigger.current?.focus();\n    if (target === \"remove\") removeButton.current?.focus();\n  }, [open, applied]);\n\n  const apply = async () => {\n    const code = draft.trim().toUpperCase();\n    if (!code || phase === \"busy\" || disabled) return;\n    const mine = ++request.current;\n    setPhase(\"busy\");\n    setError(\"\");\n    let shownAt = 0;\n    timer.current = window.setTimeout(() => {\n      shownAt = performance.now();\n      setSpinner(true);\n    }, 150);\n    const settle = (fn: () => void) => {\n      window.clearTimeout(timer.current);\n      const hold = shownAt ? Math.max(0, 300 - (performance.now() - shownAt)) : 0;\n      timer.current = window.setTimeout(() => {\n        if (mine !== request.current) return;\n        setSpinner(false);\n        fn();\n      }, hold);\n    };\n    try {\n      const result = await onApply(code);\n      settle(() => {\n        if (entry.current?.contains(document.activeElement)) refocus.current = \"remove\";\n        setPhase(\"idle\");\n        setDraft(\"\");\n        setOpen(false);\n        setApplied(result);\n        setAnnounce(`${result.code} applied, ${result.label}`);\n      });\n    } catch (e) {\n      settle(() => {\n        const message = e instanceof Error && e.message ? e.message : `${code} isn’t a valid code`;\n        setPhase(\"error\");\n        setError(message);\n        setAnnounce(message);\n        // Back to the field with the code selected, so fixing it is one keystroke away.\n        input.current?.focus();\n        input.current?.select();\n      });\n    }\n  };\n\n  const remove = () => {\n    if (!applied) return;\n    const was = applied;\n    refocus.current = \"trigger\";\n    setApplied(null);\n    setPhase(\"idle\");\n    setAnnounce(`${was.code} removed`);\n    onRemove?.(was);\n  };\n\n  const swap = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, height: 0, filter: \"blur(2px)\" },\n    animate: { opacity: 1, height: \"auto\", filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0 } : { opacity: 0, height: 0, filter: \"blur(2px)\" },\n    transition: { duration: 0.24, ease: ease.inOut },\n    // Clipped only while the height moves, so focus rings aren't cut off at rest.\n    onAnimationStart: () => setMoving(true),\n    onAnimationComplete: () => setMoving(false),\n    className: cn(moving && \"overflow-hidden\"),\n  };\n  const invalid = phase === \"error\";\n  const busy = phase === \"busy\";\n\n  return (\n    <div data-state={applied ? \"applied\" : open ? \"open\" : \"closed\"} className={cn(\"flex flex-col\", className)} {...rest}>\n      <AnimatePresence initial={false}>\n        {applied ? (\n          <motion.div key=\"applied\" {...swap}>\n            <div className=\"flex min-h-8 items-center justify-between gap-3 py-0.5\">\n              <motion.span\n                // Same starting pose on server and client; reduced motion just skips the scale.\n                initial={{ scale: 0.9, opacity: 0 }}\n                animate={{ scale: 1, opacity: 1 }}\n                transition={reduce ? { duration: 0.12, scale: { duration: 0 } } : { ...spring.pop, delay: 0.08 }}\n                className=\"inline-flex h-7 min-w-0 items-center gap-1.5 rounded-md bg-success-soft pl-2 pr-0.5 text-success\"\n              >\n                <Tag size={14} className=\"shrink-0\" />\n                <span className=\"truncate font-mono text-[12px] font-medium tracking-[0.04em]\">{applied.code}</span>\n                <button\n                  ref={removeButton}\n                  type=\"button\"\n                  onClick={remove}\n                  disabled={disabled}\n                  aria-label={`Remove code ${applied.code}`}\n                  className={cn(\n                    \"relative grid size-6 shrink-0 place-items-center rounded-[5px] text-success/80 outline-none\",\n                    \"transition-[background-color,color,scale] duration-150 hover:bg-success-soft hover:text-success active:scale-90 active:duration-75\",\n                    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-success\",\n                    \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n                  )}\n                >\n                  <X size={13} />\n                </button>\n              </motion.span>\n              <motion.span\n                initial={{ opacity: 0, x: 6 }}\n                animate={{ opacity: 1, x: 0 }}\n                transition={reduce ? { duration: 0.12, x: { duration: 0 } } : { duration: 0.26, ease: ease.out, delay: 0.12 }}\n                className=\"tabular shrink-0 text-[13px] font-medium text-success\"\n              >\n                {applied.label}\n              </motion.span>\n            </div>\n          </motion.div>\n        ) : (\n          <motion.div key=\"entry\" ref={entry} {...swap}>\n            <Collapsible.Root\n              open={open}\n              disabled={disabled}\n              onOpenChange={(next) => {\n                if (next) refocus.current = \"input\";\n                setOpen(next);\n              }}\n            >\n              <Collapsible.Trigger\n                ref={trigger}\n                className={cn(\n                  \"group/trigger relative -mx-1 flex h-8 items-center gap-1.5 rounded-md px-1 text-[12.5px] text-fg-2 outline-none\",\n                  \"transition-colors duration-150 hover:text-fg data-panel-open:text-fg data-disabled:pointer-events-none data-disabled:opacity-50\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n                )}\n              >\n                <Tag size={14} className=\"text-fg-3 transition-colors duration-150 group-hover/trigger:text-fg-2\" />\n                {label}\n                <ChevronDown\n                  size={14}\n                  className=\"text-fg-3 transition-transform duration-200 ease-out-expo group-data-panel-open/trigger:rotate-180 motion-reduce:transition-none\"\n                />\n              </Collapsible.Trigger>\n              <Collapsible.Panel\n                className={cn(\n                  \"-mx-1 h-(--collapsible-panel-height) overflow-hidden px-1 transition-[height,opacity] duration-240 ease-in-out-quart\",\n                  \"data-starting-style:h-0 data-starting-style:opacity-0 data-ending-style:h-0 data-ending-style:opacity-0 data-ending-style:duration-180\",\n                  \"motion-reduce:transition-[opacity]\",\n                )}\n              >\n                <div className=\"flex flex-col gap-1.5 pb-1 pt-1.5\">\n                  <div className=\"flex gap-2\">\n                    <input\n                      ref={input}\n                      id={`${id}-input`}\n                      value={draft}\n                      onChange={(e) => {\n                        setDraft(e.target.value);\n                        if (invalid) setPhase(\"idle\");\n                      }}\n                      onKeyDown={(e) => {\n                        if (e.key === \"Enter\") {\n                          e.preventDefault();\n                          apply();\n                        }\n                        if (e.key === \"Escape\" && !draft) {\n                          e.preventDefault();\n                          refocus.current = \"trigger\";\n                          setOpen(false);\n                        }\n                      }}\n                      disabled={disabled}\n                      readOnly={busy}\n                      placeholder={placeholder}\n                      aria-label=\"Promo code\"\n                      aria-invalid={invalid || undefined}\n                      aria-describedby={invalid ? `${id}-error` : undefined}\n                      autoComplete=\"off\"\n                      autoCapitalize=\"characters\"\n                      autoCorrect=\"off\"\n                      spellCheck={false}\n                      enterKeyHint=\"done\"\n                      className={cn(\n                        \"h-8 min-w-0 flex-1 rounded-lg border bg-raised px-2.5 font-mono text-base uppercase tracking-[0.04em] text-fg outline-none sm:text-[12.5px]\",\n                        \"shadow-[var(--shadow)] placeholder:font-sans placeholder:normal-case placeholder:tracking-normal placeholder:text-fg-4\",\n                        \"transition-[border-color,box-shadow] duration-150\",\n                        invalid\n                          ? \"border-danger/70 focus:ring-3 focus:ring-danger/15\"\n                          : \"border-line-2 hover:border-fg-4 focus:border-fg-4 focus:ring-3 focus:ring-fg/10\",\n                      )}\n                    />\n                    <button\n                      type=\"button\"\n                      onClick={apply}\n                      disabled={disabled || !draft.trim()}\n                      aria-busy={busy || undefined}\n                      className={cn(\n                        \"relative inline-flex h-8 shrink-0 items-center justify-center rounded-lg border border-line-2 bg-raised px-3 text-[12.5px] font-medium text-fg shadow-[var(--shadow)] outline-none\",\n                        \"transition-[background-color,border-color,opacity,scale] duration-150 hover:border-fg-4 hover:bg-hover 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                        \"disabled:opacity-50 disabled:hover:border-line-2 disabled:hover:bg-raised aria-busy:cursor-progress aria-busy:active:scale-100\",\n                      )}\n                    >\n                      <span className={cn(\"transition-opacity duration-150\", spinner && \"opacity-0\")}>{applyLabel}</span>\n                      {spinner && (\n                        <span className=\"absolute inset-0 grid place-items-center\">\n                          <Loader size={15} className=\"animate-spin motion-reduce:animate-none\" />\n                          <span className=\"sr-only\">Checking code</span>\n                        </span>\n                      )}\n                    </button>\n                  </div>\n                  {/* The error opens in place under the field and closes as soon as they type. */}\n                  <div\n                    className={cn(\n                      \"grid transition-[grid-template-rows,opacity] duration-200 ease-in-out-quart\",\n                      invalid ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\",\n                    )}\n                  >\n                    <p id={`${id}-error`} className=\"flex min-h-0 items-start gap-1.5 overflow-hidden text-[12px] leading-[1.45] text-danger\">\n                      <Alert size={13} className=\"mt-[2px] shrink-0\" />\n                      <span>{error}</span>\n                    </p>\n                  </div>\n                </div>\n              </Collapsible.Panel>\n            </Collapsible.Root>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/promo-code"
}
