// 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 ================== function dollars(v) { if (v == null || Number.isNaN(Number(v))) return "$0.00"; return `$${Number(v).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; } // ================== 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 ================== 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: new Date(p.created_at || p.createdAt || Date.now()), // Single fields league: isParlay ? undefined : p.league, event: isParlay ? undefined : p.event, selection: isParlay ? undefined : p.selection, odds: isParlay ? undefined : p.odds, market: isParlay ? undefined : inferredMarket, line: isParlay ? undefined : line, total: isParlay ? undefined : total, // 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, 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) { let message = (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}`; throw new Error(message); } 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 }) { 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)} )} {displayName} ); } function MatchupFromEvent({ league, event }) { 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(); const final = 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, }; } } return null; } 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 }) { 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)}]` : ""} {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 ================== 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; const overdue = 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"; const moneylineOdds = Number(item.odds); const moneylineToWin = item.market === "moneyline" && Number.isFinite(moneylineOdds) && Number.isFinite(item.stake) && item.stake > 0 ? typeof moneylineProfit === "function" ? moneylineProfit(item.stake, moneylineOdds) : moneylineOdds >= 0 ? item.stake * (moneylineOdds / 100) : item.stake * (100 / Math.abs(moneylineOdds)) : null; 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; const endedNow = typeof hasPickEnded === "function" ? hasPickEnded(item) : false; if (!score.final && !endedNow) 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") { const allLegOdds = (item.legs || []) .map((l) => l?.odds) .filter((x) => x !== null && x !== undefined && x !== ""); const canPrice = allLegOdds.length === (item.legs?.length || 0) && allLegOdds.every((o) => !Number.isNaN(Number(o))); const potential = canPrice ? potentialParlayWin(item.stake, allLegOdds) : null; return (
    Parlay ({item.legs.length} legs) {item.handle ? ` - ${item.handle}` : ""}
    {new Date(item.createdAt).toLocaleString()}
    Stake: {dollars(item.stake)}
    Status: {statusText}
    {potential != null ? ( <>To Win: {dollars(potential)} ) : ( To Win: - )}
    {(onReact || onDelete) && (
    {onReact && ( )} {onDelete && ( )}
    )}
    ); } // SINGLE PICK const lineDetail = formatLineDetail(item); return (
    {item.league?.toUpperCase()} - {item.handle ? ` - ${item.handle}` : ""}
    {new Date(item.createdAt).toLocaleString()}
    {item.selection} {lineDetail ? ( {lineDetail} ) : null}
    {score && (
    {score.awayName} {score.awayScore}-{score.homeScore} {score.homeName} {score.statusText ? ` (${score.statusText})` : ""}
    )}
    Stake: {dollars(item.stake)}
    Status: {statusText}
    {item.market === "moneyline" && (
    To Win:{" "} {moneylineToWin != null ? ( {dollars(moneylineToWin)} ) : ( - )}
    )}
    {(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.useLiveScore = useLiveScore; window.resolveMoneyline = resolveMoneyline; // Support CommonJS imports for tests or Node usage if (typeof module !== "undefined") { module.exports = { americanToDecimal, potentialParlayWin, normalizePicks, createParlay, BetRow, useLiveScore, resolveMoneyline, }; }