Mr. Doge
Components

Bet Slip

Panel for selected picks, single or parlay mode, with an optional stake input and conflict warnings.

No picks selected yet.

"use client"import { useLayoutEffect, useRef, useState } from "react"import { MatchCard } from "@/registry/mrdoge-ui/match-card/match-card"import { OddsSelector } from "@/registry/mrdoge-ui/odds-selector/odds-selector"import { OddsSelectorSkeleton, OddsLinesSkeleton } from "@/registry/mrdoge-ui/odds-selector/odds-selector-skeleton"import { BetSlip } from "@/registry/mrdoge-ui/bet-slip/bet-slip"import { matchToMatchCardProps, toOddsOptions } from "@/lib/mrdoge-adapters/match-card"import { toOddsLines } from "@/lib/mrdoge-adapters/odds-lines"import { toBetSlipPick } from "@/lib/mrdoge-adapters/bet-slip"import { toConflictCandidates, getConflictingIds } from "@/lib/mrdoge-adapters/conflicts"import { useMatch } from "@/registry/mrdoge-ui/use-match/use-match"import { useOdds } from "@/registry/mrdoge-ui/use-odds/use-odds"import { useOddsMovement } from "@/registry/mrdoge-ui/use-odds-movement/use-odds-movement"import {  useSharedUpcomingMatchId,  MATCH_RESULT_BET_TYPES,  DOUBLE_CHANCE_BET_TYPES,  TOTAL_GOALS_BET_TYPES,} from "@/components/docs/demos/use-shared-demo-matches"export function BetSlipDemo() {  const matchId = useSharedUpcomingMatchId()  const match = useMatch({ matchId: matchId ?? undefined })  const matchResultMarkets = useOdds({ matchId: matchId ?? undefined, betTypes: MATCH_RESULT_BET_TYPES })  const matchResult = matchResultMarkets?.[0]  const matchResultMovement = useOddsMovement(matchResult)  const [matchResultSelectedId, setMatchResultSelectedId] = useState<string | undefined>()  const doubleChanceMarkets = useOdds({ matchId: matchId ?? undefined, betTypes: DOUBLE_CHANCE_BET_TYPES })  const doubleChance = doubleChanceMarkets?.[0]  const doubleChanceMovement = useOddsMovement(doubleChance)  const [doubleChanceSelectedId, setDoubleChanceSelectedId] = useState<string | undefined>()  const totalGoalsMarkets = useOdds({ matchId: matchId ?? undefined, betTypes: TOTAL_GOALS_BET_TYPES })  const totalGoalsLines = totalGoalsMarkets ? toOddsLines(totalGoalsMarkets) : undefined  const [totalGoalsSelectedIds, setTotalGoalsSelectedIds] = useState<string[]>([])  const [stake, setStake] = useState("")  const [pickStakes, setPickStakes] = useState<Record<string, string>>({})  const [submitState, setSubmitState] = useState<"idle" | "loading" | "success" | "error">("idle")  // Picks are derived directly from each panel's current selection rather  // than tracked separately, so removing one here clears the matching  // selection above.  const picks =    match && matchId      ? [          matchResult && matchResultSelectedId            ? toBetSlipPick(match, matchResult, matchResultSelectedId, matchResultMovement)            : undefined,          doubleChance && doubleChanceSelectedId            ? toBetSlipPick(match, doubleChance, doubleChanceSelectedId, doubleChanceMovement)            : undefined,          ...totalGoalsSelectedIds.map((lineId) => {            const market = totalGoalsMarkets?.find((m) => m.lines.some((line) => line.id === lineId))            return market ? toBetSlipPick(match, market, lineId) : undefined          }),        ].filter((pick): pick is NonNullable<typeof pick> => pick !== undefined)      : []  // Adding a 2nd pick defaults to parlay; dropping below 2 forces back to  // single. Freely switchable in between. BetSlip holds no state of its  // own, so this lives here. useLayoutEffect (not useEffect) so this  // resolves before the browser paints, otherwise the new pick would  // briefly render in single mode for one frame before flipping to  // parlay.  const [mode, setMode] = useState<"single" | "parlay">("single")  const prevPicksLength = useRef(picks.length)  useLayoutEffect(() => {    const prev = prevPicksLength.current    const curr = picks.length    if (prev < 2 && curr >= 2) setMode("parlay")    else if (curr < 2 && mode === "parlay") setMode("single")    prevPicksLength.current = curr  }, [picks.length, mode])  if (    matchId === null ||    match === null ||    matchResultMarkets === null ||    doubleChanceMarkets === null ||    totalGoalsMarkets === null  ) {    return <p className="text-sm text-fd-muted-foreground">No upcoming match to show right now.</p>  }  // Any Match Result selection blocks the entire Double Chance market;  // Total Goals lines can also conflict with each other across  // thresholds. One shared candidate pool covers all three panels.  const conflictCandidates =    matchId && matchResultMarkets && doubleChanceMarkets && totalGoalsMarkets      ? toConflictCandidates(matchId, [...matchResultMarkets, ...doubleChanceMarkets, ...totalGoalsMarkets])      : []  const matchResultDisabledIds = Array.from(    getConflictingIds(doubleChanceSelectedId ? [doubleChanceSelectedId] : [], conflictCandidates)  )  const doubleChanceDisabledIds = Array.from(    getConflictingIds(matchResultSelectedId ? [matchResultSelectedId] : [], conflictCandidates)  )  const totalGoalsDisabledIds = Array.from(getConflictingIds(totalGoalsSelectedIds, conflictCandidates))  return (    <div className="flex w-full max-w-sm flex-col gap-4">      {match === undefined ? <MatchCard loading /> : <MatchCard {...matchToMatchCardProps(match)} />}      {matchResult === undefined ? (        <OddsSelectorSkeleton optionCount={3} label className="w-full" />      ) : (        <OddsSelector          label={matchResult.displayName}          options={toOddsOptions(matchResult, { labelFrom: "code", movementById: matchResultMovement })}          selectedId={matchResultSelectedId}          onSelect={setMatchResultSelectedId}          disabledIds={matchResultDisabledIds}          className="w-full"        />      )}      {doubleChance === undefined ? (        <OddsSelectorSkeleton optionCount={3} label className="w-full" />      ) : (        <OddsSelector          label={doubleChance.displayName}          options={toOddsOptions(doubleChance, { labelFrom: "code", movementById: doubleChanceMovement })}          selectedId={doubleChanceSelectedId}          onSelect={setDoubleChanceSelectedId}          disabledIds={doubleChanceDisabledIds}          className="w-full"        />      )}      {totalGoalsLines === undefined ? (        <OddsLinesSkeleton rowCount={4} className="w-full" />      ) : (        <OddsSelector          label="Total Goals"          lines={totalGoalsLines}          selectedLineIds={totalGoalsSelectedIds}          onSelectLine={(id, selected) =>            setTotalGoalsSelectedIds((ids) => (selected ? [...ids, id] : ids.filter((i) => i !== id)))          }          disabledIds={totalGoalsDisabledIds}          enableSliderView          collapsible          className="w-full"        />      )}      <BetSlip        picks={picks}        onRemovePick={(id) => {          if (id === matchResultSelectedId) setMatchResultSelectedId(undefined)          if (id === doubleChanceSelectedId) setDoubleChanceSelectedId(undefined)          setTotalGoalsSelectedIds((ids) => ids.filter((i) => i !== id))          setPickStakes(({ [id]: _removed, ...rest }) => rest)        }}        mode={mode}        onModeChange={setMode}        stake={stake}        onStakeChange={setStake}        pickStakes={pickStakes}        onPickStakeChange={(id, value) => setPickStakes((prev) => ({ ...prev, [id]: value }))}        onSubmit={() => {          // Fake submit: no real endpoint here, just enough to show the states.          setSubmitState("loading")          setTimeout(() => {            setSubmitState("success")            setTimeout(() => {              setMatchResultSelectedId(undefined)              setDoubleChanceSelectedId(undefined)              setTotalGoalsSelectedIds([])              setPickStakes({})              setStake("")              setSubmitState("idle")            }, 1500)          }, 1000)        }}        submitState={submitState}        className="w-full"      />    </div>  )}

A pick is basically a selected odds line: every panel above the slip is an Odds Selector. Match Result and Double Chance describe the same result two ways, so picking one blocks the other market entirely. See Conflicts below for the warn-instead-of-block alternative.

No real-money language

Stake and payout are neutral terms by design. This is a UI building block for any betting app, not a real-money wagering flow. Compute the payout math however your product needs to.

Multi-game parlay

The example above is a same-game parlay: every pick shares one match. A parlay can just as often span several different matches instead, each in its own group. This example resolves 3 separate upcoming matches, each a real Match Card with its own odds row:

No picks selected yet.

"use client"import { useEffect, useState } from "react"import type { Match } from "@mrdoge/protocol"import { MatchCard } from "@/registry/mrdoge-ui/match-card/match-card"import { BetSlip, type BetSlipPick } from "@/registry/mrdoge-ui/bet-slip/bet-slip"import { matchToMatchCardProps } from "@/lib/mrdoge-adapters/match-card"import { toBetSlipPick } from "@/lib/mrdoge-adapters/bet-slip"import { getMrDogeClient } from "@/registry/mrdoge-ui/mrdoge-client/mrdoge-client"import { useMatch } from "@/registry/mrdoge-ui/use-match/use-match"import { useOdds } from "@/registry/mrdoge-ui/use-odds/use-odds"import { useOddsMovement } from "@/registry/mrdoge-ui/use-odds-movement/use-odds-movement"import { useMatches } from "@/registry/mrdoge-ui/use-matches/use-matches"import { MATCH_RESULT_BET_TYPES } from "@/components/docs/demos/use-shared-demo-matches"const GAME_COUNT = 3// Resolves the first `count` distinct upcoming matches that have a Match// Result market posted, checked in parallel.function useMultiMatchIds(candidates: Match[] | null | undefined, count: number) {  const [resolvedIds, setResolvedIds] = useState<string[] | null | undefined>(undefined)  useEffect(() => {    if (!candidates || candidates.length === 0) return    let cancelled = false    Promise.all(      candidates.map((candidate) =>        getMrDogeClient()          .odds.list({ matchId: candidate.id, betTypes: MATCH_RESULT_BET_TYPES })          .then((markets) => (markets.length > 0 ? candidate.id : null))          .catch(() => null)      )    ).then((results) => {      if (cancelled) return      const ids = results.filter((id): id is string => id !== null).slice(0, count)      setResolvedIds(ids.length > 0 ? ids : null)    })    return () => {      cancelled = true    }  }, [candidates, count])  if (candidates === undefined) return undefined  if (candidates === null || candidates.length === 0) return null  return resolvedIds}// Fetches one match's own data and reports its current pick up to the// parent, which owns the combined BetSlip.function MultiGameSelector({  matchId,  onPickChange,}: {  matchId: string  onPickChange: (matchId: string, pick: BetSlipPick | undefined, clear: () => void) => void}) {  const match = useMatch({ matchId })  const markets = useOdds({ matchId, betTypes: MATCH_RESULT_BET_TYPES })  const market = markets?.[0]  const movement = useOddsMovement(market)  const [selectedId, setSelectedId] = useState<string | undefined>()  const pick = match && market && selectedId ? toBetSlipPick(match, market, selectedId, movement) : undefined  useEffect(() => {    onPickChange(matchId, pick, () => setSelectedId(undefined))    // eslint-disable-next-line react-hooks/exhaustive-deps -- only the derived pick's own fields should retrigger this; onPickChange closes over a stable setState setter  }, [matchId, pick?.id, pick?.price, pick?.unavailable, pick?.movement])  if (match === null) {    return <p className="text-sm text-fd-muted-foreground">Couldn't load this match right now.</p>  }  if (match === undefined) {    return <MatchCard loading oddsLoading oddsPosition="right" className="w-full" />  }  return (    <MatchCard      {...matchToMatchCardProps(match, market, movement)}      oddsLoading={market === undefined}      oddsPosition="right"      selectedOddsId={selectedId}      onSelectOdds={setSelectedId}      className="w-full"    />  )}export function BetSlipMultiGameDemo() {  // Bounded to today/tomorrow: "upcoming" alone can include matches  // stuck on that status well past their real kickoff.  const today = new Date()  const tomorrow = new Date(today)  tomorrow.setDate(tomorrow.getDate() + 1)  const upcomingMatches = useMatches({    sports: ["soccer"],    status: ["upcoming"],    startDate: today.toISOString().slice(0, 10),    endDate: tomorrow.toISOString().slice(0, 10),    limit: 20,  })  const matchIds = useMultiMatchIds(upcomingMatches, GAME_COUNT)  const [entries, setEntries] = useState<Record<string, { pick?: BetSlipPick; clear: () => void }>>({})  const [mode, setMode] = useState<"single" | "parlay">("parlay")  const [stake, setStake] = useState("")  const [pickStakes, setPickStakes] = useState<Record<string, string>>({})  const [submitState, setSubmitState] = useState<"idle" | "loading" | "success" | "error">("idle")  if (matchIds === null) {    return <p className="text-sm text-fd-muted-foreground">No upcoming matches to show right now.</p>  }  const picks = matchIds    ? matchIds.map((id) => entries[id]?.pick).filter((pick): pick is BetSlipPick => pick !== undefined)    : []  return (    <div className="flex w-full max-w-xl flex-col items-center gap-4">      {matchIds === undefined        ? Array.from({ length: GAME_COUNT }).map((_, index) => (            <MatchCard key={index} loading oddsLoading oddsPosition="right" className="w-full" />          ))        : matchIds.map((id) => (            <MultiGameSelector              key={id}              matchId={id}              onPickChange={(pickMatchId, pick, clear) =>                setEntries((prev) => ({ ...prev, [pickMatchId]: { pick, clear } }))              }            />          ))}      <BetSlip        picks={picks}        onRemovePick={(id) => {          const entry = Object.values(entries).find((e) => e.pick?.id === id)          entry?.clear()          setPickStakes(({ [id]: _removed, ...rest }) => rest)        }}        mode={mode}        onModeChange={setMode}        stake={stake}        onStakeChange={setStake}        pickStakes={pickStakes}        onPickStakeChange={(id, value) => setPickStakes((prev) => ({ ...prev, [id]: value }))}        onSubmit={() => {          // Fake submit: no real endpoint here, just enough to show the states.          setSubmitState("loading")          setTimeout(() => {            setSubmitState("success")            setTimeout(() => {              Object.values(entries).forEach((entry) => entry.clear())              setPickStakes({})              setStake("")              setSubmitState("idle")            }, 1500)          }, 1000)        }}        submitState={submitState}        className="w-full"      />    </div>  )}

Installation

pnpm dlx shadcn@latest add https://mrdoge.co/r/bet-slip.json

Use with the Mr. Doge SDK

BetSlip takes plain props, so it works with any data source. See the Bet Slip Adapter for the real function building a BetSlipPick from a match, market, and selected line id. It's the one behind both examples above, including the home/away team crests shown per match group.

Conflicts

BetSlip can only ever flag a conflicting pick: it has no "add pick" affordance of its own, so preventing one from being added in the first place is an Odds Selector (or host-app) concern. Pass conflictingPickIds for the warn-instead-of-block case, e.g. picks loaded from storage with no live disabledIds upstream:

import { getConflictingPickIds } from "@/lib/mrdoge-adapters/conflicts"

<BetSlip
  picks={picks}
  onRemovePick={removePick}
  conflictingPickIds={Array.from(getConflictingPickIds(picks))}
/>

See the Conflict Adapter for the full rule set.

Props

Prop

Type

BetSlipPick

Prop

Type

BetSlipMatchGroup

<BetSlip> renders this internally once per distinct matchId in picks, but it's also exported on its own (import { BetSlipMatchGroup } from "@/components/bet-slip") for composing your own layout around a match's crests and its selections.

Prop

Type

BetSlipPickRow

One selection line within a match group: market, selection, price, and a remove button. Doesn't render team or event info itself; that's BetSlipMatchGroup's job, shown once per match rather than once per pick. Also exported on its own for composing outside a group entirely.

connected (default true) draws the circle-and-line connector; set it to false for a group of picks that are grouped for display but aren't one combined bet (e.g. singles from the same match). The circle stays, the connecting lines don't, and a divider separates rows instead.

The same circle also doubles as a settlement indicator: pass result (and prevResult, for the line above it) once a pick is settled to reuse this row for a post-placement history view instead of the active betslip.

Prop

Type

On this page