Odds Selector
Labeled odds card for a market, selectable options with price movement and suspended states.
"use client"import { 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 } from "@/registry/mrdoge-ui/odds-selector/odds-selector-skeleton"import { matchToMatchCardProps, toOddsOptions } from "@/lib/mrdoge-adapters/match-card"import { useLiveMatch } from "@/registry/mrdoge-ui/use-live-match/use-live-match"import { useOdds } from "@/registry/mrdoge-ui/use-odds/use-odds"import { useSharedOddsMatchId, MATCH_RESULT_BET_TYPES } from "@/components/docs/demos/use-shared-demo-matches"export function OddsSelectorDemo() { const matchId = useSharedOddsMatchId() const match = useLiveMatch({ matchId: matchId ?? undefined }) const markets = useOdds({ matchId: matchId ?? undefined, betTypes: MATCH_RESULT_BET_TYPES }) const [selectedId, setSelectedId] = useState<string | undefined>() if (matchId === null || match === null || markets === null) { return <p className="text-sm text-fd-muted-foreground">No trending match to show right now.</p> } const market = markets?.[0] // Odds close once the match ends, same as matchToMatchCardProps: drop // them rather than freeze on the last live value. const showOdds = match?.status !== "completed" return ( <div className="flex w-full max-w-sm flex-col gap-4"> {match === undefined ? <MatchCard loading /> : <MatchCard {...matchToMatchCardProps(match)} />} {showOdds && (market === undefined ? ( <OddsSelectorSkeleton optionCount={3} className="w-full" /> ) : ( <OddsSelector options={toOddsOptions(market)} selectedId={selectedId} onSelect={setSelectedId} className="w-full" /> ))} </div> )}Odds Board
Rendering several markets at once is just several Odds Selectors: there's no separate component for it. This example shows a match's Match Result, Double Chance, and Total Goals lines stacked together, each with its own selection state and live price movement. Match Result and Double Chance describe overlapping information about the same result, so picking either one live-disables the entire other panel, via the same Conflict Adapter used in Multiple Lines below:
"use client"import { 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 } from "@/registry/mrdoge-ui/odds-selector/odds-selector-skeleton"import { matchToMatchCardProps, toOddsOptions } from "@/lib/mrdoge-adapters/match-card"import { pickMostBalancedMarket } from "@/lib/mrdoge-adapters/odds-lines"import { toConflictCandidates, getConflictingIds } from "@/lib/mrdoge-adapters/conflicts"import { useLiveMatch } from "@/registry/mrdoge-ui/use-live-match/use-live-match"import { useOdds } from "@/registry/mrdoge-ui/use-odds/use-odds"import { useOddsMovement } from "@/registry/mrdoge-ui/use-odds-movement/use-odds-movement"import { useSharedOddsMatchId, MATCH_RESULT_BET_TYPES, TOTAL_GOALS_BET_TYPES, DOUBLE_CHANCE_BET_TYPES,} from "@/components/docs/demos/use-shared-demo-matches"export function OddsSelectorBoardDemo() { const matchId = useSharedOddsMatchId() const match = useLiveMatch({ 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 totalGoalsMarkets = useOdds({ matchId: matchId ?? undefined, betTypes: TOTAL_GOALS_BET_TYPES }) const totalGoals = totalGoalsMarkets ? pickMostBalancedMarket(totalGoalsMarkets) : undefined const totalGoalsMovement = useOddsMovement(totalGoals) const [totalGoalsSelectedId, setTotalGoalsSelectedId] = 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>() if ( matchId === null || match === null || matchResultMarkets === null || totalGoalsMarkets === null || doubleChanceMarkets === null ) { return <p className="text-sm text-fd-muted-foreground">No trending match to show right now.</p> } // Odds close once the match ends, same as matchToMatchCardProps: drop // them rather than freeze on the last live value. const showOdds = match?.status !== "completed" // Match Result and Double Chance describe overlapping information // about the same result, so any selection in one blocks the entire // other market rather than just a specific contradicting option. const mrDcCandidates = matchId && matchResultMarkets && doubleChanceMarkets ? toConflictCandidates(matchId, [...matchResultMarkets, ...doubleChanceMarkets]) : [] const matchResultDisabledIds = Array.from( getConflictingIds(doubleChanceSelectedId ? [doubleChanceSelectedId] : [], mrDcCandidates) ) const doubleChanceDisabledIds = Array.from( getConflictingIds(matchResultSelectedId ? [matchResultSelectedId] : [], mrDcCandidates) ) return ( <div className="flex w-full max-w-sm flex-col gap-4"> {match === undefined ? <MatchCard loading /> : <MatchCard {...matchToMatchCardProps(match)} />} {showOdds && (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" /> ))} {showOdds && (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" /> ))} {showOdds && (totalGoals === undefined ? ( <OddsSelectorSkeleton optionCount={2} label className="w-full" /> ) : ( <OddsSelector label={totalGoals.displayName} options={toOddsOptions(totalGoals, { movementById: totalGoalsMovement })} selectedId={totalGoalsSelectedId} onSelect={setTotalGoalsSelectedId} className="w-full" /> ))} </div> )}Multiple Lines
Some bet types post one market per line instead of one market total:
SOCCER_UNDER_OVER posts a separate market for each Over/Under threshold
(0.5, 1.5, 2.5, ...). Pass all of them to the lines prop instead of
options, and Odds Selector renders one row per market, Over on the
left, Under on the right. Unlike options, more than one line can be
selected at once: compatible cross-threshold picks like Over 1.5 +
Under 4.5 are both allowed, while genuinely incompatible ones like
Under 1.5 + Over 4.5 are blocked via disabledIds, computed here from the
Conflict Adapter. enableSliderView
adds a header toggle to browse them one at a time via a slider instead,
and collapsible adds a toggle to collapse the whole thing down to just
its header:
"use client"import { useState } from "react"import { MatchCard } from "@/registry/mrdoge-ui/match-card/match-card"import { OddsSelector } from "@/registry/mrdoge-ui/odds-selector/odds-selector"import { OddsLinesSkeleton } from "@/registry/mrdoge-ui/odds-selector/odds-selector-skeleton"import { matchToMatchCardProps } from "@/lib/mrdoge-adapters/match-card"import { toOddsLines } from "@/lib/mrdoge-adapters/odds-lines"import { toConflictCandidates, getConflictingIds } from "@/lib/mrdoge-adapters/conflicts"import { useLiveMatch } from "@/registry/mrdoge-ui/use-live-match/use-live-match"import { useOdds } from "@/registry/mrdoge-ui/use-odds/use-odds"import { useSharedOddsMatchId, TOTAL_GOALS_BET_TYPES } from "@/components/docs/demos/use-shared-demo-matches"export function OddsSelectorLinesDemo() { const matchId = useSharedOddsMatchId() const match = useLiveMatch({ matchId: matchId ?? undefined }) const markets = useOdds({ matchId: matchId ?? undefined, betTypes: TOTAL_GOALS_BET_TYPES }) const [selectedLineIds, setSelectedLineIds] = useState<string[]>([]) if (matchId === null || match === null || markets === null) { return <p className="text-sm text-fd-muted-foreground">No trending match to show right now.</p> } const lines = markets ? toOddsLines(markets) : undefined // Odds close once the match ends, same as matchToMatchCardProps: drop // them rather than freeze on the last live value. const showOdds = match?.status !== "completed" const conflictCandidates = matchId && markets ? toConflictCandidates(matchId, markets) : [] const disabledIds = Array.from(getConflictingIds(selectedLineIds, conflictCandidates)) return ( <div className="flex w-full max-w-sm flex-col gap-4"> {match === undefined ? <MatchCard loading /> : <MatchCard {...matchToMatchCardProps(match)} />} {showOdds && (lines === undefined ? ( <OddsLinesSkeleton rowCount={4} className="w-full" /> ) : ( <OddsSelector label="Total Goals" lines={lines} selectedLineIds={selectedLineIds} onSelectLine={(id, selected) => setSelectedLineIds((ids) => (selected ? [...ids, id] : ids.filter((i) => i !== id))) } disabledIds={disabledIds} enableSliderView collapsible className="w-full" /> ))} </div> )}Installation
pnpm dlx shadcn@latest add https://mrdoge.co/r/odds-selector.jsonUse with the Mr. Doge SDK
Odds Selector takes plain props, so it works with any data source. See the Odds Selector Adapter for the real functions behind the examples above.
Props
Prop
Type
OddsOption
Prop
Type
OddsLine
Prop
Type