{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "payment-failed",
  "type": "registry:component",
  "title": "Payment Failed",
  "description": "A billing state for a declined renewal that names the card and the reason, counts down the grace period, and puts the fixes right there: retry, switch to a saved card or add a new one.",
  "categories": [
    "component",
    "Empty & Error"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/badge.json",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/collapsible.json",
    "https://coss.com/ui/r/input.json",
    "https://coss.com/ui/r/radio-group.json"
  ],
  "files": [
    {
      "path": "registry/devl/payment-failed.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ChevronDownIcon, ClockIcon, PlusIcon, TriangleAlertIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type FormEvent, type ReactNode, useEffect, useId, useRef, useState } from \"react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Collapsible, CollapsiblePanel, CollapsibleTrigger } from \"@/components/ui/collapsible\";\nimport { Input } from \"@/components/ui/input\";\nimport { Radio, RadioGroup } from \"@/components/ui/radio-group\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CardBrand = \"visa\" | \"mastercard\" | \"amex\" | \"card\";\n\nexport interface PaymentCard {\n  id: string;\n  brand: CardBrand;\n  last4: string;\n  expiry: string;\n  note?: string;\n}\n\nexport interface NewCardDetails {\n  number: string;\n  expiry: string;\n  cvc: string;\n}\n\nexport interface PaymentFailedProps {\n  amount?: number;\n  currency?: string;\n  plan?: string;\n  cards?: PaymentCard[];\n  failedCardId?: string;\n  declineReason?: string;\n  declineCode?: string;\n  declineExplanation?: string;\n  declines?: Record<string, string>;\n  graceUntil?: string;\n  graceSeconds?: number;\n  graceTotalSeconds?: number;\n  billingEmail?: string;\n  status?: \"due\" | \"processing\" | \"paid\";\n  paidCardId?: string;\n  reasons?: string[];\n  onPay?: (cardId: string) => void;\n  onAddCard?: (details: NewCardDetails) => void;\n  className?: string;\n}\n\nconst brandNames: Record<CardBrand, string> = { visa: \"Visa\", mastercard: \"Mastercard\", amex: \"Amex\", card: \"Card\" };\n\nconst defaultCards: PaymentCard[] = [\n  { id: \"visa\", brand: \"visa\", last4: \"4242\", expiry: \"08/27\" },\n  { id: \"mc\", brand: \"mastercard\", last4: \"8812\", expiry: \"03/28\", note: \"Backup\" },\n];\n\nconst defaultReasons = [\n  \"The card hit its spending limit, or was replaced or reported lost.\",\n  \"The bank flagged a new or larger charge as unusual and held it.\",\n  \"The card expired, or the billing address doesn't match the bank's.\",\n];\n\nconst ease = [0.23, 1, 0.32, 1] as const;\nconst roll = { type: \"spring\", stiffness: 320, damping: 32 } as const;\nconst spring = { type: \"spring\", stiffness: 420, damping: 34 } as const;\n\n// Stays false until mounted so the server render and the first client render match.\nfunction useSafeReducedMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\nconst cardName = (card: PaymentCard) => `${brandNames[card.brand]} •••• ${card.last4}`;\n\nfunction useCountdown(start: number, running: boolean) {\n  const [left, setLeft] = useState(start);\n  useEffect(() => {\n    setLeft(start);\n    if (!running || start <= 0) return;\n    const began = Date.now();\n    const id = window.setInterval(() => setLeft(Math.max(0, start - Math.floor((Date.now() - began) / 1000))), 250);\n    return () => window.clearInterval(id);\n  }, [start, running]);\n  return left;\n}\n\nfunction Roll({ text, reduce }: { text: string; reduce: boolean }) {\n  const chars = text.split(\"\");\n  return (\n    <span aria-hidden className=\"tabular-nums leading-[1.2em]\">\n      {chars.map((char, i) => {\n        const key = chars.length - i;\n        if (!/\\d/.test(char)) return <span key={`s${key}`}>{char}</span>;\n        return (\n          <span className=\"relative inline-block [clip-path:inset(0)]\" key={`d${key}`}>\n            <span className=\"invisible\">{char}</span>\n            <motion.span\n              animate={{ y: `${-Number(char) * 1.2}em` }}\n              className=\"absolute inset-x-0 top-0 flex flex-col\"\n              initial={false}\n              transition={reduce ? { duration: 0 } : roll}\n            >\n              {Array.from({ length: 10 }, (_, n) => (\n                <span className=\"block h-[1.2em] text-center\" key={n}>\n                  {n}\n                </span>\n              ))}\n            </motion.span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Collapse({ show, reduce, children }: { show: boolean; reduce: boolean; children: ReactNode }) {\n  return (\n    <AnimatePresence initial={false}>\n      {show && (\n        <motion.div\n          animate={{ height: \"auto\", opacity: 1 }}\n          className=\"overflow-hidden\"\n          exit={{ height: 0, opacity: 0 }}\n          initial={{ height: 0, opacity: 0 }}\n          transition={reduce ? { duration: 0 } : { duration: 0.26, ease }}\n        >\n          {children}\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\nconst ditherPixels = [\n  [0, 0, 0.3], [3, 0, 0.5], [9, 1, 0.35], [11, 0, 0.5], [11, 4, 0.3], [0, 7, 0.35], [11, 11, 0.5], [1, 11, 0.3], [7, 11, 0.4],\n] as const;\n\nconst cardPixels = (() => {\n  const pixels: { x: number; y: number; o: number }[] = [];\n  for (let y = 2; y <= 9; y++) {\n    for (let x = 1; x <= 10; x++) {\n      const corner = (x === 1 || x === 10) && (y === 2 || y === 9);\n      if (corner) continue;\n      const edge = x === 1 || x === 10 || y === 2 || y === 9;\n      const stripe = y === 4;\n      const digits = y === 7 && x >= 3 && x <= 5;\n      if (edge || stripe || digits) pixels.push({ x, y, o: 1 });\n      else if ((x + y) % 2 === 0) pixels.push({ x, y, o: 0.35 });\n    }\n  }\n  return pixels;\n})();\n\nfunction CardGlyph({ paid }: { paid: boolean }) {\n  return (\n    <svg aria-hidden className=\"size-6\" shapeRendering=\"crispEdges\" viewBox=\"0 0 12 12\">\n      {cardPixels.map(({ x, y, o }) => (\n        <rect fill=\"currentColor\" height={1} key={`${x}-${y}`} opacity={o} width={1} x={x} y={y} />\n      ))}\n      {ditherPixels.map(([x, y, o]) => (\n        <motion.rect\n          animate={{ opacity: paid ? 0 : o }}\n          fill=\"currentColor\"\n          height={1}\n          initial={false}\n          key={`d${x}-${y}`}\n          transition={{ duration: 0.3 }}\n          width={1}\n          x={x}\n          y={y}\n        />\n      ))}\n    </svg>\n  );\n}\n\nfunction BrandMark({ brand }: { brand: CardBrand }) {\n  return (\n    <span\n      aria-hidden\n      className=\"flex h-6 w-9 shrink-0 items-center justify-center rounded-sm border bg-background font-medium font-mono text-[9px] text-muted-foreground tracking-wide\"\n    >\n      {{ visa: \"VISA\", mastercard: \"MC\", amex: \"AMEX\", card: \"CARD\" }[brand]}\n    </span>\n  );\n}\n\nfunction DrawnCheck({ reduce }: { reduce: boolean }) {\n  return (\n    <span className=\"flex size-5 shrink-0 items-center justify-center rounded-full bg-success/12 text-success-foreground\">\n      <svg aria-hidden className=\"size-3\" fill=\"none\" viewBox=\"0 0 16 16\">\n        <motion.path\n          animate={{ pathLength: 1 }}\n          d=\"M3.5 8.5l3 3 6-7\"\n          initial={reduce ? false : { pathLength: 0 }}\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={2.2}\n          transition={{ duration: 0.32, delay: 0.15, ease: [0.65, 0, 0.35, 1] }}\n        />\n      </svg>\n    </span>\n  );\n}\n\nexport function PaymentFailed({\n  amount = 96,\n  currency = \"USD\",\n  plan = \"Team\",\n  cards = defaultCards,\n  failedCardId = \"visa\",\n  declineReason = \"insufficient funds\",\n  declineCode = \"insufficient_funds\",\n  declineExplanation,\n  declines = { visa: \"Declined Oct 28: insufficient funds\" },\n  graceUntil = \"Nov 4\",\n  graceSeconds = 6 * 86400 + 9 * 3600 + 41 * 60 + 12,\n  graceTotalSeconds = 7 * 86400,\n  billingEmail = \"billing@ridgeline.app\",\n  status = \"due\",\n  paidCardId,\n  reasons = defaultReasons,\n  onPay,\n  onAddCard,\n  className,\n}: PaymentFailedProps) {\n  const reduce = useSafeReducedMotion();\n  const paid = status === \"paid\";\n  const processing = status === \"processing\";\n  const left = useCountdown(graceSeconds, !paid);\n  const [selected, setSelected] = useState(failedCardId);\n  const [adding, setAdding] = useState(false);\n  const known = useRef(cards.map((c) => c.id));\n\n  useEffect(() => {\n    const fresh = cards.find((c) => !known.current.includes(c.id));\n    known.current = cards.map((c) => c.id);\n    if (fresh) {\n      setSelected(fresh.id);\n      setAdding(false);\n    }\n  }, [cards]);\n\n  const price = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency }).format(amount);\n  const failedCard = cards.find((c) => c.id === failedCardId);\n  const selectedCard = cards.find((c) => c.id === selected) ?? cards[0];\n  const chargedCard = cards.find((c) => c.id === paidCardId) ?? selectedCard;\n  const d = Math.floor(left / 86400);\n  const h = Math.floor((left % 86400) / 3600);\n  const m = Math.floor((left % 3600) / 60);\n  const s = left % 60;\n  const graceLeft = graceTotalSeconds > 0 ? Math.min(1, left / graceTotalSeconds) : 0;\n\n  return (\n    <section\n      aria-label=\"Billing status\"\n      className={cn(\"@container w-full max-w-md overflow-hidden rounded-2xl border bg-card shadow-xs/5\", className)}\n    >\n      <div className=\"grid gap-5 p-5\">\n        <div className=\"flex items-start gap-3\">\n          <span\n            className={cn(\n              \"flex size-10 shrink-0 items-center justify-center rounded-xl border bg-card shadow-xs/5 transition-colors duration-300\",\n              paid ? \"text-success-foreground\" : \"text-destructive-foreground\",\n            )}\n          >\n            <CardGlyph paid={paid} />\n          </span>\n          <div className=\"grid min-w-0 flex-1 gap-1\">\n            <div className=\"flex items-start justify-between gap-3\">\n              <h3 aria-live=\"polite\" className=\"text-balance font-semibold text-base\">\n                {paid ? \"Payment received\" : \"Your payment didn't go through\"}\n              </h3>\n              <Badge className=\"mt-0.5 transition-colors duration-300\" variant={paid ? \"success\" : \"warning\"}>\n                {paid ? \"Paid\" : \"Past due\"}\n              </Badge>\n            </div>\n            <p className=\"text-pretty text-muted-foreground text-sm\">\n              {paid\n                ? `${cardName(chargedCard)} was charged for the ${plan} plan.`\n                : failedCard\n                  ? `${cardName(failedCard)} was declined: ${declineReason}. ${plan} plan, ${price} a month.`\n                  : `Your card was declined: ${declineReason}.`}\n            </p>\n            {!paid && (\n              <Collapsible>\n                <CollapsibleTrigger className=\"group/why -ml-1 mt-1 inline-flex h-7 cursor-pointer items-center gap-1 rounded-md px-1 font-medium text-muted-foreground text-xs outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\">\n                  Why was it declined?\n                  <ChevronDownIcon aria-hidden className=\"size-3.5 transition-transform duration-200 group-data-panel-open/why:rotate-180\" />\n                </CollapsibleTrigger>\n                <CollapsiblePanel>\n                  <div className=\"grid gap-2 pt-1 pb-0.5 text-muted-foreground text-xs\">\n                    <p className=\"text-pretty text-foreground\">\n                      {declineExplanation ?? `The account didn't have ${price} free when we tried. A retry works once the money clears.`}\n                    </p>\n                    <p>Banks also decline when:</p>\n                    <ul className=\"grid list-disc gap-1 pl-4\">\n                      {reasons.map((reason) => (\n                        <li className=\"text-pretty\" key={reason}>\n                          {reason}\n                        </li>\n                      ))}\n                    </ul>\n                    <p>\n                      Your bank can confirm which. Decline code <code className=\"rounded-xs bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground\">{declineCode}</code>\n                    </p>\n                  </div>\n                </CollapsiblePanel>\n              </Collapsible>\n            )}\n          </div>\n        </div>\n\n        <Collapse reduce={reduce} show={!paid}>\n          <div className=\"grid gap-5\">\n            <div className=\"grid gap-2 rounded-xl border bg-muted/40 p-3\">\n              <p className=\"flex items-start gap-1.5 font-medium text-sm\">\n                <ClockIcon aria-hidden className=\"mt-0.5 size-3.5 shrink-0 text-warning-foreground\" />\n                <span className=\"text-balance\">Your workspace stays fully active until {graceUntil}</span>\n              </p>\n              <div className=\"flex items-center gap-3\">\n                <div aria-hidden className=\"h-1.5 flex-1 overflow-hidden rounded-full bg-muted\">\n                  <motion.div\n                    animate={{ scaleX: graceLeft }}\n                    className=\"h-full origin-left rounded-full bg-warning\"\n                    initial={false}\n                    transition={reduce ? { duration: 0 } : { duration: 0.6, ease }}\n                  />\n                </div>\n                <span className=\"shrink-0 text-muted-foreground text-xs\">\n                  <span className=\"sr-only\">{`${d} days ${h} hours left`}</span>\n                  <span aria-hidden className=\"inline-flex gap-1 font-medium text-foreground\">\n                    <span>\n                      <Roll reduce={reduce} text={String(d)} />\n                      <span className=\"text-muted-foreground\">d</span>\n                    </span>\n                    <span>\n                      <Roll reduce={reduce} text={pad(h)} />\n                      <span className=\"text-muted-foreground\">h</span>\n                    </span>\n                    <span>\n                      <Roll reduce={reduce} text={pad(m)} />\n                      <span className=\"text-muted-foreground\">m</span>\n                    </span>\n                    <span>\n                      <Roll reduce={reduce} text={pad(s)} />\n                      <span className=\"text-muted-foreground\">s</span>\n                    </span>\n                  </span>\n                </span>\n              </div>\n              <p className=\"text-pretty text-muted-foreground text-xs\">After {graceUntil} it turns read-only until it's paid. Nothing is deleted.</p>\n            </div>\n\n            <fieldset className=\"grid gap-2\">\n              <legend className=\"mb-2 font-medium text-muted-foreground text-xs\">Pay with</legend>\n              <RadioGroup className=\"gap-0 overflow-hidden rounded-xl border\" onValueChange={(v) => setSelected(String(v))} value={selected}>\n                <AnimatePresence initial={false}>\n                  {cards.map((card) => (\n                    <CardOption card={card} decline={declines[card.id]} disabled={processing} key={card.id} reduce={reduce} />\n                  ))}\n                </AnimatePresence>\n              </RadioGroup>\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {adding ? (\n                  <motion.div\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={{ opacity: 0, transition: { duration: 0.12 } }}\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n                    key=\"form\"\n                    transition={{ duration: 0.2, ease }}\n                  >\n                    <NewCardForm onCancel={() => setAdding(false)} onSubmit={(details) => onAddCard?.(details)} />\n                  </motion.div>\n                ) : (\n                  <motion.div animate={{ opacity: 1 }} exit={{ opacity: 0 }} initial={{ opacity: 0 }} key=\"add\" transition={{ duration: 0.15 }}>\n                    <Button className=\"-ml-1\" disabled={processing} onClick={() => setAdding(true)} size=\"sm\" variant=\"ghost\">\n                      <PlusIcon />\n                      Add a new card\n                    </Button>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </fieldset>\n          </div>\n        </Collapse>\n      </div>\n\n      <div className=\"flex min-h-15 flex-wrap items-center gap-x-3 gap-y-2 border-t bg-muted/40 px-5 py-3\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {paid ? (\n            <motion.p\n              animate={{ opacity: 1 }}\n              className=\"flex items-center gap-2 text-sm\"\n              initial={{ opacity: 0 }}\n              key=\"paid\"\n              transition={{ duration: 0.2 }}\n            >\n              <DrawnCheck reduce={reduce} />\n              <span className=\"text-pretty\">\n                Paid · <span className=\"tabular-nums\">{price}</span> · receipt sent to <span className=\"font-medium\">{billingEmail}</span>\n              </span>\n            </motion.p>\n          ) : (\n            <motion.div\n              animate={{ opacity: 1 }}\n              className=\"flex w-full flex-wrap items-center justify-between gap-x-3 gap-y-2\"\n              exit={{ opacity: 0 }}\n              initial={{ opacity: 0 }}\n              key=\"due\"\n              transition={{ duration: 0.15 }}\n            >\n              <Button className=\"@max-sm:w-full\" disabled={adding} loading={processing} onClick={() => onPay?.(selectedCard.id)}>\n                {selectedCard.id === failedCardId ? `Retry ${cardName(selectedCard)}` : `Pay ${price} with ${cardName(selectedCard)}`}\n              </Button>\n              <span className=\"text-muted-foreground text-xs tabular-nums @max-sm:hidden\">{price} due</span>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </section>\n  );\n}\n\nfunction CardOption({ card, decline, disabled, reduce }: { card: PaymentCard; decline?: string; disabled: boolean; reduce: boolean }) {\n  const id = useId();\n  return (\n    <motion.label\n      animate={{ opacity: 1, height: \"auto\" }}\n      className=\"block cursor-pointer border-b transition-colors last:border-b-0 hover:bg-accent/40 has-data-checked:bg-accent/40\"\n      htmlFor={id}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n      transition={spring}\n    >\n      <span className=\"flex items-center gap-3 px-3 py-2.5\">\n        <Radio aria-describedby={decline ? `${id}-decline` : undefined} disabled={disabled} id={id} value={card.id} />\n        <BrandMark brand={card.brand} />\n        <span className=\"grid min-w-0 flex-1\">\n          <span className=\"truncate font-medium text-sm\">\n            {brandNames[card.brand]} <span className=\"tabular-nums\">•••• {card.last4}</span>\n          </span>\n          <span className=\"flex items-center gap-1.5 text-muted-foreground text-xs tabular-nums\">\n            Expires {card.expiry}\n            {card.note && (\n              <Badge size=\"sm\" variant=\"outline\">\n                {card.note}\n              </Badge>\n            )}\n          </span>\n        </span>\n      </span>\n      <AnimatePresence initial={false} mode=\"wait\">\n        {decline && (\n          <motion.span\n            animate={{ opacity: 1, height: \"auto\" }}\n            className=\"block overflow-hidden\"\n            exit={{ opacity: 0, height: 0 }}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n            key={decline}\n            transition={{ duration: 0.22, ease }}\n          >\n            <span className=\"flex items-start gap-1.5 px-3 pb-2.5 pl-10.5 text-destructive-foreground text-xs\" id={`${id}-decline`}>\n              <TriangleAlertIcon aria-hidden className=\"mt-px size-3.5 shrink-0\" />\n              <span className=\"text-pretty\">{decline}</span>\n            </span>\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </motion.label>\n  );\n}\n\nfunction formatNumber(raw: string) {\n  return raw\n    .replace(/\\D/g, \"\")\n    .slice(0, 16)\n    .replace(/(\\d{4})(?=\\d)/g, \"$1 \");\n}\n\nfunction formatExpiry(raw: string) {\n  const digits = raw.replace(/\\D/g, \"\").slice(0, 4);\n  return digits.length > 2 ? `${digits.slice(0, 2)} / ${digits.slice(2)}` : digits;\n}\n\nfunction NewCardForm({ onSubmit, onCancel }: { onSubmit: (details: NewCardDetails) => void; onCancel: () => void }) {\n  const id = useId();\n  const [number, setNumber] = useState(\"\");\n  const [expiry, setExpiry] = useState(\"\");\n  const [cvc, setCvc] = useState(\"\");\n  const digits = number.replace(/\\D/g, \"\");\n  const month = Number(expiry.slice(0, 2));\n  const valid = digits.length >= 15 && expiry.replace(/\\D/g, \"\").length === 4 && month >= 1 && month <= 12 && cvc.length >= 3;\n\n  const submit = (event: FormEvent) => {\n    event.preventDefault();\n    if (valid) onSubmit({ number: digits, expiry: expiry.replace(/\\s/g, \"\"), cvc });\n  };\n\n  return (\n    <form className=\"grid gap-3 rounded-xl border p-3\" onSubmit={submit}>\n      <div className=\"grid gap-1.5\">\n        <label className=\"font-medium text-xs\" htmlFor={`${id}-number`}>\n          Card number\n        </label>\n        <Input\n          autoComplete=\"cc-number\"\n          className=\"tabular-nums\"\n          id={`${id}-number`}\n          inputMode=\"numeric\"\n          onChange={(e) => setNumber(formatNumber(e.target.value))}\n          placeholder=\"1234 1234 1234 1234\"\n          value={number}\n        />\n      </div>\n      <div className=\"grid grid-cols-2 gap-3\">\n        <div className=\"grid gap-1.5\">\n          <label className=\"font-medium text-xs\" htmlFor={`${id}-expiry`}>\n            Expiry\n          </label>\n          <Input\n            autoComplete=\"cc-exp\"\n            className=\"tabular-nums\"\n            id={`${id}-expiry`}\n            inputMode=\"numeric\"\n            onChange={(e) => setExpiry(formatExpiry(e.target.value))}\n            placeholder=\"MM / YY\"\n            value={expiry}\n          />\n        </div>\n        <div className=\"grid gap-1.5\">\n          <label className=\"font-medium text-xs\" htmlFor={`${id}-cvc`}>\n            CVC\n          </label>\n          <Input\n            autoComplete=\"cc-csc\"\n            className=\"tabular-nums\"\n            id={`${id}-cvc`}\n            inputMode=\"numeric\"\n            onChange={(e) => setCvc(e.target.value.replace(/\\D/g, \"\").slice(0, 4))}\n            placeholder=\"123\"\n            value={cvc}\n          />\n        </div>\n      </div>\n      <div className=\"flex justify-end gap-2\">\n        <Button onClick={onCancel} size=\"sm\" variant=\"ghost\">\n          Cancel\n        </Button>\n        <Button disabled={!valid} size=\"sm\" type=\"submit\" variant=\"outline\">\n          Add card\n        </Button>\n      </div>\n    </form>\n  );\n}\n"
    }
  ]
}