{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "coupon-field",
  "type": "registry:component",
  "title": "Coupon Field",
  "description": "A promo code field that checks your code, then either folds it into a tag while the savings line slides in and the total rolls down, or shakes once and says exactly why it failed.",
  "categories": [
    "component",
    "Commerce"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/spinner.json"
  ],
  "files": [
    {
      "path": "registry/devl/coupon-field.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CircleAlertIcon, TagIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useAnimationControls, useReducedMotion } from \"motion/react\";\nimport { type FormEvent, useEffect, useId, useRef, useState } from \"react\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CouponResult =\n  | {\n      ok: true;\n      label: string;\n      percent?: number;\n      amount?: number;\n      freeShipping?: boolean;\n    }\n  | { ok: false; reason: string };\n\nexport interface SummaryLine {\n  label: string;\n  amount: number;\n}\n\nexport interface CouponFieldProps {\n  lines?: SummaryLine[];\n  shipping?: number;\n  validate: (code: string) => Promise<CouponResult>;\n  onApplied?: (code: string | null, savings: number) => void;\n  currency?: string;\n  locale?: string;\n  title?: string;\n  className?: string;\n}\n\ntype State =\n  | { status: \"idle\" }\n  | { status: \"checking\" }\n  | { status: \"error\"; reason: string }\n  | { status: \"applied\"; code: string; result: Extract<CouponResult, { ok: true }> };\n\nexport function CouponField({\n  lines = [],\n  shipping = 0,\n  validate,\n  onApplied,\n  currency = \"USD\",\n  locale = \"en-US\",\n  title = \"Order summary\",\n  className,\n}: CouponFieldProps) {\n  const reduce = useReducedMotion();\n  const [code, setCode] = useState(\"\");\n  const [state, setState] = useState<State>({ status: \"idle\" });\n  const shake = useAnimationControls();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const removeRef = useRef<HTMLButtonElement>(null);\n  const request = useRef(0);\n  const inputId = useId();\n  const errorId = useId();\n  useEffect(\n    () => () => {\n      request.current++;\n    },\n    [],\n  );\n\n  const fmt = (n: number) => new Intl.NumberFormat(locale, { style: \"currency\", currency }).format(n);\n  const subtotal = lines.reduce((n, l) => n + l.amount, 0);\n  const applied = state.status === \"applied\" ? state.result : null;\n  const shipOff = applied?.freeShipping ? shipping : 0;\n  const itemsOff = applied ? Math.min(subtotal, (applied.percent ? (subtotal * applied.percent) / 100 : 0) + (applied.amount ?? 0)) : 0;\n  const savings = Math.round((itemsOff + shipOff) * 100) / 100;\n  const total = subtotal + shipping - savings;\n\n  const submit = async (event: FormEvent) => {\n    event.preventDefault();\n    const value = code.trim().toUpperCase();\n    if (!value || state.status === \"checking\") return;\n    const id = ++request.current;\n    setState({ status: \"checking\" });\n    const result = await validate(value);\n    if (id !== request.current) return;\n    if (result.ok) {\n      setState({ status: \"applied\", code: value, result });\n      const s = Math.round(\n        (Math.min(subtotal, (result.percent ? (subtotal * result.percent) / 100 : 0) + (result.amount ?? 0)) + (result.freeShipping ? shipping : 0)) * 100,\n      ) / 100;\n      onApplied?.(value, s);\n      requestAnimationFrame(() => removeRef.current?.focus({ preventScroll: true }));\n    } else {\n      setState({ status: \"error\", reason: result.reason });\n      if (!reduce) shake.start({ x: [0, -5, 5, -3, 3, 0], transition: { duration: 0.32, ease: \"easeOut\" } });\n      inputRef.current?.focus();\n    }\n  };\n\n  const remove = () => {\n    setState({ status: \"idle\" });\n    setCode(\"\");\n    onApplied?.(null, 0);\n    requestAnimationFrame(() => inputRef.current?.focus());\n  };\n\n  const ease = [0.23, 1, 0.32, 1] as const;\n  const error = state.status === \"error\";\n  const checking = state.status === \"checking\";\n\n  return (\n    <div className={cn(\"w-full max-w-[360px] rounded-2xl border bg-card p-4 text-card-foreground shadow-xs/5\", className)}>\n      <h3 className=\"font-semibold text-sm\">{title}</h3>\n\n      {lines.length > 0 && (\n        <ul className=\"mt-3 space-y-1.5 text-sm\">\n          {lines.map((l) => (\n            <li className=\"flex justify-between gap-3\" key={l.label}>\n              <span className=\"truncate text-muted-foreground\">{l.label}</span>\n              <span className=\"tabular-nums\">{fmt(l.amount)}</span>\n            </li>\n          ))}\n        </ul>\n      )}\n\n      <div className=\"mt-4\">\n        <label className=\"mb-1.5 block font-medium text-xs\" htmlFor={inputId}>\n          Promo code\n        </label>\n        <div className=\"relative h-9\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {applied && state.status === \"applied\" ? (\n              <motion.div\n                animate={{ opacity: 1, scale: 1 }}\n                className=\"flex h-9 items-center gap-2 rounded-lg border border-success/30 bg-success/8 ps-2.5 pe-1 text-sm\"\n                exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.12 } }}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97 }}\n                key=\"applied\"\n                transition={{ duration: 0.22, ease }}\n              >\n                <TagIcon className=\"size-3.5 shrink-0 text-success-foreground\" />\n                <span className=\"font-medium font-mono text-[13px] tracking-wide\">{state.code}</span>\n                <span className=\"truncate text-success-foreground text-xs\">{applied.label}</span>\n                <button\n                  aria-label={`Remove code ${state.code}`}\n                  className=\"ms-auto flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-[calc(var(--radius-lg)-4px)] text-muted-foreground outline-none transition-colors hover:bg-success/12 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                  onClick={remove}\n                  ref={removeRef}\n                  type=\"button\"\n                >\n                  <XIcon className=\"size-3.5\" />\n                </button>\n              </motion.div>\n            ) : (\n              <motion.form\n                animate={shake}\n                className=\"h-9\"\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                initial={false}\n                key=\"form\"\n                onSubmit={submit}\n              >\n                <div\n                  className={cn(\n                    \"flex h-9 items-center rounded-lg border bg-background ps-3 pe-1 shadow-xs/5 transition-[border-color,box-shadow] duration-150 focus-within:ring-[3px] dark:bg-input/32\",\n                    error ? \"border-destructive/60 ring-destructive/16 focus-within:border-destructive/70\" : \"focus-within:border-ring focus-within:ring-ring/24\",\n                  )}\n                >\n                  <input\n                    aria-describedby={error ? errorId : undefined}\n                    aria-invalid={error || undefined}\n                    autoCapitalize=\"characters\"\n                    autoComplete=\"off\"\n                    className=\"h-full min-w-0 flex-1 bg-transparent font-mono text-[13px] uppercase tracking-wide outline-none placeholder:font-sans placeholder:text-muted-foreground placeholder:normal-case placeholder:tracking-normal\"\n                    id={inputId}\n                    onChange={(e) => {\n                      setCode(e.target.value);\n                      if (error) setState({ status: \"idle\" });\n                    }}\n                    placeholder=\"Enter a code\"\n                    readOnly={checking}\n                    ref={inputRef}\n                    spellCheck={false}\n                    value={code}\n                  />\n                  <button\n                    className=\"relative flex h-7 min-w-16 cursor-pointer items-center justify-center rounded-[calc(var(--radius-lg)-4px)] bg-primary px-3 font-medium text-primary-foreground text-xs outline-none transition-[opacity,scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background active:scale-[0.97] disabled:cursor-default disabled:opacity-40\"\n                    aria-busy={checking || undefined}\n                    disabled={!code.trim()}\n                    type=\"submit\"\n                  >\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      <motion.span\n                        animate={{ opacity: 1, scale: 1 }}\n                        className=\"flex items-center\"\n                        exit={{ opacity: 0, scale: 0.8 }}\n                        initial={{ opacity: 0, scale: 0.8 }}\n                        key={checking ? \"spin\" : \"apply\"}\n                        transition={{ duration: 0.14 }}\n                      >\n                        {checking ? <Spinner className=\"size-3.5\" /> : \"Apply\"}\n                      </motion.span>\n                    </AnimatePresence>\n                  </button>\n                </div>\n              </motion.form>\n            )}\n          </AnimatePresence>\n        </div>\n        <AnimatePresence initial={false}>\n          {error && (\n            <motion.p\n              animate={{ opacity: 1, height: \"auto\" }}\n              className=\"overflow-hidden text-destructive-foreground text-xs\"\n              exit={{ opacity: 0, height: 0 }}\n              id={errorId}\n              initial={reduce ? { opacity: 0, height: \"auto\" } : { opacity: 0, height: 0 }}\n              key=\"err\"\n              role=\"alert\"\n              transition={{ duration: 0.2, ease }}\n            >\n              <span className=\"flex items-start gap-1.5 pt-1.5\">\n                <CircleAlertIcon className=\"mt-px size-3.5 shrink-0\" />\n                {state.reason}\n              </span>\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"mt-4 space-y-1.5 border-t pt-3 text-sm\">\n        <div className=\"flex justify-between\">\n          <span className=\"text-muted-foreground\">Subtotal</span>\n          <span className=\"tabular-nums\">{fmt(subtotal)}</span>\n        </div>\n        <div className=\"flex justify-between\">\n          <span className=\"text-muted-foreground\">Shipping</span>\n          <span className={cn(\"tabular-nums transition-colors duration-200\", shipOff ? \"text-muted-foreground line-through\" : \"\")}>{fmt(shipping)}</span>\n        </div>\n        <AnimatePresence initial={false}>\n          {savings > 0 && state.status === \"applied\" && (\n            <motion.div\n              animate={{ opacity: 1, height: \"auto\" }}\n              className=\"overflow-hidden\"\n              exit={{ opacity: 0, height: 0 }}\n              initial={reduce ? { opacity: 0, height: \"auto\" } : { opacity: 0, height: 0 }}\n              key=\"savings\"\n              transition={{ duration: 0.24, ease }}\n            >\n              <motion.div\n                animate={{ x: 0 }}\n                className=\"flex justify-between text-success-foreground\"\n                initial={reduce ? false : { x: -8 }}\n                transition={{ duration: 0.28, ease }}\n              >\n                <span className=\"flex items-center gap-1.5\">\n                  <TagIcon className=\"size-3.5\" />\n                  {state.code}\n                </span>\n                <span className=\"font-medium tabular-nums\">−{fmt(savings)}</span>\n              </motion.div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n        <div className=\"flex items-baseline justify-between border-t pt-2.5\">\n          <span className=\"font-medium\">Total</span>\n          <RollingNumber className=\"font-semibold text-base\" text={fmt(total)} />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction RollingNumber({ text, className }: { text: string; className?: string }) {\n  const reduce = useReducedMotion();\n  const chars = text.split(\"\");\n  return (\n    <span aria-label={text} className={cn(\"inline-flex tabular-nums leading-6\", className)} role=\"img\">\n      {chars.map((c, i) => {\n        const key = chars.length - i;\n        if (!/\\d/.test(c)) {\n          return (\n            <span aria-hidden key={`s${key}`}>\n              {c}\n            </span>\n          );\n        }\n        return (\n          <span aria-hidden className=\"relative inline-block h-6 overflow-hidden [mask-image:linear-gradient(transparent,black_15%,black_85%,transparent)]\" key={`d${key}`}>\n            <motion.span\n              animate={{ y: `${-Number(c) * 10}%` }}\n              className=\"flex flex-col\"\n              initial={false}\n              transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 260, damping: 30 }}\n            >\n              {Array.from({ length: 10 }, (_, d) => (\n                <span className=\"h-6\" key={d}>\n                  {d}\n                </span>\n              ))}\n            </motion.span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n"
    }
  ]
}