{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "catalog-table",
  "title": "Catalog table",
  "description": "A pokerdata.io game's packs: tabs, search, filter chips, sortable columns and a card layout on phones, with Details, Browse and Download on every row. Bring your own range and purchase drawers.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "src/components/pokerdata/catalog-table.tsx",
      "content": "\"use client\";\n\nimport {\n  Suspense,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport Link from \"next/link\";\nimport { useSearchParams } from \"next/navigation\";\nimport { ChevronUp, Search, SearchX } from \"lucide-react\";\n\n/**\n * The one catalog table — every dataset PokerData serves, preflop and postflop,\n * in a single tabbed, filterable, sortable table.\n *\n * Adapted from React Bits Pro `data-table-1` (sortable heads, faceted filter\n * chips, scroll fades, mobile card fallback), the same\n * way `pd-shell` is adapted from `app-shell-1`. The block's hardcoded demo\n * dataset is replaced by a tab-driven spec, because the two datasets have\n * genuinely different columns — preflop packs have a file size and a raise\n * tree, postflop packs have boards and a pot. Tabs let each keep its own\n * columns, filters and sort instead of forcing a lowest-common-denominator row.\n *\n * Everything below the spec is presentation: rows arrive fully formatted from\n * the server, carrying a `value` only where sorting must not follow the\n * display string — \"1.4 GB\" and \"12bb\" sort as numbers.\n *\n * Shared by pokerdata.io's game pages and pokerstudy.ai's /downloads. The\n * drawers are the host's: `B` is what Browse opens and `T` what Buy opens,\n * and `renderBrowse` / `renderBuy` draw them (or `onBuy` handles Buy without a\n * drawer). Without `renderBrowse` there is no Browse button, without either\n * buy prop no Buy button; `renderRowAction` replaces a row's Buy/Download slot.\n */\n\nconst cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(\" \");\n\nexport type CatalogCell = {\n  /** What renders in the cell. */\n  text: string;\n  /** Sort key, when the display string would sort wrongly (sizes, bb, counts). */\n  value?: number;\n};\n\nexport type CatalogRow<B = unknown, T = unknown> = {\n  id: string;\n  /** Column key → cell. Keys must match the tab's `columns`. */\n  cells: Record<string, CatalogCell>;\n  /** Which filter chip this row belongs to. */\n  facet: string;\n  /** Primary row action; null when the row isn't reachable. */\n  action: { href: string; label: string } | null;\n  /**\n   * Purchase action: opens the buy drawer instead of navigating. Takes the\n   * primary slot when set (rows carry either `action` or `buy`, not both).\n   */\n  buy?: T & { label: string };\n  /** Optional secondary action: opens the range drawer with this pack. */\n  browse?: B & { label: string; title: string };\n  /** Optional link to the pack's technical details page (preflop packs). */\n  details?: { href: string; label: string };\n  /** Secondary line under the name in the mobile card layout. */\n  subtitle: string;\n  /** Planned but not solved yet: listed with a \"Coming soon\" tag, no actions. */\n  soon?: boolean;\n};\n\nexport type CatalogColumn = {\n  key: string;\n  label: string;\n  align?: \"right\";\n  width?: string;\n  sortable?: boolean;\n};\n\nexport type CatalogTab<B = unknown, T = unknown> = {\n  key: string;\n  label: string;\n  /** Small pill after the label, e.g. \"ICM\" on the ICM stages. */\n  tag?: string;\n  /** First entry is the row name — always visible, always first. */\n  columns: CatalogColumn[];\n  facets: { value: string; label: string }[];\n  facetAllLabel: string;\n  rows: CatalogRow<B, T>[];\n  /** The search box's placeholder; no search box without one. */\n  searchPlaceholder?: string;\n  /** The tab's packs sold as one bundle: its price and buy drawer. */\n  bundle?: {\n    price: string;\n    /** Struck-through price beside it. */\n    compareAt?: string;\n    owned: boolean;\n    buy?: T & { label: string };\n  };\n  defaultSort: { key: string; dir: \"asc\" | \"desc\" };\n  /**\n   * Footer note. Plain data, not JSX: this component is a client boundary and\n   * the tabs are built in a server component, where JSX with several children\n   * crosses the RSC boundary as an unkeyed array and React warns about it.\n   */\n  note?: { text: string; link?: { href: string; label: string }; tail?: string };\n};\n\nconst headBase =\n  \"h-9 px-3 text-xs font-medium text-neutral-500 first:pl-4 sm:first:pl-6 last:pr-4 sm:last:pr-6\";\nconst cellBase = \"px-3 text-[13px] first:pl-4 sm:first:pl-6 last:pr-4 sm:last:pr-6\";\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// Browse is the free action on every row, so it is the filled pill; Download\n// (paid) is the outlined one. A grey text link read as disabled.\nconst browsePill =\n  \"mr-2 inline-flex h-7 cursor-pointer items-center rounded-full bg-neutral-100 px-3 text-xs font-medium text-neutral-900 transition-colors duration-150 hover:bg-white\";\n// Details is a plain link to the pack's spec page: outlined like Download,\n// before Browse so the paid action stays last.\nconst detailsPill =\n  \"mr-2 inline-flex h-7 items-center rounded-full border border-neutral-700 px-3 text-xs text-neutral-300 transition-colors duration-150 hover:border-neutral-500 hover:text-neutral-100\";\n// A planned row: a quiet tag where the actions would be.\nconst soonTag =\n  \"inline-flex h-7 items-center rounded-full border border-dashed border-neutral-700 px-3 text-xs text-neutral-500\";\nconst chipBase =\n  \"inline-flex h-8 shrink-0 cursor-pointer items-center rounded-[var(--rb-r-md,8px)] px-2.5 text-[13px] font-medium transition-[transform,background-color,border-color,color] duration-150 active:scale-[0.97]\";\nconst chipOn = \"bg-neutral-100 text-neutral-900\";\nconst chipOff =\n  \"border border-neutral-800 bg-neutral-800 text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100\";\n\nfunction useScrollFadeX<T extends HTMLElement>() {\n  const ref = useRef<T>(null);\n  const [edges, setEdges] = useState({ start: false, end: false });\n\n  const update = useCallback(() => {\n    const el = ref.current;\n    if (!el) return;\n    const { scrollLeft, scrollWidth, clientWidth } = el;\n    setEdges({\n      start: scrollLeft > 1,\n      end: Math.ceil(scrollLeft + clientWidth) < scrollWidth - 1,\n    });\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\n  return { ref, edges, onScroll: update };\n}\n\nfunction SortHead({\n  col,\n  sort,\n  onSort,\n}: {\n  col: CatalogColumn;\n  sort: { key: string; dir: \"asc\" | \"desc\" };\n  onSort: (key: string) => void;\n}) {\n  const active = sort.key === col.key;\n  const cls = cx(headBase, col.align === \"right\" && \"text-right\", col.width);\n\n  if (!col.sortable) {\n    return (\n      <th scope=\"col\" className={cls}>\n        {col.label}\n      </th>\n    );\n  }\n\n  return (\n    <th\n      scope=\"col\"\n      aria-sort={active ? (sort.dir === \"asc\" ? \"ascending\" : \"descending\") : \"none\"}\n      className={cls}\n    >\n      <button\n        type=\"button\"\n        onClick={() => onSort(col.key)}\n        className={cx(\n          \"inline-flex h-9 max-w-full cursor-pointer items-center gap-1 text-xs font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--rb-accent,oklch(100%_0_0))]\",\n          col.align === \"right\" && \"flex-row-reverse\",\n          active ? \"text-neutral-100\" : \"text-neutral-500 hover:text-neutral-100\",\n        )}\n      >\n        <span className=\"truncate\">{col.label}</span>\n        <ChevronUp\n          aria-hidden\n          className={cx(\n            \"h-3 w-3 shrink-0 transition-[opacity,transform] duration-150\",\n            active ? \"opacity-100\" : \"opacity-0\",\n            active && sort.dir === \"desc\" && \"rotate-180\",\n          )}\n        />\n      </button>\n    </th>\n  );\n}\n\nfunction rowForBrowse<B, T>(\n  tabs: CatalogTab<B, T>[],\n  id: string | undefined,\n): { tabKey: string; target: B } | null {\n  if (!id) return null;\n  for (const tab of tabs) {\n    const row = tab.rows.find((r) => r.id === id && r.browse);\n    if (row?.browse) return { tabKey: tab.key, target: row.browse };\n  }\n  return null;\n}\n\nfunction setBrowseQuery(id: string | null) {\n  const url = new URL(window.location.href);\n  if (id) url.searchParams.set(\"browse\", id);\n  else url.searchParams.delete(\"browse\");\n  const next = `${url.pathname}${url.search}${url.hash}`;\n  window.history.replaceState(null, \"\", next);\n}\n\nfunction BrowseFromQuery<B, T>({\n  tabs,\n  resolveBrowseId,\n  onOpen,\n}: {\n  tabs: CatalogTab<B, T>[];\n  resolveBrowseId?: (id: string) => B | null;\n  onOpen: (hit: { tabKey?: string; target: B }) => void;\n}) {\n  const params = useSearchParams();\n  const id = params.get(\"browse\");\n  useEffect(() => {\n    if (!id) return;\n    const row = rowForBrowse(tabs, id);\n    const target = row?.target ?? resolveBrowseId?.(id) ?? null;\n    if (!target) return;\n    onOpen({ tabKey: row?.tabKey, target });\n  }, [id, tabs, resolveBrowseId, onOpen]);\n  return null;\n}\n\n/** A tab's packs as one bundle: price, struck price, Buy (or Owned). */\nfunction SectionOffer<T>({\n  bundle,\n  onBuy,\n}: {\n  bundle: NonNullable<CatalogTab<unknown, T>[\"bundle\"]>;\n  onBuy?: (target: T) => void;\n}) {\n  return (\n    <div className=\"ml-auto flex shrink-0 items-center gap-3\">\n      <span className=\"text-[13px] text-neutral-400\">\n        Whole section{\" \"}\n        <span className=\"font-medium text-neutral-100\">{bundle.price}</span>\n        {bundle.compareAt && (\n          <span className=\"ml-1.5 text-neutral-500 line-through\">{bundle.compareAt}</span>\n        )}\n      </span>\n      {bundle.owned ? (\n        <span className=\"text-[13px] text-neutral-400\">Owned</span>\n      ) : bundle.buy && onBuy ? (\n        <button\n          type=\"button\"\n          onClick={() => onBuy(bundle.buy!)}\n          className={cx(\n            \"inline-flex h-8 cursor-pointer items-center rounded-full bg-neutral-100 px-3.5 text-[13px] font-medium text-neutral-900 transition-colors duration-150 hover:bg-white\",\n            focus,\n          )}\n        >\n          {bundle.buy.label}\n        </button>\n      ) : null}\n    </div>\n  );\n}\n\nexport function CatalogTable<B, T>({\n  tabs,\n  initialBrowseId,\n  banner,\n  resolveBrowseId,\n  renderBrowse,\n  renderBuy,\n  onBuy: buyHandler,\n  renderRowAction,\n}: {\n  tabs: CatalogTab<B, T>[];\n  initialBrowseId?: string;\n  /** An offer above the tabs: every section at once (the ICM page). */\n  banner?: { title: string; text: string; buy?: T & { label: string } };\n  /** A `?browse=<id>` that matches no row: the drawer target for it, if any. */\n  resolveBrowseId?: (id: string) => B | null;\n  /** The range drawer, open on `target` (null: closed). */\n  renderBrowse?: (target: B | null, close: () => void) => ReactNode;\n  /** The purchase drawer, open on `target` (null: closed). */\n  renderBuy?: (target: T | null, close: () => void) => ReactNode;\n  /** Buy without a drawer (e.g. straight to checkout); `renderBuy` wins. */\n  onBuy?: (target: T) => void;\n  /** A row's own Buy/Download slot; undefined keeps the row's `buy` / `action`. */\n  renderRowAction?: (row: CatalogRow<B, T>) => ReactNode | undefined;\n}) {\n  const opened = renderBrowse\n    ? (rowForBrowse(tabs, initialBrowseId) ??\n      (initialBrowseId\n        ? {\n            tabKey: tabs[0]?.key ?? \"\",\n            target: resolveBrowseId?.(initialBrowseId) ?? null,\n          }\n        : null))\n    : null;\n  const [tabKey, setTabKey] = useState(\n    opened?.target ? opened.tabKey : (tabs[0]?.key ?? \"\"),\n  );\n  const [browseTarget, setBrowseTarget] = useState<B | null>(opened?.target ?? null);\n  const openFromQuery = useCallback((hit: { tabKey?: string; target: B }) => {\n    if (hit.tabKey) setTabKey(hit.tabKey);\n    setBrowseTarget(hit.target);\n  }, []);\n  const [buyTarget, setBuyTarget] = useState<T | null>(null);\n  const onBuy = renderBuy ? setBuyTarget : buyHandler;\n  // Without a drawer to open, a row has no Browse.\n  const browseOf = (r: CatalogRow<B, T>) => (renderBrowse ? r.browse : undefined);\n  const tab = tabs.find((t) => t.key === tabKey) ?? tabs[0];\n\n  const [query, setQuery] = useState(\"\");\n  const [facet, setFacet] = useState<string | null>(null);\n  const [sortByTab, setSortByTab] = useState<Record<string, { key: string; dir: \"asc\" | \"desc\" }>>(\n    {},\n  );\n  const { ref: chipsRef, edges: chipEdges, onScroll: onChipsScroll } =\n    useScrollFadeX<HTMLDivElement>();\n  // The postflop tab is wider than a laptop viewport; fade the right edge so the\n  // horizontal scroll is discoverable rather than a silently clipped column.\n  const { ref: bodyRef, edges: bodyEdges, onScroll: onBodyScroll } =\n    useScrollFadeX<HTMLDivElement>();\n\n  const sort = sortByTab[tab.key] ?? tab.defaultSort;\n\n  function switchTab(key: string) {\n    setTabKey(key);\n    setQuery(\"\");\n    setFacet(null);\n  }\n\n  function toggleSort(key: string) {\n    setSortByTab((s) => {\n      const current = s[tab.key] ?? tab.defaultSort;\n      const next =\n        current.key === key\n          ? { key, dir: current.dir === \"asc\" ? (\"desc\" as const) : (\"asc\" as const) }\n          : { key, dir: \"asc\" as const };\n      return { ...s, [tab.key]: next };\n    });\n  }\n\n  const columns = tab.columns;\n\n  /**\n   * Search runs over every cell of the row, including cells with no column\n   * (the raise tree), so a query can hit more than the table shows. Deriving\n   * the haystack from the cells rather than a separate server-built string\n   * is what makes that true: a rendered value can never be unsearchable.\n   * (\"SB vs BTN\" reads as one phrase in the Seats column, so it matches as one.)\n   */\n  const searchable = useMemo(\n    () =>\n      tab.rows.map((row) => ({\n        row,\n        hay: Object.values(row.cells)\n          .map((c) => c.text)\n          .join(\" \")\n          .toLowerCase(),\n      })),\n    [tab.rows],\n  );\n\n  const rows = useMemo(() => {\n    const q = query.trim().toLowerCase();\n    const filtered = searchable\n      .filter(({ row, hay }) => (!facet || row.facet === facet) && (!q || hay.includes(q)))\n      .map(({ row }) => row);\n    const factor = sort.dir === \"asc\" ? 1 : -1;\n    return [...filtered].sort((a, b) => {\n      const x = a.cells[sort.key];\n      const y = b.cells[sort.key];\n      if (x?.value !== undefined && y?.value !== undefined) return (x.value - y.value) * factor;\n      return (x?.text ?? \"\").localeCompare(y?.text ?? \"\") * factor;\n    });\n  }, [searchable, query, facet, sort]);\n\n  // Mobile cards title by the name cell even when no \"name\" column is shown\n  // (the preflop tab dropped its Pack column but keeps the cell for search).\n  // An ICM game page shows each pack's stage (\"Near the bubble\") in place of\n  // the name, on desktop and mobile alike.\n  const nameKey = tab.columns.some((c) => c.key === \"stage\")\n    ? \"stage\"\n    : tab.rows[0]?.cells?.name !== undefined\n      ? \"name\"\n      : (tab.columns[0]?.key ?? \"name\");\n\n  return (\n    <>\n    {banner && (\n      <div className=\"flex flex-col gap-3 rounded-[var(--rb-r-xl,12px)] border border-neutral-700 bg-neutral-900 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6\">\n        <div className=\"flex min-w-0 flex-col gap-0.5\">\n          <span className=\"text-[15px] font-medium text-neutral-100\">{banner.title}</span>\n          <span className=\"text-[13px] text-neutral-400\">{banner.text}</span>\n        </div>\n        {banner.buy && onBuy && (\n          <button\n            type=\"button\"\n            onClick={() => onBuy(banner.buy!)}\n            className={cx(\n              \"inline-flex h-9 shrink-0 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\",\n              focus,\n            )}\n          >\n            {banner.buy.label}\n          </button>\n        )}\n      </div>\n    )}\n    <div className=\"flex min-h-0 w-full flex-col overflow-hidden rounded-[var(--rb-r-xl,12px)] border border-neutral-800 bg-neutral-950\">\n      {/* Tabs */}\n      <div className=\"flex shrink-0 items-center gap-1 overflow-x-auto border-b border-neutral-800 bg-neutral-900 px-2 [scrollbar-width:none] sm:px-4 [&::-webkit-scrollbar]:hidden\">\n        {tabs.map((t) => {\n          const active = t.key === tab.key;\n          return (\n            <button\n              key={t.key}\n              type=\"button\"\n              role=\"tab\"\n              aria-selected={active}\n              onClick={() => switchTab(t.key)}\n              className={cx(\n                \"relative h-11 shrink-0 cursor-pointer whitespace-nowrap px-3 text-[13px] font-medium transition-colors duration-150\",\n                active ? \"text-neutral-100\" : \"text-neutral-500 hover:text-neutral-200\",\n                focus,\n              )}\n            >\n              {t.label}\n              {t.tag && (\n                <span className=\"ml-1.5 rounded border border-sky-800/60 bg-sky-950/60 px-1 py-px text-[10px] font-semibold tracking-wide text-sky-300\">\n                  {t.tag}\n                </span>\n              )}\n              <span className=\"ml-1.5 text-xs tabular-nums text-neutral-500\">{t.rows.length}</span>\n              <span\n                aria-hidden\n                className={cx(\n                  \"absolute inset-x-2 -bottom-px h-0.5 rounded-full transition-opacity duration-150\",\n                  active ? \"bg-neutral-100 opacity-100\" : \"opacity-0\",\n                )}\n              />\n            </button>\n          );\n        })}\n      </div>\n\n      {/* Toolbar: search, facet chips, the tab's bundle */}\n      {(tab.searchPlaceholder || tab.facets.length > 0 || tab.bundle) && (\n      <div className=\"flex shrink-0 flex-wrap items-center gap-2 bg-neutral-900 px-4 py-2 sm:h-12 sm:flex-nowrap sm:py-0 sm:px-6\">\n        {tab.searchPlaceholder && (\n        <label className=\"relative min-w-0 flex-1 sm:max-w-xs\">\n          <span className=\"sr-only\">{tab.searchPlaceholder}</span>\n          <Search\n            aria-hidden\n            className=\"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500\"\n          />\n          <input\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            placeholder={tab.searchPlaceholder}\n            className={cx(\n              \"h-9 w-full rounded-[var(--rb-r-md,8px)] border border-neutral-800 bg-neutral-950 pl-9 pr-3 text-sm text-neutral-100 placeholder:text-neutral-500 transition-colors duration-150 hover:border-neutral-700 focus:border-white\",\n              focus,\n            )}\n          />\n        </label>\n        )}\n\n        {tab.facets.length > 0 && (\n        <div className=\"relative -mx-4 order-last w-[calc(100%+2rem)] sm:order-none sm:mx-0 sm:w-auto\">\n          <div\n            ref={chipsRef}\n            onScroll={onChipsScroll}\n            className=\"flex items-center gap-1.5 overflow-x-auto px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:overflow-visible sm:px-0\"\n          >\n            <button\n              type=\"button\"\n              aria-pressed={facet === null}\n              onClick={() => setFacet(null)}\n              className={cx(chipBase, facet === null ? chipOn : chipOff, focus)}\n            >\n              {tab.facetAllLabel}\n            </button>\n            {tab.facets.map((f) => {\n              const active = facet === f.value;\n              return (\n                <button\n                  key={f.value}\n                  type=\"button\"\n                  aria-pressed={active}\n                  onClick={() => setFacet(active ? null : f.value)}\n                  className={cx(chipBase, active ? chipOn : chipOff, focus)}\n                >\n                  {f.label}\n                </button>\n              );\n            })}\n          </div>\n          <div\n            aria-hidden\n            className={cx(\n              \"pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-neutral-900 to-transparent transition-opacity duration-200 sm:hidden\",\n              chipEdges.start ? \"opacity-100\" : \"opacity-0\",\n            )}\n          />\n          <div\n            aria-hidden\n            className={cx(\n              \"pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-neutral-900 to-transparent transition-opacity duration-200 sm:hidden\",\n              chipEdges.end ? \"opacity-100\" : \"opacity-0\",\n            )}\n          />\n        </div>\n        )}\n\n        {tab.bundle && <SectionOffer bundle={tab.bundle} onBuy={onBuy} />}\n      </div>\n      )}\n\n      {/* Table (md+) */}\n      <div className=\"relative min-h-0 flex-1\">\n        <div ref={bodyRef} onScroll={onBodyScroll} className=\"h-full overflow-auto\">\n        <table className=\"hidden w-full min-w-[820px] border-collapse text-left md:table\">\n          <caption className=\"sr-only\">{tab.label} catalog</caption>\n          <thead className=\"sticky top-0 z-20 bg-neutral-900\">\n            <tr>\n              {columns.map((c) => (\n                <SortHead key={c.key} col={c} sort={sort} onSort={toggleSort} />\n              ))}\n              <th scope=\"col\" className={cx(headBase, \"w-[200px] text-right\")}>\n                <span className=\"sr-only\">Open</span>\n              </th>\n            </tr>\n          </thead>\n          <tbody className=\"divide-y divide-neutral-800/70\">\n            {rows.map((r) => (\n              <tr\n                key={r.id}\n                className={cx(\n                  \"h-11 transition-colors duration-150 hover:bg-neutral-900/60\",\n                  r.soon && \"[&_td:not(:last-child)]:text-neutral-500\",\n                )}\n              >\n                {columns.map((c) => (\n                  <td\n                    key={c.key}\n                    className={cx(\n                      cellBase,\n                      c.align === \"right\" && \"text-right tabular-nums\",\n                      c.key === nameKey\n                        ? \"max-w-[300px] font-medium text-neutral-100\"\n                        : \"whitespace-nowrap text-neutral-400\",\n                    )}\n                  >\n                    {c.key === nameKey && browseOf(r) ? (\n                      // The pack name opens the same drawer as Browse: the\n                      // obvious thing to click on a row is its name.\n                      <button\n                        type=\"button\"\n                        onClick={() => {\n                          setBrowseTarget(r.browse ?? null);\n                          setBrowseQuery(r.id);\n                        }}\n                        className={cx(\n                          \"block max-w-full cursor-pointer truncate text-left underline-offset-4 hover:underline\",\n                          focus,\n                        )}\n                      >\n                        {r.cells[c.key]?.text ?? \"—\"}\n                      </button>\n                    ) : (\n                      <span className=\"block truncate\">{r.cells[c.key]?.text ?? \"—\"}</span>\n                    )}\n                  </td>\n                ))}\n                <td className={cx(cellBase, \"whitespace-nowrap text-right\")}>\n                  {r.soon && <span className={soonTag}>Coming soon</span>}\n                  {r.details && (\n                    <Link\n                      href={r.details.href}\n                      aria-label={`${r.details.label}: ${r.cells[nameKey]?.text ?? r.id}`}\n                      className={cx(detailsPill, focus)}\n                    >\n                      {r.details.label}\n                    </Link>\n                  )}\n                  {browseOf(r) && (\n                    <button\n                      type=\"button\"\n                      onClick={() => {\n                        setBrowseTarget(r.browse ?? null);\n                        setBrowseQuery(r.id);\n                      }}\n                      aria-label={`Browse ${r.browse!.title}`}\n                      className={cx(browsePill, focus)}\n                    >\n                      {r.browse!.label}\n                    </button>\n                  )}\n                  {renderRowAction?.(r) ?? (r.buy && onBuy ? (\n                    <button\n                      type=\"button\"\n                      onClick={() => onBuy(r.buy!)}\n                      className={cx(\n                        \"inline-flex h-7 cursor-pointer items-center rounded-full border border-neutral-700 px-3 text-xs text-neutral-300 transition-colors duration-150 hover:border-neutral-500 hover:text-neutral-100\",\n                        focus,\n                      )}\n                    >\n                      {r.buy.label}\n                    </button>\n                  ) : (\n                    r.action && (\n                      <Link\n                        href={r.action.href}\n                        className={cx(\n                          \"inline-flex h-7 items-center rounded-full border border-neutral-700 px-3 text-xs text-neutral-300 transition-colors duration-150 hover:border-neutral-500 hover:text-neutral-100\",\n                          focus,\n                        )}\n                      >\n                        {r.action.label}\n                      </Link>\n                    )\n                  ))}\n                </td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n\n        {/* Cards (below md) */}\n        <div className=\"space-y-2 p-4 md:hidden\">\n          {rows.map((r) => (\n            <div key={r.id} className=\"rounded-[var(--rb-r-xl,12px)] bg-neutral-900 p-4\">\n              <div className=\"flex items-start justify-between gap-3\">\n                {browseOf(r) ? (\n                  <button\n                    type=\"button\"\n                    onClick={() => {\n                      setBrowseTarget(r.browse ?? null);\n                      setBrowseQuery(r.id);\n                    }}\n                    className={cx(\n                      \"min-w-0 flex-1 cursor-pointer text-left text-[13px] font-medium text-neutral-100\",\n                      focus,\n                    )}\n                  >\n                    {r.cells[nameKey]?.text}\n                  </button>\n                ) : (\n                  <p className=\"min-w-0 flex-1 text-[13px] font-medium text-neutral-100\">\n                    {r.cells[nameKey]?.text}\n                  </p>\n                )}\n                <span className=\"flex shrink-0 items-center gap-1.5\">\n                  {r.soon && <span className={soonTag}>Coming soon</span>}\n                  {r.details && (\n                    <Link\n                      href={r.details.href}\n                      aria-label={`${r.details.label}: ${r.cells[nameKey]?.text ?? r.id}`}\n                      className={cx(detailsPill, \"mr-0\", focus)}\n                    >\n                      {r.details.label}\n                    </Link>\n                  )}\n                  {browseOf(r) && (\n                    <button\n                      type=\"button\"\n                      onClick={() => {\n                        setBrowseTarget(r.browse ?? null);\n                        setBrowseQuery(r.id);\n                      }}\n                      aria-label={`Browse ${r.browse!.title}`}\n                      className={cx(browsePill, focus)}\n                    >\n                      {r.browse!.label}\n                    </button>\n                  )}\n                  {renderRowAction?.(r) ?? (r.buy && onBuy ? (\n                    <button\n                      type=\"button\"\n                      onClick={() => onBuy(r.buy!)}\n                      className=\"cursor-pointer rounded-full border border-neutral-700 px-3 py-1 text-xs text-neutral-300\"\n                    >\n                      {r.buy.label}\n                    </button>\n                  ) : (\n                    r.action && (\n                      <Link\n                        href={r.action.href}\n                        className=\"rounded-full border border-neutral-700 px-3 py-1 text-xs text-neutral-300\"\n                      >\n                        {r.action.label}\n                      </Link>\n                    )\n                  ))}\n                </span>\n              </div>\n              <p className=\"mt-1 text-xs text-neutral-500\">{r.subtitle}</p>\n            </div>\n          ))}\n        </div>\n\n        {rows.length === 0 && (\n          <div className=\"flex min-h-[320px] flex-col items-center justify-center px-6 py-16 text-center\">\n            <span\n              aria-hidden\n              className=\"mb-3 flex h-10 w-10 items-center justify-center rounded-[var(--rb-r-lg,10px)] bg-neutral-800 text-neutral-400\"\n            >\n              <SearchX className=\"h-5 w-5\" />\n            </span>\n            <p className=\"text-sm font-medium text-neutral-100\">Nothing matches</p>\n            <p className=\"mt-1 max-w-xs text-xs text-neutral-400\">\n              Try a different search, or clear the filter.\n            </p>\n            <button\n              type=\"button\"\n              onClick={() => {\n                setQuery(\"\");\n                setFacet(null);\n              }}\n              className={cx(\n                \"mt-4 inline-flex h-8 cursor-pointer items-center rounded-[var(--rb-r-md,8px)] border border-neutral-800 bg-neutral-900 px-2.5 text-[13px] font-medium text-neutral-100 transition-colors duration-150 hover:bg-neutral-800\",\n                focus,\n              )}\n            >\n              Clear filters\n            </button>\n          </div>\n        )}\n        </div>\n        <div\n          aria-hidden\n          className={cx(\n            \"pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-neutral-950 to-transparent transition-opacity duration-200\",\n            bodyEdges.end ? \"opacity-100\" : \"opacity-0\",\n          )}\n        />\n      </div>\n\n      <footer className=\"flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-neutral-800 bg-neutral-900 px-4 py-3 text-xs text-neutral-500 sm:px-6\">\n        <span className=\"tabular-nums\">\n          Showing {rows.length} of {tab.rows.length}\n        </span>\n        {tab.note && (\n          <span>\n            {tab.note.text}\n            {tab.note.link && (\n              <Link\n                href={tab.note.link.href}\n                className=\"underline underline-offset-2 hover:text-neutral-300\"\n              >\n                {tab.note.link.label}\n              </Link>\n            )}\n            {tab.note.tail}\n          </span>\n        )}\n      </footer>\n\n      {renderBrowse && (\n        <Suspense fallback={null}>\n          <BrowseFromQuery tabs={tabs} resolveBrowseId={resolveBrowseId} onOpen={openFromQuery} />\n        </Suspense>\n      )}\n      {renderBrowse?.(browseTarget, () => {\n        setBrowseTarget(null);\n        setBrowseQuery(null);\n      })}\n      {renderBuy?.(buyTarget, () => setBuyTarget(null))}\n    </div>\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/pokerdata/catalog-table.tsx"
    }
  ],
  "type": "registry:block"
}