// bets.jsx // Complete module: normalize picks from API, odds helpers, and BetRow component // React is loaded globally via a CDN script in index.html. // ================== FORMAT HELPERS ================== // -$1,775.00, never $-1,775.00: the one money formatter (utils/money.js) function dollars(v) { return window.BBMoney.formatMoney(v); } // ================== ODDS HELPERS ================== function americanToDecimal(american) { const x = Number(american); if (!Number.isFinite(x)) return null; if (x > 0) return 1 + x / 100; if (x < 0) return 1 + 100 / Math.abs(x); return 2.0; } function potentialParlayWin(stake, legsAmericanOdds) { if (typeof calculateParlayProfit === "function") { return calculateParlayProfit(stake, legsAmericanOdds); } const s = Number(stake); if (!Number.isFinite(s) || s <= 0) return 0; if (!Array.isArray(legsAmericanOdds) || legsAmericanOdds.length === 0) return 0; let dec = 1.0; for (const o of legsAmericanOdds) { const d = americanToDecimal(o); if (d == null) return null; dec *= d; } return s * (dec - 1.0); } // ================== NORMALIZER ================== // ESPN win probability + id-matched team colors from the API (utils/winprob.js renders them). All nullable. function winProbFields(src) { return { win_prob: src.win_prob ?? null, win_prob_kind: src.win_prob_kind ?? null, win_prob_side: src.win_prob_side ?? null, win_prob_updated_at: src.win_prob_updated_at ?? null, win_prob_hist: Array.isArray(src.win_prob_hist) ? src.win_prob_hist : [], away_color: src.away_color ?? null, home_color: src.home_color ?? null, }; } function normalizePicks(apiPicks, { includeHandle = false } = {}) { return (apiPicks || []).map((p) => { const isParlay = Array.isArray(p.legs) && p.legs.length > 0; const rawMarket = String(p.market || "").trim().toLowerCase(); const line = p.line ?? p.spread ?? p.spread_line ?? p.point ?? null; const total = p.total ?? p.total_points ?? p.totalPoints ?? null; const inferredMarket = rawMarket || (line != null ? "spread" : total != null ? "total" : p.odds != null ? "moneyline" : ""); return { id: p.id, type: isParlay ? "parlay" : "single", handle: includeHandle ? p.handle : undefined, stake: Number(p.stake ?? 0), status: (p.status || "open").toLowerCase(), start: p.start || p.kickoff || p.start_time || p.starts_at || p.game_time, createdAt: window.BBPlaced.placedAt(p) || new Date(p.created_at || p.createdAt || Date.now()), // "Placed 4:12 PM" + LIVE tag (utils/placed.js), from the raw row so the API field names live in one place placedAt: window.BBPlaced.placedAt(p), isLive: window.BBPlaced.isLiveBet(p), // Single fields league: isParlay ? undefined : p.league, event: isParlay ? undefined : p.event, // an accepted counter (api #275) keeps the request's text ("Ole Miss -3.5"): show the line actually bet selection: isParlay ? undefined : (p.counter && window.BBCounter ? window.BBCounter.withLine(p.selection, inferredMarket, inferredMarket === "total" ? total : line) : p.selection), odds: isParlay ? undefined : p.odds, market: isParlay ? undefined : inferredMarket, line: isParlay ? undefined : line, total: isParlay ? undefined : total, away_rank: isParlay ? undefined : (p.away_rank ?? null), home_rank: isParlay ? undefined : (p.home_rank ?? null), // api #275: the bettor's original ask when this wager came from an accepted counter offer counter: isParlay ? null : (p.counter && typeof p.counter === "object" ? p.counter : null), ...(isParlay ? {} : winProbFields(p)), // Parlay legs legs: isParlay ? p.legs.map((l, idx) => ({ idx, league: l.league, event: l.event, selection: l.selection, market: (l.market || "").toLowerCase(), side: l.side, odds: l.odds, line: l.line, total: l.total, away_rank: l.away_rank ?? null, home_rank: l.home_rank ?? null, ...winProbFields(l), start: l.start || l.kickoff || l.start_time || l.starts_at || l.game_time, })) : null, }; }); } async function createParlay({ apiBase, token, stake, legs, withCode }) { const numericStake = Number(stake); if (!Number.isFinite(numericStake) || numericStake <= 0) { throw new Error("Stake must be greater than zero"); } if (!Array.isArray(legs) || legs.length < 2) { throw new Error("Parlay requires at least two legs"); } const sanitizedLegs = legs.map((raw, idx) => { const league = String(raw.league || "").trim().toUpperCase(); const event = String(raw.event || "").trim().toUpperCase(); const selection = String(raw.selection || "").trim().toUpperCase(); const market = String(raw.market || "").trim().toLowerCase(); if (!league || !event || !selection || !market) { throw new Error(`Leg ${idx + 1} is missing required fields`); } const allowedMarkets = ["moneyline", "spread", "total"]; if (!allowedMarkets.includes(market)) { throw new Error(`Leg ${idx + 1} has an invalid market`); } const sanitized = { league, event, selection, market }; const side = raw.side != null ? String(raw.side).trim() : ""; if (side) sanitized.side = side.toUpperCase(); if (raw.odds != null && String(raw.odds).trim() !== "") { const odds = Number(raw.odds); if (!Number.isFinite(odds)) { throw new Error(`Leg ${idx + 1} has invalid odds`); } sanitized.odds = odds; } const hasLine = raw.line != null && String(raw.line).trim() !== ""; const hasTotal = raw.total != null && String(raw.total).trim() !== ""; let lineValue = null; let totalValue = null; if (hasLine) { lineValue = Number(raw.line); if (!Number.isFinite(lineValue)) { throw new Error(`Leg ${idx + 1} has an invalid line`); } } if (hasTotal) { totalValue = Number(raw.total); if (!Number.isFinite(totalValue)) { throw new Error(`Leg ${idx + 1} has an invalid total`); } } if (market === "spread") { if (lineValue == null) { throw new Error(`Leg ${idx + 1} requires a spread line`); } sanitized.line = lineValue; } else if (market === "total") { if (totalValue == null && lineValue != null) { totalValue = lineValue; } if (totalValue == null) { throw new Error(`Leg ${idx + 1} requires a total value`); } sanitized.total = totalValue; if (lineValue != null) { sanitized.line = lineValue; } } return sanitized; }); const payload = { stake: numericStake, legs: sanitizedLegs }; const urlBase = `${apiBase.replace(/\/$/, "")}/picks/parlay`; const url = typeof withCode === "function" ? withCode(urlBase) : urlBase; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(payload), }); let text = ""; try { text = await res.text(); } catch {} let data = null; try { data = text ? JSON.parse(text) : null; } catch {} if (!res.ok) { // The server's own words (utils/odds-moved.js errorMessage: error, string/object detail, message, reason, 422 msgs) const said = data && window.BBOddsMoved ? window.BBOddsMoved.errorMessage(res.status, data, "") : ""; let message = (said && !/^Failed to place bet/.test(said) ? said : "") || (data && typeof data.error === "string" && data.error) || (Array.isArray(data?.detail) ? data.detail .map((d) => (typeof d?.msg === "string" ? d.msg : "")) .filter(Boolean) .join("; ") : null) || (text && text.trim()) || `HTTP ${res.status}`; const err = new Error(message); err.status = res.status; err.code = data && typeof data.code === "string" ? data.code : undefined; // e.g. "live_cutoff" (409) throw err; } if (!data?.ok) throw new Error((data && data.error) || "Failed to create parlay"); return data.id; } // ================== LIVE SCORE HELPERS ================== function normalizeName(s) { return String(s || "") .toLowerCase() .replace(/[^a-z0-9 ]+/g, " ") .replace(/\s+/g, " ") .trim(); } function teamsRoughMatch(a, b) { const na = normalizeName(a); const nb = normalizeName(b); return na === nb || na.includes(nb) || nb.includes(na); } function parseTeamsFromEvent(ev) { const src = String(ev || "").trim(); if (!src.includes("@")) return { away: "", home: "" }; const parts = src.split("@"); if (parts.length < 2) return { away: "", home: "" }; const away = String(parts[0] || "").trim(); let home = String(parts.slice(1).join("@")).trim(); home = home .replace(/[\u2022,].*$/, "") .replace(/\s+\d{1,2}\/\d{1,2}(?:\/\d{2,4})?.*$/i, "") .replace(/\s+\d{4}-\d{2}-\d{2}.*$/i, "") .replace(/\s+\d{1,2}:\d{2}(?::\d{2})?\s*(?:AM|PM)?(?:\s*[A-Z]{2,4})?.*$/i, "") .trim(); if (away && home) return { away, home }; return { away: "", home: "" }; } function teamInitials(name) { const parts = String(name || "").trim().split(/\s+/).filter(Boolean); if (!parts.length) return "TM"; if (parts.length === 1) return parts[0].slice(0, 3).toUpperCase(); return parts.slice(0, 3).map((p) => p[0]).join("").toUpperCase(); } function TeamLabel({ team, league, compact = false, rank = null }) { const displayName = (typeof getTeamDisplayName === "function" ? getTeamDisplayName(team, { leagueKey: league }) : String(team || "")) || String(team || ""); const [logoSrc, setLogoSrc] = React.useState(() => { if (typeof window !== "undefined" && typeof window.getCachedTeamLogo === "function") { return window.getCachedTeamLogo(league, displayName) || ""; } return ""; }); React.useEffect(() => { let cancelled = false; const cached = typeof window !== "undefined" && typeof window.getCachedTeamLogo === "function" ? window.getCachedTeamLogo(league, displayName) : ""; if (cached) { setLogoSrc(cached); return () => { cancelled = true; }; } if (typeof window !== "undefined" && typeof window.resolveTeamLogo === "function") { window.resolveTeamLogo(league, displayName) .then((resolved) => { if (!cancelled && resolved) setLogoSrc(String(resolved)); }) .catch(() => {}); } return () => { cancelled = true; }; }, [league, displayName]); if (!displayName) return null; return ( {logoSrc ? ( {`${displayName} ) : ( {teamInitials(displayName)} )} {typeof window !== "undefined" && window.TeamRankBadge ? : null} {displayName} ); } // "Win% 62 ▁▃▅" chip (spreads and moneylines alike) (utils/winprob.js), only while the game is live; nothing when null. function BetWinProb({ item, detail = false, live = false }) { if (typeof window === "undefined" || !window.WinProbBadge || !item || !live) return null; return ; } // Same chip where no score is loaded yet (Bet Details): reads the game's ESPN state itself. function LiveWinProb({ item, league, detail = false, wrap = false }) { const score = useLiveScore(league || item?.league, item?.event, item?.start || item?.starts_at); if (!(score && score.isLive) || !window.BBWinProb || !window.BBWinProb.winProbInfo(item)) return null; const chip = ; return wrap ?
{chip}
: chip; } function SelectionRank({ item }) { if (typeof window === "undefined" || !window.TeamRankBadge || !window.selectionRank || !item) return null; return ; } function MatchupFromEvent({ league, event, awayRank = null, homeRank = null }) { const { away, home } = parseTeamsFromEvent(event); if (!away || !home) return {event}; return ( @ ); } function findGame(scoreboard, awayName, homeName) { const events = (scoreboard && scoreboard.events) || []; for (const ev of events) { const comp = (ev.competitions && ev.competitions[0]) || null; if (!comp || !Array.isArray(comp.competitors)) continue; const home = comp.competitors.find((c) => (c.homeAway || "").toLowerCase() === "home"); const away = comp.competitors.find((c) => (c.homeAway || "").toLowerCase() === "away"); if (!home || !away) continue; const homeNameActual = home.team?.displayName || home.team?.shortDisplayName || home.team?.name || ""; const awayNameActual = away.team?.displayName || away.team?.shortDisplayName || away.team?.name || ""; if (teamsRoughMatch(homeName, homeNameActual) && teamsRoughMatch(awayName, awayNameActual)) { const statusType = comp.status?.type || ev.status?.type || {}; const fallbackStatus = ev.status?.type || {}; const detailSources = [ statusType.detail, statusType.description, statusType.shortDetail, fallbackStatus.detail, fallbackStatus.description, fallbackStatus.shortDetail, ]; const statusText = detailSources.find((v) => typeof v === "string" && v.trim()) || ""; const state = String(statusType.state || fallbackStatus.state || "").toLowerCase(); // postponed/cancelled/suspended come back as state "post" with 0-0: never a final (never graded) const final = !/POSTPONED|CANCEL|SUSPENDED|ABANDONED/i.test(String(statusType.name || fallbackStatus.name || "") + " " + statusText) && (statusType.completed === true || fallbackStatus.completed === true || state === "post" || statusText.toLowerCase().includes("final")); return { awayName: awayNameActual, homeName: homeNameActual, awayScore: Number(away.score || 0), homeScore: Number(home.score || 0), final, statusText, // Live-bet cutoff (utils/live-cutoff.js): the same ESPN fields the server checks. ...cutoffFields(ev, comp, statusType, fallbackStatus, state, final), }; } } return null; } function cutoffFields(ev, comp, statusType, fallbackStatus, state, final) { const status = comp.status || ev.status || {}; const name = String(statusType.name || fallbackStatus.name || ""); const isVoid = /POSTPONED|CANCEL|SUSPENDED|ABANDONED/i.test(name); return { period: status.period ?? null, clock: status.displayClock ?? null, clockSeconds: status.clock ?? null, regulationPeriods: comp.format?.regulation?.periods ?? null, statusName: name, statusDetail: statusType.shortDetail || fallbackStatus.shortDetail || null, outs: comp.situation?.outs ?? null, isLive: !final && !isVoid && state === "in", isFinal: !!final, isVoid, }; } function resolveMoneyline(pick, score) { if (!pick || !score) return null; const sel = String(pick.selection || "").toLowerCase(); if (teamsRoughMatch(sel, score.homeName)) { if (score.homeScore > score.awayScore) return "won"; if (score.homeScore < score.awayScore) return "lost"; return "push"; } if (teamsRoughMatch(sel, score.awayName)) { if (score.awayScore > score.homeScore) return "won"; if (score.awayScore < score.homeScore) return "lost"; return "push"; } return null; } function normalizeLeagueKey(value) { const key = String(value || "").toLowerCase().trim(); if (!key) return ""; if (["college-basketball", "mens-college-basketball", "ncaam", "cbb"].includes(key)) { return "ncaab"; } return key; } function useLiveScore(league, event, start) { const [score, setScore] = React.useState(null); React.useEffect(() => { const leagueKey = normalizeLeagueKey(league); if (!leagueKey || !event) return; const sport = { nfl: "football/nfl", cfb: "football/college-football", mlb: "baseball/mlb", nba: "basketball/nba", ncaab: "basketball/mens-college-basketball", nhl: "hockey/nhl", }[leagueKey]; if (!sport) return; let cancelled = false; const load = async () => { const t = start != null ? new Date(start) : typeof parsePickStartTime === "function" ? parsePickStartTime({ event }) : null; if (!t || isNaN(t)) return; const y = t.getFullYear(); const m = String(t.getMonth() + 1).padStart(2, "0"); const d = String(t.getDate()).padStart(2, "0"); try { const r = await fetch( `https://site.api.espn.com/apis/site/v2/sports/${sport}/scoreboard?dates=${y}${m}${d}` ); const j = await r.json(); const { away, home } = parseTeamsFromEvent(event); const g = findGame(j, away, home); if (!cancelled && g) setScore(g); } catch {} }; load(); const id = setInterval(load, 60000); return () => { cancelled = true; clearInterval(id); }; }, [league, event, start]); return score; } function LegRow({ leg, open = true }) { const score = useLiveScore(leg.league, leg.event, leg.start); return (
  • {leg.league?.toUpperCase()}{" "} - {leg.selection}{" "} {leg.market === "spread" && leg.line != null ? `(${leg.line >= 0 ? "+" : ""}${leg.line})` : ""} {leg.market === "total" && leg.total != null ? `(O/U ${leg.total})` : ""}{" "} {leg.odds != null ? `[${String(leg.odds)}]` : ""} {open && score?.isLive ? <>{" "} : null} {score && ( <> {" "}- {score.awayName} {score.awayScore}-{score.homeScore} {score.homeName} {score.statusText ? ` (${score.statusText})` : ""} )}
  • ); } function formatOddsValue(value) { const numeric = Number(value); if (!Number.isFinite(numeric)) return value != null ? String(value) : null; return numeric > 0 ? `+${numeric}` : `${numeric}`; } function extractSpreadFromSelection(selection) { if (!selection) return null; const match = String(selection).match(/(?:^|\s)([+-]\d+(?:\.\d+)?)(?=\s|$)/); if (!match) return null; const numeric = Number(match[1]); return Number.isFinite(numeric) ? numeric : null; } function formatLineDetail(item) { const parsedLine = item.line ?? extractSpreadFromSelection(item.selection); const market = item.market || (parsedLine != null ? "spread" : item.total != null ? "total" : "moneyline"); const oddsText = formatOddsValue(item.odds); if (market === "spread") { if (parsedLine == null) return null; const lineText = `${parsedLine >= 0 ? "+" : ""}${parsedLine}`; return oddsText ? `Spread ${lineText} (${oddsText})` : `Spread ${lineText}`; } if (market === "total") { const total = item.total ?? item.line; if (total == null) return null; return oddsText ? `Total ${total} (${oddsText})` : `Total ${total}`; } if (market === "moneyline") { if (parsedLine != null) { return oddsText ?? null; } return oddsText ? `Moneyline ${oddsText}` : "Moneyline"; } return oddsText ? `Odds ${oddsText}` : null; } // ================== ROW COMPONENT ================== // "To win: $181.82" on open wagers, "Won $181.82" / "Lost $200.00" / "Push · $200.00 returned" // on graded ones. Module level (never declared inside a render function). const WAGER_TONE_CLASS = { win: "text-green-600", loss: "text-red-600", push: "opacity-80", open: "" }; function WagerResultText({ line, className = "" }) { if (!line) return null; if (line.kind === "open") { return (
    To win: {line.toWin != null ? {dollars(line.toWin)} : n/a}
    ); } return
    {line.label}
    ; } function BetRow({ item, onDelete, onReact, onAutoResolve, rowId, highlighted = false }) { function handleClick(e) { if (!onReact) return; if (e.target !== e.currentTarget) return; onReact(item); } const clickEvents = onReact ? { onClick: handleClick } : {}; const rawStatus = String(item.status || item.state || "").trim().toLowerCase(); const closedStatuses = [ "won", "lost", "void", "push", "graded", "settled", "closed", "canceled", "cancelled", ]; const started = typeof hasPickStarted === "function" ? hasPickStarted(item) : false; const ended = typeof hasPickEnded === "function" ? hasPickEnded(item) : false; // A live-bet request waiting on (or rejected by) the bookie (utils/pending-live.js): not a bet yet. const request = item.requestState === "pending" || item.requestState === "rejected" ? item.requestState : null; const overdue = !request && ended && !closedStatuses.includes(rawStatus); const openish = [ "open", "pending", "in_progress", "ungraded", "live", "active", "pending_approval", "proposed", ]; let statusText; if (closedStatuses.includes(rawStatus)) statusText = item.status || rawStatus; else if (ended) statusText = "overdue"; else if (started) statusText = "pending"; else if (openish.includes(rawStatus) || rawStatus === "") statusText = "open"; else statusText = item.status || item.state || "open"; if (request) statusText = item.requestLabel || (request === "pending" ? "Pending – waiting on group admin" : "Rejected by group admin"); // To win / result line from the one shared helper (utils/towin.js) const wagerLine = request === "rejected" ? null : window.BBToWin.wagerResult(item); const score = useLiveScore(item.league, item.event, item.start); const resolvedRef = React.useRef(false); React.useEffect(() => { if (!onAutoResolve || resolvedRef.current) return; if (!score) return; if (closedStatuses.includes(rawStatus)) return; // Only settle on a real ESPN final (never on the 12h "ended" clock heuristic, which would // grade on whatever partial/0-0 score is showing), and only straight moneylines here: // resolveMoneyline ignores the line, so a spread/run line/total must not go through it // (the full-market grader handles those). if (!score.final) return; if (item.type === "parlay" || (Array.isArray(item.legs) && item.legs.length > 0)) return; const mkt = String(item.market || "").toLowerCase().trim(); const legacyMl = !mkt && (item.line == null || item.line === "") && !/[+-]\s*\d/.test(String(item.selection || "")); if (!["moneyline", "ml", "h2h"].includes(mkt) && !legacyMl) return; const result = resolveMoneyline(item, score); if (!result) return; resolvedRef.current = true; const finalScore = `${score.awayName} ${score.awayScore}-${score.homeScore} ${score.homeName}`; onAutoResolve(item, result, finalScore); }, [score, onAutoResolve, item, rawStatus]); if (item.type === "parlay") { return (
    Parlay ({item.legs.length} legs) {item.handle ? ` - ${item.handle}` : ""}
      {item.legs.map((l) => ( ))}
    Stake: {dollars(item.stake)}
    Status: {statusText}
    {(onReact || onDelete) && (
    {onReact && ( )} {onDelete && ( )}
    )}
    ); } // SINGLE PICK const lineDetail = formatLineDetail(item); const CounterNote = window.BBCounterUI && window.BBCounterUI.CounterHistoryNote; const isOpen = !closedStatuses.includes(rawStatus); return (
    {item.league?.toUpperCase()} -{" "} {item.handle ? {` - ${item.handle}`} : ""}
    {item.selection} {lineDetail ? ( {lineDetail} ) : null} {isOpen && score?.isLive ? : null}
    {CounterNote && } {score && (
    {score.awayName} {score.awayScore}-{score.homeScore} {score.homeName} {score.statusText ? ` (${score.statusText})` : ""}
    )}
    Stake: {dollars(item.stake)}
    Status: {statusText}
    {(onReact || onDelete) && (
    {onReact && ( )} {onDelete && ( )}
    )}
    ); } // Expose helpers globally for browser usage window.americanToDecimal = americanToDecimal; window.potentialParlayWin = potentialParlayWin; window.normalizePicks = normalizePicks; window.createParlay = createParlay; window.BetRow = BetRow; window.WagerResultText = WagerResultText; window.useLiveScore = useLiveScore; window.LiveWinProb = LiveWinProb; window.resolveMoneyline = resolveMoneyline; // Support CommonJS imports for tests or Node usage if (typeof module !== "undefined") { module.exports = { americanToDecimal, potentialParlayWin, normalizePicks, createParlay, BetRow, useLiveScore, resolveMoneyline, }; }