{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "payouts-dialog",
  "title": "Payouts dialog",
  "description": "\"View payouts\" for a tournament pack: a button that opens the payout table the solve used, equal prizes collapsed into one row.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "path": "src/components/pokerdata/payouts-dialog.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { dollars, payoutRows } from \"@/lib/pokerdata/payouts\";\n\n/**\n * \"View payouts\" on an ICM game page: a button that opens the payout table\n * the packs were solved with, as a centered dialog.\n *\n * Adapted from React Bits Pro `app-dialog-1` (standard dialog with a footer),\n * the way `catalog-table` is adapted from `data-table-1`: same fade-in,\n * focus trap, Escape and backdrop to close, scroll fades on a long body,\n * and focus returned to the trigger. The light theme and the demo's plan\n * change list are replaced by the app's dark neutrals and a ruled ledger\n * of places and prizes, with equal prizes collapsed into one row.\n */\n\nconst cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(\" \");\n\nconst EASE: [number, number, number, number] = [0.2, 0, 0, 1];\n\nconst focus =\n  \"focus-visible:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--rb-accent,oklch(100%_0_0))]\";\nconst triggerButton =\n  \"inline-flex h-7 shrink-0 cursor-pointer items-center gap-1 rounded-full border border-neutral-700 px-3 text-xs text-neutral-300 transition-[color,border-color,transform] duration-150 hover:border-neutral-500 hover:text-neutral-100 active:scale-[0.98] motion-reduce:active:scale-100\";\nconst ghostIconButton =\n  \"inline-flex size-7 cursor-pointer items-center justify-center rounded-[var(--rb-r-sm,6px)] text-neutral-400 transition-colors duration-150 hover:bg-neutral-100/5 hover:text-neutral-100\";\nconst secondaryButton =\n  \"inline-flex h-8 cursor-pointer items-center justify-center rounded-[var(--rb-r-sm,6px)] border border-neutral-700 bg-neutral-900 px-4 text-[14px]/[16px] font-[450] text-neutral-100 transition-[border-color,transform] duration-150 hover:border-neutral-500 active:scale-[0.98] motion-reduce:active:scale-100\";\n\nconst FOCUSABLE =\n  'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\nfunction getFocusable(container: HTMLElement | null) {\n  return Array.from(container?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []);\n}\n\nfunction useDialogFocus(open: boolean, onClose: () => void) {\n  const panelRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const wasOpenRef = useRef(open);\n\n  useEffect(() => {\n    if (!open) return;\n    const panel = panelRef.current;\n    const doc = panel?.ownerDocument ?? document;\n    (getFocusable(panel)[0] ?? panel)?.focus({ preventScroll: true });\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        onClose();\n        return;\n      }\n      if (event.key !== \"Tab\") return;\n      const focusable = getFocusable(panel);\n      if (focusable.length === 0) return;\n      const first = focusable[0];\n      const last = focusable[focusable.length - 1];\n      const active = doc.activeElement;\n      if (event.shiftKey && active === first) {\n        event.preventDefault();\n        last.focus({ preventScroll: true });\n      } else if (!event.shiftKey && active === last) {\n        event.preventDefault();\n        first.focus({ preventScroll: true });\n      }\n    };\n    doc.addEventListener(\"keydown\", onKeyDown);\n    return () => doc.removeEventListener(\"keydown\", onKeyDown);\n  }, [open, onClose]);\n\n  useEffect(() => {\n    if (wasOpenRef.current && !open) triggerRef.current?.focus({ preventScroll: true });\n    wasOpenRef.current = open;\n  }, [open]);\n\n  return { panelRef, triggerRef };\n}\n\nfunction useScrollFade<T extends HTMLElement>() {\n  const ref = useRef<T>(null);\n  const [edges, setEdges] = useState({ start: false, end: false });\n  const update = useCallback(() => {\n    const el = ref.current;\n    if (!el) return;\n    const { scrollTop, scrollHeight, clientHeight } = el;\n    setEdges({\n      start: scrollTop > 1,\n      end: Math.ceil(scrollTop + clientHeight) < scrollHeight - 1,\n    });\n  }, []);\n  useEffect(() => {\n    update();\n    const el = ref.current;\n    const view = el?.ownerDocument.defaultView;\n    if (!el || !view?.ResizeObserver) return;\n    const observer = new view.ResizeObserver(update);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [update]);\n  return { ref, edges, onScroll: update };\n}\n\nexport function PayoutsDialog({\n  payouts,\n  source,\n  paid,\n}: {\n  /** Prize per paid place, 1st first, in dollars. */\n  payouts: readonly number[];\n  /** Where the table comes from, under the title. */\n  source: string;\n  /** \"30 paid (15%)\", the header cell's value. */\n  paid: string;\n}) {\n  const prefersReducedMotion = useReducedMotion();\n  const [open, setOpen] = useState(false);\n  const keyboardOpenRef = useRef(false);\n  const [keyboardOpen, setKeyboardOpen] = useState(false);\n  const closeDialog = useCallback(() => setOpen(false), []);\n  const { panelRef, triggerRef } = useDialogFocus(open, closeDialog);\n  const { ref: bodyRef, edges, onScroll } = useScrollFade<HTMLDivElement>();\n\n  const fade = !prefersReducedMotion && !keyboardOpen;\n  const rows = payoutRows(payouts);\n  const pool = payouts.reduce((t, p) => t + p, 0);\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        onPointerDown={() => {\n          keyboardOpenRef.current = false;\n        }}\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\" || event.key === \" \") keyboardOpenRef.current = true;\n        }}\n        onClick={() => {\n          setKeyboardOpen(keyboardOpenRef.current);\n          setOpen(true);\n        }}\n        className={cx(triggerButton, focus)}\n      >\n        View payouts\n      </button>\n\n      <AnimatePresence initial={false}>\n        {open && (\n          <>\n            <motion.div\n              aria-hidden=\"true\"\n              className=\"fixed inset-0 z-50 bg-neutral-950/70\"\n              onClick={closeDialog}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.12, ease: EASE } }}\n              transition={{ duration: fade ? 0.18 : 0, ease: EASE }}\n            />\n            <div className=\"pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6\">\n              <motion.div\n                ref={panelRef}\n                role=\"dialog\"\n                aria-modal=\"true\"\n                aria-labelledby=\"payouts-dialog-title\"\n                tabIndex={-1}\n                className=\"pointer-events-auto flex max-h-full w-full max-w-md flex-col overflow-hidden rounded-[var(--rb-r-lg,10px)] border border-neutral-800 bg-neutral-900 text-neutral-100 shadow-[0_1px_1px_rgb(0_0_0/0.3),0_24px_48px_-12px_rgb(0_0_0/0.6)] outline-none\"\n                initial={{ opacity: 0, scale: fade ? 0.98 : 1 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, transition: { duration: 0.12, ease: EASE } }}\n                transition={{ duration: fade ? 0.18 : 0, ease: EASE }}\n              >\n                <div className=\"mx-1 mt-1 flex min-h-0 flex-1 flex-col overflow-hidden rounded-[var(--rb-r-sm,6px)] border border-neutral-800 bg-neutral-950\">\n                  <header className=\"relative flex shrink-0 flex-col gap-1.5 px-5 pt-5 pb-4 pe-14\">\n                    <h2\n                      id=\"payouts-dialog-title\"\n                      className=\"text-[16px]/[20px] font-[450] tracking-[-0.015em] text-neutral-100\"\n                    >\n                      Payout structure\n                    </h2>\n                    <p className=\"text-[14px]/[20px] text-neutral-400\">\n                      {source}. {paid}, {dollars(pool)} across the paid places.\n                    </p>\n                    <div className=\"absolute end-4 top-4 flex\">\n                      <button\n                        type=\"button\"\n                        aria-label=\"Close dialog\"\n                        onClick={closeDialog}\n                        className={cx(ghostIconButton, focus)}\n                      >\n                        <X aria-hidden className=\"size-4 shrink-0\" />\n                      </button>\n                    </div>\n                  </header>\n\n                  <div className=\"relative min-h-0 flex-1\">\n                    <div ref={bodyRef} onScroll={onScroll} className=\"h-full overflow-y-auto px-5 pb-5\">\n                      <div className=\"grid grid-cols-[1fr_auto_auto] gap-x-6 text-[13px]/[16px] text-neutral-500\">\n                        <span className=\"pb-2\">Place</span>\n                        <span className=\"pb-2 text-right\">Prize</span>\n                        <span className=\"pb-2 text-right\">Share</span>\n                      </div>\n                      <dl className=\"divide-y divide-neutral-100/8\">\n                        {rows.map((row) => (\n                          <div\n                            key={row.places}\n                            className=\"grid min-h-10 grid-cols-[1fr_auto_auto] items-center gap-x-6 py-2\"\n                          >\n                            <dt className=\"text-[14px]/[16px] font-[450] text-neutral-300\">\n                              {row.places}\n                            </dt>\n                            <dd className=\"text-right text-[14px]/[16px] tabular-nums text-neutral-100\">\n                              {dollars(row.prize)}\n                            </dd>\n                            <dd className=\"w-12 text-right text-[13px]/[16px] tabular-nums text-neutral-500\">\n                              {(row.share * 100).toFixed(1)}%\n                            </dd>\n                          </div>\n                        ))}\n                      </dl>\n                      <p className=\"pt-4 text-[13px]/[18px] text-neutral-500\">\n                        Near-the-money packs pay every place here; the final table packs pay the\n                        first 8. Heads-up packs are chip EV and use no payouts.\n                      </p>\n                    </div>\n                    <div\n                      aria-hidden=\"true\"\n                      className={cx(\n                        \"pointer-events-none absolute inset-x-0 top-0 h-8 bg-linear-to-b from-neutral-950 to-transparent transition-opacity duration-[120ms]\",\n                        edges.start ? \"opacity-100\" : \"opacity-0\",\n                      )}\n                    />\n                    <div\n                      aria-hidden=\"true\"\n                      className={cx(\n                        \"pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-t from-neutral-950 to-transparent transition-opacity duration-[120ms]\",\n                        edges.end ? \"opacity-100\" : \"opacity-0\",\n                      )}\n                    />\n                  </div>\n                </div>\n\n                <footer className=\"flex min-h-11 shrink-0 items-center justify-end px-2 py-2\">\n                  <button\n                    type=\"button\"\n                    onClick={closeDialog}\n                    className={cx(secondaryButton, focus, \"w-full sm:w-auto\")}\n                  >\n                    Close\n                  </button>\n                </footer>\n              </motion.div>\n            </div>\n          </>\n        )}\n      </AnimatePresence>\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/payouts-dialog.tsx"
    },
    {
      "path": "src/lib/pokerdata/payouts.ts",
      "content": "/** \"1st\", \"2nd\", \"11th\". */\nexport function ordinal(n: number): string {\n  const mod100 = n % 100;\n  const suffix =\n    mod100 >= 11 && mod100 <= 13 ? \"th\" : ([\"th\", \"st\", \"nd\", \"rd\"][n % 10] ?? \"th\");\n  return `${n}${suffix}`;\n}\n\nexport const dollars = (n: number) => `$${n.toLocaleString(\"en-US\")}`;\n\n/** Equal prizes in a run collapse to one row: \"10th–11th · $748\". */\nexport function payoutRows(payouts: readonly number[]) {\n  const rows: { places: string; first: number; prize: number; share: number }[] = [];\n  const pool = payouts.reduce((t, p) => t + p, 0);\n  let start = 0;\n  while (start < payouts.length) {\n    let end = start;\n    while (end + 1 < payouts.length && payouts[end + 1] === payouts[start]) end++;\n    rows.push({\n      places: start === end ? ordinal(start + 1) : `${ordinal(start + 1)}–${ordinal(end + 1)}`,\n      first: start + 1,\n      prize: payouts[start],\n      share: payouts[start] / pool,\n    });\n    start = end + 1;\n  }\n  return rows;\n}\n",
      "type": "registry:lib",
      "target": "lib/pokerdata/payouts.ts"
    }
  ],
  "type": "registry:component"
}