{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "payment-selector",
  "title": "Payment Selector",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "How the shopper pays: one ruled list of methods (card, wallet, pay in instalments, bank) where the chosen one opens onto your payment provider's own fields. It never renders a card-number field: each panel is a renderPanel slot for Stripe's Payment Element, Adyen's Drop-in, PayPal or a redirect notice, kept mounted once opened so a provider iframe keeps the half-typed card. Panels sit outside the radio group, so arrow keys move between methods without reaching into a provider's fields, yet render under their own method as one object. Instalment lines from the order total, unavailable methods with their reason in words, a provider error in the open panel, loading and empty states. The heavy rule slides to the chosen method and its panel grows to its measured height (Motion; a fade under reduced motion).",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "radio-group",
    "skeleton"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/payment-selector/payment-selector.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert } from \"lucide-react\"\nimport { MotionConfig, motion, useReducedMotion } from \"motion/react\"\n\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * How the shopper pays: one ruled list of methods — card, wallet, pay in\n * instalments, bank — where the chosen one opens onto your payment\n * provider's own fields.\n *\n *   <PaymentSelector\n *     methods={methods}\n *     value={method}\n *     onValueChange={setMethod}\n *     renderPanel={(m) => (m.id === \"card\" ? <PaymentElement /> : null)}\n *     total={777.6}\n *   />\n *\n * It never renders a card-number, expiry or CVC field. Each method's panel\n * is a slot — `renderPanel` — for Stripe's Payment Element, Adyen's Drop-in,\n * PayPal's buttons or a redirect notice. A panel stays mounted once opened\n * (`keepMounted`), hidden rather than destroyed, so a provider's iframe keeps\n * the half-typed card when the shopper looks at another method and comes\n * back. Panels sit outside the radio group, so the arrow keys move between\n * methods without ever reaching into a provider's fields — and laid out\n * under their own method, joined to it as one object. The heavy rule slides\n * to the chosen method and its panel grows to its measured height (Motion;\n * a fade under reduced motion).\n */\n\nexport interface PaymentMethod {\n  id: string\n  label: string\n  /** \"Visa, Mastercard, Amex\". */\n  description?: React.ReactNode\n  /** Your brand marks, at the row's end. ecomcn ships none: they are trademarks. */\n  icon?: React.ReactNode\n  kind: \"card\" | \"wallet\" | \"bnpl\" | \"bank\" | \"other\"\n  /** Pay in N: shows \"3 payments of $259.20\" from `total`. */\n  instalments?: number\n  /** False when it can't be used here — Apple Pay outside Safari, say. */\n  available?: boolean\n  /** Why, in words: \"Needs Safari on an Apple device\". */\n  unavailableReason?: string\n}\n\n/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */\nconst MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const\nconst FADE = { duration: 0.15 } as const\n\nconst subscribeNothing = () => () => {}\n\n/** Your locale, or the visitor's once hydrated — so server and client HTML agree. */\nfunction useFormatLocale(locale?: string) {\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)\n  return locale ?? (hydrated ? undefined : \"en-US\")\n}\n\n/* ─── context ──────────────────────────────────────────────────────────── */\n\nexport interface PaymentSelectorContextValue {\n  methods: PaymentMethod[]\n  /** The chosen method's id, or null before the shopper chooses. */\n  value: string | null\n  select: (id: string) => void\n  selected: PaymentMethod | undefined\n  /** Methods opened so far — their panels stay mounted while `keepMounted`. */\n  opened: ReadonlySet<string>\n  keepMounted: boolean\n  renderPanel?: (method: PaymentMethod) => React.ReactNode\n  /** A provider failure, shown in the chosen method's panel. */\n  error: React.ReactNode\n  /** \"3 payments of $259.20\", or null when the method or `total` doesn't say. */\n  instalmentText: (method: PaymentMethod) => string | null\n  formatMoney: (amount: number) => string\n  /** Where a method sits in the list — rows and panels are placed by it. */\n  indexOf: (id: string) => number\n  panelId: (id: string) => string\n  /** For the sliding rule's shared layout id. */\n  scope: string\n  disabled: boolean\n}\n\nconst PaymentSelectorContext = React.createContext<PaymentSelectorContextValue | null>(null)\n\nexport function usePaymentSelector() {\n  const context = React.useContext(PaymentSelectorContext)\n  if (!context) {\n    throw new Error(\"usePaymentSelector must be used within <PaymentSelector>.\")\n  }\n  return context\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface PaymentSelectorProps\n  extends Omit<React.FieldsetHTMLAttributes<HTMLFieldSetElement>, \"onChange\" | \"defaultValue\" | \"name\" | \"children\"> {\n  methods: PaymentMethod[]\n  value: string | null\n  onValueChange: (id: string) => void\n  /** The provider slot. Return null for a method with nothing to show. */\n  renderPanel?: (method: PaymentMethod) => React.ReactNode\n  /** The order total, for instalment lines. */\n  total?: number\n  currency?: string\n  /** `undefined` follows the visitor's locale, once hydrated. */\n  locale?: string\n  /** A provider failure — \"Your card was declined\" — shown in the open panel. */\n  error?: React.ReactNode\n  /** Keep a panel mounted once opened, so a provider's iframe keeps its state. Defaults to true. */\n  keepMounted?: boolean\n  /** \"Payment\", by default. */\n  legend?: React.ReactNode\n  /** Keeps the legend for screen readers when your page already has a heading. */\n  hideLegend?: boolean\n  /** The radio group's name, for a plain form post. */\n  name?: string\n  /** The methods aren't known yet. */\n  loading?: boolean\n  disabled?: boolean\n  children?: React.ReactNode\n}\n\nexport function PaymentSelector({\n  methods,\n  value,\n  onValueChange,\n  renderPanel,\n  total,\n  currency = \"USD\",\n  locale: localeProp,\n  error = null,\n  keepMounted = true,\n  legend = \"Payment\",\n  hideLegend = false,\n  name,\n  loading = false,\n  disabled = false,\n  className,\n  children,\n  ...props\n}: PaymentSelectorProps) {\n  const locale = useFormatLocale(localeProp)\n  const uid = React.useId()\n  const legendId = `${uid}-legend`\n\n  // Every method opened so far; adjusted during render, so a panel mounts\n  // in the same commit as its selection.\n  const [opened, setOpened] = React.useState<ReadonlySet<string>>(() => new Set(value ? [value] : []))\n  if (value && !opened.has(value)) setOpened(new Set([...opened, value]))\n\n  const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"currency\", currency }), [locale, currency])\n  const formatMoney = React.useCallback((amount: number) => money.format(amount), [money])\n  const instalmentText = React.useCallback(\n    (method: PaymentMethod) => {\n      const n = method.instalments\n      if (!n || n < 2 || total === undefined) return null\n      return `${n} payments of ${money.format(Math.round((total / n) * 100) / 100)}`\n    },\n    [money, total]\n  )\n\n  const ids = methods.map((m) => m.id).join(\"\\n\")\n  const indexOf = React.useCallback((id: string) => ids.split(\"\\n\").indexOf(id), [ids])\n  const panelId = React.useCallback((id: string) => `${uid}-panel-${id}`, [uid])\n\n  const context = React.useMemo<PaymentSelectorContextValue>(\n    () => ({\n      methods,\n      value,\n      select: onValueChange,\n      selected: methods.find((m) => m.id === value),\n      opened,\n      keepMounted,\n      renderPanel,\n      error,\n      instalmentText,\n      formatMoney,\n      indexOf,\n      panelId,\n      scope: uid,\n      disabled,\n    }),\n    [methods, value, onValueChange, opened, keepMounted, renderPanel, error, instalmentText, formatMoney, indexOf, panelId, uid, disabled]\n  )\n\n  const rows = methods.length * 2\n  return (\n    <PaymentSelectorContext.Provider value={context}>\n      <MotionConfig reducedMotion=\"user\">\n        <fieldset\n          data-slot=\"payment-selector\"\n          aria-busy={loading || undefined}\n          className={cn(\"min-w-0\", className)}\n          {...props}\n        >\n          <legend id={legendId} className={cn(\"mb-3 text-base font-medium\", hideLegend && \"sr-only\")}>\n            {legend}\n          </legend>\n          {loading ? (\n            <PaymentSelectorSkeleton />\n          ) : methods.length === 0 ? (\n            <p data-slot=\"payment-selector-empty\" className=\"border px-4 py-5 text-sm text-muted-foreground\">\n              No payment method can be used for this order.\n            </p>\n          ) : (\n            // Methods and panels share one grid: the radio group spans it as\n            // a subgrid holding the method rows (odd rows), and each panel is\n            // placed in the row under its method (even rows) — outside the\n            // group in the DOM, so arrow keys never reach a provider's field.\n            // Both are pinned to column 1: left to auto-placement, a panel\n            // would find column 1 taken by the group and open a second one.\n            <div data-slot=\"payment-selector-list\" className=\"grid grid-cols-1 border\" style={{ gridTemplateRows: `repeat(${rows}, auto)` }}>\n              <RadioGroup\n                value={value ?? \"\"}\n                onValueChange={(next: unknown) => onValueChange(String(next))}\n                name={name}\n                disabled={disabled}\n                aria-labelledby={legendId}\n                className=\"grid gap-0\"\n                style={{ gridColumn: 1, gridRow: `1 / span ${rows}`, gridTemplateRows: \"subgrid\" }}\n              >\n                {children ?? methods.map((method) => <PaymentSelectorMethod key={method.id} method={method} />)}\n              </RadioGroup>\n              {methods.map((method) => (\n                <PaymentSelectorPanel key={method.id} method={method} />\n              ))}\n            </div>\n          )}\n        </fieldset>\n      </MotionConfig>\n    </PaymentSelectorContext.Provider>\n  )\n}\n\n/* ─── parts ────────────────────────────────────────────────────────────── */\n\nexport interface PaymentSelectorMethodProps extends React.HTMLAttributes<HTMLLabelElement> {\n  method: PaymentMethod\n}\n\n/** One method: the radio, its name, what it takes or costs, and your mark. */\nexport function PaymentSelectorMethod({ method, className, children, ...props }: PaymentSelectorMethodProps) {\n  const { value, indexOf, panelId, instalmentText, scope, disabled } = usePaymentSelector()\n  const reduce = useReducedMotion()\n  const selected = value === method.id\n  const unavailable = method.available === false\n  const instalments = unavailable ? null : instalmentText(method)\n  const noteId = `${panelId(method.id)}-note`\n  const hasNote = unavailable || instalments || method.description\n\n  return (\n    <label\n      data-slot=\"payment-selector-method\"\n      data-state={selected ? \"checked\" : \"unchecked\"}\n      data-unavailable={unavailable || undefined}\n      style={{ gridRow: indexOf(method.id) * 2 + 1 }}\n      className={cn(\n        \"relative flex min-h-14 cursor-pointer items-center gap-4 border-t px-4 py-3.5 transition-colors first:border-t-0\",\n        selected ? \"bg-secondary/50\" : \"hover:bg-secondary/30\",\n        (unavailable || disabled) && \"cursor-not-allowed hover:bg-transparent\",\n        className\n      )}\n      {...props}\n    >\n      {selected ? (\n        <motion.span\n          aria-hidden\n          layoutId={reduce ? undefined : `${scope}-rule`}\n          transition={MORPH}\n          className=\"absolute inset-y-0 -left-px w-0.5 bg-foreground\"\n        />\n      ) : null}\n      <RadioGroupItem\n        value={method.id}\n        disabled={unavailable || disabled}\n        aria-controls={panelId(method.id)}\n        aria-describedby={hasNote ? noteId : undefined}\n      />\n      <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <span className={cn(\"text-sm font-medium\", unavailable && \"text-muted-foreground line-through decoration-1\")}>\n          {method.label}\n        </span>\n        {hasNote ? (\n          <span id={noteId} className=\"flex flex-col text-[13px] leading-snug text-muted-foreground\">\n            {unavailable ? (method.unavailableReason ?? \"Not available for this order\") : null}\n            {!unavailable && instalments ? <span className=\"text-foreground tabular-nums\">{instalments}</span> : null}\n            {!unavailable && method.description ? <span>{method.description}</span> : null}\n          </span>\n        ) : null}\n      </span>\n      {method.icon ? (\n        <span aria-hidden className={cn(\"flex shrink-0 items-center gap-1.5 text-muted-foreground\", unavailable && \"opacity-50\")}>\n          {method.icon}\n        </span>\n      ) : null}\n      {children}\n    </label>\n  )\n}\n\n/**\n * The provider slot under one method. Mounted from the first time its\n * method is chosen; while another is chosen it folds to nothing and is\n * hidden, never unmounted (unless `keepMounted` is off).\n */\nfunction PaymentSelectorPanel({ method }: { method: PaymentMethod }) {\n  const { value, opened, keepMounted, renderPanel, error, indexOf, panelId } = usePaymentSelector()\n  const reduce = useReducedMotion()\n  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)\n  const open = value === method.id\n  if (!open && !(keepMounted && opened.has(method.id))) return null\n\n  const body = renderPanel?.(method) ?? null\n  const showError = open && error\n  if (body === null && !showError) return null\n\n  const shown = reduce\n    ? { opacity: 1, height: \"auto\", visibility: \"visible\" as const }\n    : { opacity: 1, height: \"auto\", visibility: \"visible\" as const, transitionEnd: { overflow: \"visible\" } }\n  const folded = { opacity: 0, height: 0, overflow: \"hidden\", transitionEnd: { visibility: \"hidden\" as const } }\n\n  return (\n    <motion.div\n      id={panelId(method.id)}\n      data-slot=\"payment-selector-panel\"\n      data-method={method.id}\n      data-state={open ? \"open\" : \"closed\"}\n      aria-hidden={open ? undefined : true}\n      style={{ gridColumn: 1, gridRow: indexOf(method.id) * 2 + 2 }}\n      // Opened after load: grow in. Open in the first render: already there.\n      initial={hydrated ? { opacity: 0, height: 0, overflow: \"hidden\" } : false}\n      animate={open ? shown : folded}\n      transition={reduce ? FADE : MORPH}\n      className=\"relative min-w-0 bg-secondary/50\"\n    >\n      <span aria-hidden className=\"absolute inset-y-0 -left-px w-0.5 bg-foreground\" />\n      <div className=\"px-4 pt-1 pb-5 sm:pl-12\">\n        {showError ? (\n          <p role=\"alert\" className=\"mb-3 flex items-start gap-2 text-sm text-destructive\">\n            <CircleAlert className=\"mt-0.5 size-4 shrink-0\" aria-hidden />\n            <span>{error}</span>\n          </p>\n        ) : null}\n        {body}\n      </div>\n    </motion.div>\n  )\n}\n\n/** Three ruled rows in the list's shape, while the methods load. */\nfunction PaymentSelectorSkeleton() {\n  return (\n    <div data-slot=\"payment-selector-skeleton\" className=\"border\">\n      <span className=\"sr-only\" role=\"status\">\n        Loading payment methods\n      </span>\n      {[0, 1, 2].map((row) => (\n        <div key={row} aria-hidden className=\"flex min-h-14 items-center gap-4 border-t px-4 py-3.5 first:border-t-0\">\n          <Skeleton className=\"size-4 rounded-full\" />\n          <span className=\"flex flex-1 flex-col gap-2\">\n            <Skeleton className=\"h-3.5 w-32 rounded-none\" />\n            <Skeleton className=\"h-3 w-48 rounded-none\" />\n          </span>\n          <Skeleton className=\"h-5 w-10 rounded-none\" />\n        </div>\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/payment-selector.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "checkout"
  ],
  "type": "registry:block"
}