{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "odds-selector",
  "title": "Odds Selector",
  "description": "Labeled odds card for a market: selectable options with price movement and suspended states.",
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/mrdoge-ui/odds-selector/odds-selector.tsx",
      "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport { ChevronDown, LayoutGrid, SlidersHorizontal } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\nexport type OddsMovement = \"up\" | \"down\" | \"flat\"\n\nexport interface OddsOption {\n  id: string\n  /** Selection label, e.g. \"1\", \"X\", \"2\" or a team name / \"Draw\". */\n  label: string\n  /** Formatted price, e.g. \"1.85\". */\n  price: string\n  movement?: OddsMovement\n  suspended?: boolean\n}\n\n/**\n * One market rendered as a two-column row, e.g. Over/Under at a single\n * goal threshold. Use `lines` (a market per row) instead of `options` (a\n * flat row of a single market) for bet types that post one market per\n * line: see the \"Multiple Lines\" example.\n */\nexport interface OddsLine {\n  id: string\n  /** Short label for the line itself, e.g. \"2.5\". Shown above the slider in slider view. Omit if there's no natural short label. */\n  label?: string\n  over: OddsOption\n  under: OddsOption\n}\n\nexport interface OddsSelectorProps {\n  /** Market name shown as a header above the options, e.g. \"Match Result\". Omit to render just the options. */\n  label?: string\n  /** A single market's selections, laid out in one row. Ignored when `lines` is set. */\n  options?: OddsOption[]\n  /** Id of the currently selected option, for `options` mode. */\n  selectedId?: string\n  /** Called with the pressed option's id, or `undefined` when pressing the already-selected option deselects it. For `options` mode. */\n  onSelect?: (id: string | undefined) => void\n  /** Several markets, one per row, e.g. every Over/Under goal threshold stacked in one card. Takes over `options` when set. */\n  lines?: OddsLine[]\n  /** Ids of every currently selected option across `lines`. Unlike `options`, more than one row can be selected at once. */\n  selectedLineIds?: string[]\n  /** Called with a pressed option's id and the resulting selected state (not a bare toggle), for `lines` mode. */\n  onSelectLine?: (id: string, selected: boolean) => void\n  /**\n   * Ids that render as unavailable, e.g. because they're logically\n   * incompatible with the current selection. Applies to both `options`\n   * and `lines`. Computed externally (e.g. via a conflict adapter) and\n   * passed in; OddsSelector never decides this itself. Unlike\n   * `suspended`, a disabled option still shows its real price: it's\n   * fully priced and available, just not currently combinable.\n   */\n  disabledIds?: string[]\n  /** Adds a header toggle to browse `lines` one at a time via a slider instead of a stacked list. No effect with fewer than 2 lines. */\n  enableSliderView?: boolean\n  /** Adds a header toggle to collapse the selector down to just its header. Requires `label` (or `lines`/`enableSliderView`) to have a header to put it in. */\n  collapsible?: boolean\n  /**\n   * \"card\" (default) renders its own border/background, for standalone\n   * use. \"bare\" drops that chrome, for embedding inside another card\n   * (e.g. Match Card's odds row) without a card-in-a-card look.\n   */\n  variant?: \"card\" | \"bare\"\n  className?: string\n}\n\n// A price shortening (going down) means the market now thinks the outcome\n// is more likely: the conventional \"hot\" direction in real odds boards,\n// shown green. Drifting (going up) means the opposite, shown red. Same\n// mechanical rule regardless of market type.\nconst movementColor: Record<OddsMovement, string> = {\n  up: \"text-destructive\",\n  down: \"text-emerald-600 dark:text-emerald-500\",\n  flat: \"\",\n}\n\nfunction OddsOptionButton({\n  option,\n  selected,\n  disabled,\n  onClick,\n  layout,\n}: {\n  option: OddsOption\n  selected: boolean\n  /** Conflict-disabled, distinct from `option.suspended`: still shows the real price. */\n  disabled?: boolean\n  onClick: () => void\n  layout: \"column\" | \"row\"\n}) {\n  const inactive = option.suspended || disabled\n  return (\n    <button\n      type=\"button\"\n      disabled={inactive}\n      onClick={(e) => {\n        e.stopPropagation()\n        onClick()\n      }}\n      className={cn(\n        \"flex min-w-0 cursor-pointer\",\n        layout === \"column\"\n          ? \"flex-col items-center justify-center gap-0.5 px-2 py-2.5\"\n          : \"items-center justify-between gap-2 px-3 py-2.5 text-left\",\n        selected ? \"bg-primary text-primary-foreground\" : \"hover:bg-accent/50\",\n        inactive && \"pointer-events-none opacity-50\"\n      )}\n    >\n      <span\n        className={cn(\n          \"truncate text-xs\",\n          layout === \"column\" && \"max-w-full\",\n          selected ? \"text-primary-foreground/70\" : \"text-muted-foreground\"\n        )}\n      >\n        {option.label}\n      </span>\n      <span\n        className={cn(\n          \"text-sm font-semibold\",\n          !selected && !option.suspended && option.movement && movementColor[option.movement]\n        )}\n      >\n        {option.suspended ? \"—\" : option.price}\n      </span>\n    </button>\n  )\n}\n\nfunction OddsLineRow({\n  line,\n  selectedIds,\n  disabledIds,\n  onToggle,\n}: {\n  line: OddsLine\n  selectedIds: string[]\n  disabledIds?: string[]\n  onToggle: (id: string, selected: boolean) => void\n}) {\n  return (\n    <div className=\"grid grid-cols-2 divide-x\">\n      {[line.over, line.under].map((option) => {\n        const selected = selectedIds.includes(option.id)\n        return (\n          <OddsOptionButton\n            key={option.id}\n            option={option}\n            layout=\"row\"\n            selected={selected}\n            disabled={disabledIds?.includes(option.id)}\n            onClick={() => onToggle(option.id, !selected)}\n          />\n        )\n      })}\n    </div>\n  )\n}\n\n// Starts the slider on the line closest to an even split (e.g. 1.90/2.00)\n// rather than always the first/lowest threshold, which is often the most\n// lopsided line (e.g. 1.17/4.45).\nfunction mostBalancedIndex(lines: OddsLine[]): number {\n  let bestIndex = 0\n  let bestSpread = Infinity\n  lines.forEach((line, i) => {\n    const spread = Math.abs(parseFloat(line.over.price) - parseFloat(line.under.price))\n    if (spread < bestSpread) {\n      bestIndex = i\n      bestSpread = spread\n    }\n  })\n  return bestIndex\n}\n\nfunction OddsLinesSlider({\n  lines,\n  selectedIds,\n  disabledIds,\n  onSelectLine,\n}: {\n  lines: OddsLine[]\n  selectedIds: string[]\n  disabledIds?: string[]\n  onSelectLine?: (id: string, selected: boolean) => void\n}) {\n  const [index, setIndex] = useState(() => mostBalancedIndex(lines))\n  // Lines can arrive/change size as live data updates; clamp rather than\n  // point past the end.\n  const currentIndex = Math.min(index, lines.length - 1)\n  const line = lines[currentIndex]\n\n  return (\n    <div className=\"flex flex-col\">\n      <OddsLineRow\n        line={line}\n        selectedIds={selectedIds}\n        disabledIds={disabledIds}\n        onToggle={(id, selected) => onSelectLine?.(id, selected)}\n      />\n      <div className=\"flex flex-col items-center gap-1 border-t px-3 py-3\">\n        {line.label ? (\n          <span className=\"text-sm font-semibold text-card-foreground\">{line.label}</span>\n        ) : null}\n        <input\n          type=\"range\"\n          min={0}\n          max={lines.length - 1}\n          step={1}\n          value={currentIndex}\n          onChange={(e) => setIndex(Number(e.target.value))}\n          className=\"w-full cursor-pointer accent-primary\"\n          aria-label=\"Select line\"\n        />\n      </div>\n    </div>\n  )\n}\n\nexport function OddsSelector({\n  label,\n  options = [],\n  selectedId,\n  onSelect,\n  lines,\n  selectedLineIds = [],\n  onSelectLine,\n  disabledIds,\n  enableSliderView = false,\n  collapsible = false,\n  variant = \"card\",\n  className,\n}: OddsSelectorProps) {\n  const [collapsed, setCollapsed] = useState(false)\n  const [linesView, setLinesView] = useState<\"list\" | \"slider\">(\"list\")\n  const showLinesToggle = Boolean(lines && lines.length > 1 && enableSliderView)\n  const showHeader = Boolean(label) || showLinesToggle || collapsible\n\n  return (\n    <div\n      className={cn(\n        \"flex h-full flex-col overflow-hidden\",\n        variant === \"card\" && \"rounded-xl border bg-card\",\n        className\n      )}\n    >\n      {showHeader ? (\n        <div\n          className={cn(\n            \"flex h-10 items-center justify-between gap-2 py-2 pl-3 pr-2 border-b\",\n            collapsed && \"border-b-transparent\"\n          )}\n        >\n          <span className=\"truncate text-sm font-medium text-card-foreground\">{label}</span>\n          <div className=\"flex items-center gap-1\">\n            {showLinesToggle ? (\n              <div className=\"flex items-center gap-1 rounded-md border p-0.5\">\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon-xs\"\n                  aria-label=\"List view\"\n                  aria-pressed={linesView === \"list\"}\n                  onClick={() => setLinesView(\"list\")}\n                  className={cn(\n                    \"rounded\",\n                    linesView === \"list\"\n                      ? \"bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground\"\n                      : \"text-muted-foreground hover:text-foreground\"\n                  )}\n                >\n                  <LayoutGrid className=\"size-3.5\" />\n                </Button>\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon-xs\"\n                  aria-label=\"Slider view\"\n                  aria-pressed={linesView === \"slider\"}\n                  onClick={() => setLinesView(\"slider\")}\n                  className={cn(\n                    \"rounded\",\n                    linesView === \"slider\"\n                      ? \"bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground\"\n                      : \"text-muted-foreground hover:text-foreground\"\n                  )}\n                >\n                  <SlidersHorizontal className=\"size-3.5\" />\n                </Button>\n              </div>\n            ) : null}\n            {collapsible ? (\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"icon-xs\"\n                aria-label={collapsed ? \"Expand\" : \"Collapse\"}\n                aria-expanded={!collapsed}\n                onClick={() => setCollapsed((c) => !c)}\n                className=\"rounded text-muted-foreground hover:text-foreground\"\n              >\n                <ChevronDown className={cn(\"size-3.5 transition-transform\", collapsed && \"-rotate-90\")} />\n              </Button>\n            ) : null}\n          </div>\n        </div>\n      ) : null}\n      {!collapsed &&\n        (lines ? (\n          showLinesToggle && linesView === \"slider\" ? (\n            <OddsLinesSlider\n              lines={lines}\n              selectedIds={selectedLineIds}\n              disabledIds={disabledIds}\n              onSelectLine={onSelectLine}\n            />\n          ) : (\n            <div className=\"divide-y\">\n              {lines.map((line) => (\n                <OddsLineRow\n                  key={line.id}\n                  line={line}\n                  selectedIds={selectedLineIds}\n                  disabledIds={disabledIds}\n                  onToggle={(id, selected) => onSelectLine?.(id, selected)}\n                />\n              ))}\n            </div>\n          )\n        ) : (\n          <div className=\"grid flex-1 auto-cols-fr grid-flow-col divide-x\">\n            {options.map((option) => {\n              const selected = option.id === selectedId\n              return (\n                <OddsOptionButton\n                  key={option.id}\n                  option={option}\n                  layout=\"column\"\n                  selected={selected}\n                  disabled={disabledIds?.includes(option.id)}\n                  onClick={() => onSelect?.(selected ? undefined : option.id)}\n                />\n              )\n            })}\n          </div>\n        ))}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/mrdoge-ui/odds-selector/odds-selector-skeleton.tsx",
      "content": "import { cn } from \"@/lib/utils\"\n\n/**\n * Same DOM shape as OddsSelector's own \"card\" variant (label bar + button\n * grid), sized from the same text tokens (text-xs/text-sm line-heights):\n * matches the real rendered height exactly once options load, rather than\n * a guessed pixel value that drifts out of sync.\n */\nexport function OddsSelectorSkeleton({\n  optionCount = 3,\n  label = false,\n  className,\n}: {\n  optionCount?: number\n  label?: boolean\n  className?: string\n}) {\n  return (\n    <div className={cn(\"flex flex-col overflow-hidden rounded-xl border bg-card\", className)}>\n      {label ? (\n        <div className=\"h-10 border-b py-2.5 pl-3 pr-2\">\n          <div className=\"flex h-5 items-center\">\n            <span className=\"h-2.5 w-28 animate-pulse rounded bg-muted\" />\n          </div>\n        </div>\n      ) : null}\n      <div className=\"grid auto-cols-fr grid-flow-col divide-x\">\n        {Array.from({ length: optionCount }).map((_, i) => (\n          <div key={i} className=\"flex flex-col items-center justify-center gap-0.5 px-2 py-2.5\">\n            <div className=\"flex h-4 items-center\">\n              <span className=\"h-2 w-4 animate-pulse rounded bg-muted\" />\n            </div>\n            <div className=\"flex h-5 items-center\">\n              <span className=\"h-3 w-10 animate-pulse rounded bg-muted\" />\n            </div>\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n\n/**\n * Placeholder for a lines table (one row per market, two columns per row).\n * Real row count is dynamic (however many lines a match currently has),\n * so this renders a reasonable generic count while loading rather than\n * trying to predict the exact number.\n */\nexport function OddsLinesSkeleton({\n  rowCount = 4,\n  className,\n}: {\n  rowCount?: number\n  className?: string\n}) {\n  return (\n    <div className={cn(\"flex flex-col overflow-hidden rounded-xl border bg-card\", className)}>\n      <div className=\"h-10 border-b py-2.5 pl-3 pr-2\">\n        <div className=\"flex h-5 items-center\">\n          <span className=\"h-2.5 w-28 animate-pulse rounded bg-muted\" />\n        </div>\n      </div>\n      <div className=\"divide-y\">\n        {Array.from({ length: rowCount }).map((_, i) => (\n          <div key={i} className=\"grid grid-cols-2 divide-x\">\n            {Array.from({ length: 2 }).map((_, col) => (\n              <div key={col} className=\"flex items-center justify-between gap-2 px-3 py-2.5\">\n                <div className=\"flex h-4 items-center\">\n                  <span className=\"h-2.5 w-16 animate-pulse rounded bg-muted\" />\n                </div>\n                <div className=\"flex h-5 items-center\">\n                  <span className=\"h-3 w-8 animate-pulse rounded bg-muted\" />\n                </div>\n              </div>\n            ))}\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}