{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bet-slip",
  "title": "Bet Slip",
  "description": "Panel for selected picks, with single/parlay mode, optional stake input, and computed potential payout.",
  "registryDependencies": [
    "button",
    "input",
    "match-card-compact"
  ],
  "files": [
    {
      "path": "registry/mrdoge-ui/bet-slip/bet-slip.tsx",
      "content": "\"use client\"\n\nimport { Check, CheckCircle2, Circle, Layers, Loader2, MinusCircle, Ticket, TriangleAlert, X, XCircle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { MatchCardCompact } from \"@/registry/mrdoge-ui/match-card-compact/match-card-compact\"\n\nexport interface BetSlipPick {\n  id: string\n  eventLabel: string\n  market: string\n  selection: string\n  /** Decimal odds, e.g. 1.85. */\n  price: number\n  /** Colors the price: \"down\" (shortening, more likely) green, \"up\" (drifting, less likely) red. Static here; for live data, see useOddsMovement. */\n  movement?: \"up\" | \"down\" | \"flat\"\n  /** True once the underlying line is no longer available. Dims the row and disables nothing else; removal stays a user action. */\n  unavailable?: boolean\n  /** Shown once per match group via BetSlipMatchGroup when both are present. */\n  home?: { name: string; logoUrl?: string }\n  away?: { name: string; logoUrl?: string }\n  /** Shown right-aligned in the match group header via BetSlipMatchGroup, e.g. \"Aug 9\". */\n  kickoff?: Date | string\n  /** Match id, required (with betType/code) for conflict-checking via the Conflict Adapter, and to group picks from the same match together. */\n  matchId?: string\n  /** Market sysname, e.g. \"SOCCER_MATCH_RESULT\". */\n  betType?: string\n  /** Outcome code, e.g. \"1\", \"1X\", \"O\". */\n  code?: string\n  /** Parsed Over/Under threshold, when applicable. */\n  threshold?: number\n}\n\nexport interface BetSlipProps {\n  picks: BetSlipPick[]\n  onRemovePick?: (id: string) => void\n  /** \"single\" (default) treats every pick independently. \"parlay\" combines them into one bet with a combined price. Only switchable once there are 2+ picks: same as sportsbooks, a parlay needs 2 legs. */\n  mode?: \"single\" | \"parlay\"\n  onModeChange?: (mode: \"single\" | \"parlay\") => void\n  /** Parlay mode's combined stake. Controlled: BetSlip holds no state of its own. Renders (with a combined payout) only when onStakeChange is passed and mode is \"parlay\". */\n  stake?: string\n  onStakeChange?: (value: string) => void\n  /** Single mode's stakes, one per pick, keyed by pick id. Rendered as its own input below each pick row; the footer becomes a read-only \"Total stake\" + \"Potential payout\" sum once onPickStakeChange is passed and mode is \"single\". */\n  pickStakes?: Record<string, string>\n  onPickStakeChange?: (id: string, value: string) => void\n  /** Ids from `picks` that conflict with another pick in the slip, computed externally (e.g. via the Conflict Adapter) and rendered as a warning. BetSlip only ever flags; it has no \"add pick\" affordance of its own to prevent one from being added in the first place. */\n  conflictingPickIds?: string[]\n  /** Called when the submit button is pressed. Renders the button only when this is passed; BetSlip has no idea what \"submitting\" means for your product (auth and the actual request are yours; report progress back via submitState). */\n  onSubmit?: () => void\n  /** Overrides the button's default label (\"Place parlay\" in parlay mode, \"Place bet\" otherwise). Only applies to the idle state. */\n  submitLabel?: string\n  /** Disables the submit button regardless of submitState, e.g. while picks still conflict. */\n  submitDisabled?: boolean\n  /** \"idle\" (default) | \"loading\" | \"success\" | \"error\". Set this from your submit request's own state; swaps the button's icon/label and disables it during \"loading\"/\"success\". */\n  submitState?: \"idle\" | \"loading\" | \"success\" | \"error\"\n  /** Shown above the button when submitState is \"error\". BetSlip has no toast/notification system of its own, so this is just a plain inline message; use your own if you have one. */\n  submitError?: string\n  className?: string\n}\n\n/**\n * Groups a match's crests and event label around any number of selection\n * rows for that match: picks from the same match render once here\n * instead of repeating team info per row. `BetSlip` uses this internally,\n * grouping `picks` by `matchId`; also exported for composing your own\n * layout around individual match groups.\n */\nexport function BetSlipMatchGroup({\n  home,\n  away,\n  eventLabel,\n  kickoff,\n  children,\n}: {\n  home?: { name: string; logoUrl?: string }\n  away?: { name: string; logoUrl?: string }\n  eventLabel: string\n  /** Right-aligned in the header when set, e.g. \"Aug 9\". */\n  kickoff?: Date | string\n  children: React.ReactNode\n}) {\n  return (\n    <div>\n      {home && away ? (\n        <MatchCardCompact home={home} away={away} label={eventLabel} kickoff={kickoff} className=\"px-3 py-2.5\" />\n      ) : (\n        <div className=\"flex items-center gap-2 px-3 py-2.5\">\n          <span className=\"truncate text-sm font-medium\">{eventLabel}</span>\n        </div>\n      )}\n      <div className=\"border-t\">{children}</div>\n    </div>\n  )\n}\n\nexport type BetSlipPickResult = \"won\" | \"lost\" | \"push\"\n\nconst resultIcon: Record<BetSlipPickResult, typeof Circle> = {\n  won: CheckCircle2,\n  lost: XCircle,\n  push: MinusCircle,\n}\nconst resultColor: Record<BetSlipPickResult, string> = {\n  won: \"text-emerald-600 dark:text-emerald-500\",\n  lost: \"text-destructive\",\n  push: \"text-amber-500\",\n}\nconst resultLineColor: Record<BetSlipPickResult, string> = {\n  won: \"bg-emerald-600/50 dark:bg-emerald-500/50\",\n  lost: \"bg-destructive/50\",\n  push: \"bg-amber-500/50\",\n}\n// Same mechanical rule as OddsSelector's movementColor: a price\n// shortening (down) means more likely (green), drifting (up) means less\n// likely (red).\nconst movementColor: Record<NonNullable<BetSlipPick[\"movement\"]>, string> = {\n  up: \"text-destructive\",\n  down: \"text-emerald-600 dark:text-emerald-500\",\n  flat: \"\",\n}\n\n/**\n * One selection line within a match group: market, selection, price, and\n * a remove button. Doesn't render team/event info itself; that's\n * `BetSlipMatchGroup`'s job, shown once per match rather than once per\n * pick.\n *\n * `connected` (default true) draws a circle-and-line connector down the\n * left side between legs of the same group; pass `position`\n * (\"first\"/\"middle\"/\"last\", in order) alongside\n * it. Set `connected={false}` for singles, where picks from the same\n * match are still grouped but aren't one combined bet. The circle stays\n * (so removal still reads as \"removing one pick\"), the connecting lines\n * don't, and a divider separates rows instead. The same connector\n * doubles as a settlement indicator: pass `result` once a pick is\n * settled to swap the outline circle for a colored won/lost/push icon,\n * and `prevResult` (the previous leg's result, for the top line segment)\n * to keep the connector's color continuous between legs.\n *\n * `onStakeChange` renders this row's own stake input below the\n * selection, for singles mode where every pick has an independent\n * stake instead of one combined amount in the footer.\n */\nexport function BetSlipPickRow({\n  pick,\n  onRemove,\n  conflicting,\n  position = \"single\",\n  connected = true,\n  result = null,\n  prevResult = null,\n  stake,\n  onStakeChange,\n}: {\n  pick: BetSlipPick\n  onRemove?: () => void\n  conflicting?: boolean\n  position?: \"single\" | \"first\" | \"middle\" | \"last\"\n  connected?: boolean\n  result?: BetSlipPickResult | null\n  prevResult?: BetSlipPickResult | null\n  stake?: string\n  onStakeChange?: (value: string) => void\n}) {\n  const isFirstInGroup = position === \"single\" || position === \"first\"\n  const showLineTop = connected && (position === \"middle\" || position === \"last\")\n  const showLineBottom = connected && (position === \"middle\" || position === \"first\")\n  const Icon = result ? resultIcon[result] : Circle\n  const iconColor = result ? resultColor[result] : \"text-muted-foreground\"\n  const topLineColor = prevResult ? resultLineColor[prevResult] : \"bg-border\"\n  const bottomLineColor = result ? resultLineColor[result] : \"bg-border\"\n  return (\n    <div className={cn(!connected && !isFirstInGroup && \"border-t\", pick.unavailable && \"opacity-50\")}>\n      <div className=\"flex items-stretch pr-2\">\n        <div className=\"flex w-8 shrink-0 flex-col items-center\">\n          <div className={cn(\"w-px flex-1\", showLineTop ? topLineColor : \"bg-transparent\")} />\n          <Icon className={cn(\"size-3 shrink-0\", iconColor)} />\n          <div className={cn(\"w-px flex-1\", showLineBottom ? bottomLineColor : \"bg-transparent\")} />\n        </div>\n        <div className=\"flex min-w-0 flex-1 items-center justify-between gap-2 py-2.5\">\n          <div className=\"flex min-w-0 flex-col gap-0.5\">\n            <p className=\"truncate text-sm font-medium\">{pick.selection}</p>\n            <p className=\"truncate text-xs text-muted-foreground\">\n              {pick.market}\n              {pick.unavailable ? \" · No longer available\" : null}\n            </p>\n            {conflicting ? (\n              <p className=\"mt-0.5 flex items-center gap-1 text-xs text-destructive\">\n                <TriangleAlert className=\"size-3\" />\n                Conflicts with another pick\n              </p>\n            ) : null}\n          </div>\n          <div className=\"flex shrink-0 items-center gap-2\">\n            <span\n              className={cn(\n                \"text-sm font-semibold tabular-nums\",\n                pick.unavailable && \"line-through\",\n                !pick.unavailable && pick.movement && movementColor[pick.movement]\n              )}\n            >\n              {pick.price.toFixed(2)}\n            </span>\n            <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" aria-label=\"Remove pick\" onClick={onRemove}>\n              <X className=\"size-3.5\" />\n            </Button>\n          </div>\n        </div>\n      </div>\n      {onStakeChange ? (\n        <div className=\"flex items-center justify-between gap-3 px-3 pb-3\">\n          <label className=\"text-sm text-muted-foreground\">Stake</label>\n          <Input\n            type=\"text\"\n            inputMode=\"decimal\"\n            placeholder=\"0.00\"\n            value={stake ?? \"\"}\n            onChange={(event) => {\n              const value = event.target.value\n              if (/^\\d*\\.?\\d*$/.test(value)) onStakeChange(value)\n            }}\n            className=\"h-7 w-20 text-right text-xs tabular-nums\"\n          />\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\n// Picks that share a matchId group into one BetSlipMatchGroup; picks\n// without one (hand-built, no adapter) each get their own group so\n// nothing silently merges. Groups keep first-appearance order.\nfunction groupPicksByMatch(picks: BetSlipPick[]) {\n  const groups: {\n    key: string\n    home?: BetSlipPick[\"home\"]\n    away?: BetSlipPick[\"away\"]\n    eventLabel: string\n    kickoff?: BetSlipPick[\"kickoff\"]\n    picks: BetSlipPick[]\n  }[] = []\n  const indexByKey = new Map<string, number>()\n\n  for (const pick of picks) {\n    const key = pick.matchId ?? pick.id\n    const existingIndex = indexByKey.get(key)\n    if (existingIndex !== undefined) {\n      groups[existingIndex].picks.push(pick)\n    } else {\n      indexByKey.set(key, groups.length)\n      groups.push({ key, home: pick.home, away: pick.away, eventLabel: pick.eventLabel, kickoff: pick.kickoff, picks: [pick] })\n    }\n  }\n\n  return groups\n}\n\nexport function BetSlip({\n  picks,\n  onRemovePick,\n  mode = \"single\",\n  onModeChange,\n  stake,\n  onStakeChange,\n  pickStakes,\n  onPickStakeChange,\n  conflictingPickIds,\n  onSubmit,\n  submitLabel,\n  submitDisabled,\n  submitState = \"idle\",\n  submitError,\n  className,\n}: BetSlipProps) {\n  // A parlay needs 2+ legs; below that there's nothing to combine, so\n  // \"single\" is the only mode that makes sense regardless of what `mode`\n  // holds (same rule real sportsbooks use).\n  const effectiveMode: \"single\" | \"parlay\" = picks.length >= 2 ? mode : \"single\"\n  const combinedPrice = picks.reduce((total, pick) => total * pick.price, 1)\n  const stakeValue = Number(stake)\n  const payout = stakeValue > 0 ? stakeValue * combinedPrice : 0\n  const totalPickStake = picks.reduce((sum, pick) => sum + (Number(pickStakes?.[pick.id]) || 0), 0)\n  const totalPickPayout = picks.reduce(\n    (sum, pick) => sum + (Number(pickStakes?.[pick.id]) || 0) * pick.price,\n    0\n  )\n  const showFooter =\n    picks.length > 0 &&\n    (effectiveMode === \"parlay\" || (effectiveMode === \"single\" && Boolean(onPickStakeChange)) || Boolean(onSubmit))\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full flex-col overflow-hidden rounded-xl border bg-card text-card-foreground\",\n        className\n      )}\n    >\n      {picks.length > 0 ? <div className=\"flex h-10 items-center justify-between gap-2 border-b py-2 pl-3 pr-2\">\n        <span className=\"truncate text-sm font-medium\">\n          {`${effectiveMode === \"parlay\" ? \"Parlay\" : \"Singles\"}`}\n        </span>\n        {picks.length >= 2 ?\n          <div className=\"flex items-center gap-1 rounded-md border p-0.5\">\n            {([\"single\", \"parlay\"] as const).map((m) => {\n              const Icon = m === \"single\" ? Ticket : Layers\n              const active = effectiveMode === m\n              return (\n                <Button\n                  key={m}\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon-xs\"\n                  aria-label={m === \"single\" ? \"Single bets\" : \"Parlay\"}\n                  aria-pressed={active}\n                  onClick={() => onModeChange?.(m)}\n                  className={cn(\n                    \"rounded\",\n                    active\n                      ? \"bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground\"\n                      : \"text-muted-foreground hover:text-foreground\"\n                  )}\n                >\n                  <Icon className=\"size-3.5\" />\n                </Button>\n              )\n            })}\n          </div>\n          : null}\n      </div> : null}\n\n      {picks.length === 0 ? (\n        <div className=\"flex flex-col items-center gap-1.5 p-6 text-center\">\n          <Ticket className=\"size-6 text-muted-foreground\" />\n          <p className=\"text-sm text-muted-foreground\">No picks selected yet.</p>\n        </div>\n      ) : (\n        <div className=\"divide-y\">\n          {groupPicksByMatch(picks).map((group) => (\n            <BetSlipMatchGroup key={group.key} home={group.home} away={group.away} eventLabel={group.eventLabel} kickoff={group.kickoff}>\n              {group.picks.map((pick, index) => (\n                <BetSlipPickRow\n                  key={pick.id}\n                  pick={pick}\n                  position={\n                    group.picks.length === 1\n                      ? \"single\"\n                      : index === 0\n                        ? \"first\"\n                        : index === group.picks.length - 1\n                          ? \"last\"\n                          : \"middle\"\n                  }\n                  connected={effectiveMode === \"parlay\"}\n                  onRemove={() => onRemovePick?.(pick.id)}\n                  conflicting={conflictingPickIds?.includes(pick.id)}\n                  stake={effectiveMode === \"single\" ? pickStakes?.[pick.id] : undefined}\n                  onStakeChange={\n                    effectiveMode === \"single\" && onPickStakeChange\n                      ? (value) => onPickStakeChange(pick.id, value)\n                      : undefined\n                  }\n                />\n              ))}\n            </BetSlipMatchGroup>\n          ))}\n        </div>\n      )}\n\n      {showFooter ? (\n        <div className=\"flex flex-col gap-3 border-t p-3\">\n          {effectiveMode === \"parlay\" ? (\n            <div className=\"flex items-center justify-between text-sm\">\n              <span className=\"flex items-center gap-1.5 text-muted-foreground\">\n                <Layers className=\"size-3.5\" />\n                {picks.length} legs\n              </span>\n              <span className=\"font-semibold tabular-nums\">{combinedPrice.toFixed(2)}</span>\n            </div>\n          ) : null}\n          {effectiveMode === \"parlay\" && onStakeChange ? (\n            <>\n              <div className=\"flex items-center justify-between gap-3\">\n                <label htmlFor=\"bet-slip-stake\" className=\"text-sm text-muted-foreground\">\n                  Stake\n                </label>\n                <Input\n                  id=\"bet-slip-stake\"\n                  type=\"text\"\n                  inputMode=\"decimal\"\n                  placeholder=\"0.00\"\n                  value={stake ?? \"\"}\n                  onChange={(event) => {\n                    const value = event.target.value\n                    if (/^\\d*\\.?\\d*$/.test(value)) onStakeChange(value)\n                  }}\n                  className=\"w-24 text-right tabular-nums\"\n                />\n              </div>\n              <div className=\"flex items-center justify-between text-sm\">\n                <span className=\"text-muted-foreground\">Potential payout</span>\n                <span className=\"font-semibold tabular-nums\">{payout.toFixed(2)}</span>\n              </div>\n            </>\n          ) : null}\n          {effectiveMode === \"single\" && onPickStakeChange ? (\n            <>\n              <div className=\"flex items-center justify-between text-sm\">\n                <span className=\"text-muted-foreground\">Total stake</span>\n                <span className=\"font-semibold tabular-nums\">{totalPickStake.toFixed(2)}</span>\n              </div>\n              <div className=\"flex items-center justify-between text-sm\">\n                <span className=\"text-muted-foreground\">Potential payout</span>\n                <span className=\"font-semibold tabular-nums\">{totalPickPayout.toFixed(2)}</span>\n              </div>\n            </>\n          ) : null}\n          {onSubmit ? (\n            <div className=\"flex flex-col gap-1.5\">\n              {submitState === \"error\" && submitError ? (\n                <p className=\"text-xs text-destructive\">{submitError}</p>\n              ) : null}\n              <Button\n                type=\"button\"\n                onClick={onSubmit}\n                disabled={submitDisabled || submitState === \"loading\" || submitState === \"success\"}\n                className=\"w-full\"\n              >\n                {submitState === \"loading\" ? (\n                  <>\n                    <Loader2 className=\"size-4 animate-spin\" />\n                    Placing...\n                  </>\n                ) : submitState === \"success\" ? (\n                  <>\n                    <Check className=\"size-4\" />\n                    Placed\n                  </>\n                ) : (\n                  (submitLabel ?? (effectiveMode === \"parlay\" ? \"Place parlay\" : \"Place bet\"))\n                )}\n              </Button>\n            </div>\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}