{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "buy-drawer",
  "title": "Buy drawer",
  "description": "pokerdata.io's slide-in purchase panel: pack or bundle, the table graphic, total, email, billing country and the EU withdrawal waiver. Pass your own checkout call; it returns the Stripe URL to go to.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.pokerstudy.ai/r/pack-table.json"
  ],
  "files": [
    {
      "path": "src/components/pokerdata/buy-drawer.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { PackTableViz } from \"@/components/pokerdata/pack-table-viz\";\n\n/**\n * pokerdata.io's slide-in purchase panel: the catalog's Buy buttons open it\n * with the row's pack preselected. A segmented control switches between the\n * pack and its bundle; the table graphic and stack pills describe the\n * selection, and the total, email, country and checkout button stay at the\n * bottom of the viewport. EU/EEA/UK buyers tick the digital-content waiver.\n *\n * Shared by pokerdata.io and pokerstudy.ai's /downloads, each selling on its\n * own Stripe account. The site starts the checkout (`onCheckout` returns the\n * Stripe URL to go to), decides the email (prefilled from the account, or\n * fixed to it with `emailLocked`) and words the legal line under the button.\n */\n\n/** One way to buy: the pack itself, or a bundle that includes it. */\nexport type BuyOption = {\n  /** Checkout id: a pack or bundle id. */\n  id: string;\n  name: string;\n  price: string;\n  /** What the same packs cost bought singly; set on a bundle. */\n  compareAt?: string;\n  /** Table size, for the table graphic. */\n  seats: number;\n  /** Stack depths covered, one pill each. */\n  stackBbs: number[];\n  rake?: string;\n  /** Game type pill, e.g. \"No Limit Hold'em\". */\n  gameType?: string;\n  /** The button: \"Buy this pack\". */\n  cta: string;\n};\n\nexport type BuyDrawerTarget = {\n  /** The pack whose row was clicked; anchors the details link. */\n  simId: string;\n  title: string;\n  options: BuyOption[];\n  defaultId: string;\n};\n\nexport type BuyCheckout = {\n  id: string;\n  email: string;\n  /** Billing country, ISO 3166-1 alpha-2. */\n  country: string;\n  waiverAccepted: boolean;\n  /** The selected option's price, for analytics. */\n  priceCents?: number;\n};\n\n/** EU-27, EEA (IS, LI, NO) and the UK: a 14-day withdrawal right on digital goods. */\nconst WITHDRAWAL_RIGHT_COUNTRIES = new Set([\n  \"AT\",\n  \"BE\",\n  \"BG\",\n  \"HR\",\n  \"CY\",\n  \"CZ\",\n  \"DK\",\n  \"EE\",\n  \"FI\",\n  \"FR\",\n  \"DE\",\n  \"GR\",\n  \"HU\",\n  \"IE\",\n  \"IT\",\n  \"LV\",\n  \"LT\",\n  \"LU\",\n  \"MT\",\n  \"NL\",\n  \"PL\",\n  \"PT\",\n  \"RO\",\n  \"SK\",\n  \"SI\",\n  \"ES\",\n  \"SE\",\n  \"IS\",\n  \"LI\",\n  \"NO\",\n  \"GB\",\n]);\n\nconst OTHER_BILLING_COUNTRIES = [\n  \"US\",\n  \"CA\",\n  \"AU\",\n  \"NZ\",\n  \"CH\",\n  \"JP\",\n  \"KR\",\n  \"SG\",\n  \"IN\",\n  \"BR\",\n  \"MX\",\n  \"AE\",\n  \"ZA\",\n  \"AR\",\n  \"CL\",\n  \"IL\",\n  \"TW\",\n  \"HK\",\n  \"PH\",\n  \"MY\",\n  \"TH\",\n  \"ID\",\n];\n\nexport function hasWithdrawalRight(country: string | null | undefined): boolean {\n  return !!country && WITHDRAWAL_RIGHT_COUNTRIES.has(country.trim().toUpperCase());\n}\n\n/** The billing country picker's options, sorted by name. */\nexport function billingCountryOptions(): { value: string; label: string }[] {\n  const names =\n    typeof Intl !== \"undefined\" ? new Intl.DisplayNames([\"en\"], { type: \"region\" }) : null;\n  return [...new Set([...WITHDRAWAL_RIGHT_COUNTRIES, ...OTHER_BILLING_COUNTRIES])]\n    .map((value) => ({ value, label: names?.of(value) ?? value }))\n    .sort((a, b) => a.label.localeCompare(b.label));\n}\n\nconst EMAIL_RE = /^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/;\n\nconst EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];\nconst EASE_DRAWER: [number, number, number, number] = [0.32, 0.72, 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))]\";\n\nconst cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(\" \");\n\ntype BodyProps = {\n  /** Visitor's country from geo, preselected in the picker. */\n  geoCountry?: string | null;\n  /** The account's email: prefilled (never over what was typed). */\n  email?: string | null;\n  /** Buy with `email` only; the field is read-only. */\n  emailLocked?: boolean;\n  /** Start the checkout; resolve to the URL to send the buyer to, or throw. */\n  onCheckout: (checkout: BuyCheckout) => Promise<string>;\n  /** The pack's technical details page; no link when absent. */\n  detailsHref?: (simId: string) => string | null;\n  /** The line under the button; default: pokerdata.io's terms, privacy and refund policy. */\n  footer?: ReactNode;\n};\n\nexport function BuyDrawer({\n  target,\n  onClose,\n  ...body\n}: BodyProps & {\n  target: BuyDrawerTarget | null;\n  onClose: () => void;\n}) {\n  const reduce = useReducedMotion();\n  const panelRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (!target) return;\n    const doc = panelRef.current?.ownerDocument ?? document;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") onClose();\n    };\n    doc.addEventListener(\"keydown\", onKeyDown);\n    return () => doc.removeEventListener(\"keydown\", onKeyDown);\n  }, [target, onClose]);\n\n  if (typeof document === \"undefined\") return null;\n\n  return createPortal(\n    <AnimatePresence>\n      {target && (\n        <>\n          <motion.div\n            aria-hidden=\"true\"\n            onClick={onClose}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.16, ease: EASE_OUT } }}\n            transition={{ duration: 0.24, ease: EASE_OUT }}\n            className=\"fixed inset-0 z-40 bg-neutral-950/60 backdrop-blur-[2px]\"\n          />\n          <motion.aside\n            ref={panelRef}\n            role=\"dialog\"\n            aria-modal=\"true\"\n            aria-label={`Buy ${target.title}`}\n            initial={reduce ? { opacity: 0 } : { x: \"100%\" }}\n            animate={reduce ? { opacity: 1 } : { x: 0 }}\n            exit={\n              reduce\n                ? { opacity: 0, transition: { duration: 0.16 } }\n                : { x: \"100%\", transition: { duration: 0.22, ease: EASE_DRAWER } }\n            }\n            transition={{ duration: 0.32, ease: EASE_DRAWER }}\n            className=\"fixed inset-y-0 right-0 z-50 flex h-dvh w-full flex-col border-l border-neutral-800 bg-neutral-950 sm:max-w-md\"\n          >\n            {/* Keyed so opening a different pack resets the selection. */}\n            <BuyDrawerBody key={target.simId} target={target} {...body} />\n          </motion.aside>\n        </>\n      )}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\nfunction BuyDrawerBody({\n  target,\n  geoCountry,\n  email: accountEmail,\n  emailLocked = false,\n  onCheckout,\n  detailsHref,\n  footer,\n}: BodyProps & { target: BuyDrawerTarget }) {\n  const [selectedId, setSelectedId] = useState(target.defaultId);\n  const [typed, setTyped] = useState(\"\");\n  const [country, setCountry] = useState(geoCountry ?? \"\");\n  const [waiver, setWaiver] = useState(false);\n  const [busy, setBusy] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const selected = target.options.find((o) => o.id === selectedId) ?? target.options[0];\n  const email = (emailLocked ? accountEmail : typed || accountEmail)?.trim() ?? \"\";\n  const withdrawalProtected = hasWithdrawalRight(country);\n  const validEmail = EMAIL_RE.test(email);\n  // The waiver only counts while the country has a withdrawal right.\n  const waived = withdrawalProtected && waiver;\n  const canBuy = validEmail && !!country && (!withdrawalProtected || waived);\n  const details = detailsHref?.(target.simId);\n\n  async function checkout() {\n    setBusy(true);\n    setError(null);\n    try {\n      window.location.href = await onCheckout({\n        id: selected.id,\n        email,\n        country,\n        waiverAccepted: waived,\n        priceCents: priceToCents(selected.price),\n      });\n    } catch (err) {\n      setError((err as Error).message || \"Could not start checkout.\");\n      setBusy(false);\n    }\n  }\n\n  return (\n    <div className=\"grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] gap-3 p-4\">\n      {target.options.length > 1 ? (\n        <div\n          role=\"radiogroup\"\n          aria-label=\"Purchase options\"\n          className=\"grid grid-cols-2 rounded-full border border-neutral-800 bg-neutral-900/60 p-1\"\n        >\n          {target.options.map((option) => {\n            const active = option.id === selected.id;\n            return (\n              <button\n                key={option.id}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={active}\n                onClick={() => setSelectedId(option.id)}\n                className={cx(\n                  \"h-10 cursor-pointer rounded-full px-4 text-sm font-medium transition-colors duration-150\",\n                  active\n                    ? \"bg-neutral-100 text-neutral-900\"\n                    : \"text-neutral-400 hover:text-neutral-200\",\n                  focus,\n                )}\n              >\n                {option.compareAt ? \"Bundle\" : \"Individual\"}\n              </button>\n            );\n          })}\n        </div>\n      ) : (\n        <div className=\"flex h-10 items-center justify-between gap-3\">\n          <span className=\"truncate text-sm font-medium text-neutral-100\">{selected.name}</span>\n          <span className=\"shrink-0 text-sm text-neutral-400\">{selected.price}</span>\n        </div>\n      )}\n\n      <div className=\"min-h-0 overflow-y-auto\">\n        <PackTableViz\n          seats={selected.seats}\n          stackBbs={selected.stackBbs}\n          rake={selected.rake}\n          compact\n        />\n        {details && (\n          <a\n            href={details}\n            className={cx(\n              \"mt-3 inline-flex text-[13px] text-neutral-400 underline underline-offset-2 transition-colors duration-150 hover:text-neutral-100\",\n              focus,\n            )}\n          >\n            Technical details: file format, tree and solver run →\n          </a>\n        )}\n      </div>\n\n      <div className=\"flex flex-col gap-2.5 border-t border-neutral-800 pt-3\">\n        <div className=\"flex items-end justify-between\">\n          <span className=\"text-sm text-neutral-400\">Total</span>\n          <span className=\"text-2xl font-semibold text-neutral-50\">{selected.price}</span>\n        </div>\n\n        <div className=\"flex flex-col gap-1.5\">\n          <label htmlFor=\"bd-email\" className=\"text-[12px] font-medium text-neutral-300\">\n            Email\n          </label>\n          <input\n            id=\"bd-email\"\n            type=\"email\"\n            autoComplete=\"email\"\n            inputMode=\"email\"\n            required\n            readOnly={emailLocked}\n            value={email}\n            onChange={(e) => setTyped(e.target.value)}\n            placeholder=\"you@example.com\"\n            className={cx(\n              \"h-10 w-full rounded-[10px] border border-neutral-800 bg-neutral-900 px-3 text-sm text-neutral-100 placeholder:text-neutral-600 transition-colors duration-150\",\n              emailLocked\n                ? \"cursor-default text-neutral-300\"\n                : \"hover:border-neutral-700 focus:border-white\",\n              focus,\n            )}\n          />\n          <p className=\"text-[11px] leading-relaxed text-neutral-500\">\n            {emailLocked\n              ? \"Your account's email: the receipt goes here and the pack shows up in Your packs.\"\n              : \"Your receipt and downloads are tied to this email.\"}\n          </p>\n        </div>\n\n        <div className=\"flex flex-col gap-1.5\">\n          <label htmlFor=\"bd-country\" className=\"text-[12px] font-medium text-neutral-300\">\n            Your country\n          </label>\n          <select\n            id=\"bd-country\"\n            value={country}\n            onChange={(e) => setCountry(e.target.value)}\n            className={cx(\n              \"h-10 w-full cursor-pointer rounded-[10px] border border-neutral-800 bg-neutral-900 px-3 text-sm text-neutral-100 transition-colors duration-150 hover:border-neutral-700 focus:border-white\",\n              focus,\n            )}\n          >\n            <option value=\"\">Select country</option>\n            {billingCountryOptions().map((option) => (\n              <option key={option.value} value={option.value}>\n                {option.label}\n              </option>\n            ))}\n          </select>\n          {withdrawalProtected && (\n            <label className=\"flex cursor-pointer items-start gap-2.5 rounded-[10px] bg-neutral-900 px-3 py-2.5\">\n              <input\n                type=\"checkbox\"\n                checked={waiver}\n                onChange={(e) => setWaiver(e.target.checked)}\n                className=\"mt-0.5 h-4 w-4 shrink-0 cursor-pointer appearance-none rounded-[4px] border border-neutral-700 bg-neutral-950 transition-colors duration-150 checked:border-white checked:bg-white hover:border-neutral-600 focus-visible:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--rb-accent,oklch(100%_0_0))]\"\n              />\n              <span className=\"min-w-0 text-[11px] leading-relaxed text-neutral-400\">\n                I expressly request immediate delivery of this digital content and acknowledge that\n                I thereby lose my 14-day right of withdrawal once the download is made available.\n              </span>\n            </label>\n          )}\n        </div>\n\n        <button\n          type=\"button\"\n          onClick={() => void checkout()}\n          disabled={!canBuy || busy}\n          className={cx(\n            \"inline-flex h-10 cursor-pointer items-center justify-center rounded-full bg-neutral-100 px-4 text-sm font-medium text-neutral-900 transition-colors duration-150 hover:bg-white disabled:opacity-60\",\n            focus,\n          )}\n        >\n          {busy ? \"Opening checkout…\" : `${selected.cta} →`}\n        </button>\n        {error && <p className=\"text-[11px] leading-relaxed text-red-400\">{error}</p>}\n        <p className=\"text-[11px] leading-relaxed text-neutral-500\">\n          {footer ?? <PokerDataTerms />}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nfunction PokerDataTerms() {\n  const link = \"underline underline-offset-2 hover:text-neutral-300\";\n  return (\n    <>\n      By continuing you agree to PokerData&apos;s{\" \"}\n      <a href=\"/terms\" className={link}>\n        terms of use\n      </a>\n      ,{\" \"}\n      <a href=\"/privacy\" className={link}>\n        privacy policy\n      </a>\n      , and{\" \"}\n      <a href=\"/refunds\" className={link}>\n        refund policy\n      </a>\n      .\n    </>\n  );\n}\n\n/** \"$1,250\" → 125000; undefined when the label isn't a plain price. */\nfunction priceToCents(label: string): number | undefined {\n  const value = Number(label.replace(/[^0-9.]/g, \"\"));\n  return Number.isFinite(value) && value > 0 ? Math.round(value * 100) : undefined;\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/buy-drawer.tsx"
    }
  ],
  "type": "registry:block"
}