// BetRow, normalizePicks, and createParlay are loaded globally via bets.jsx const { useState, useEffect, useMemo, useCallback, useRef } = React; const APP_BUILD = (typeof window !== "undefined" && window.__BB_BUILD) ? String(window.__BB_BUILD) : "dev"; const PUSH_OPT_IN_KEY = "bb_push_opt_in"; const PUSH_REPAIR_LAST_ATTEMPT_KEY = "bb_push_repair_last_attempt"; const PUSH_REPAIR_COOLDOWN_MS = 6 * 60 * 60 * 1000; const VERSION_FEED_URL = "/version.json"; const UPGRADE_DISMISSED_BUILD_KEY = "bb_upgrade_dismissed_build"; const UPGRADE_DISMISSED_UNTIL_KEY = "bb_upgrade_dismissed_until"; const UPGRADE_SNOOZE_MS = 4 * 60 * 60 * 1000; const THEME_TEAM_BRANDING = { browns: { label: "Cleveland Browns", logo: "/images/team-logos/browns.png", fallback: "CLE" }, packers: { label: "Green Bay Packers", logo: "/images/team-logos/packers.png", fallback: "GB" }, buckeyes: { label: "Ohio State Buckeyes", logo: "/images/team-logos/buckeyes.png", fallback: "OSU" }, steelers: { label: "Pittsburgh Steelers", logo: "/images/team-logos/steelers.png", fallback: "PIT" }, cowboys: { label: "Dallas Cowboys", logo: "/images/team-logos/cowboys.png", fallback: "DAL" }, patriots: { label: "New England Patriots", logo: "/images/team-logos/patriots.png", fallback: "NE" }, chiefs: { label: "Kansas City Chiefs", logo: "/images/team-logos/chiefs.png", fallback: "KC" }, raiders: { label: "Las Vegas Raiders", logo: "/images/team-logos/raiders.png", fallback: "LV" }, giants: { label: "New York Giants", logo: "/images/team-logos/giants.png", fallback: "NYG" }, eagles: { label: "Philadelphia Eagles", logo: "/images/team-logos/eagles.png", fallback: "PHI" }, lakers: { label: "Los Angeles Lakers", logo: "/images/team-logos/lakers.png", fallback: "LAL" }, yankees: { label: "New York Yankees", logo: "/images/team-logos/yankees.png", fallback: "NYY" }, bulls: { label: "Chicago Bulls", logo: "/images/team-logos/bulls.png", fallback: "CHI" }, michigan: { label: "Michigan Wolverines", logo: "/images/team-logos/michigan.png", fallback: "MICH" }, }; function normalizeBuildToken(value) { return String(value || "").trim(); } function compareBuildTokens(a, b) { const tokenize = (value) => String(value || "") .split(/[^0-9A-Za-z]+/) .filter(Boolean) .map((part) => { const n = Number(part); return Number.isFinite(n) ? n : part.toLowerCase(); }); const ta = tokenize(a); const tb = tokenize(b); const len = Math.max(ta.length, tb.length); for (let i = 0; i < len; i += 1) { const av = i < ta.length ? ta[i] : 0; const bv = i < tb.length ? tb[i] : 0; if (typeof av === "number" && typeof bv === "number") { if (av > bv) return 1; if (av < bv) return -1; continue; } const as = String(av); const bs = String(bv); if (as > bs) return 1; if (as < bs) return -1; } return 0; } function TeamThemeLogo({ theme, alt, className = "" }) { const [failed, setFailed] = useState(false); const brand = THEME_TEAM_BRANDING[theme]; if (!brand) return null; if (failed) { return ( {brand.fallback} ); } return ( {alt setFailed(true)} /> ); } function getTeamInitials(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 TeamIdentity({ team, leagueKey, compact = false, className = "" }) { const displayName = getTeamDisplayName(team, { leagueKey }) || String(team || "").trim(); const [logoSrc, setLogoSrc] = useState(() => { if (typeof window !== "undefined" && typeof window.getCachedTeamLogo === "function") { return window.getCachedTeamLogo(leagueKey, displayName) || ""; } return ""; }); useEffect(() => { let cancelled = false; const cached = typeof window !== "undefined" && typeof window.getCachedTeamLogo === "function" ? window.getCachedTeamLogo(leagueKey, displayName) : ""; if (cached) { setLogoSrc(cached); return () => { cancelled = true; }; } if (typeof window !== "undefined" && typeof window.resolveTeamLogo === "function") { window.resolveTeamLogo(leagueKey, displayName) .then((resolved) => { if (!cancelled && resolved) setLogoSrc(String(resolved)); }) .catch(() => {}); } return () => { cancelled = true; }; }, [leagueKey, displayName]); if (!displayName) return null; return ( {logoSrc ? ( {`${displayName} ) : ( {getTeamInitials(displayName)} )} {displayName} ); } // ================== DEFAULTS ================== // Prefer same-origin proxy in production (kinjar) to avoid CORS; fall back to remote API. const DEFAULTS = { API_BASE: (typeof window !== 'undefined' && window.location && window.location.hostname === 'blackbook.kinjar.com') ? '/api/bb' : "https://blackbook-api.fly.dev", GROUP_CODE: "", JWT: "" }; const getApiBase = () => (localStorage.getItem("bb_api_base") || DEFAULTS.API_BASE).replace(/\/$/, ""); for (const [k, v] of Object.entries({ bb_api_base: DEFAULTS.API_BASE, bb_group_code: DEFAULTS.GROUP_CODE, bb_token: DEFAULTS.JWT, })) { if (!localStorage.getItem(k)) localStorage.setItem(k, v); } // ================== CONFIG/UTILS ================== const LEAGUES = [ { key: "nfl", name: "NFL" }, { key: "cfb", name: "CFB" }, { key: "mlb", name: "MLB" }, { key: "nba", name: "NBA" }, { key: "ncaab", name: "NCAAB" }, { key: "nhl", name: "NHL" }, { key: "ufc", name: "UFC" }, ]; const LEAGUE_BRANDING = { nfl: { abbr: "NFL", logo: "/images/league-logos/nfl.png", logoFallback: "/images/league-logos/nfl.svg" }, cfb: { abbr: "CFB", logo: "/images/league-logos/cfb.svg", logoFallback: "/images/league-logos/cfb.svg" }, mlb: { abbr: "MLB", logo: "/images/league-logos/mlb.png", logoFallback: "/images/league-logos/mlb.svg" }, nba: { abbr: "NBA", logo: "/images/league-logos/nba.png", logoFallback: "/images/league-logos/nba.svg" }, ncaab: { abbr: "NCAAB", logo: "/images/league-logos/ncaab.svg", logoFallback: "/images/league-logos/ncaab.svg" }, nhl: { abbr: "NHL", logo: "/images/league-logos/nhl.png", logoFallback: "/images/league-logos/nhl.svg" }, ufc: { abbr: "UFC", logo: "/images/league-logos/ufc.png", logoFallback: "/images/league-logos/ufc.svg" }, }; const LEAGUE_META = new Map(LEAGUES.map((entry) => [entry.key, entry])); const SPORTS_MENU_GROUPS = [ { id: "football", label: "Football", description: "Sundays & Saturdays", leagues: ["nfl", "cfb"] }, { id: "basketball", label: "Basketball", description: "Pro & college", leagues: ["nba", "ncaab"] }, { id: "baseball", label: "Baseball", description: "Summer slate", leagues: ["mlb"] }, { id: "hockey", label: "Hockey", description: "On the ice", leagues: ["nhl"] }, { id: "misc", label: "Misc", description: "Other sports", leagues: ["ufc"] }, ]; const LEAGUE_ALIASES = new Map([ // Basketball ["college-basketball", "ncaab"], ["mens-college-basketball", "ncaab"], ["ncaam", "ncaab"], ["cbb", "ncaab"], // College football - FanDuel / ESPN use "ncaaf" or "college-football" ["ncaaf", "cfb"], ["college-football", "cfb"], ["football/college-football", "cfb"], ["ncaa-football", "cfb"], // NHL aliases ["hockey", "nhl"], ["ice-hockey", "nhl"], ["hockey/nhl", "nhl"], // NFL ["football/nfl", "nfl"], // MLB ["baseball", "mlb"], ["baseball/mlb", "mlb"], // NBA ["basketball/nba", "nba"], // MMA / UFC ["mma", "ufc"], ["mixed-martial-arts", "ufc"], ]); const ODDS_LEAGUE_PARAMS = { ncaab: [ "basketball/mens-college-basketball", "basketball/college-basketball", "basketball/ncaab", ], ufc: [ "mma/mixed-martial-arts", "mma/ufc", "ufc", "mma", ], }; const ODDS_FAILURE_CACHE = new Map(); const ODDS_FAILURE_CACHE_MS = 30 * 1000; const ODDS_FAILURE_CACHE_MAX = 64; function setOddsFailureCache(cacheKey, message) { const nextEntry = { ts: Date.now(), message }; if (ODDS_FAILURE_CACHE.size >= ODDS_FAILURE_CACHE_MAX) { const oldestKey = ODDS_FAILURE_CACHE.keys().next().value; if (oldestKey !== undefined) ODDS_FAILURE_CACHE.delete(oldestKey); } ODDS_FAILURE_CACHE.set(cacheKey, nextEntry); } function normalizeLeagueKey(value) { const key = String(value || "").toLowerCase().trim(); if (!key) return ""; return LEAGUE_ALIASES.get(key) || key; } const fmtDate = (d) => { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; }; const addDaysToIsoDate = (isoDate, days) => { const [year, month, day] = isoDate.split("-").map((value) => Number(value)); const dt = new Date(Date.UTC(year, month - 1, day)); dt.setUTCDate(dt.getUTCDate() + days); return dt.toISOString().slice(0, 10); }; function toEasternDateKey(date) { const fmtET = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit", }); const localMidday = new Date( date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0 ); return fmtET.format(localMidday); } // Generate a unique stable key for picks and parlays. Parlays and single // picks use different prefixes so that numeric IDs from separate tables do not // collide when merged in maps or used as React keys. function pickKey(p) { const base = p && p.id != null ? String(p.id) : String(p && p.selection); return Array.isArray(p?.legs) && p.legs.length > 0 ? `parlay-${base}` : `pick-${base}`; } // Helper utilities like `dollars`, `signOdds`, `moneylineProfit`, and // `profitFromPick` are loaded globally via scripts in the utils/ // directory. function parseAmount(value) { if (value == null) return null; if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) return null; const paren = trimmed.match(/^\((.*)\)$/); if (paren) { const inner = parseAmount(paren[1]); return inner == null ? null : -inner; } const normalized = trimmed.replace(/[$,\s]/g, ""); if (!normalized) return null; const num = Number(normalized); return Number.isFinite(num) ? num : null; } return null; } const MAX_BET_OVERRIDES_KEY = "bb_max_bet_overrides_v1"; function parseMaxBetAmount(value) { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : null; } function readMaxBetOverrides() { try { const raw = localStorage.getItem(MAX_BET_OVERRIDES_KEY); if (!raw) return {}; const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } } function getMaxBetOverride(handle) { const key = String(handle || "").trim().toLowerCase(); if (!key) return null; const map = readMaxBetOverrides(); if (!Object.prototype.hasOwnProperty.call(map, key)) return null; return parseMaxBetAmount(map[key]); } function formatOddsLabel(value) { if (value == null || value === "") return null; const numeric = Number(value); if (Number.isFinite(numeric)) { if (typeof signOdds === "function") return signOdds(numeric); return numeric > 0 ? `+${numeric}` : `${numeric}`; } return String(value); } 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; } // Returns a full description of one parlay leg, e.g. "Cowboys -7 (-110) • NFL: Cowboys @ Giants • won" function formatLegDetail(leg, opts = {}) { if (!leg || typeof leg !== "object") return ""; const showResult = opts.showResult !== false; const sel = String(leg.selection || "").trim(); const mkt = String(leg.market || "").trim().toLowerCase(); const odds = formatOddsLabel(leg.odds); const line = leg.line ?? null; const total = leg.total ?? null; let pick = sel; if (mkt === "spread" && line != null) pick = `${sel} ${Number(line) >= 0 ? "+" : ""}${line}`; else if (mkt === "total" && total != null) pick = `${leg.side === "over" ? "O" : "U"} ${total}`; if (odds) pick += ` (${odds})`; const game = String(leg.event || "").trim(); const league = String(leg.league || "").toUpperCase(); const parts = [pick]; if (game) parts.push(league ? `${league}: ${game}` : game); if (showResult && leg.result) parts.push(String(leg.result).toUpperCase()); return parts.join(" • "); } function formatPickLineDetail(pick) { if (!pick || typeof pick !== "object") return null; const rawMarket = String(pick.market || "").trim().toLowerCase(); const line = pick.line ?? pick.spread ?? pick.spread_line ?? pick.point ?? null; const total = pick.total ?? pick.total_points ?? pick.totalPoints ?? null; const parsedLine = line ?? extractSpreadFromSelection(pick.selection); const oddsText = formatOddsLabel(pick.odds); const market = rawMarket || (parsedLine != null ? "spread" : total != null ? "total" : oddsText ? "moneyline" : ""); if (market === "spread") { if (parsedLine == null) return null; const lineText = `${parsedLine >= 0 ? "+" : ""}${parsedLine}`; return oddsText ? `Spread ${lineText} (${oddsText})` : `Spread ${lineText}`; } if (market === "total") { 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 null; } function getFirstAmount(obj, keys) { for (const key of keys) { if (!key) continue; const val = parseAmount(obj[key]); if (val != null) return val; } return null; } function getPreferredAmount(obj, keys, positiveThreshold = 0.005) { if (!obj || typeof obj !== "object" || !Array.isArray(keys)) return null; let firstFinite = null; let bestPositive = null; for (const key of keys) { if (!key) continue; const val = parseAmount(obj[key]); if (val == null) continue; if (firstFinite == null) firstFinite = val; if (Number.isFinite(val) && val > positiveThreshold) { if (bestPositive == null || val > bestPositive) { bestPositive = val; } } } return bestPositive != null ? bestPositive : firstFinite; } const STAKE_KEYS = [ "stake", "risk", "risk_amount", "riskAmount", "amount_risked", "amountRisked", "amount_wagered", "amountWagered", "pending", "pending_amount", "pendingAmount", "wager", "bet_amount", "betAmount", "amount", "amountBet", ]; const WINNINGS_KEYS = [ "amount_won", "amountWon", "win_amount", "winAmount", "winning_amount", "winningAmount", "to_win", "toWin", "winnings", "win", "potential_win", "potentialWin", "expected_win", "expectedWin", "profit_estimate", "profitEstimate", ]; function resolveStake(pick) { if (!pick || typeof pick !== "object") return 0; const stake = getFirstAmount(pick, STAKE_KEYS); return stake != null ? stake : 0; } const amountOrZero = (value, fallback = 0) => { const parsed = parseAmount(value); return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : fallback; }; const amountOrNull = (value) => { const parsed = parseAmount(value); return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null; }; const MS_IN_DAY = 24 * 60 * 60 * 1000; const MAX_SUMMARY_DAYS = 365; function clampDays(value, fallback = 30) { const num = Number(value); if (!Number.isFinite(num) || num <= 0) return fallback; return Math.min(MAX_SUMMARY_DAYS, Math.max(1, Math.round(num))); } const OUTCOME_ALIASES = { win: "won", wins: "won", winner: "won", winning: "won", loss: "lost", losses: "lost", loser: "lost", lose: "lost", voided: "void", cancel: "canceled", canceled: "canceled", cancelled: "canceled", "cashout": "canceled", "cash_out": "canceled", "graded_win": "won", "graded_loss": "lost", "settled_win": "won", "settled_loss": "lost", }; function normalizeOutcome(value) { if (value == null) return ""; const trimmed = String(value).trim().toLowerCase(); if (!trimmed) return ""; const collapsed = trimmed.replace(/\s+/g, "_"); if (OUTCOME_ALIASES[collapsed]) return OUTCOME_ALIASES[collapsed]; const base = collapsed.split(/[^a-z]+/)[0]; if (OUTCOME_ALIASES[base]) return OUTCOME_ALIASES[base]; return collapsed; } const CLOSED_STATUSES = new Set(["won", "lost", "void", "push", "graded", "settled", "closed", "canceled"]); const OPENISH_STATUSES = new Set([ "open", "pending", "pending_approval", "pending_review", "in_progress", "ungraded", "live", "active", "proposed", "proposal", ]); function resolvePickOutcome(p) { if (!p || typeof p !== "object") return ""; return normalizeOutcome( p.result ?? p.outcome ?? p.state ?? p.status ?? p.settlement_result ?? p.settlementResult ); } function profitOnly(p) { const outcome = resolvePickOutcome(p); const stake = resolveStake(p); const direct = getFirstAmount(p, [ "net_profit", "netProfit", "net", "profit", "balance_change", "balanceChange", ]); const calculated = typeof profitFromPick === "function" ? parseAmount(profitFromPick(p)) : null; const threshold = 0.005; const chooseProfit = (primary, secondary, fallback = null) => { const primaryFinite = Number.isFinite(primary); const secondaryFinite = Number.isFinite(secondary); const fallbackFinite = Number.isFinite(fallback); if (primaryFinite && Math.abs(primary) > threshold) return primary; if (secondaryFinite && Math.abs(secondary) > threshold) return secondary; if (fallbackFinite && Math.abs(fallback) > threshold) return fallback; if (primaryFinite) return primary; if (secondaryFinite) return secondary; if (fallbackFinite) return fallback; return 0; }; if (outcome === "won") { const winnings = getPreferredAmount(p, WINNINGS_KEYS, threshold); const fallbackNet = Number.isFinite(calculated) ? calculated - stake : null; let derived = null; let derivedSource = ""; if (winnings != null) { derived = winnings; derivedSource = "winnings"; } else { const payout = getFirstAmount(p, [ "payout", "payout_amount", "payoutAmount", "return", "total_return", ]); if (payout != null) { const payoutMinusStake = payout - stake; if (Number.isFinite(payoutMinusStake) && payoutMinusStake >= -threshold) { derived = payoutMinusStake; derivedSource = "payoutMinusStake"; } else if (Number.isFinite(payout) && payout >= -threshold) { derived = payout; derivedSource = "payoutNet"; } } if (derived == null && fallbackNet != null) { derived = fallbackNet; derivedSource = "calculated"; } } let directWin = Number.isFinite(direct) && direct >= -0.005 ? direct : null; // If the server-reported direct profit is essentially zero but our // calculated profit (fallbackNet) is meaningful, prefer the calculated // value so clients see the expected payout (e.g., the $100 win the user // reported). if ( directWin != null && Math.abs(directWin) <= threshold && Number.isFinite(fallbackNet) && fallbackNet > threshold ) { directWin = null; // let chooseProfit prefer derived/fallbackNet } if ( derivedSource !== "winnings" && Number.isFinite(derived) && Number.isFinite(fallbackNet) && fallbackNet > threshold ) { if (derived < -threshold) { derived = null; } else if ( (derivedSource === "payoutMinusStake" || derivedSource === "payoutNet") && Math.abs(fallbackNet - derived) > threshold ) { derived = null; } } let resolved = chooseProfit( directWin, derived, fallbackNet ); if ( resolved < -threshold && Number.isFinite(fallbackNet) && fallbackNet > threshold ) { resolved = fallbackNet; } return resolved; } if (outcome === "lost") { const losses = getFirstAmount(p, [ "amount_lost", "amountLost", "loss", "loss_amount", "lossAmount", ]); let derived = null; if (losses != null) { derived = -Math.abs(losses); } else if (calculated != null) { derived = calculated; } else if (stake) { derived = -Math.abs(stake); } const directLoss = Number.isFinite(direct) && direct <= 0.005 ? direct : null; return chooseProfit( directLoss, derived, stake ? -Math.abs(stake) : calculated ); } if (Number.isFinite(direct)) return direct; if (Number.isFinite(calculated)) return calculated; return 0; } function useApiBase() { const [apiBase, setApiBase] = useState(localStorage.getItem("bb_api_base") || DEFAULTS.API_BASE); useEffect(() => { fetch("/api/config").then((r) => (r.ok ? r.json() : null)).then((j) => { const v = localStorage.getItem("bb_api_base") || DEFAULTS.API_BASE; setApiBase(j?.apiBase || v); }).catch(() => {}); }, []); useEffect(() => { const id = setInterval(() => { const v = localStorage.getItem("bb_api_base") || DEFAULTS.API_BASE; if (v !== apiBase) setApiBase(v); }, 700); return () => clearInterval(id); }, [apiBase]); return apiBase; } function useToken() { const [token, setToken] = useState(localStorage.getItem("bb_token") || ""); const save = (t) => { localStorage.setItem("bb_token", t); setToken(t); }; const clear = () => { localStorage.removeItem("bb_token"); setToken(""); }; useEffect(() => { const id = setInterval(() => { const v = localStorage.getItem("bb_token") || ""; if (v !== token) setToken(v); }, 700); return () => clearInterval(id); }, [token]); return { token, save, clear }; } function normalizeGroupCodeValue(value) { if (value == null) return ""; if (typeof value === "number" && Number.isFinite(value)) return String(value); if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) return ""; return trimmed.toLowerCase(); } if (Array.isArray(value)) { for (const item of value) { const candidate = normalizeGroupCodeValue(item); if (candidate) return candidate; } return ""; } if (typeof value === "object") { const nested = [ value.group_code, value.groupCode, value.code, value.id, value.value, value.key, value.slug, ]; for (const item of nested) { const candidate = normalizeGroupCodeValue(item); if (candidate) return candidate; } } return ""; } function collectGroupCodeCandidates(entity) { const values = new Set(); const push = (val) => { if (val == null) return; if (Array.isArray(val)) { for (const item of val) push(item); return; } if (typeof val === "object") { const nested = [ val.group_code, val.groupCode, val.code, val.id, val.value, val.key, val.slug, val.group_id, val.groupId, ]; for (const item of nested) push(item); return; } const normalized = normalizeGroupCodeValue(val); if (normalized) values.add(normalized); }; if (!entity || typeof entity !== "object") return Array.from(values); const directCandidates = [ entity.group_code, entity.groupCode, entity.group, entity.code, entity.group_id, entity.groupId, entity.groupID, entity.group_key, entity.groupKey, entity.group_slug, entity.slug, entity.meta?.group_code, entity.meta?.groupCode, entity.metadata?.group_code, entity.metadata?.groupCode, entity.bet?.group_code, entity.pick?.group_code, entity.user?.group_code, entity.user?.groupCode, entity.user?.group?.code, entity.owner?.group_code, entity.owner?.groupCode, entity.owner?.group?.code, entity.groups, entity.group_codes, entity.groupCodes, entity.groupIds, entity.group_ids, ]; for (const candidate of directCandidates) { push(candidate); } return Array.from(values); } function matchesGroupCode(entity, code) { if (!code) return true; const target = normalizeGroupCodeValue(code); if (!target) return true; if (!entity || typeof entity !== "object") return true; const candidates = collectGroupCodeCandidates(entity); if (candidates.length === 0) return true; return candidates.some((candidate) => candidate === target); } function withTenantQuery(url, tenantCode) { if (!tenantCode) return url; const raw = String(url || ""); const hashIndex = raw.indexOf("#"); const base = hashIndex >= 0 ? raw.slice(0, hashIndex) : raw; const hash = hashIndex >= 0 ? raw.slice(hashIndex) : ""; const encoded = encodeURIComponent(tenantCode); const hasGroupCode = /[?&]group_code=/.test(base); const hasCode = /[?&]code=/.test(base); let next = base; if (!hasGroupCode) { next += (next.includes("?") ? "&" : "?") + "group_code=" + encoded; } if (!hasCode) { next += (next.includes("?") ? "&" : "?") + "code=" + encoded; } return next + hash; } // =============== ODDS (via backend proxy) =============== async function fetchGamesFanDuelOnly(apiBase, leagueKey, date, options = {}) { const endpointPath = String(options.endpointPath || "/odds/fd"); const includeMeta = !!options.includeMeta; const allowBookFallback = options.allowBookFallback !== false; const requestHeaders = options.headers && typeof options.headers === "object" ? options.headers : null; const extraQuery = options.extraQuery && typeof options.extraQuery === "object" ? options.extraQuery : {}; const normalizedLeagueKey = normalizeLeagueKey(leagueKey); const leagueParamCandidates = ODDS_LEAGUE_PARAMS[normalizedLeagueKey] || normalizedLeagueKey; const leagueParams = Array.isArray(leagueParamCandidates) ? leagueParamCandidates.slice() : [leagueParamCandidates]; if (!leagueParams.includes(normalizedLeagueKey)) leagueParams.push(normalizedLeagueKey); const fmtET = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit", }); const fmtETHour = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", hour: "2-digit", hour12: false, }); const localTargetDate = fmtDate(new Date(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0)); const target = toEasternDateKey(date); const endpointCandidates = [endpointPath]; if (allowBookFallback && endpointPath === "/odds/fd") { endpointCandidates.push("/odds/dk", "/odds/mgm", "/odds/caesars", "/odds/pn"); } function bookMetaForEndpoint(path) { const code = String(path || "").split("/").filter(Boolean).pop() || "fd"; if (code === "fd") return { key: "fanduel", label: "FanDuel", titleHints: ["fanduel"] }; if (code === "dk") return { key: "draftkings", label: "DraftKings", titleHints: ["draftkings"] }; if (code === "mgm") return { key: "betmgm", label: "BetMGM", titleHints: ["betmgm", "mgm"] }; if (code === "caesars") return { key: "caesars", label: "Caesars", titleHints: ["caesars"] }; if (code === "pn") return { key: "pinnacle", label: "Pinnacle", titleHints: ["pinnacle"] }; return { key: code, label: code.toUpperCase(), titleHints: [code] }; } function eventMatchesLeague(ev, normalizedKey) { if (!ev || !normalizedKey) return true; const hay = `${ev?.sport_key || ""} ${ev?.sport_title || ""}`.toLowerCase(); // Only enforce strict sport matching for UFC where cross-sport leakage // has been observed. Keep other leagues permissive to avoid filtering out // valid odds events when upstream metadata is inconsistent. if (normalizedKey !== "ufc") return true; if (!hay.trim()) return false; const rules = { nfl: [/americanfootball[_\s-]?nfl/, /\bnfl\b/], cfb: [/americanfootball[_\s-]?ncaa/, /college[_\s-]?football/, /\bncaaf\b/, /\bcfb\b/], mlb: [/baseball[_\s-]?mlb/, /\bmlb\b/], nba: [/basketball[_\s-]?nba/, /\bnba\b/], ncaab: [/basketball[_\s-]?ncaa/, /college[_\s-]?basketball/, /\bncaab\b/, /\bcbb\b/, /\bncaam\b/], nhl: [/icehockey[_\s-]?nhl/, /ice[_\s-]?hockey/, /\bnhl\b/], ufc: [/\bmma\b/, /mixed[_\s-]?martial[_\s-]?arts/, /\bufc\b/], }; const patterns = rules[normalizedKey]; if (!patterns || patterns.length === 0) return true; return patterns.some((rx) => rx.test(hay)); } async function loadForLeagueParam(leagueParam, endpointPathForBook) { const cacheKey = `${endpointPathForBook}|${leagueParam}|${target}`; const now = Date.now(); if (ODDS_FAILURE_CACHE.has(cacheKey)) { const cached = ODDS_FAILURE_CACHE.get(cacheKey); if (now - cached.ts <= ODDS_FAILURE_CACHE_MS) { const err = new Error(cached.message || "Odds unavailable from provider. Please try again later."); err.isOddsUnavailable = true; throw err; } ODDS_FAILURE_CACHE.delete(cacheKey); } const bookMeta = bookMetaForEndpoint(endpointPathForBook); const queryParams = new URLSearchParams({ league: leagueParam, date: target, }); for (const [k, v] of Object.entries(extraQuery)) { if (v == null || v === "") continue; queryParams.set(k, String(v)); } const url = `${apiBase.replace(/\/$/, "")}${endpointPathForBook}?${queryParams.toString()}`; let r = null; let networkErr = null; for (let attempt = 0; attempt < 2; attempt += 1) { try { r = await fetch(url, requestHeaders ? { headers: requestHeaders } : undefined); // Retry one time on transient upstream/server failures. if (!r.ok && r.status >= 500 && attempt === 0) continue; break; } catch (err) { networkErr = err; if (attempt === 0) continue; } } if (!r) { throw networkErr || new Error("Network error while fetching odds"); } if (!r.ok) { let body = ""; let parsed = null; try { parsed = await r.json(); } catch { try { body = await r.text(); } catch {} } const detail = parsed?.error || parsed?.detail || body; const upstreamOddsIssue = /upstream odds/i.test(String(detail || "")); const msg = upstreamOddsIssue ? "Odds unavailable from provider. Please try again later." : String(detail || `HTTP ${r.status}`); const err = new Error(msg); if (parsed?.quota) err.quota = parsed.quota; err.status = r.status; if (upstreamOddsIssue || r.status === 404 || r.status === 502) { err.isOddsUnavailable = true; setOddsFailureCache(cacheKey, msg); } throw err; } const payload = await r.json(); if (!payload?.ok) { const raw = payload?.error || "Odds error"; const upstreamOddsIssue = /upstream odds/i.test(String(raw)); const err = new Error( upstreamOddsIssue ? "Odds unavailable from provider. Please try again later." : raw ); if (upstreamOddsIssue) err.isOddsUnavailable = true; throw err; } const events = Array.isArray(payload.games) ? payload.games : []; const games = []; // Compare event dates in Eastern Time so midnight ET games are included const nextTarget = addDaysToIsoDate(target, 1); for (const ev of events) { if (!eventMatchesLeague(ev, normalizedLeagueKey)) continue; const t = new Date(ev.commence_time); const eventDateLocal = fmtDate(t); const eventDateET = fmtET.format(t); const sameLocalDay = eventDateLocal === localTargetDate; const sameEasternDay = eventDateET === target; if (!sameLocalDay && !sameEasternDay) { if (eventDateET !== nextTarget) continue; const eventHour = Number(fmtETHour.format(t)); if (!Number.isFinite(eventHour) || eventHour > 5) continue; } const homeName = ev.home_team; const awayName = ev.away_team; const preferredBook = (ev.bookmakers || []).find((b) => { const key = String(b?.key || "").toLowerCase(); const title = String(b?.title || "").toLowerCase(); if (bookMeta.key && key === bookMeta.key) return true; return bookMeta.titleHints.some((hint) => title.includes(hint)); }); const marketBook = preferredBook || (ev.bookmakers || [])[0]; if (!marketBook) continue; let mlHome=null, mlAway=null, spreadHome=null, spreadAway=null, total=null; let spreadHomeOdds=null, spreadAwayOdds=null, overOdds=null, underOdds=null; for (const mkt of marketBook.markets || []) { if (mkt.key === "h2h") { const oh = (mkt.outcomes || []).find((o) => o.name === homeName); const oa = (mkt.outcomes || []).find((o) => o.name === awayName); if (oh?.price != null) mlHome = Number(oh.price); if (oa?.price != null) mlAway = Number(oa.price); } else if (mkt.key === "spreads") { const oh = (mkt.outcomes || []).find((o) => o.name === homeName); const oa = (mkt.outcomes || []).find((o) => o.name === awayName); if (oh?.point != null) spreadHome = Number(oh.point); if (oa?.point != null) spreadAway = Number(oa.point); // Capture the payout price too. For MLB (run line) and NHL (puck // line) the line is a fixed ±1.5 and the PRICE is the real bet, so // dropping it made those bets pay even money. if (oh?.price != null) spreadHomeOdds = Number(oh.price); if (oa?.price != null) spreadAwayOdds = Number(oa.price); } else if (mkt.key === "totals") { const over = (mkt.outcomes || []).find((o) => String(o.name).toLowerCase() === "over"); const under = (mkt.outcomes || []).find((o) => String(o.name).toLowerCase() === "under"); if (over?.point != null) total = Number(over.point); if (over?.price != null) overOdds = Number(over.price); if (under?.price != null) underOdds = Number(under.price); } } games.push({ id: ev.id || `${awayName}@${homeName}-${ev.commence_time}`, league: normalizedLeagueKey, start: t, status: { completed: false }, awayTeam: awayName, homeTeam: homeName, venue: "", homeSpread: Number.isFinite(spreadHome) ? spreadHome : null, awaySpread: Number.isFinite(spreadAway) ? spreadAway : null, homeSpreadOdds: Number.isFinite(spreadHomeOdds) ? spreadHomeOdds : null, awaySpreadOdds: Number.isFinite(spreadAwayOdds) ? spreadAwayOdds : null, overUnder: Number.isFinite(total) ? total : null, overOdds: Number.isFinite(overOdds) ? overOdds : null, underOdds: Number.isFinite(underOdds) ? underOdds : null, moneyline: { home: Number.isFinite(mlHome) ? mlHome : null, away: Number.isFinite(mlAway) ? mlAway : null, }, source: "odds", book: marketBook.title || bookMeta.label, }); } games.sort((a, b) => a.start - b.start); if (!includeMeta) return games; return { games, quota: payload?.quota || null, charged: !!payload?.charged, onRequest: !!payload?.on_request, }; } const hasUsableOdds = (game) => { if (!game || typeof game !== "object") return false; return [ game?.moneyline?.home, game?.moneyline?.away, game?.homeSpread, game?.awaySpread, game?.overUnder, ].some((value) => Number.isFinite(Number(value))); }; let lastError = null; let lastResult = includeMeta ? { games: [], quota: null, charged: false, onRequest: false } : []; for (const endpointCandidate of endpointCandidates) { for (const lp of leagueParams) { try { const result = await loadForLeagueParam(lp, endpointCandidate); const games = includeMeta ? (result?.games || []) : result; if (games.length) { const pricedCount = games.filter(hasUsableOdds).length; if (pricedCount > 0) return includeMeta ? result : games; // Do not stop at this source if it returned events but no usable prices. lastError = lastError || new Error("No priced odds returned"); continue; } if (includeMeta && result) lastResult = result; lastError = lastError || new Error("No odds returned"); } catch (e) { lastError = e; continue; } } } if (lastError) throw lastError; return includeMeta ? lastResult : []; } function SportsMenu({ value, onChange, visibleLeagueKeys, loading = false }) { const hasVisibilityFilter = Array.isArray(visibleLeagueKeys); const visibleSet = hasVisibilityFilter ? new Set(visibleLeagueKeys.map((key) => normalizeLeagueKey(key))) : null; const leagues = LEAGUES.filter( (entry) => LEAGUE_META.has(entry.key) && (!hasVisibilityFilter || visibleSet.has(normalizeLeagueKey(entry.key))) ); function LeagueChipBadge({ leagueKey, leagueName }) { const branding = LEAGUE_BRANDING[leagueKey] || { abbr: leagueName, logo: "" }; const [logoSrc, setLogoSrc] = useState(branding.logo || ""); useEffect(() => { setLogoSrc(branding.logo || ""); }, [branding.logo]); const fallbackText = String(branding.abbr || leagueName || "SPORT") .replace(/[^a-z0-9]/gi, "") .slice(0, 5) .toUpperCase(); const handleBadgeImageError = () => { if (branding.logoFallback && logoSrc !== branding.logoFallback) { setLogoSrc(branding.logoFallback); return; } setLogoSrc(""); }; return ( ); } return (
Leagues {loading ? ( ) : null}
{leagues.map((entry) => { const active = entry.key === value; return ( ); })}
); } // =============== SETTINGS MODAL =============== function SettingsModal({ visible, onClose, theme, onThemeChange }) { const [apiBase, setApiBase] = useState(localStorage.getItem("bb_api_base") || DEFAULTS.API_BASE); const [code, setCode] = useState(localStorage.getItem("bb_group_code") || DEFAULTS.GROUP_CODE); const [token, setToken] = useState(localStorage.getItem("bb_token") || DEFAULTS.JWT); const [localTheme, setLocalTheme] = useState(theme || "light"); const selectedThemeBrand = THEME_TEAM_BRANDING[localTheme] || null; useEffect(() => { setLocalTheme(theme || "light"); }, [theme, visible]); if (!visible) return null; const save = (e) => { e.preventDefault(); localStorage.setItem("bb_api_base", apiBase.trim()); localStorage.setItem("bb_group_code", code.trim()); localStorage.setItem("bb_token", token.trim()); onThemeChange(localTheme); onClose(true); }; return (
onClose(false)} />

Settings

{selectedThemeBrand && (
Team Theme {selectedThemeBrand.label}
)}
); } // =============== APP =============== class AdminErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, message: "" }; this.handleRetry = this.handleRetry.bind(this); } static getDerivedStateFromError(error) { return { hasError: true, message: String(error?.message || "Unknown admin render error"), }; } componentDidCatch(error, info) { try { console.error("Admin render failed", error, info); } catch (_) {} } handleRetry() { this.setState({ hasError: false, message: "" }); } render() { if (!this.state.hasError) return this.props.children; return (

Admin panel failed to load

The page hit a runtime error, but the app is still alive.

{this.state.message}
); } } function BookiePage({ apiBase, token, withCode }) { const { useState: s, useEffect: e } = React; const [data, setData] = s(null); const [loading, setLoading] = s(true); const [error, setError] = s(""); const load = async () => { setLoading(true); setError(""); try { const res = await fetch(withCode(`${apiBase}/admin/bookie-summary`), { headers: { Authorization: `Bearer ${token}` }, }); const json = await res.json(); if (!res.ok || json.ok === false) throw new Error(json.error || `HTTP ${res.status}`); setData(json); } catch (err) { setError(String(err?.message || "Failed to load")); } finally { setLoading(false); } }; e(() => { load(); }, []); const dollars = (n) => { if (n == null) return "—"; const abs = Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return (n < 0 ? "-$" : "$") + abs; }; const posNeg = (n) => n == null ? "—" : n >= 0 ? `+${dollars(n)}` : dollars(n); const col = (n) => n == null ? "" : n > 0 ? "text-green-400" : n < 0 ? "text-red-400" : ""; return (

Bookie Summary

{error &&
{error}
} {data && ( <>
Total Bettors Balance
{dollars(data.current_balance)}
Sum of all bettor accounts
Bookie Position
{posNeg(data.bookie_position)}
What the house is up or down
YTD Payins
{dollars(data.ytd_payins)}
Cash deposited this year
YTD Payouts
{dollars(data.ytd_payouts)}
Cash withdrawn this year
YTD Net Cash (Payins − Payouts)
{posNeg(data.ytd_net_cash)}
Per-Bettor Balance
{(data.users || []).sort((a, b) => b.balance - a.balance).map((u) => ( ))}
Bettor Balance
{u.handle} {posNeg(u.balance)}
)}
); } function App() { const apiBaseAll = useApiBase(); const { token, save, clear } = useToken(); const [me, setMe] = useState(null); const [view, setView] = useState("games"); // "games" | "summary" | "board" | "bookie" | "admin" | "notify" const [league, setLeague] = useState("nfl"); const [date, setDate] = useState(() => new Date()); const [availableLeagueKeys, setAvailableLeagueKeys] = useState(() => LEAGUES.map((entry) => entry.key)); const [leagueAvailabilityLoading, setLeagueAvailabilityLoading] = useState(false); const gamesLoadSeqRef = useRef(0); const leagueAvailabilitySeqRef = useRef(0); const [games, setGames] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [refreshing, setRefreshing] = useState(false); const [oddsMode, setOddsMode] = useState({ onRequestEnabled: true, dailyCap: null, cooldownSeconds: null, globalDailyCap: null, }); const [oddsRequestByGame, setOddsRequestByGame] = useState({}); const [query, setQuery] = useState(""); const [oddsView, setOddsView] = useState("all"); const handleLeagueChange = useCallback((nextLeague) => { setLeague(nextLeague); setQuery(""); }, []); const [drawerOpen, setDrawerOpen] = useState(false); const [bet, setBet] = useState(null); const [viewPick, setViewPick] = useState(null); useEffect(() => { if (!drawerOpen) return; const isSmallScreen = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(max-width: 640px)").matches; if (isSmallScreen) return; const originalOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = originalOverflow; }; }, [drawerOpen]); const [joinVisible, setJoinVisible] = useState(false); const [settingsVisible, setSettingsVisible] = useState(false); const [profileVisible, setProfileVisible] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [deferredPrompt, setDeferredPrompt] = useState(null); const [pushEnabled, setPushEnabled] = useState(false); const [theme, setTheme] = useState(() => localStorage.getItem("bb_theme") || "light"); const activeThemeBrand = THEME_TEAM_BRANDING[theme] || null; const [upgradeNotice, setUpgradeNotice] = useState(null); const [accountRestrictionNotice, setAccountRestrictionNotice] = useState(""); useEffect(() => { if (typeof window !== "undefined" && typeof window.preloadAllLeagueTeamLogos === "function") { window.preloadAllLeagueTeamLogos().catch(() => {}); } }, []); useEffect(() => { const root = document.documentElement; root.classList.remove( "dark", "theme-browns", "theme-packers", "theme-buckeyes", "theme-steelers", "theme-cowboys", "theme-patriots", "theme-chiefs", "theme-raiders", "theme-giants", "theme-eagles", "theme-lakers", "theme-yankees", "theme-bulls", "theme-michigan" ); if (theme === "dark") root.classList.add("dark"); else if (theme === "browns") root.classList.add("theme-browns"); else if (theme === "packers") root.classList.add("theme-packers"); else if (theme === "buckeyes") root.classList.add("theme-buckeyes"); else if (theme === "steelers") root.classList.add("theme-steelers"); else if (theme === "cowboys") root.classList.add("theme-cowboys"); else if (theme === "patriots") root.classList.add("theme-patriots"); else if (theme === "chiefs") root.classList.add("theme-chiefs"); else if (theme === "raiders") root.classList.add("theme-raiders"); else if (theme === "giants") root.classList.add("theme-giants"); else if (theme === "eagles") root.classList.add("theme-eagles"); else if (theme === "lakers") root.classList.add("theme-lakers"); else if (theme === "yankees") root.classList.add("theme-yankees"); else if (theme === "bulls") root.classList.add("theme-bulls"); else if (theme === "michigan") { root.classList.add("theme-michigan"); // Removed redirect to external site - keep theme only } localStorage.setItem("bb_theme", theme); }, [theme]); useEffect(() => { let mounted = true; const checkForUpgrade = async () => { try { const response = await fetch(`${VERSION_FEED_URL}?t=${Date.now()}`, { cache: "no-store" }); if (!response.ok) return; const payload = await response.json(); const latestBuild = normalizeBuildToken(payload.build || payload.version); if (!latestBuild || compareBuildTokens(latestBuild, APP_BUILD) <= 0) { if (mounted) setUpgradeNotice(null); return; } const dismissedBuild = localStorage.getItem(UPGRADE_DISMISSED_BUILD_KEY) || ""; const dismissedUntil = Number(localStorage.getItem(UPGRADE_DISMISSED_UNTIL_KEY) || "0"); if (dismissedBuild === latestBuild && Number.isFinite(dismissedUntil) && dismissedUntil > Date.now()) { return; } if (dismissedBuild !== latestBuild) { localStorage.removeItem(UPGRADE_DISMISSED_BUILD_KEY); localStorage.removeItem(UPGRADE_DISMISSED_UNTIL_KEY); } if (mounted) { setUpgradeNotice({ latestBuild, message: typeof payload.message === "string" ? payload.message : "A newer version is available.", }); } } catch { // Fail quietly; banner is best-effort and should never block app use. } }; checkForUpgrade(); const intervalId = setInterval(checkForUpgrade, 5 * 60 * 1000); const onVisibility = () => { if (document.visibilityState === "visible") checkForUpgrade(); }; const onSwUpdate = () => { if (!mounted) return; setUpgradeNotice({ latestBuild: `${APP_BUILD}-sw`, message: "A newer app version has been downloaded and is ready.", }); }; document.addEventListener("visibilitychange", onVisibility); window.addEventListener("bb-sw-update-available", onSwUpdate); return () => { mounted = false; clearInterval(intervalId); document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("bb-sw-update-available", onSwUpdate); }; }, []); const dismissUpgradeNotice = useCallback(() => { if (upgradeNotice?.latestBuild) { localStorage.setItem(UPGRADE_DISMISSED_BUILD_KEY, upgradeNotice.latestBuild); localStorage.setItem(UPGRADE_DISMISSED_UNTIL_KEY, String(Date.now() + UPGRADE_SNOOZE_MS)); } setUpgradeNotice(null); }, [upgradeNotice]); const reloadForUpgrade = useCallback(() => { if (typeof window !== "undefined") { try { const url = new URL(window.location.href); url.searchParams.set("bb_update", String(Date.now())); window.location.replace(url.toString()); } catch { window.location.reload(); } } }, []); const [myPicks, setMyPicks] = useState([]); const [myBal, setMyBal] = useState({ value: null, loading: false, error: "" }); const [summaryDaysInput, setSummaryDaysInput] = useState("30"); const [summaryBettor, setSummaryBettor] = useState("me"); const [board, setBoard] = useState({ rows: [], loading: false, error: "", note: "", recentDays: 0 }); const [messageModal, setMessageModal] = useState({ open: false, target: "", mode: "single", targets: [], text: "", duration: 15, sending: false, error: "", success: "", }); const [expandedHandles, setExpandedHandles] = useState(new Set()); const [boardFocusPickKey, setBoardFocusPickKey] = useState(""); const openEveryoneMessageModal = useCallback(() => { setMessageModal({ open: true, target: "", mode: "single", targets: [], text: "", duration: 15, sending: false, error: "", success: "", }); }, []); const closeMessageModal = useCallback(() => { setMessageModal({ open: false, target: "", mode: "single", targets: [], text: "", duration: 15, sending: false, error: "", success: "", }); }, []); const sendUserMessage = useCallback(async () => { const mode = messageModal.mode === "everyone" ? "everyone" : (messageModal.mode === "multiple" ? "multiple" : "single"); const target = String(messageModal.target || "").trim(); const selectedTargets = Array.isArray(messageModal.targets) ? messageModal.targets.map((h) => String(h || "").trim()).filter(Boolean) : []; const text = String(messageModal.text || "").trim(); const duration = Number(messageModal.duration) || 15; if (mode === "single" && !target) { setMessageModal((prev) => ({ ...prev, error: "Choose a bettor first." })); return; } if (!text) { setMessageModal((prev) => ({ ...prev, error: "Enter a message to send." })); return; } if (!token) { setMessageModal((prev) => ({ ...prev, error: "Please sign in first." })); return; } setMessageModal((prev) => ({ ...prev, sending: true, error: "", success: "" })); try { const members = (Array.isArray(board?.rows) ? board.rows : []) .map((row) => String(row?.handle || "").trim()) .filter(Boolean); const selfHandle = String(me?.handle || "").trim().toLowerCase(); let targets = []; if (mode === "everyone") { targets = Array.from(new Set(members.filter((h) => h.toLowerCase() !== selfHandle))); } else if (mode === "multiple") { targets = Array.from(new Set(selectedTargets.filter((h) => h.toLowerCase() !== selfHandle))); } else { targets = target ? [target] : []; } if (targets.length === 0) { throw new Error(mode === "everyone" ? "No eligible bettors found to message." : "Choose at least one bettor."); } let sent = 0; let failed = 0; let firstError = ""; for (const targetHandle of targets) { try { const res = await fetch(withCode(`${apiBase}/notify/user/message`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ target_handle: targetHandle, message: text, duration_seconds: duration, }), }); const json = await res.json(); if (!res.ok || json.ok === false) { throw new Error(json?.error || `Request failed (${res.status})`); } sent += 1; } catch (err) { failed += 1; if (!firstError) firstError = err?.message || "Failed to send one or more messages."; } } if (sent === 0) { throw new Error(firstError || "Failed to send messages."); } const successText = mode === "everyone" ? (failed > 0 ? `Sent to ${sent} bettor(s); ${failed} failed.` : `Sent to all ${sent} bettor(s).`) : mode === "multiple" ? (failed > 0 ? `Sent to ${sent} selected bettor(s); ${failed} failed.` : `Sent to ${sent} selected bettor(s).`) : `Message sent to ${targets[0]}.`; setMessageModal((prev) => ({ ...prev, sending: false, error: "", success: successText, text: "", duration: 15, })); } catch (err) { setMessageModal((prev) => ({ ...prev, sending: false, error: err?.message || "Failed to send message.", })); } }, [apiBase, board?.rows, me?.handle, messageModal, token, withCode]); const moneylineToWin = useMemo(() => { if (!bet || bet.market !== "moneyline") return null; const stake = Number(bet.stake); const odds = Number(bet.odds); if (!Number.isFinite(stake) || stake <= 0) return null; if (!Number.isFinite(odds)) return null; if (typeof moneylineProfit === "function") return moneylineProfit(stake, odds); return odds >= 0 ? stake * (odds / 100) : stake * (100 / Math.abs(odds)); }, [bet]); const boardBalanceTrend = useMemo(() => { const rows = Array.isArray(board?.rows) ? board.rows : []; if (rows.length === 0) return []; const DAY_MS = 24 * 60 * 60 * 1000; const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const start = new Date(today); start.setDate(today.getDate() - 29); const startMs = start.getTime(); const endMs = today.getTime() + DAY_MS; const dayKeys = Array.from({ length: 30 }, (_, offset) => { const day = new Date(startMs + offset * DAY_MS); return fmtDate(day); }); const totalsByHandle = new Map(); const handleOrder = []; const ensureHandleRecord = (row, index) => { const rawHandle = row?.handle ?? row?.bettor ?? row?.user ?? row?.member ?? row?.name ?? row?.id ?? `bettor-${index + 1}`; const trimmed = typeof rawHandle === "string" ? rawHandle.trim() : String(rawHandle || ""); const key = trimmed || `bettor-${index + 1}`; if (!totalsByHandle.has(key)) { const deltas = new Map(dayKeys.map((d) => [d, 0])); totalsByHandle.set(key, { label: typeof row?.handle === "string" && row.handle.trim() ? row.handle.trim() : typeof row?.name === "string" && row.name.trim() ? row.name.trim() : key, deltas, balance: null, }); handleOrder.push(key); } return key; }; rows.forEach((row, index) => { const key = ensureHandleRecord(row, index); const record = totalsByHandle.get(key); if (!record) return; const deltas = record.deltas; if (Number.isFinite(row?.balance)) { record.balance = Number(row.balance); } const recent = Array.isArray(row?.recent) ? row.recent : []; for (const pick of recent) { if (!pick || typeof pick !== "object") continue; const outcome = normalizeOutcome(pick.outcome || pick.result || pick.status); if (!CLOSED_STATUSES.has(outcome)) continue; const profit = Number(profitOnly(pick)); if (!Number.isFinite(profit) || Math.abs(profit) < 0.005) continue; let ts = Number(pick.recentTs); if (!Number.isFinite(ts) || ts <= 0) { ts = resolvePickTimestamp(pick); } if (!Number.isFinite(ts) || ts <= 0) continue; if (ts < startMs || ts >= endMs) continue; const settled = new Date(ts); const dayKey = fmtDate(settled); if (!deltas.has(dayKey)) continue; const nextTotal = (deltas.get(dayKey) || 0) + profit; deltas.set(dayKey, nextTotal); } }); const seriesList = []; for (const key of handleOrder) { const record = totalsByHandle.get(key); if (!record) continue; const changesByDay = dayKeys.map((day) => record.deltas.get(day) || 0); const totalChange = changesByDay.reduce((sum, value) => sum + value, 0); const hasRecordedBalance = Number.isFinite(record.balance); const baseBalance = hasRecordedBalance ? record.balance - totalChange : 0; let running = 0; const values = dayKeys.map((day, idx) => { const change = changesByDay[idx]; running += change; const total = baseBalance + running; return { date: day, total }; }); const hasMeaningfulValues = values.some((point) => Math.abs(Number(point?.total) || 0) > 0.005); if (!hasMeaningfulValues && !(hasRecordedBalance && Math.abs(record.balance) > 0.005)) continue; seriesList.push({ key, name: record.label || key, values, }); } if (seriesList.length === 0) { return []; } const baselineSeries = { key: "baseline", name: "Baseline (0)", color: "#94a3b8", values: dayKeys.map((day) => ({ date: day, total: 0 })), }; return [baselineSeries, ...seriesList]; }, [board?.rows]); const boardBalanceSeries = useMemo( () => boardBalanceTrend.filter((series) => series.key !== "baseline"), [boardBalanceTrend], ); const boardBalanceTotal = useMemo(() => { if (!Array.isArray(boardBalanceSeries) || boardBalanceSeries.length === 0) return 0; return boardBalanceSeries.reduce((sum, series) => { if (!Array.isArray(series?.values) || series.values.length === 0) return sum; const lastPoint = series.values[series.values.length - 1]; const total = Number(lastPoint?.total) || 0; return sum + total; }, 0); }, [boardBalanceSeries]); const formatSignedDollars = useCallback((value) => { const amount = Number(value); if (!Number.isFinite(amount)) return "$0.00"; const normalized = Math.abs(amount) < 0.005 ? 0 : amount; const formatted = dollars(normalized); if (normalized > 0) return `+${formatted}`; return formatted; }, []); const boardBalanceSummary = useMemo(() => { if (!Array.isArray(boardBalanceSeries) || boardBalanceSeries.length === 0) { return "Balance activity from the past 30 days"; } const bettorCount = boardBalanceSeries.length; const formattedTotal = dollars(boardBalanceTotal); return bettorCount === 1 ? `Current balance ${formattedTotal}` : `Combined balance ${formattedTotal} across ${bettorCount} bettors`; }, [boardBalanceSeries, boardBalanceTotal]); const formatBalanceValue = useCallback((value) => formatSignedDollars(value), [formatSignedDollars]); const summaryDays = useMemo(() => clampDays(summaryDaysInput, 30), [summaryDaysInput]); const summaryDaysLabel = useMemo( () => (summaryDays === 1 ? "Last Day" : `Last ${summaryDays} Days`), [summaryDays] ); const summaryBettorOptions = useMemo(() => { const options = [ { value: "me", label: me?.handle ? `Me (${me.handle})` : "Me", }, ]; const seen = new Set(); if (me?.handle) seen.add(me.handle); for (const row of board?.rows || []) { const handle = typeof row?.handle === "string" ? row.handle.trim() : ""; if (!handle || seen.has(handle)) continue; seen.add(handle); options.push({ value: handle, label: handle }); } return options; }, [board?.rows, me]); const summaryBettorLabel = useMemo(() => { if (summaryBettor === "me") { return me?.handle ? `Me (${me.handle})` : "Me"; } const row = (board?.rows || []).find((entry) => entry.handle === summaryBettor); return row?.handle || summaryBettor; }, [summaryBettor, board?.rows, me]); const summaryIsLoading = summaryBettor !== "me" && board.loading; const summaryPicks = useMemo(() => { if (summaryBettor === "me") return myPicks; const row = (board?.rows || []).find((entry) => entry.handle === summaryBettor); return Array.isArray(row?.recent) ? row.recent : []; }, [summaryBettor, myPicks, board?.rows]); // Parlay builder state const [parlayStake, setParlayStake] = useState(""); const [parlayLegs, setParlayLegs] = useState([]); // array of bet-like objects const [lastParlayPreview, setLastParlayPreview] = useState(null); // { id, stake, legs, combinedOdds, potential } const parlayProfit = useMemo(() => { const s = Number(parlayStake); const oddsNums = parlayLegs.map((l) => Number(l.odds)); if (!Number.isFinite(s) || s <= 0) return 0; if (oddsNums.length === 0) return 0; if (oddsNums.some((o) => !Number.isFinite(o))) return 0; return calculateParlayProfit(s, oddsNums); }, [parlayStake, parlayLegs]); const addLegToParlay = (leg) => setParlayLegs((ls) => [...ls, leg]); const removeParlayLeg = (i) => setParlayLegs((ls) => ls.filter((_, idx) => idx !== i)); const updateParlayLegOdds = (i, val) => setParlayLegs((ls) => ls.map((l, idx) => idx === i ? { ...l, odds: val } : l)); // Admin state includes contact fields const [admin, setAdmin] = useState({ loading: false, error: "", users: [], picks: [], queue: [], log: [], resume: {}, credit_hold_threshold: null }); // Push notification tools state const [pushForm, setPushForm] = useState({ message: "Blackbook - hello from Admin", busy: false, result: "", daily_enabled: false, daily_time: "08:00", daily_tz: Intl.DateTimeFormat().resolvedOptions().timeZone || "America/New_York", supports_schedule: false, }); const apiBase = localStorage.getItem("bb_api_base") || apiBaseAll; const groupCode = (localStorage.getItem("bb_group_code") || "").trim(); const withCode = useCallback( (u) => withTenantQuery(u, groupCode), [groupCode] ); useEffect(() => { let cancelled = false; async function loadOddsMode() { const modeEndpoints = [ `${apiBase}/odds-mode`, `${apiBase}/odds/config`, `${apiBase}/odds/mode`, ]; try { for (const endpoint of modeEndpoints) { const r = await fetch(withCode(endpoint)); const j = await r.json().catch(() => ({})); if (cancelled) return; if (r.ok && j?.ok) { setOddsMode({ onRequestEnabled: !!j.on_request_enabled, dailyCap: Number.isFinite(Number(j.daily_cap)) ? Number(j.daily_cap) : null, cooldownSeconds: Number.isFinite(Number(j.cooldown_seconds)) ? Number(j.cooldown_seconds) : null, globalDailyCap: Number.isFinite(Number(j.global_daily_cap)) ? Number(j.global_daily_cap) : null, }); return; } } } catch {} if (!cancelled) { setOddsMode({ onRequestEnabled: true, dailyCap: null, cooldownSeconds: null, globalDailyCap: null, }); } } loadOddsMode(); return () => { cancelled = true; }; }, [apiBase, withCode]); useEffect(() => { if (view !== "games") return; let cancelled = false; const reqId = ++leagueAvailabilitySeqRef.current; const delayMs = 450; setLeagueAvailabilityLoading(true); async function loadLeagueAvailability() { const targetDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0); const checks = await Promise.all( LEAGUES.map(async (entry) => { const key = normalizeLeagueKey(entry.key); let scoreboardStatus = "unknown"; // has | none | unknown if (key === "ufc") { // UFC is not reliably represented in scoreboard endpoints used for team sports. // Probe odds directly so UFC chips hide when there are no UFC events. try { const ufcGames = await fetchGamesFanDuelOnly(apiBase, key, targetDate, { allowBookFallback: false }); scoreboardStatus = Array.isArray(ufcGames) && ufcGames.length > 0 ? "has" : "none"; } catch { // Fail closed for UFC to avoid sticky always-visible chips. scoreboardStatus = "none"; } return { key, scoreboardStatus }; } try { const scoreboard = await fetchESPNScoreboard(key, targetDate); const fallbackGames = gamesFromScoreboard(scoreboard, key); // League chips should reflect games for the selected day, not just live games. const scoreboardCandidates = fallbackGames; scoreboardStatus = scoreboardCandidates.length > 0 ? "has" : "none"; } catch { scoreboardStatus = "unknown"; } return { key, scoreboardStatus }; }) ); if (cancelled || reqId !== leagueAvailabilitySeqRef.current) return; setAvailableLeagueKeys((prev) => { const prevSet = new Set((Array.isArray(prev) ? prev : []).map((k) => normalizeLeagueKey(k))); const currentLeagueKey = normalizeLeagueKey(league); const currentLeagueStatus = checks.find((entry) => normalizeLeagueKey(entry.key) === currentLeagueKey)?.scoreboardStatus || "unknown"; const nextVisible = []; for (const entry of checks) { const key = normalizeLeagueKey(entry.key); const scoreboardStatus = entry.scoreboardStatus; let hasGames; if (scoreboardStatus === "has") { hasGames = true; } else if (scoreboardStatus === "none") { hasGames = false; } else { // Preserve prior visibility when scoreboard is inconclusive. hasGames = prevSet.has(key); } if (hasGames) nextVisible.push(key); } // Preserve the selected chip only while status is unknown to avoid flicker. if (currentLeagueKey && currentLeagueStatus === "unknown" && !nextVisible.includes(currentLeagueKey)) { nextVisible.push(currentLeagueKey); } const knownCount = checks.filter((entry) => entry.scoreboardStatus !== "unknown").length; if (nextVisible.length > 0) return nextVisible; if (knownCount > 0) return []; if (prevSet.size > 0) return Array.from(prevSet); return LEAGUES.map((item) => normalizeLeagueKey(item.key)); }); // If selected league has no events, move to the first visible league. const normalizedCurrentLeague = normalizeLeagueKey(league); const currentLeagueStatus = checks.find((entry) => normalizeLeagueKey(entry.key) === normalizedCurrentLeague)?.scoreboardStatus || "unknown"; if (currentLeagueStatus === "none") { const firstVisible = checks.find((entry) => entry.scoreboardStatus === "has"); const fallbackLeague = normalizeLeagueKey(firstVisible?.key || ""); if (fallbackLeague && fallbackLeague !== normalizedCurrentLeague) { setLeague(fallbackLeague); setQuery(""); } } if (!cancelled && reqId === leagueAvailabilitySeqRef.current) { setLeagueAvailabilityLoading(false); } } const timer = setTimeout(() => { loadLeagueAvailability(); }, delayMs); return () => { cancelled = true; clearTimeout(timer); if (reqId === leagueAvailabilitySeqRef.current) { setLeagueAvailabilityLoading(false); } }; }, [apiBase, date, league, oddsMode.onRequestEnabled, view]); const openPickById = useCallback(async (id) => { try { const r = await fetch( withCode(`${apiBase}/picks/${encodeURIComponent(id)}`), token ? { headers: { Authorization: `Bearer ${token}` } } : undefined, ); const j = await r.json(); const pick = j.pick || j; if (pick) setViewPick(pick); } catch {} }, [apiBase, withCode, token]); const closePickModal = useCallback(() => { setViewPick(null); try { const url = new URL(window.location.href); if (url.searchParams.has("pick")) { url.searchParams.delete("pick"); window.history.replaceState({}, "", url); } } catch {} }, []); useEffect(() => { const params = new URLSearchParams(window.location.search); const pid = params.get("pick"); if (pid) openPickById(pid); }, [openPickById]); useEffect(() => { const handler = (e) => { if (e && typeof e.preventDefault === "function") { e.preventDefault(); } setDeferredPrompt(e || null); }; if (typeof window !== "undefined") { window.addEventListener("beforeinstallprompt", handler); } return () => { if (typeof window !== "undefined") { window.removeEventListener("beforeinstallprompt", handler); } }; }, []); const triggerInstall = useCallback(() => { if (!deferredPrompt || typeof deferredPrompt.prompt !== "function") return; deferredPrompt.prompt(); if (deferredPrompt.userChoice && typeof deferredPrompt.userChoice.finally === "function") { deferredPrompt.userChoice.finally(() => setDeferredPrompt(null)); } else { setDeferredPrompt(null); } }, [deferredPrompt]); // Me useEffect(() => { if (!token) { setMe(null); return; } fetch(`${apiBase}/auth/me`, { headers: { Authorization: `Bearer ${token}` }}) .then((r) => r.json()) .then((j) => { if (j.ok) { const hold = readHoldFromAuthPayload(j); const roles = Array.isArray(j.roles) ? j.roles : j.role ? [j.role] : []; const rawMaxBet = j.max_bet ?? j.max_bet_threshold ?? j.bet_limit ?? j.bet_limit_amount ?? getMaxBetOverride(j.handle); setMe({ handle: j.handle, roles, push_opt_in: !!j.push_opt_in, credit_hold: hold.held, credit_hold_reason: hold.reason, credit_hold_message: hold.message, max_bet: hold.maxBet ?? parseMaxBetAmount(rawMaxBet), }); if (j.push_opt_in) localStorage.setItem(PUSH_OPT_IN_KEY, "1"); setAccountRestrictionNotice(hold.held ? (hold.message || CREDIT_HOLD_DEFAULT_MESSAGE) : ""); } else { setMe(null); setAccountRestrictionNotice(""); } }) .catch(() => { setMe(null); setAccountRestrictionNotice(""); }); }, [apiBase, token, withCode]); const isAdmin = Array.isArray(me?.roles) ? me.roles.includes("admin") : false; const isAuthed = !!me; const checkPush = useCallback(async () => { if (!('serviceWorker' in navigator)) { setPushEnabled(false); return; } try { const reg = await navigator.serviceWorker.getRegistration("/") || await navigator.serviceWorker.getRegistration() || await navigator.serviceWorker.ready; const sub = await reg?.pushManager.getSubscription(); setPushEnabled(!!sub); } catch { setPushEnabled(false); } }, []); const shouldPersistPush = useCallback(() => { if (me?.push_opt_in) return true; return localStorage.getItem(PUSH_OPT_IN_KEY) === "1"; }, [me?.push_opt_in]); const togglePush = useCallback(async () => { if (pushEnabled) { try { const reg = await navigator.serviceWorker.getRegistration("/") || await navigator.serviceWorker.getRegistration() || await navigator.serviceWorker.ready; const sub = await reg?.pushManager.getSubscription(); if (sub) { try { await fetch(withCode(`${apiBase}/notify/push/subscribe`), { method: "DELETE", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, }); } catch {} await sub.unsubscribe(); } } catch {} localStorage.setItem(PUSH_OPT_IN_KEY, "0"); setPushEnabled(false); } else { try { const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent); const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; if (isIos && !isStandalone) { alert('To enable push notifications on iOS, install this app to your home screen: tap Share and select "Add to Home Screen".'); return; } await optInPush(apiBase, token); localStorage.setItem(PUSH_OPT_IN_KEY, "1"); await checkPush(); } catch (e) { alert(e.message || "Opt-in failed"); } } }, [pushEnabled, apiBase, token, withCode, checkPush]); useEffect(() => { if (isAuthed) checkPush(); else setPushEnabled(false); }, [isAuthed, checkPush]); useEffect(() => { if (!isAuthed || !token) return; if (!shouldPersistPush()) return; if (typeof Notification === "undefined" || Notification.permission !== "granted") return; if (pushEnabled) return; const lastAttempt = Number(localStorage.getItem(PUSH_REPAIR_LAST_ATTEMPT_KEY) || 0); if (Date.now() - lastAttempt < PUSH_REPAIR_COOLDOWN_MS) return; localStorage.setItem(PUSH_REPAIR_LAST_ATTEMPT_KEY, String(Date.now())); optInPush(apiBase, token) .then(() => { localStorage.setItem(PUSH_OPT_IN_KEY, "1"); return checkPush(); }) .catch(() => {}); }, [apiBase, token, isAuthed, pushEnabled, checkPush, shouldPersistPush]); // ======== Admin helpers ======== const isClosedPick = (p) => { const status = normalizeOutcome(p.status || p.state); if (status && CLOSED_STATUSES.has(status)) return true; if (typeof p.settled === "boolean" && p.settled) return true; if (p.result) { const resultStatus = normalizeOutcome(p.result); if (!resultStatus || CLOSED_STATUSES.has(resultStatus)) return true; return true; } return false; }; const loadAdmin = useCallback(async () => { if (!token || !isAdmin) return null; setAdmin((a) => ({ ...a, loading: true, error: "" })); try { const r = await fetch(withCode(`${apiBase}/admin/overview`), { headers: { Authorization: `Bearer ${token}` } }); const j = await r.json(); if (!j.ok) throw new Error(j.error || "Failed"); const remote = j.picks || []; const openRemote = remote.filter((p) => !isClosedPick(p)); const log = Array.isArray(j.log) ? j.log : remote.filter((p) => isClosedPick(p)); const open = openRemote; const users = (j.users || []).map(u => ({ ...u, __push_opt_in: !!u.push_opt_in, __saving: false, })); const thresholdRaw = j.credit_hold_threshold ?? j.resume?.credit_hold_threshold ?? null; const creditHoldThreshold = Number.isFinite(Number(thresholdRaw)) ? Number(thresholdRaw) : null; let proposals = []; try { const r2 = await fetch(withCode(`${apiBase}/admin/proposals`), { headers: { Authorization: `Bearer ${token}` } }); const j2 = await r2.json(); proposals = j2.proposals || []; } catch {} const next = { loading: false, error: "", users, picks: open, queue: proposals, log, resume: j.resume || {}, credit_hold_threshold: creditHoldThreshold, }; setAdmin(next); return next; } catch (e) { const errState = { loading: false, error: String(e.message || e), users: [], picks: [], queue: [], log: [], resume: {}, credit_hold_threshold: null, }; setAdmin(errState); return errState; } }, [apiBase, token, isAdmin, withCode]); const optimisticMoveClosed = useCallback((p, result, finalScore) => { setAdmin((prev) => { const key = pickKey(p); const open = prev.picks.filter((x) => pickKey(x) !== key); const closedItem = { ...p, result, status: result, state: result, settled: true }; if (finalScore) closedItem.final_score = finalScore; return { ...prev, picks: open, log: [closedItem, ...prev.log] }; }); }, []); const adminEditPick = useCallback(async (p, patch) => { const { __skipOptimistic = false, ...restPatch } = patch || {}; const body = { ...restPatch }; const skipOptimistic = !!__skipOptimistic; const settingResult = Object.prototype.hasOwnProperty.call(restPatch, "result"); if (settingResult) { const res = body.result; if (!("status" in body)) body.status = res; if (!("state" in body)) body.state = res; if (!("settled" in body)) body.settled = true; body.log = true; if (!skipOptimistic) { optimisticMoveClosed(p, res, patch.final_score); const handle = p.handle || p.user || p.better || p.owner || ""; const stake = resolveStake(p); const profitRaw = typeof profitFromPick === "function" ? profitFromPick({ ...p, result: res }) : 0; const profit = typeof profitRaw === "number" && Number.isFinite(profitRaw) ? profitRaw : amountOrZero(profitRaw); setBoard((prev) => { const rows = prev.rows.map((r) => { if (r.handle !== handle) return r; const currentPending = amountOrZero(r.pending); const pending = Math.max(0, currentPending - stake); const currentBalance = amountOrNull(r.balance); const balance = currentBalance != null ? currentBalance + profit : profit; return { ...r, pending, balance }; }); return { ...prev, rows }; }); setMyBal((prev) => { const isMe = me?.handle && me.handle === handle; return isMe && typeof prev.value === "number" ? { ...prev, value: prev.value + profit } : prev; }); } } if (Array.isArray(p.legs) && p.legs.length > 0) { body.parlay = true; } try { const r = await fetch(withCode(`${apiBase}/admin/picks/${encodeURIComponent(p.id)}`), { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(body), }); let j; try { j = await r.json(); } catch { j = { ok: false, error: `HTTP ${r.status} ${r.statusText}` }; } // Refresh server state regardless of success so UI stays consistent await Promise.allSettled([loadAdmin(), loadBoard(), loadMyPicks(), loadMyBalance()]); if (!(j.ok)) { alert(j.error || "Failed to update pick"); } else { // If server returned txn metadata, surface it so admins notice ledger issues try { const txnDuplicate = !!j.txn_duplicate; const txnError = j.txn_error; const txnNote = j.txn_note; if (txnError) { alert(`Warning: ledger insert failed: ${txnError}${txnNote ? ` (note: ${txnNote})` : ""}`); } else if (txnDuplicate) { // Idempotent grading retries are expected; keep these silent. } } catch (e) { // ignore } } } catch (e) { await Promise.allSettled([loadAdmin(), loadBoard(), loadMyPicks(), loadMyBalance()]); alert(e.message || "Network error"); } }, [apiBase, token, withCode, loadAdmin, optimisticMoveClosed, loadBoard, loadMyPicks, loadMyBalance, me]); const notifyLiveBet = useCallback(async (status, proposal) => { try { const headers = { "Content-Type": "application/json" }; if (status !== "proposed") headers.Authorization = `Bearer ${token}`; const message = `Live bet ${status}\n${proposal.selection || ''} ${proposal.odds ? `(odds ${proposal.odds})` : ''}\nStake: $${Number(proposal.stake || 0)}`; const r = await fetch(withCode(`${apiBase}/admin/notify/push/test`), { method: "POST", headers, body: JSON.stringify({ message }), }); if (!r.ok) console.error("Push notify failed", await r.text().catch(() => r.statusText)); } catch (e) { console.error("Push notify failed", e); } }, [apiBase, token, withCode]); const adminApprove = useCallback(async (p, overrides) => { try { const hasEdits = overrides && typeof overrides === "object" && Object.keys(overrides).length > 0; const r = await fetch(withCode(`${apiBase}/admin/proposals/${encodeURIComponent(p.id)}/approve`), { method: "POST", headers: hasEdits ? { "Content-Type": "application/json", Authorization: `Bearer ${token}` } : { Authorization: `Bearer ${token}` }, ...(hasEdits ? { body: JSON.stringify(overrides) } : {}), }); const j = await r.json().catch(() => ({ ok: false })); if (!j.ok) alert(j.error || "Failed to approve"); else await notifyLiveBet("approved", { ...p, ...(overrides || {}) }); await loadAdmin(); } catch (e) { await loadAdmin(); alert(e.message || "Network error"); } }, [apiBase, token, withCode, loadAdmin, notifyLiveBet]); const adminReject = useCallback(async (p) => { if (!confirm("Reject this live bet?")) return; try { const r = await fetch(withCode(`${apiBase}/admin/proposals/${encodeURIComponent(p.id)}`), { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); const j = await r.json().catch(() => ({ ok: false })); if (!j.ok) alert(j.error || "Failed to reject"); else await notifyLiveBet("denied", p); await loadAdmin(); } catch (e) { await loadAdmin(); alert(e.message || "Network error"); } }, [apiBase, token, withCode, loadAdmin, notifyLiveBet]); const adminAutoGradeAll = useCallback(async () => { if (!isAdmin) return; try { const a = await loadAdmin(); const SUPPORTED = ["nfl", "cfb", "mlb", "nba", "ncaab", "nhl"]; // Cache of already-fetched scoreboards { "league-date": scoreboard } const sbCache = new Map(); async function getScoreboard(leagueKey, dateObj) { const k = `${leagueKey}-${fmtDate(dateObj)}`; if (sbCache.has(k)) return sbCache.get(k); const sb = await fetchESPNScoreboard(leagueKey, dateObj); sbCache.set(k, sb); return sb; } // Grade a single pick or parlay leg against a scoreboard game. // Returns {result, finalScore} or null. async function gradeItem(p) { const leagueKey = normalizeLeagueKey(p.league); if (!SUPPORTED.includes(leagueKey)) { console.warn(`Auto-grade: skipping pick #${p.id} - unsupported league "${p.league}" (normalized: "${leagueKey}")`); return null; } const start = parsePickStartTime(p); if (!start) { console.warn(`Auto-grade: skipping pick #${p.id} - cannot parse start time (event: "${p.event || p.selection}")`); return null; } const { away, home } = parsePickTeams(p); if (!away || !home) { console.warn(`Auto-grade: skipping pick #${p.id} - cannot parse teams from: "${p.event || p.selection}"`); return null; } // Try the game date plus +/-1 day to handle UTC vs local-time edge cases. // Then use the nearest scheduled matchup; if that nearest game is not // final yet, skip grading so we do not settle against yesterday's result. let nearestMatch = null; for (const offsetDays of [0, -1, 1]) { const tryDate = new Date(start.getTime() + offsetDays * 24 * 60 * 60 * 1000); const scoreboard = await getScoreboard(leagueKey, tryDate); if (!scoreboard) continue; const gi = findBestGameMatch(scoreboard, away, home, start); if (!gi) continue; if (!nearestMatch || gi.timeDiffMs < nearestMatch.timeDiffMs) { nearestMatch = gi; } } if (!nearestMatch) { console.warn(`Auto-grade: could not find completed game for pick #${p.id}: "${away}" @ "${home}" around ${yyyymmdd(start)} (league: ${leagueKey})`); return null; } if (!Number.isFinite(nearestMatch.timeDiffMs) || nearestMatch.timeDiffMs > AUTO_GRADE_MAX_TIME_DIFF_MS) { console.warn( `Auto-grade: skipping pick #${p.id} - nearest matchup is too far from scheduled start (${Math.round((nearestMatch.timeDiffMs || 0) / 3600000)}h)` ); return null; } if (!nearestMatch.completed) return null; if (!Number.isFinite(nearestMatch.homeScore) || !Number.isFinite(nearestMatch.awayScore)) return null; const result = gradePick(p, nearestMatch); if (!result) return null; return { result, finalScore: `${nearestMatch.awayName} ${nearestMatch.awayScore}-${nearestMatch.homeScore} ${nearestMatch.homeName}`, }; } for (const p of (a?.picks || [])) { if (!isOpenPickShared(p)) continue; // Parlay: grade every leg, derive overall result if (Array.isArray(p.legs) && p.legs.length > 0) { const legResults = await Promise.all(p.legs.map(gradeItem)); // If any leg is null (can't grade yet), skip the whole parlay if (legResults.includes(null)) continue; let parlayResult = "won"; for (const r of legResults) { if (r.result === "lost") { parlayResult = "lost"; break; } if (r.result === "push" && parlayResult === "won") parlayResult = "push"; } const finalScore = legResults.map(r => r.finalScore).join(" | "); await adminEditPick(p, { result: parlayResult, final_score: finalScore }); continue; } // Single pick const graded = await gradeItem(p); if (graded) { await adminEditPick(p, { result: graded.result, final_score: graded.finalScore }); } } await loadMyPicks(); if (view === "board") await loadBoard(); } catch (e) { console.warn("Auto-grade failed", e); } }, [isAdmin, loadAdmin, adminEditPick, loadMyPicks, loadBoard, view]); const adminAdjust = useCallback( async (handle, amountInput) => { const n = Number(amountInput); if (!Number.isFinite(n) || n === 0) { alert("Enter a non-zero amount"); return; } const kind = n > 0 ? "payin" : "payout"; const amountStr = Math.abs(n).toFixed(2); setAdmin(prev => { const next = { ...prev, users: [...prev.users] }; const u = next.users.find(u => u.handle === handle); if (u) u.balance = Number(u.balance || 0) + n; if (typeof next.resume?.totalBalance === "number") { next.resume = { ...next.resume, totalBalance: Number(next.resume.totalBalance) + n }; } return next; }); setBoard(prev => { const rows = prev.rows.map(r => r.handle === handle ? { ...r, balance: Number(r.balance ?? 0) + n } : r ); return { ...prev, rows }; }); setMyBal(prev => { const isMe = me?.handle && me.handle === handle; return isMe && typeof prev.value === "number" ? { ...prev, value: prev.value + n } : prev; }); try { const r = await fetch(withCode(`${apiBase}/admin/adjust-balance`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ handle, kind, amount: amountStr }), }); let j; try { j = await r.json(); } catch { j = { ok: r.ok }; } await Promise.allSettled([loadAdmin(), loadBoard(), loadMyBalance()]); if (!(r.ok && j.ok !== false)) alert(j.error || `HTTP ${r.status} ${r.statusText}`); } catch (e) { await Promise.allSettled([loadAdmin(), loadBoard(), loadMyBalance()]); alert(e.message || "Network error"); } }, [apiBase, token, withCode, loadAdmin, loadBoard, loadMyBalance, me] ); const adminSetHold = useCallback( async (handle, onHold, reason, amount, message) => { const nextHold = !!onHold; const normalizedReason = reason === "auto" || reason === "manual" ? reason : undefined; const parsedAmount = Number(amount); const holdAmount = nextHold && Number.isFinite(parsedAmount) && parsedAmount !== 0 ? parsedAmount : undefined; const holdMessage = nextHold && typeof message === "string" && message.trim() ? message.trim() : undefined; const holdPayload = { on_hold: nextHold, ...(nextHold && normalizedReason ? { reason: normalizedReason } : {}), ...(holdAmount != null ? { amount: holdAmount } : {}), ...(holdMessage != null ? { message: holdMessage } : {}), }; try { const holdUrl = withCode(`${apiBase}/admin/users/${encodeURIComponent(handle)}/hold`); const sendHoldRequest = (method) => fetch(holdUrl, { method, headers: { "Content-Type": "application/json", Authorization: `******` }, body: JSON.stringify(holdPayload), }); let r = await sendHoldRequest("PATCH"); if (r.status === 405) { console.warn("Credit hold PATCH not allowed; retrying with POST"); r = await sendHoldRequest("POST"); } let j; try { j = await r.json(); } catch { j = { ok: r.ok }; } await Promise.allSettled([loadAdmin(), loadBoard(), loadMyBalance()]); if (!(r.ok && j.ok !== false)) alert(j.error || `HTTP ${r.status} ${r.statusText}`); } catch (e) { await Promise.allSettled([loadAdmin(), loadBoard(), loadMyBalance()]); alert(e.message || "Network error"); } }, [apiBase, withCode, loadAdmin, loadBoard, loadMyBalance] ); const adminSetThreshold = useCallback( async (amountInput) => { const threshold = Number(amountInput); if (!Number.isFinite(threshold)) { alert("Enter a valid threshold amount"); return; } try { const r = await fetch(withCode(`${apiBase}/admin/credit-hold-threshold`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `******` }, body: JSON.stringify({ threshold }), }); let j; try { j = await r.json(); } catch { j = { ok: r.ok }; } await Promise.allSettled([loadAdmin(), loadBoard()]); if (!(r.ok && j.ok !== false)) alert(j.error || `HTTP ${r.status} ${r.statusText}`); } catch (e) { await Promise.allSettled([loadAdmin(), loadBoard()]); alert(e.message || "Network error"); } }, [apiBase, withCode, loadAdmin, loadBoard] ); // Date label const dayLabel = useMemo(() => { const today = fmtDate(new Date()); const chosen = fmtDate(date); if (chosen === today) return "Today"; const tmrw = fmtDate(new Date(Date.now() + 86400000)); if (chosen === tmrw) return "Tomorrow"; return new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric" }).format(date); }, [date]); const isTodaySelected = useMemo(() => fmtDate(date) === fmtDate(new Date()), [date]); // ======== Games load ======== const FALLBACK_SCHEDULE_MESSAGE = ""; const ON_REQUEST_ODDS_MESSAGE = ""; const ON_REQUEST_SCHEDULE_MESSAGE = ""; const mergeMissingScoreboardGames = useCallback((primaryGames, scoreboardGames, { liveOnly = false } = {}) => { const base = Array.isArray(primaryGames) ? primaryGames.slice() : []; const extras = Array.isArray(scoreboardGames) ? scoreboardGames : []; if (!extras.length) return base; const merged = base.slice(); for (const g of extras) { if (!g) continue; if (liveOnly) { const ts = g?.start instanceof Date ? g.start.getTime() : NaN; const started = Number.isFinite(ts) && ts <= Date.now(); if (!started || g?.status?.completed) continue; } const exists = merged.some((m) => ( teamsRoughMatch(m?.awayTeam, g?.awayTeam) && teamsRoughMatch(m?.homeTeam, g?.homeTeam) )); if (!exists) merged.push(g); } merged.sort((a, b) => { const ta = a?.start instanceof Date ? a.start.getTime() : 0; const tb = b?.start instanceof Date ? b.start.getTime() : 0; return ta - tb; }); return merged; }, []); const loadGames = useCallback(async () => { const reqId = ++gamesLoadSeqRef.current; setLoading(true); setError(""); setOddsRequestByGame({}); const leagueKey = normalizeLeagueKey(league); const canUseScoreboardFallback = leagueKey !== "ufc"; const scoreboardPromise = canUseScoreboardFallback ? fetchESPNScoreboard(leagueKey, date).catch(() => null) : Promise.resolve(null); try { if (oddsMode.onRequestEnabled) { let scheduleGames = []; let msg = ""; try { const oddsGames = await fetchGamesFanDuelOnly(apiBase, league, date); if (oddsGames.length) { // Keep pregame prices available so upcoming games can be wagered normally. scheduleGames = oddsGames; msg = ON_REQUEST_ODDS_MESSAGE; } } catch {} if (scheduleGames.length) { const scoreboard = await scoreboardPromise; if (scoreboard) { const fallbackGames = gamesFromScoreboard(scoreboard, league); for (const g of scheduleGames) { const gi = findGame(scoreboard, g.awayTeam, g.homeTeam); if (gi) { g.awayScore = gi.awayScore; g.homeScore = gi.homeScore; g.status = { ...(g.status || {}), completed: gi.completed, detail: gi.statusText || g.status?.detail }; } } scheduleGames = mergeMissingScoreboardGames(scheduleGames, fallbackGames, { liveOnly: true }); } } if (!scheduleGames.length) { const scoreboard = await scoreboardPromise; const fallbackGames = gamesFromScoreboard(scoreboard, league); if (fallbackGames.length) { scheduleGames = fallbackGames; msg = ON_REQUEST_SCHEDULE_MESSAGE; } } if (reqId !== gamesLoadSeqRef.current) return; setGames(scheduleGames); setError(scheduleGames.length ? msg : "No games available for this day."); return; } const fdGames = await fetchGamesFanDuelOnly(apiBase, league, date); const scoreboard = await scoreboardPromise; let finalGames = fdGames; let nextError = ""; if (scoreboard && finalGames.length) { const fallbackGames = gamesFromScoreboard(scoreboard, league); for (const g of finalGames) { const gi = findGame(scoreboard, g.awayTeam, g.homeTeam); if (gi) { g.awayScore = gi.awayScore; g.homeScore = gi.homeScore; g.status = { ...(g.status || {}), completed: gi.completed }; } } finalGames = mergeMissingScoreboardGames(finalGames, fallbackGames, { liveOnly: true }); } if ((!finalGames || finalGames.length === 0) && scoreboard) { const fallbackGames = gamesFromScoreboard(scoreboard, league); if (fallbackGames.length) { finalGames = fallbackGames; nextError = FALLBACK_SCHEDULE_MESSAGE; } } if (reqId !== gamesLoadSeqRef.current) return; setGames(finalGames); setError(nextError); } catch (e) { const scoreboard = await scoreboardPromise; const fallbackGames = gamesFromScoreboard(scoreboard, league); if (reqId !== gamesLoadSeqRef.current) return; if (fallbackGames.length) { setGames(fallbackGames); setError(FALLBACK_SCHEDULE_MESSAGE); } else if (e?.isOddsUnavailable) { setGames([]); setError("Odds unavailable from provider. Please try again later."); } else { setGames([]); setError(e.message || "Failed to load"); } } finally { if (reqId !== gamesLoadSeqRef.current) return; setLoading(false); } }, [apiBase, league, date, oddsMode.onRequestEnabled, mergeMissingScoreboardGames]); useEffect(() => { if (view === "games") loadGames(); }, [view, loadGames]); useEffect(() => { if (view !== "games") return; const now = Date.now(); const startsSoon = games.some((g) => Math.abs(g.start.getTime() - now) < 3 * 60 * 60 * 1000); const periodMs = oddsMode.onRequestEnabled ? 180000 : (startsSoon ? 40000 : 60000); const id = setInterval(() => { refreshDay(); }, periodMs); return () => clearInterval(id); }, [view, league, date, games, oddsMode.onRequestEnabled]); async function refreshDay() { setRefreshing(true); try { await loadGames(); } finally { setRefreshing(false); } } const requestLiveOddsForGame = useCallback(async (game) => { if (!oddsMode.onRequestEnabled) return; if (!token) { alert("Sign in to request live odds."); return; } const gameKey = game?.id || `${game?.awayTeam || "away"}@${game?.homeTeam || "home"}`; setOddsRequestByGame((prev) => ({ ...prev, [gameKey]: { loading: true, note: "Requesting live odds...", unavailable: false }, })); try { let result; try { result = await fetchGamesFanDuelOnly(apiBase, game.league || league, game.start || date, { endpointPath: "/odds/fd/request", includeMeta: true, headers: { Authorization: `Bearer ${token}` }, extraQuery: { game_id: gameKey }, }); } catch (err) { if (err?.status === 404 || err?.status === 405) { result = await fetchGamesFanDuelOnly(apiBase, game.league || league, game.start || date, { endpointPath: "/odds/fd", includeMeta: true, }); } else { throw err; } } const candidates = Array.isArray(result?.games) ? result.games : []; const matched = candidates.find((entry) => { if (!entry) return false; const byId = entry.id && game.id && String(entry.id) === String(game.id); if (byId) return true; const awayMatch = teamsRoughMatch(entry.awayTeam, game.awayTeam); const homeMatch = teamsRoughMatch(entry.homeTeam, game.homeTeam); return awayMatch && homeMatch; }); if (matched) { const updatedGame = { ...game, homeSpread: matched.homeSpread, awaySpread: matched.awaySpread, overUnder: matched.overUnder, moneyline: matched.moneyline, source: "fanduel", }; setGames((prev) => prev.map((g) => ( String(g.id) === String(game.id) ? updatedGame : g ))); if (isLiveGame(updatedGame)) { openLiveProposal(updatedGame); } } const quota = result?.quota || null; const remaining = Number.isFinite(Number(quota?.remaining)) ? Number(quota.remaining) : null; const charged = !!result?.charged; const baseNote = matched ? "Live odds updated." : "No matching odds found for this game."; const quotaNote = remaining != null ? ` ${remaining} request${remaining === 1 ? "" : "s"} remaining today${charged ? " (charged)" : " (cache hit)"}.` : ""; setOddsRequestByGame((prev) => ({ ...prev, [gameKey]: { loading: false, note: `${baseNote}${quotaNote}`, remaining, unavailable: !matched, }, })); } catch (e) { const quota = e?.quota || null; const remaining = Number.isFinite(Number(quota?.remaining)) ? Number(quota.remaining) : null; const note = e?.message || "Unable to request live odds right now."; setOddsRequestByGame((prev) => ({ ...prev, [gameKey]: { loading: false, note, remaining, unavailable: /no matching odds found|odds unavailable/i.test(String(note || "")), }, })); } }, [apiBase, date, league, oddsMode.onRequestEnabled, token]); // ======== Picks ======== const loadMyPicks = useCallback(async () => { if (!token) { setMyPicks([]); return []; } try { const r = await fetch(withCode(`${apiBase}/picks/mine?limit=500`), { headers: { Authorization: `Bearer ${token}` }, }); const j = await r.json(); let proposals = []; try { const r2 = await fetch( withCode(`${apiBase}/picks/mine?status=proposal&limit=500`), { headers: { Authorization: `Bearer ${token}` } } ); const j2 = await r2.json(); proposals = j2.picks || []; } catch {} const merged = new Map(); for (const p of (j.ok ? j.picks : [])) merged.set(pickKey(p), p); for (const p of proposals) merged.set(pickKey(p), p); const arr = Array.from(merged.values()); const items = normalizePicks(arr); setMyPicks(items); return items; } catch { setMyPicks([]); return []; } }, [apiBase, token, withCode]); useEffect(() => { if (token) loadMyPicks(); }, [token, loadMyPicks]); // ======== Balance ======== const loadMyBalance = useCallback(async () => { if (!token) { setMyBal({ value: null, loading: false, error: "" }); return; } setMyBal((prev) => ({ ...prev, loading: true, error: "" })); try { const r = await fetch(withCode(`${apiBase}/balances/me`), { headers: { Authorization: `Bearer ${token}` } }); const j = await r.json(); setMyBal({ value: typeof j.balance !== "undefined" ? Number(j.balance) : null, loading: false, error: j.ok === false ? j.error || "" : "" }); } catch (e) { setMyBal({ value: null, loading: false, error: e.message }); } }, [apiBase, token, withCode]); useEffect(() => { if (token && me) loadMyBalance(); }, [token, me, loadMyBalance]); // ======== Board ======== function isOpenPickShared(p) { const status = normalizeOutcome(p.status || p.state); if (status) { if (OPENISH_STATUSES.has(status)) return true; if (CLOSED_STATUSES.has(status)) return false; } const outcome = normalizeOutcome(p.result || p.outcome); if (outcome && CLOSED_STATUSES.has(outcome)) return false; if (typeof p.settled === "boolean") return !p.settled; return !(p.result || p.outcome); } const loadBoard = useCallback(async (options = {}) => { const recentDays = clampDays(options.recentDays, 30); if (!token) { setBoard({ rows: [], loading: false, error: "", note: "", recentDays: 0 }); return; } setBoard((prev) => ({ ...prev, loading: true, error: "", note: "", recentDays })); const auth = { Authorization: `Bearer ${token}` }; const code = groupCode; const withCodeLocal = (u) => withTenantQuery(u, code); // Prefer more generic endpoints first to avoid 404s on deployments that // don't expose the -all variants. Include a high limit because `/picks` // defaults to a small page size and can hide many open bets on the board. // Try `/picks?status=open&limit=500` then `/picks/open-all`. const pickUrls = ["/picks?status=open&limit=500", "/picks/open-all"].map((x) => withCodeLocal(`${apiBase}${x}`)); const now = Date.now(); const cutoffRecent = now - recentDays * MS_IN_DAY; let picks = null; let usedFallback = false; for (const u of pickUrls) { try { const r = await fetch(u, { headers: auth }); if (!r.ok) continue; const j = await r.json(); const arr = j.picks || (Array.isArray(j) ? j : null); if (Array.isArray(arr)) { picks = arr; break; } } catch {} } if (!Array.isArray(picks) || picks.length === 0) { try { const r = await fetch(`${apiBase}/picks/mine?status=open&limit=500`, { headers: auth }); const j = await r.json(); picks = j.ok && Array.isArray(j.picks) ? j.picks.map((p) => ({ ...p, handle: me?.handle || localStorage.getItem("bb_handle") || "Me" })) : []; usedFallback = true; } catch { picks = []; usedFallback = true; } } picks = Array.isArray(picks) ? picks.filter((p) => matchesGroupCode(p, code)) : []; const byUser = new Map(); const ensureRow = (rawHandle) => { const trimmed = typeof rawHandle === "string" ? rawHandle.trim() : ""; const handle = trimmed || "Unknown"; if (!byUser.has(handle)) { byUser.set(handle, { handle, openCount: 0, pending: 0, overdueCount: 0, overdueAmount: 0, picks: [], recent: [], }); } return byUser.get(handle); }; for (const p of picks) { if (!isOpenPickShared(p)) continue; const handle = p.handle || p.user || p.better || p.owner || "Unknown"; const stake = resolveStake(p); const overdue = hasPickEnded(p); const cur = ensureRow(handle); cur.openCount += 1; cur.pending = amountOrZero(cur.pending) + stake; cur.picks.push(p); if (overdue) { cur.overdueCount += 1; cur.overdueAmount = amountOrZero(cur.overdueAmount) + stake; } } // Prefer endpoints that return a larger recent/settled history first so // the board can build a 30-day activity series. Older deployments may // not expose the -all aliases, so fall back to `/picks/recent` and // `/picks/settled` (and finally the generic `/picks`) when necessary. // Request a larger `limit` where supported to avoid the small default // (the generic `/picks` endpoint defaults to 25 and can truncate the // history prematurely). const historyUrls = [ `/picks/recent-all?limit=500`, `/picks/settled-all?limit=500`, `/picks/recent?limit=500`, `/picks/settled?limit=500`, `/picks?limit=500`, `/picks/recent-all`, `/picks/settled-all`, `/picks/recent`, `/picks/settled`, ].map((x) => withCodeLocal(`${apiBase}${x}`)); let recent = null; for (const u of historyUrls) { try { const r = await fetch(u, { headers: auth }); if (!r.ok) continue; const j = await r.json(); const arr = j.picks || (Array.isArray(j) ? j : null); if (Array.isArray(arr)) { recent = arr; break; } } catch {} } if (!Array.isArray(recent)) recent = []; recent = recent.filter((p) => matchesGroupCode(p, code)); for (const p of recent) { if (!isClosedPick(p)) continue; const outcome = resolvePickOutcome(p); const derivedOutcome = deriveOutcomeFromFinalScore(p); const resolvedOutcome = derivedOutcome && derivedOutcome !== outcome ? derivedOutcome : outcome; let ts = resolvePickTimestamp(p); if (!Number.isFinite(ts) || ts <= 0) { const fallback = new Date( p.settled_at || p.graded_at || p.updated_at || p.placed_at || p.at || p.created_at || now ); ts = fallback.getTime(); } if (!Number.isFinite(ts) || ts < cutoffRecent) continue; const handle = p.handle || p.user || p.better || p.owner || "Unknown"; const cur = ensureRow(handle); const normalizedProfit = profitOnly({ ...p, result: resolvedOutcome }); cur.recent.push({ ...p, normalizedProfit, outcome: resolvedOutcome, recentTs: ts }); } try { const rb = await fetch(withCodeLocal(`${apiBase}/balances`), { headers: auth }); if (rb.ok) { const jb = await rb.json(); const toArray = (value) => { if (Array.isArray(value)) return value.filter((item) => item != null); if (value && typeof value === "object") { const items = []; for (const [key, val] of Object.entries(value)) { if (val == null) continue; if (typeof val === "object" && !Array.isArray(val)) { items.push({ handle: val.handle ?? val.user ?? val.name ?? key, ...val }); } else { items.push({ handle: key, balance: val }); } } return items; } return []; }; const parseNumber = (value) => { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) return null; const negative = trimmed.startsWith("(") && trimmed.endsWith(")"); const cleaned = trimmed.replace(/[^0-9.\-]+/g, ""); if (!cleaned) return null; const num = Number(cleaned); if (!Number.isFinite(num)) return null; return negative ? -num : num; } return null; }; const resolveHandle = (entry, fallback) => { const candidates = [ entry?.handle, entry?.user?.handle, entry?.user, entry?.name, entry?.nickname, entry?.better, entry?.owner, fallback, ]; for (const cand of candidates) { if (typeof cand !== "string") continue; const trimmed = cand.trim(); if (trimmed) return trimmed; } return null; }; let balanceEntries = toArray(jb?.balances); if (balanceEntries.length === 0 && Array.isArray(jb?.users)) { balanceEntries = toArray(jb.users); } if (balanceEntries.length === 0 && Array.isArray(jb)) { balanceEntries = toArray(jb); } for (const rawEntry of balanceEntries) { if (rawEntry == null) continue; const entry = typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : { balance: rawEntry }; if (!matchesGroupCode(entry, code)) continue; const handle = resolveHandle(entry, typeof rawEntry === "string" ? rawEntry : undefined); if (!handle) continue; const row = ensureRow(handle); const balance = parseNumber( entry.balance ?? entry.available ?? entry.amount ?? entry.value ?? entry.total ?? entry.current_balance ?? entry.current ); if (balance != null) row.balance = balance; const pending = parseNumber( entry.pending ?? entry.pending_amount ?? entry.openPending ?? entry.open_pending ?? entry.outstanding ?? entry.openBalance ); if (pending != null && (row.picks.length === 0 || !Number.isFinite(Number(row.pending)) || pending > Number(row.pending))) { row.pending = pending; } const openCount = parseNumber( entry.openCount ?? entry.open_count ?? entry.open ?? entry.openBets ?? entry.open_bets ); if ( openCount != null && (row.picks.length === 0 || !Number.isFinite(Number(row.openCount)) || openCount > Number(row.openCount)) ) { row.openCount = openCount; } const settledCount = parseNumber(entry.settledCount ?? entry.settled_count ?? entry.settled); if (settledCount != null) { row.settledCount = settledCount; } } } } catch {} const rows = Array.from(byUser.values()); for (const row of rows) { row.pending = amountOrZero(row.pending); row.overdueAmount = amountOrZero(row.overdueAmount); row.openCount = Number.isFinite(Number(row.openCount)) ? Number(row.openCount) : row.picks.length; row.overdueCount = Number.isFinite(Number(row.overdueCount)) ? Number(row.overdueCount) : 0; row.balance = amountOrNull(row.balance); row.recent = row.recent .filter((item) => { const ts = resolvePickTimestamp(item); return Number.isFinite(ts) && ts >= cutoffRecent; }) .sort((a, b) => resolvePickTimestamp(b) - resolvePickTimestamp(a)); } rows.sort((a, b) => b.pending - a.pending); setBoard({ rows, loading: false, error: rows.length ? "" : "No open bets found.", note: usedFallback ? "Showing your open bets (group endpoint returned empty)." : "", recentDays, }); }, [apiBase, token, groupCode, me]); useEffect(() => { if (view === "board" && token) loadBoard(); }, [view, token, loadBoard]); useEffect(() => { if (view !== "summary" || !token) return; const loadedDays = Number(board.recentDays) || 0; const shouldLoad = board.rows.length === 0 || summaryDays > loadedDays; if (shouldLoad && !board.loading) { loadBoard({ recentDays: summaryDays }); } }, [view, token, summaryDays, loadBoard, board.rows.length, board.loading, board.recentDays]); // ======== Betting helpers ======== // ======== ESPN AUTO-GRADER (Admin only) ======== // Map front-end league key to ESPN sport path const ESPN_LEAGUE_KEY = { nfl: "football/nfl", cfb: "football/college-football", mlb: "baseball/mlb", nba: "basketball/nba", ncaab: "basketball/mens-college-basketball", nhl: "hockey/nhl", ufc: "mma/ufc", }; // Prevent same-team games on adjacent days from being auto-graded incorrectly. const AUTO_GRADE_MAX_TIME_DIFF_MS = 18 * 60 * 60 * 1000; function yyyymmdd(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}${m}${day}`; } // Try to extract teams from pick selection or event text function parsePickTeams(p) { // Picks are commonly shaped like: "Away Team @ Home Team ". const src = String(p.event || p.matchup || p.selection || "").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(); return { away, home, }; } const TEAM_NAME_ALIAS_REPLACEMENTS = [ [/\bmtl\b/g, "montreal"], [/\bsouthern miss\b/g, "southern mississippi"], [/\bsaint louis\b/g, "st louis"], ]; function normalizeName(s) { if (s == null) return ""; let normalized = String(s) .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/&/g, " and ") .replace(/[^a-z0-9 ]+/g, " ") .replace(/\s+/g, " ") .trim(); if (!normalized) return ""; for (const [pattern, replacement] of TEAM_NAME_ALIAS_REPLACEMENTS) { normalized = normalized.replace(pattern, replacement); } return normalized; } function teamsRoughMatch(a, b) { const left = normalizeName(a); const right = normalizeName(b); if (!left || !right) return false; return left === right || left.includes(right) || right.includes(left); } function parseFinalScoreValue(value) { if (value == null) return null; if (typeof value === "string") { const m = value.match(/^\s*(.+?)\s+(\d+)\s*[-:]\s*(\d+)\s+(.+?)\s*$/); if (!m) return null; const awayScore = Number(m[2]); const homeScore = Number(m[3]); if (!Number.isFinite(awayScore) || !Number.isFinite(homeScore)) return null; return { awayName: m[1].trim(), homeName: m[4].trim(), awayScore, homeScore, }; } if (typeof value === "object") { const awayScore = Number( value.awayScore ?? value.away?.score ?? value.away ?? value.a ); const homeScore = Number( value.homeScore ?? value.home?.score ?? value.home ?? value.h ); if (!Number.isFinite(awayScore) || !Number.isFinite(homeScore)) return null; return { awayName: value.awayName ?? value.away?.name ?? value.awayTeam ?? "", homeName: value.homeName ?? value.home?.name ?? value.homeTeam ?? "", awayScore, homeScore, }; } return null; } function parseFinalScore(p) { const fields = [ p?.final_score, p?.finalScore, p?.score_final, p?.scoreFinal, p?.final, ]; for (const field of fields) { const parsed = parseFinalScoreValue(field); if (parsed) return parsed; } return null; } function deriveOutcomeFromFinalScore(p) { const info = parseFinalScore(p); if (!info) return ""; const market = inferMarketFallback(p); let side = String(p?.side || "").toLowerCase(); if (!side) { const sel = String(p?.selection || p?.event || ""); if (/\bover\b/i.test(sel)) side = "over"; else if (/\bunder\b/i.test(sel)) side = "under"; else if (info.homeName && teamsRoughMatch(sel, info.homeName)) side = "home"; else if (info.awayName && teamsRoughMatch(sel, info.awayName)) side = "away"; } if (!side) return ""; const graded = gradePick( { ...p, market, side }, { homeScore: info.homeScore, awayScore: info.awayScore } ); return normalizeOutcome(graded); } function resolvePickTimestamp(p) { if (!p || typeof p !== "object") return 0; const coerce = (value) => { if (value == null || value === "") return null; if (typeof value === "number") { if (!Number.isFinite(value)) return null; if (value > 1e12) return value; if (value > 1e9) return value * 1000; return value; } if (value instanceof Date) { const ts = value.getTime(); return Number.isFinite(ts) ? ts : null; } if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) return null; const parsedDate = new Date(trimmed); const dateTs = parsedDate.getTime(); if (Number.isFinite(dateTs)) return dateTs; const numeric = Number(trimmed.replace(/[^0-9.\-]/g, "")); if (!Number.isFinite(numeric)) return null; if (trimmed.length >= 13 || numeric > 1e12) return numeric; if (trimmed.length === 10 || numeric > 1e9) return numeric * 1000; return numeric; } const parsed = new Date(value); const ts = parsed.getTime(); return Number.isFinite(ts) ? ts : null; }; const priority = [ p.recentTs, p.settledTs, p.recent_ts, p.settled_ts, ]; for (const value of priority) { const ts = coerce(value); if (Number.isFinite(ts)) return ts; } const candidates = [ p.settled_at, p.graded_at, p.updated_at, p.placed_at, p.at, p.created_at, ]; for (const value of candidates) { if (!value) continue; const ts = coerce(value); if (Number.isFinite(ts)) return ts; } return 0; } // getApiBase already defined at top; avoid duplicate definitions async function fetchJSONWithGetThenPost(url, payload) { // 1) Try GET with query string try { const r = await fetch(url + `?league=${encodeURIComponent(payload.league)}&date=${encodeURIComponent(payload.date)}`); if (r.ok) return await r.json(); if (r.status !== 405) throw new Error(`HTTP ${r.status}`); // if 405, fall through to POST attempt } catch (e) { // only proceed to POST if it looks like a method issue; otherwise rethrow if (!/HTTP 405/.test(String(e))) throw e; } // 2) Try POST with JSON body (many backends prefer this) const r2 = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!r2.ok) throw new Error(`HTTP ${r2.status}`); return await r2.json(); } async function fetchESPNScoreboard(leagueKey, dateObj) { const normalizedLeagueKey = normalizeLeagueKey(leagueKey); const api = getApiBase(); const dates = yyyymmdd(dateObj); // 'YYYYMMDD' const payload = { league: normalizedLeagueKey, date: dates }; const scoreboardLeagueKey = (() => { if (normalizedLeagueKey === "ncaaf") return "cfb"; if (normalizedLeagueKey === "college-football") return "cfb"; if (normalizedLeagueKey === "college-basketball" || normalizedLeagueKey === "mens-college-basketball") return "ncaab"; if (normalizedLeagueKey === "cbb" || normalizedLeagueKey === "ncaam") return "ncaab"; return normalizedLeagueKey; })(); function coerceScoreboardPayload(value) { if (!value || typeof value !== "object") return null; if (Array.isArray(value.events)) return value; if (value.ok && value.scoreboard && Array.isArray(value.scoreboard.events)) { return value.scoreboard; } return null; } // Try your likely mounts in order on the BlackBook API const paths = [ "/espn/scoreboard", "/api/espn/scoreboard", "/bb/espn/scoreboard", ]; for (const p of paths) { try { const j = await fetchJSONWithGetThenPost(`${api}${p}`, payload); const scoreboard = coerceScoreboardPayload(j); if (scoreboard) return scoreboard; } catch (e) { // Swallow and try next path only when it's a 404/405; rethrow other errors if you want if (!/HTTP 404|HTTP 405/.test(String(e))) { console.warn("Scoreboard fetch failed at", p, e); } } } // Same-origin frontend proxy fallback (Vercel /api) avoids browser CORS blocks. try { const localProxyUrl = `/api/espn/scoreboard?league=${encodeURIComponent(scoreboardLeagueKey)}&date=${encodeURIComponent(dates)}`; const localResp = await fetch(localProxyUrl, { cache: "no-store" }); if (localResp.ok) { const localJson = await localResp.json(); const scoreboard = coerceScoreboardPayload(localJson); if (scoreboard) return scoreboard; } } catch (e) { console.warn("Local ESPN proxy fetch failed", e); } // Fallback: hit ESPN's public API directly if proxy isn't available try { const sport = ESPN_LEAGUE_KEY[normalizedLeagueKey]; if (sport) { const url = `https://site.api.espn.com/apis/site/v2/sports/${sport}/scoreboard?dates=${dates}`; const r = await fetch(url); if (r.ok) { const j = await r.json(); const scoreboard = coerceScoreboardPayload(j); if (scoreboard) return scoreboard; } console.warn("ESPN direct fetch failed with status", r.status); } } catch (e) { console.warn("ESPN direct fetch failed", e); } console.warn("ESPN proxy unavailable. Make sure your API exposes /espn/scoreboard (GET or POST)."); return null; } // Find a specific game by (away, home) inside a scoreboard payload 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; // competitors: [{homeAway:"home"|"away", team:{displayName, shortDisplayName, name, abbreviation}, score,...}] 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)) { // Compose status information from common locations in ESPN payloads const statusType = (comp.status && comp.status.type) || (ev.status && ev.status.type) || {}; const fallbackStatus = (ev.status && 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 completed = statusType.completed === true || fallbackStatus.completed === true || state === "post" || statusText.toLowerCase().includes("final"); const homeScore = Number(home.score || 0); const awayScore = Number(away.score || 0); return { completed, homeName: homeNameActual, awayName: awayNameActual, homeScore, awayScore, statusText }; } } return null; } function findBestGameMatch(scoreboard, awayName, homeName, targetStart) { const games = gamesFromScoreboard(scoreboard, ""); const candidates = games.filter((g) => ( teamsRoughMatch(g.awayTeam, awayName) && teamsRoughMatch(g.homeTeam, homeName) )); if (!candidates.length) return null; const targetTs = targetStart instanceof Date ? targetStart.getTime() : NaN; const ranked = candidates .map((g) => { const gameTs = g.start instanceof Date ? g.start.getTime() : NaN; const timeDiffMs = Number.isFinite(targetTs) && Number.isFinite(gameTs) ? Math.abs(gameTs - targetTs) : Number.MAX_SAFE_INTEGER; return { game: g, timeDiffMs }; }) .sort((a, b) => a.timeDiffMs - b.timeDiffMs); const best = ranked[0]; if (!best) return null; return { completed: !!best.game?.status?.completed, homeName: best.game?.homeTeam || "", awayName: best.game?.awayTeam || "", homeScore: Number(best.game?.homeScore), awayScore: Number(best.game?.awayScore), statusText: String(best.game?.status?.detail || ""), timeDiffMs: best.timeDiffMs, }; } function gamesFromScoreboard(scoreboard, leagueKey) { if (!scoreboard || !Array.isArray(scoreboard.events)) return []; const games = []; for (const ev of scoreboard.events) { const comp = Array.isArray(ev.competitions) ? ev.competitions[0] : null; if (!comp || !Array.isArray(comp.competitors)) continue; const home = comp.competitors.find((c) => String(c.homeAway || "").toLowerCase() === "home"); const away = comp.competitors.find((c) => String(c.homeAway || "").toLowerCase() === "away"); if (!home || !away) continue; const homeName = home.team?.displayName || home.team?.shortDisplayName || home.team?.name || home.team?.abbreviation || ""; const awayName = away.team?.displayName || away.team?.shortDisplayName || away.team?.name || away.team?.abbreviation || ""; if (!homeName || !awayName) continue; const rawDate = comp.date || ev.date || ev.startDate; let start = rawDate ? new Date(rawDate) : new Date(); if (!Number.isFinite(start?.getTime())) start = new Date(); const statusType = (comp.status && comp.status.type) || (ev.status && ev.status.type) || {}; const fallbackStatus = (ev.status && 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 completed = statusType.completed === true || fallbackStatus.completed === true || state === "post" || statusText.toLowerCase().includes("final"); const homeScore = Number.isFinite(Number(home.score)) ? Number(home.score) : null; const awayScore = Number.isFinite(Number(away.score)) ? Number(away.score) : null; games.push({ id: ev.id || comp.id || `${awayName}@${homeName}-${start.getTime()}`, league: leagueKey, start, status: { completed, detail: statusText || undefined }, awayTeam: awayName, homeTeam: homeName, venue: comp.venue?.fullName || ev.venue?.fullName || "", homeSpread: null, awaySpread: null, overUnder: null, moneyline: { home: null, away: null }, homeScore, awayScore, source: "espn", }); } games.sort((a, b) => a.start - b.start); return games; } // Decide result for a pick given a finished game score function normalizeMarket(market) { return String(market || "").toLowerCase(); } function inferMarketFallback(pick) { const raw = normalizeMarket(pick?.market); if (raw) return raw; if (Array.isArray(pick?.legs) && pick.legs.length > 0) return "parlay"; if (pick?.total != null) return "total"; const sel = String(pick?.selection || pick?.event || pick?.side || "").toLowerCase(); if (/(\bover\b|\bunder\b|\bo\/u\b|\btotal\b|\btotals\b)/.test(sel)) return "total"; if (pick?.line != null) return "spread"; if (pick?.selection != null || pick?.odds != null) return "moneyline"; return ""; } function gradePick(p, gameInfo) { if ( typeof window !== "undefined" && typeof window.gradePickWithScore === "function" ) { return window.gradePickWithScore(p, gameInfo); } // Fallback copy (should match window.gradePickWithScore logic) const market = inferMarketFallback(p); const home = gameInfo.homeScore; const away = gameInfo.awayScore; if (market === "spread") { if (p.line == null) return null; let side = String(p.side || "").toLowerCase(); if (!side && gameInfo) { const sel = String(p.selection || ""); if (teamsRoughMatch(sel, gameInfo.homeName)) side = "home"; else if (teamsRoughMatch(sel, gameInfo.awayName)) side = "away"; } if (!side) return null; const teamScore = side === "home" ? home : away; const oppScore = side === "home" ? away : home; // Simple spread grading: use p.line directly (no sign guessing), treat // positive line as points added to the selection's score. // If selection text includes a signed line, prefer that value for grading. let effectiveLine = p.line; try { if (p.selection || p.event) { const sel = String(p.selection || p.event || ""); const m = sel.match(/([+\-]\s*\d+(?:\.\d+)?)/); if (m) { const parsed = Number(String(m[1]).replace(/\s+/g, "")); if (Number.isFinite(parsed)) { effectiveLine = parsed; } } } } catch (e) { // ignore parsing errors and fall back to stored p.line } const line = Number(effectiveLine); if (!Number.isFinite(line)) return null; const margin = teamScore - oppScore + line; if (margin > 0) return "won"; if (margin === 0) return "push"; return "lost"; } if (market === "total" || market === "totals") { if (p.total == null && p.line == null) return null; const total = Number(p.total ?? p.line); if (!Number.isFinite(total)) return null; const sum = home + away; let side = String(p.side || "").toLowerCase(); if (!side) { const sel = String(p.selection || "").toLowerCase(); if (/\bover\b/.test(sel)) side = "over"; else if (/\bunder\b/.test(sel)) side = "under"; } if (side === "over") { if (sum > total) return "won"; if (sum === total) return "push"; return "lost"; } if (side === "under") { if (sum < total) return "won"; if (sum === total) return "push"; return "lost"; } return null; } if (market === "moneyline" || !market) { let side = String(p.side || "").toLowerCase(); if (!side && gameInfo) { const sel = String(p.selection || ""); if (teamsRoughMatch(sel, gameInfo.homeName)) side = "home"; else if (teamsRoughMatch(sel, gameInfo.awayName)) side = "away"; } if (!side) return null; if (home === away) return "push"; const winner = home > away ? "home" : "away"; return winner === side ? "won" : "lost"; } return null; } // Only allow betting when game is open and we have reliable FanDuel odds. const hasReliableOdds = (g) => { if (!g || g.source === "espn") return false; const mlHome = Number(g?.moneyline?.home); const mlAway = Number(g?.moneyline?.away); const spreadHome = Number(g?.homeSpread); const spreadAway = Number(g?.awaySpread); const total = Number(g?.overUnder); return Number.isFinite(mlHome) || Number.isFinite(mlAway) || Number.isFinite(spreadHome) || Number.isFinite(spreadAway) || Number.isFinite(total); }; const canBet = (g) => !g.status?.completed && !me?.credit_hold && hasReliableOdds(g); const CREDIT_HOLD_DEFAULT_MESSAGE = "Your account is on credit hold. Please contact an administrator for support."; const readHoldFromAuthPayload = (payload) => { const held = !!(payload?.credit_hold ?? payload?.on_hold ?? payload?.hold ?? false); const reasonRaw = payload?.credit_hold_reason ?? payload?.hold_reason ?? null; const reason = reasonRaw === "manual" || reasonRaw === "auto" ? reasonRaw : null; const messageRaw = payload?.credit_hold_message ?? payload?.hold_message ?? payload?.message ?? null; const message = typeof messageRaw === "string" && messageRaw.trim() ? messageRaw.trim() : null; const rawMaxBet = payload?.max_bet ?? payload?.max_bet_threshold ?? payload?.bet_limit ?? payload?.bet_limit_amount ?? null; return { held, reason, message, maxBet: parseMaxBetAmount(rawMaxBet), }; }; const ensureNotOnCreditHold = async ({ refresh = false } = {}) => { if (!refresh) { if (!me?.credit_hold) return true; const msg = me?.credit_hold_message || CREDIT_HOLD_DEFAULT_MESSAGE; setAccountRestrictionNotice(msg); alert(msg); return false; } if (!token) return true; try { const r = await fetch(withCode(`${apiBase}/auth/me`), { headers: { Authorization: `Bearer ${token}` }, }); const j = await r.json().catch(() => ({})); if (j?.ok) { const hold = readHoldFromAuthPayload(j); setMe((prev) => { if (!prev) return prev; return { ...prev, credit_hold: hold.held, credit_hold_reason: hold.reason, credit_hold_message: hold.message, max_bet: hold.maxBet ?? prev.max_bet ?? null, }; }); if (hold.held) { const msg = hold.message || CREDIT_HOLD_DEFAULT_MESSAGE; setAccountRestrictionNotice(msg); alert(msg); return false; } setAccountRestrictionNotice(""); return true; } } catch {} if (!me?.credit_hold) return true; const msg = me?.credit_hold_message || CREDIT_HOLD_DEFAULT_MESSAGE; setAccountRestrictionNotice(msg); alert(msg); return false; }; const currentUserMaxBet = (() => { const fromProfile = parseMaxBetAmount(me?.max_bet); if (fromProfile != null) return fromProfile; return getMaxBetOverride(me?.handle); })(); const GROUP_MAX_PAYOUT = 1000; const capStakeForMaxPayout = (stakeInput, payoutInput) => { const stake = Number(stakeInput); const payout = Number(payoutInput); if (!Number.isFinite(stake) || stake <= 0) return { stake, adjusted: false }; if (!Number.isFinite(payout) || payout <= 0) return { stake, adjusted: false }; if (payout <= GROUP_MAX_PAYOUT + 1e-9) return { stake, adjusted: false }; const ratio = GROUP_MAX_PAYOUT / payout; const adjustedStakeRaw = stake * ratio; const adjustedStake = Math.max(0.01, Math.floor(adjustedStakeRaw * 100) / 100); return { stake: adjustedStake, adjusted: adjustedStake < stake, }; }; const payoutCapMessage = (adjustedStake) => ( `Wager exceeds the group's maximum payout threshold of $${GROUP_MAX_PAYOUT}. ` + `Stake was automatically reduced to ${dollars(adjustedStake)}.` ); const ensureWithinMaxBet = (stakeInput, kindLabel = "bet") => { if (currentUserMaxBet == null) return true; const stake = Number(stakeInput); if (!Number.isFinite(stake) || stake <= 0) return true; if (stake <= currentUserMaxBet) return true; alert(`Your max ${kindLabel} stake is ${dollars(currentUserMaxBet)}.`); return false; }; // Helper to detect live const isLiveGame = (g) => g.start.getTime() <= Date.now() && !g.status?.completed; const isFootballLeague = (league) => { const leagueKey = normalizeLeagueKey(league); return leagueKey === "nfl" || leagueKey === "cfb"; }; const defaultLineOddsForGame = (g) => { const leagueKey = normalizeLeagueKey(g?.league); if (typeof globalThis !== "undefined" && typeof globalThis.defaultLineOddsForLeague === "function") { return globalThis.defaultLineOddsForLeague(leagueKey); } return isFootballLeague(leagueKey) ? -100 : -110; }; const resolveMarketOddsForGame = (g, market, side) => { if (market === "moneyline") { const moneyline = side === "home" ? g?.moneyline?.home : g?.moneyline?.away; return Number.isFinite(moneyline) ? Number(moneyline) : null; } if (market === "spread") { if (isFootballLeague(g?.league)) return -100; const spreadOdds = side === "home" ? g?.homeSpreadOdds : g?.awaySpreadOdds; return Number.isFinite(spreadOdds) ? Number(spreadOdds) : defaultLineOddsForGame(g); } if (market === "total") { if (isFootballLeague(g?.league)) return -100; const totalOdds = side === "over" ? g?.overOdds : g?.underOdds; return Number.isFinite(totalOdds) ? Number(totalOdds) : defaultLineOddsForGame(g); } return null; }; const normalizeSubmittedOdds = (league, market, odds) => { const normalizedMarket = String(market || "").toLowerCase(); if (!Number.isFinite(Number(odds))) return odds; if (isFootballLeague(league) && (normalizedMarket === "spread" || normalizedMarket === "total")) { return -100; } return Number(odds); }; function selectionText(g, market, side) { if (market === "moneyline") return getTeamDisplayName(side === "home" ? g.homeTeam : g.awayTeam, { leagueKey: g.league }); if (market === "spread") { const line = side === "home" ? g.homeSpread : g.awaySpread; return `${getTeamDisplayName(side === "home" ? g.homeTeam : g.awayTeam, { leagueKey: g.league })} ${line != null ? (line > 0 ? `+${line}` : `${line}`) : ""}`.trim(); } if (market === "total") return `${getTeamDisplayName(g.awayTeam, { leagueKey: g.league })} @ ${getTeamDisplayName(g.homeTeam, { leagueKey: g.league })} ${side === "over" ? "Over" : "Under"} ${g.overUnder ?? ""}`.trim(); return ""; } async function openBet(g, market, side) { if (!(await ensureNotOnCreditHold({ refresh: true }))) return; if (!hasReliableOdds(g)) { alert("Betting is disabled until reliable odds are available for this game."); return; } const isLive = g.start.getTime() <= Date.now(); let odds = resolveMarketOddsForGame(g, market, side), line = null, total = null; if (market === "spread") { const spreadLine = side === "home" ? g.homeSpread : g.awaySpread; line = Number.isFinite(spreadLine) ? Number(spreadLine) : null; } else if (market === "total") { total = Number.isFinite(g.overUnder) ? Number(g.overUnder) : null; } setBet({ game: g, market, side, selection: selectionText(g, market, side), stake: 100, odds, line, total, isLive, }); setDrawerOpen(true); } async function openLiveProposal(g) { if (!(await ensureNotOnCreditHold({ refresh: true }))) return; if (!hasReliableOdds(g)) { alert("Betting is disabled until reliable odds are available for this game."); return; } const market = "moneyline"; const homeMoneyline = Number.isFinite(g?.moneyline?.home) ? Number(g.moneyline.home) : null; const awayMoneyline = Number.isFinite(g?.moneyline?.away) ? Number(g.moneyline.away) : null; const side = homeMoneyline != null ? "home" : "away"; const odds = side === "home" ? homeMoneyline : awayMoneyline; setBet({ game: g, market, side, selection: selectionText(g, market, side), stake: 100, odds, line: null, total: null, isLive: true, }); setDrawerOpen(true); } function isCancelable(p) { const status = normalizeOutcome(p.status || p.state); const liveish = status && ["in_progress", "live", "active"].includes(status); if (liveish) return false; // Not cancelable if graded/closed if (status && CLOSED_STATUSES.has(status)) return false; const outcome = normalizeOutcome(p.result || p.outcome); if (outcome && CLOSED_STATUSES.has(outcome)) return false; // Not cancelable once game has started if (hasPickStarted(p)) return false; // Otherwise still open/pending and not started -> cancelable return true; } async function addCurrentBetToParlay() { if (!(await ensureNotOnCreditHold({ refresh: true }))) return; if (!bet) return; const { stake, ...leg } = bet; // Block duplicate bets: same game + same market + same selection (moneyline + over on same game is fine) const duplicate = parlayLegs.find((l) => String(l.event || "").trim() === String(leg.event || "").trim() && String(l.market || "").trim() === String(leg.market || "").trim() && String(l.selection || "").trim().toLowerCase() === String(leg.selection || "").trim().toLowerCase() ); if (duplicate) { alert(`That exact bet is already in your parlay. You can combine different markets from the same game (e.g. moneyline + over).`); return; } const odds = leg.market === "moneyline" ? (leg.odds ?? "") : -100; addLegToParlay({ ...leg, odds }); setDrawerOpen(false); setView("parlay"); } async function placeBet() { if (!token) { setJoinVisible(true); return; } if (!(await ensureNotOnCreditHold({ refresh: true }))) return; const g = bet.game; if (!hasReliableOdds(g)) { alert("Betting is disabled until reliable odds are available for this game."); return; } const start = g.start instanceof Date ? g.start : new Date(g.start); const eventStr = `${getTeamDisplayName(g.awayTeam, { leagueKey: g.league })} @ ${getTeamDisplayName(g.homeTeam, { leagueKey: g.league })} ${start.toLocaleString()}`; let line = bet.line != null ? Number(bet.line) : undefined; let total = bet.total != null ? Number(bet.total) : undefined; let odds = bet.odds != null ? Number(bet.odds) : undefined; if (!bet.isLive) { if (bet.market === "moneyline" && odds == null) { odds = bet.side === "home" ? g.moneyline?.home : g.moneyline?.away; if (!Number.isFinite(odds)) odds = null; } else if (bet.market === "spread" && line == null) { line = bet.side === "home" ? g.homeSpread : g.awaySpread; if (!Number.isFinite(line)) line = null; } else if (bet.market === "total" && total == null) { total = g.overUnder; if (!Number.isFinite(total)) total = null; } } if (bet.isLive) { if (bet.market === "moneyline" && odds == null) { alert("Enter odds"); return; } if (bet.market === "spread" && (line == null || odds == null)) { alert("Enter spread and odds"); return; } if (bet.market === "total" && (total == null || odds == null)) { alert("Enter total and odds"); return; } } const normalizedOdds = normalizeSubmittedOdds(g?.league, bet.market, odds); const body = { league: (g.league || "").toUpperCase(), event: eventStr, selection: bet.selection, stake: Number(bet.stake), market: bet.market, side: bet.side ? bet.side.toUpperCase() : undefined, ...(normalizedOdds != null ? { odds: normalizedOdds } : {}), ...(line != null ? { line } : {}), ...(total != null ? { total } : {}), }; if (!Number.isFinite(body.stake) || body.stake <= 0) { alert("Enter a valid stake greater than 0."); return; } if (Number.isFinite(Number(normalizedOdds))) { const potentialPayout = typeof moneylineProfit === "function" ? moneylineProfit(body.stake, Number(normalizedOdds)) : (Number(normalizedOdds) >= 0 ? body.stake * (Number(normalizedOdds) / 100) : body.stake * (100 / Math.abs(Number(normalizedOdds)))); const capped = capStakeForMaxPayout(body.stake, potentialPayout); if (capped.adjusted) { body.stake = Number(capped.stake.toFixed(2)); setBet((prev) => (prev ? { ...prev, stake: body.stake } : prev)); alert(payoutCapMessage(body.stake)); } } if (!ensureWithinMaxBet(body.stake, "bet")) return; let r, j; const liveApprovalRequired = bet.isLive && oddsMode.onRequestEnabled && localStorage.getItem("bb_require_live_approval") !== "0"; try { const directLiveWager = bet.isLive && oddsMode.onRequestEnabled && !liveApprovalRequired; const endpoint = directLiveWager ? `${apiBase}/picks` : (bet.isLive ? `${apiBase}/picks/proposals` : `${apiBase}/picks`); r = await fetch(withCode(endpoint), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(body), }); } catch { r = null; } if (!r) { j = { ok: false, error: "No response from server" }; } else { const txt = await r.text().catch(() => ""); try { j = JSON.parse(txt); } catch { j = { ok: false, error: txt || `HTTP ${r.status} ${r.statusText}` }; } } if (j.ok) { setDrawerOpen(false); setBet(null); loadMyPicks(); if (!bet.isLive && view === "board") loadBoard(); if (!bet.isLive || oddsMode.onRequestEnabled) { try { (window.notifyNewPick || (() => {}))({ id: j.id, ...body }); } catch {} } else { try { await notifyLiveBet("proposed", { id: j.id, ...body }); } catch {} } setAccountRestrictionNotice(""); const successMessage = bet.isLive ? (liveApprovalRequired ? "Submitted for approval." : (oddsMode.onRequestEnabled ? "Live wager placed!" : "Live bet proposal submitted")) : "Bet placed!"; alert(successMessage); } else { const errMsg = j.error || j.detail || j.message || "Failed to place bet"; if (r?.status === 403) setAccountRestrictionNotice(String(errMsg)); alert(errMsg); console.error("Pick POST failed", { status: r?.status, body, response: j }); } } async function placeParlay() { if (!token) { setJoinVisible(true); return; } if (!(await ensureNotOnCreditHold({ refresh: true }))) return; const legs = parlayLegs.map((l) => { const g = l.game; const start = g.start instanceof Date ? g.start : new Date(g.start); const event = `${getTeamDisplayName(g.awayTeam, { leagueKey: g.league })} @ ${getTeamDisplayName(g.homeTeam, { leagueKey: g.league })} ${start.toLocaleString()}`; let line = l.line != null ? Number(l.line) : undefined; let total = l.total != null ? Number(l.total) : undefined; let odds = l.odds != null ? Number(l.odds) : undefined; if (l.market === "moneyline" && odds == null) { odds = l.side === "home" ? g.moneyline?.home : g.moneyline?.away; if (!Number.isFinite(odds)) odds = null; } else if (l.market === "spread" && line == null) { line = l.side === "home" ? g.homeSpread : g.awaySpread; if (!Number.isFinite(line)) line = null; } else if (l.market === "total" && total == null) { total = g.overUnder; if (!Number.isFinite(total)) total = null; } if (odds != null) odds = normalizeSubmittedOdds(g?.league, l.market, odds); return { league: (g.league || "").toUpperCase(), event, selection: l.selection, market: l.market, side: l.side ? l.side.toUpperCase() : undefined, ...(odds != null ? { odds } : {}), ...(line != null ? { line } : {}), ...(total != null ? { total } : {}), }; }); const stake = Number(parlayStake); if (!Number.isFinite(stake) || stake <= 0) { alert("Enter a valid parlay stake greater than 0."); return; } let finalStake = stake; const legOdds = legs.map((l) => Number(l.odds)); const potentialPayout = typeof calculateParlayProfit === 'function' ? calculateParlayProfit(stake, legOdds) : parlayProfit; const capped = capStakeForMaxPayout(stake, potentialPayout); if (capped.adjusted) { finalStake = Number(capped.stake.toFixed(2)); setParlayStake(String(finalStake)); alert(payoutCapMessage(finalStake)); } if (!ensureWithinMaxBet(finalStake, "parlay")) return; let createdId = null; try { createdId = await createParlay({ apiBase, token, stake: finalStake, legs, withCode }); } catch (e) { console.error("Parlay POST failed", e); const errMsg = e?.message || "Failed to place parlay"; if (/hold|threshold|max bet|insufficient bankroll|blocked/i.test(String(errMsg))) { setAccountRestrictionNotice(String(errMsg)); } alert(errMsg); return; } await loadMyPicks(); if (view === "board") await loadBoard(); // Compute combined odds and potential for preview let combinedOdds = null; try { const allOdds = legs.map((l) => l.odds).filter((o) => o !== null && o !== undefined && o !== ""); const total = typeof parlayTotalOdds === 'function' ? parlayTotalOdds(allOdds) : null; combinedOdds = typeof signOdds === "function" && total != null ? `${signOdds(Number(total))}` : total != null ? String(total) : null; } catch (e) { combinedOdds = null; } const potential = (typeof calculateParlayProfit === 'function') ? calculateParlayProfit(finalStake, legs.map(l => Number(l.odds))) : parlayProfit; setParlayLegs([]); setParlayStake(""); setLastParlayPreview({ id: createdId, stake: finalStake, legs, combinedOdds, potential }); setView("mine"); setAccountRestrictionNotice(""); try { (window.notifyNewPick || (() => {}))({ id: createdId, stake: finalStake, legs }); } catch {} alert("Parlay placed!"); } async function removePick(p) { if (!token) return; if (!isCancelable(p)) { alert("Bets cannot be removed once a game has started."); return; } if (!confirm("Remove this pending bet?")) return; const r = await fetch(withCode(`${apiBase}/picks/${encodeURIComponent(p.id)}`), { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); const j = await r.json().catch(() => ({ ok: false })); if (j.ok) { await loadMyPicks(); if (view === "board") await loadBoard(); alert("Bet removed."); } else { alert(j.error || "Failed to remove bet"); } } const autoResolve = useCallback( async (p, result, finalScore) => { await adminEditPick(p, { result, final_score: finalScore }); try { const message = `${p.selection || "Bet"} ${result.toUpperCase()} ${finalScore ? `- ${finalScore}` : ""}`; await fetch(withCode(`${apiBase}/admin/notify/push/test`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ message }), }); } catch (e) { console.error("Push notify failed", e); } }, [adminEditPick, apiBase, token, withCode] ); async function assignEmoji(p) { const emoji = prompt("React to bet", p.emoji || ""); if (!emoji) return; const key = pickKey(p); setMyPicks((m) => m.map((x) => (pickKey(x) === key ? { ...x, emoji } : x))); try { await fetch(withCode(`${apiBase}/notify/push/emoji`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ handle: me?.handle || "", emoji, bet: p.selection || "parlay", }), }); } catch (e) { console.error("emoji push failed", e); } } async function reactToGame(g) { const emoji = prompt("React to game", ""); if (!emoji) return; try { await fetch(withCode(`${apiBase}/notify/push/emoji`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ handle: me?.handle || "", emoji, bet: `${getTeamDisplayName(g.awayTeam, { leagueKey: g.league })} @ ${getTeamDisplayName(g.homeTeam, { leagueKey: g.league })}`, }), }); } catch (e) { console.error("emoji push failed", e); } } async function reactToBalance(row) { const emoji = prompt("React to balance", ""); if (!emoji) return; try { await fetch(withCode(`${apiBase}/notify/push/emoji`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ handle: me?.handle || "", emoji, bet: `${row.handle} balance`, }), }); } catch (e) { console.error("emoji push failed", e); } } // Auto-grade loop (admin only): poll every ~60s useEffect(() => { if (!isAdmin) return; let stop = false; async function tick() { await adminAutoGradeAll(); if (!stop) setTimeout(tick, 60_000); } tick(); return () => { stop = true; }; }, [isAdmin, adminAutoGradeAll]); useEffect(() => { if (view === "admin" && isAdmin) loadAdmin(); }, [view, isAdmin, loadAdmin]); const openPicks = useMemo(() => { const picks = myPicks.filter((p) => { const status = normalizeOutcome(p.status || p.state); if (status) { if (OPENISH_STATUSES.has(status)) return true; if (CLOSED_STATUSES.has(status)) return false; } const outcome = normalizeOutcome(p.result || p.outcome); if (outcome && CLOSED_STATUSES.has(outcome)) return false; if (typeof p.settled === "boolean") return !p.settled; return !(p.result || p.outcome); }); const toTs = (p) => { const raw = p?.createdAt || p?.created_at || p?.start || 0; const t = new Date(raw).getTime(); return Number.isFinite(t) ? t : 0; }; picks.sort((a, b) => toTs(b) - toTs(a)); return picks; }, [myPicks]); const pendingAmt = openPicks.reduce((s, p) => s + amountOrZero(p.stake), 0); const overdueCount = openPicks.reduce((s, p) => s + (hasPickEnded(p) ? 1 : 0), 0); const overdueAmt = openPicks.reduce( (s, p) => s + (hasPickEnded(p) ? amountOrZero(p.stake) : 0), 0 ); const balanceValue = useMemo(() => { if (myBal.value == null) return null; return Number(myBal.value); }, [myBal.value]); const stakeTrend = useMemo(() => { const DAY_MS = 24 * 60 * 60 * 1000; const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const start = new Date(today); start.setDate(today.getDate() - (summaryDays - 1)); const totals = new Map(); for (const pick of summaryPicks) { const rawDate = pick?.placed_at || pick?.placedAt || pick?.at || pick?.created_at || pick?.createdAt || Date.now(); const placedAt = new Date(rawDate); const ts = placedAt.getTime(); if (!Number.isFinite(ts)) continue; const day = new Date(placedAt.getFullYear(), placedAt.getMonth(), placedAt.getDate()); if (day < start || day > today) continue; const stakeValue = Number(pick.stake || 0); if (!Number.isFinite(stakeValue) || stakeValue <= 0) continue; const key = fmtDate(day); totals.set(key, (totals.get(key) || 0) + stakeValue); } if (totals.size === 0) return []; const result = []; for (let offset = 0; offset < summaryDays; offset += 1) { const day = new Date(start.getTime() + offset * DAY_MS); const key = fmtDate(day); result.push({ date: key, total: totals.get(key) || 0 }); } return result; }, [summaryPicks, summaryDays]); const summaryByDay = useMemo(() => { const today = new Date(); const dayKeys = []; const totals = new Map(); for (let i = 0; i < summaryDays; i++) { const d = new Date(today); d.setDate(today.getDate() - i); const key = fmtDate(d); dayKeys.push(key); totals.set(key, { total: 0, picks: [] }); } const toDate = (value) => { if (!value) return null; const d = value instanceof Date ? value : new Date(value); return isNaN(d) ? null : d; }; const pickEventDate = (p) => { if (Array.isArray(p?.legs) && p.legs.length > 0) { const legDates = p.legs .map((leg) => { const parsed = parsePickStartTime(leg); if (parsed && !isNaN(parsed)) return parsed; return ( toDate(leg.start || leg.kickoff || leg.start_time || leg.starts_at || leg.game_time) || toDate(leg.createdAt || leg.created_at) ); }) .filter(Boolean); if (legDates.length > 0) { return new Date(Math.min(...legDates.map((d) => d.getTime()))); } } const start = parsePickStartTime(p); if (start && !isNaN(start)) return start; return ( toDate(p.start || p.kickoff || p.start_time || p.starts_at || p.game_time) || toDate(p.event_date) || toDate(p.createdAt || p.created_at || p.placed_at || p.at) ); }; for (const p of summaryPicks) { const outcome = resolvePickOutcome(p); if (!outcome || !CLOSED_STATUSES.has(outcome)) continue; const eventDate = pickEventDate(p); if (!eventDate) continue; const key = fmtDate(eventDate); const bucket = totals.get(key); if (!bucket) continue; const ts = eventDate.getTime(); bucket.picks.push({ pick: p, ts: Number.isFinite(ts) ? ts : NaN }); bucket.total += profitOnly(p); } const summary = dayKeys.map((date) => { const bucket = totals.get(date) || { total: 0, picks: [] }; const sortedPicks = bucket.picks .slice() .sort((a, b) => { const ta = Number.isFinite(a.ts) ? a.ts : -Infinity; const tb = Number.isFinite(b.ts) ? b.ts : -Infinity; if (ta === tb) return 0; return tb - ta; }) .map((entry) => entry.pick); return { date, total: bucket.total, picks: sortedPicks }; }); summary.sort((a, b) => b.date.localeCompare(a.date)); return summary; }, [summaryPicks, summaryDays]); const summaryStats = useMemo(() => { return summaryByDay.reduce( (acc, day) => { acc.count += day.picks.length; acc.profit += day.total; return acc; }, { count: 0, profit: 0 } ); }, [summaryByDay]); const [openBetsCollapsed, setOpenBetsCollapsed] = useState(true); const [last30Collapsed, setLast30Collapsed] = useState(true); const [expandedDays, setExpandedDays] = useState(() => new Set()); const toggleOpenBets = useCallback(() => { setOpenBetsCollapsed((prev) => !prev); }, []); const toggleLast30Summary = useCallback(() => { setLast30Collapsed((prev) => !prev); }, []); const toggleDay = useCallback((date) => { setExpandedDays((prev) => { const next = new Set(prev); if (next.has(date)) next.delete(date); else next.add(date); return next; }); }, []); useEffect(() => { setExpandedDays(new Set()); }, [summaryDays, summaryBettor]); const authCtaText = !me ? (token ? "Sign In" : "Join / Sign In") : ""; const goSummary = () => isAuthed ? setView("summary") : setJoinVisible(true); const goBoard = () => isAuthed ? setView("board") : setJoinVisible(true); const goBookie = () => isAuthed && isAdmin ? setView("bookie") : setJoinVisible(true); const goAdmin = () => isAuthed && isAdmin ? setView("admin") : setJoinVisible(true); const goPush = () => isAuthed && isAdmin ? setView("notify") : setJoinVisible(true); const isV = (v) => view === v ? "app-v2-mobile-nav-btn--active" : "app-v2-mobile-nav-btn--inactive"; const renderMobileNavIcon = (kind) => { if (kind === "games") { return ( ); } if (kind === "parlay") { return ( ); } if (kind === "summary") { return ( ); } if (kind === "board") { return ( ); } if (kind === "admin") { return ( ); } return ( ); }; const filteredGames = useMemo(() => { const q = query.trim().toLowerCase(); const byState = games.filter((g) => { const started = g.start.getTime() <= Date.now(); const done = !!g?.status?.completed; if (oddsView === "live") return started && !done; if (oddsView === "upcoming") return !started && !done; return true; }); if (!q) return byState; const tokens = q.split(/\s+/).filter(Boolean); return byState.filter(g => { const hay = `${g.homeTeam} ${g.awayTeam}`.toLowerCase(); return tokens.every(t => hay.includes(t)); }); }, [games, query, oddsView]); const hasGamesForSelection = games.length > 0; const showHeaderThemeIcon = !!activeThemeBrand && hasGamesForSelection; const showCountNote = query.trim().length > 0; const jumpToBoardPick = useCallback((pickKeyValue) => { if (!isAuthed) { setJoinVisible(true); return; } if (!pickKeyValue) { setView("board"); return; } if (me?.handle) { setExpandedHandles((prev) => { const next = new Set(prev); next.add(me.handle); return next; }); } setBoardFocusPickKey(String(pickKeyValue)); setView("board"); }, [isAuthed, me?.handle]); const openPickTeamEntries = useMemo(() => { const rows = []; for (const pick of openPicks) { const pickRef = pickKey(pick); if (Array.isArray(pick?.legs) && pick.legs.length > 0) { for (const leg of pick.legs) { const teams = parsePickTeams({ event: leg.event, matchup: leg.event, selection: leg.selection }); if (!teams.away || !teams.home) continue; rows.push({ pickRef, league: normalizeLeagueKey(leg.league || pick.league), away: teams.away, home: teams.home, market: normalizeMarket(leg.market), stake: pick.stake, isParlayLeg: true, }); } continue; } const teams = parsePickTeams({ event: pick.event, matchup: pick.matchup, selection: pick.selection }); if (!teams.away || !teams.home) continue; rows.push({ pickRef, league: normalizeLeagueKey(pick.league), away: teams.away, home: teams.home, market: normalizeMarket(pick.market), stake: pick.stake, isParlayLeg: false, }); } return rows; }, [openPicks]); const openBetMetaByGame = useMemo(() => { const out = new Map(); for (const g of games) { const gameLeague = normalizeLeagueKey(g.league); const meta = { totalOpenBets: 0, totalStake: 0, marketCounts: { moneyline: 0, spread: 0, total: 0, other: 0 }, parlayLegCount: 0, pickRefs: [], }; for (const entry of openPickTeamEntries) { if (!entry.league || entry.league !== gameLeague) continue; const sameOrder = teamsRoughMatch(entry.away, g.awayTeam) && teamsRoughMatch(entry.home, g.homeTeam); const flippedOrder = teamsRoughMatch(entry.away, g.homeTeam) && teamsRoughMatch(entry.home, g.awayTeam); if (!sameOrder && !flippedOrder) continue; meta.totalOpenBets += 1; meta.totalStake += amountOrZero(entry.stake); if (entry.pickRef && !meta.pickRefs.includes(entry.pickRef)) meta.pickRefs.push(entry.pickRef); if (entry.market === "moneyline") meta.marketCounts.moneyline += 1; else if (entry.market === "spread") meta.marketCounts.spread += 1; else if (entry.market === "total") meta.marketCounts.total += 1; else meta.marketCounts.other += 1; if (entry.isParlayLeg) meta.parlayLegCount += 1; } if (meta.totalOpenBets > 0) { out.set(g.id, meta); } } return out; }, [games, openPickTeamEntries]); useEffect(() => { if (view !== "board" || !boardFocusPickKey) return; const targetId = `board-pick-${boardFocusPickKey}`; const el = document.getElementById(targetId); if (!el) return; el.scrollIntoView({ behavior: "smooth", block: "center" }); el.classList.remove("app-v2-betrow-focus"); // Reflow so repeated focus jumps replay animation. void el.offsetWidth; el.classList.add("app-v2-betrow-focus"); const timeoutId = setTimeout(() => el.classList.remove("app-v2-betrow-focus"), 1800); return () => clearTimeout(timeoutId); }, [view, board.rows, boardFocusPickKey]); // ======== Admin push helpers ======== async function adminSendTestPush() { if (!isAdmin) { alert("Admin only"); return; } setPushForm(f => ({ ...f, busy: true, result: "" })); try { const r = await fetch(withCode(`${apiBase}/admin/notify/push/test`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ message: pushForm.message || "Blackbook - test" }), }); const j = await r.json().catch(() => ({})); if (!r.ok || j.ok === false) throw new Error(j.error || `HTTP ${r.status}`); setPushForm(f => ({ ...f, result: JSON.stringify(j, null, 2) })); alert("Sent (queued)."); } catch (e) { setPushForm(f => ({ ...f, result: String(e.message || e) })); alert("Send failed: " + (e.message || e)); } finally { setPushForm(f => ({ ...f, busy: false })); } } // Broadcast: daily summary async function adminBlastDailySummary() { if (!isAdmin) { alert("Admin only"); return; } setPushForm(f => ({ ...f, busy: true, result: "" })); try { const r = await fetch(withCode(`${apiBase}/admin/notify/push/open-bets`), { method: "POST", headers: { Authorization: `Bearer ${token}` }, }); const j = await r.json().catch(() => ({})); if (!r.ok || j.ok === false) throw new Error(j.error || `HTTP ${r.status}`); setPushForm(f => ({ ...f, result: JSON.stringify(j, null, 2) })); alert("Daily summary broadcast queued."); } catch (e) { setPushForm(f => ({ ...f, result: String(e.message || e) })); alert("Broadcast failed: " + (e.message || e)); } finally { setPushForm(f => ({ ...f, busy: false })); } } // Load daily schedule config - tolerant of 404/405 and non-JSON async function adminLoadDailySummaryConfig() { if (!isAdmin) return; try { const r = await fetch(withCode(`${apiBase}/admin/notify/push/daily`), { headers: { Authorization: `Bearer ${token}` }, }); if (!r.ok) { if (r.status === 404 || r.status === 405) { setPushForm(f => ({ ...f, supports_schedule: false })); return; } throw new Error(`HTTP ${r.status}`); } const ct = (r.headers.get("content-type") || "").toLowerCase(); if (!ct.includes("application/json")) { setPushForm(f => ({ ...f, supports_schedule: false })); return; } const j = await r.json(); const hh = String(j.hour ?? 8).padStart(2, "0"); const mm = String(j.minute ?? 0).padStart(2, "0"); setPushForm(f => ({ ...f, supports_schedule: true, daily_enabled: !!j.enabled, daily_time: `${hh}:${mm}`, daily_tz: j.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || "America/New_York", })); } catch { setPushForm(f => ({ ...f, supports_schedule: false })); } } // Save daily schedule - warn if unsupported async function adminSaveDailySummaryConfig() { if (!isAdmin) return; setPushForm(f => ({ ...f, busy: true })); try { const [hh = "08", mm = "00"] = (pushForm.daily_time || "08:00").split(":"); const payload = { enabled: !!pushForm.daily_enabled, hour: parseInt(hh, 10) || 0, minute: parseInt(mm, 10) || 0, tz: pushForm.daily_tz || Intl.DateTimeFormat().resolvedOptions().timeZone || "America/New_York", }; const r = await fetch(withCode(`${apiBase}/admin/notify/push/daily`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(payload), }); if (r.status === 404 || r.status === 405) { alert("Your server doesn't support Daily Summary scheduling yet. Update the API first."); return; } const j = await r.json().catch(() => ({})); if (!r.ok || j.ok === false) { throw new Error((j && (j.error || j.detail)) || `HTTP ${r.status}`); } alert("Daily summary schedule saved."); } catch (e) { alert("Save failed: " + (e.message || e)); } finally { setPushForm(f => ({ ...f, busy: false })); } } // Load schedule when Push Tools is opened useEffect(() => { if (view === "notify" && isAdmin) { adminLoadDailySummaryConfig(); } }, [view, isAdmin]); const primaryNavItems = useMemo(() => [ { key: "games", label: "Games", action: () => setView("games") }, { key: "parlay", label: "Parlay", action: () => setView("parlay") }, { key: "summary", label: "Summary", action: goSummary }, { key: "board", label: "Board", action: goBoard }, ], [goSummary, goBoard]); const navItems = useMemo(() => { const items = [...primaryNavItems]; if (isAdmin) { items.push( { key: "bookie", label: "Bookie", action: goBookie }, { key: "admin", label: "Admin", action: goAdmin }, { key: "notify", label: "Push Tools", action: goPush }, ); } return items; }, [isAdmin, primaryNavItems, goBookie, goAdmin, goPush]); function GameCard({ g }) { const score = useLiveScore(g.league, `${g.awayTeam} @ ${g.homeTeam}`, g.start); const awayScore = score?.awayScore ?? g.awayScore; const homeScore = score?.homeScore ?? g.homeScore; const completed = score?.completed ?? g.status?.completed; const fmt = (n) => (n == null ? "-" : n > 0 ? `+${n}` : `${n}`); const statusLabel = completed ? "Final" : g.start.getTime() <= Date.now() ? "Live" : "Upcoming"; const dataSourceLabel = g.source === "espn" ? "ESPN" : (g.book || "Odds"); const betMeta = openBetMetaByGame.get(g.id) || null; const requestKey = g?.id || `${g?.awayTeam || "away"}@${g?.homeTeam || "home"}`; const requestState = oddsRequestByGame[requestKey] || null; const liveCheckMode = oddsMode.onRequestEnabled && isLiveGame(g) && !hasReliableOdds(g); const noLiveOddsAvailable = liveCheckMode && !!requestState?.unavailable; const liveButtonLabel = me?.credit_hold ? "Credit Hold Active" : liveCheckMode ? (noLiveOddsAvailable ? "No Live Odds Available" : "Check Live Odds") : (oddsMode.onRequestEnabled ? "Place Live Wager" : "Propose Live Bet"); const liveButtonAction = liveCheckMode ? () => requestLiveOddsForGame(g) : () => openLiveProposal(g); const liveButtonBusy = !!requestState?.loading; const liveButtonDisabled = liveCheckMode ? (!!me?.credit_hold || liveButtonBusy) : (!canBet(g) || liveButtonBusy); const showScore = awayScore != null && homeScore != null && (g.start.getTime() <= Date.now() || completed); return (
@
{new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(g.start)}
{showScore && (
{awayScore} - {homeScore}
)} {statusLabel} {dataSourceLabel}
{betMeta && ( )}
{isLiveGame(g) ? ( ) : (
{oddsMode.onRequestEnabled && requestState?.note && (
{requestState.note}
)}
Spread
Total
Moneyline
)}
); } // ======== RENDER ======== return (
{upgradeNotice && (
Update available {upgradeNotice.message}
)} {/* Header */}
Blackbook sportsbook logo {showHeaderThemeIcon && ( )}
{showHeaderThemeIcon && (
Theme {activeThemeBrand.label}
)}
Live Product BLACKBOOK SPORTSBOOK
{/* Compact More dropdown for desktop to reduce header clutter */}
{menuOpen && (
{deferredPrompt && ( )} {isAuthed && ( )}
{me ? ( <>
Hi, {me.handle}{isAdmin ? admin : null}
) : ( )}
)}
{menuOpen && (
Live Product BLACKBOOK SPORTSBOOK
{deferredPrompt && ( )} {isAuthed && ( )} {me ? ( <>
Hi, {me.handle} {isAdmin && admin}
) : ( )}
)} {isAuthed && (me?.credit_hold || accountRestrictionNotice) && (
Account restriction
{me?.credit_hold_message || accountRestrictionNotice || CREDIT_HOLD_DEFAULT_MESSAGE}
)} {/* ======== VIEWS ======== */}
{view === "games" ? ( <>
{isAuthed && ( )}
{dayLabel}
{hasGamesForSelection && }
{/* Search bar */}
setQuery(e.target.value)} /> {query && ( )}
{showCountNote && (
Showing {filteredGames.length} of {games.length}
)}
{(loading || leagueAvailabilityLoading) &&
Loading games...
} {error && !(leagueAvailabilityLoading && /No games available for this day\.?/i.test(String(error))) &&
{error}
} {!loading && !leagueAvailabilityLoading && !error && games.length === 0 && (
-
No {league.toUpperCase()} lines available for this date
Try another day or refresh for updated odds.
)} {!loading && !error && games.length > 0 && filteredGames.length === 0 && query.trim() && (
-
No games match "{query.trim()}"
Try another search or switch league/date filters.
)}
{filteredGames.map((g) => ( ))}
) : view === "parlay" ? (

Parlay Builder

Moneyline odds in American format (e.g., +150, -110). Spreads and totals default to -100.

{parlayLegs.length ? (
{parlayLegs.map((l, i) => (
{l.selection}
{l.market === "moneyline" && (
{l.odds != null ? signOdds(Number(l.odds)) : "ML"}
)}
))}
) : (
No legs added. Add at least two bets from the Games view.
)}
{parlayLegs.length === 1 && (
Add one more leg to submit your parlay.
)}
Profit: {dollars(parlayProfit)}
) : view === "summary" ? ( <> {/* Balance */}
My Balance
{myBal.loading ? "..." : balanceValue == null ? "-" : dollars(balanceValue)}
{myBal.error &&
{myBal.error}
}
Updated after wins and losses

My Open Bets

Pending: {dollars(pendingAmt)} {overdueCount > 0 && ( ({overdueCount} overdue, {dollars(overdueAmt)}) )}
{openBetsCollapsed ? (
{openPicks.length === 0 ? "You have no open bets." : "Expand to view your open bets."}
) : (
{openPicks.length === 0 &&
You have no open bets.
} {openPicks.map((p) => ( removePick(p) : undefined} onReact={() => assignEmoji(p)} onAutoResolve={autoResolve} /> ))}
)}

{summaryDaysLabel}

{summaryIsLoading ? ( `Loading ${summaryBettorLabel} performance...` ) : summaryStats.count === 0 ? ( `No settled bets for ${summaryBettorLabel} in the last ${summaryDays} days.` ) : ( <> {summaryStats.count} settled bets |{" "} = 0 ? "text-green-600" : "text-red-600" }`} > {dollars(summaryStats.profit)} {" "} net | {summaryBettorLabel} )}
{last30Collapsed ? (
{summaryIsLoading ? "Loading bettor activity..." : summaryStats.count === 0 ? "Nothing to review yet." : "Expand to review each day."}
) : summaryIsLoading ? (
Loading bettor activity...
) : summaryStats.count === 0 ? (
No settled bets recorded yet.
) : (
{summaryByDay.map((d) => ( toggleDay(d.date)} > {expandedDays.has(d.date) && ( )} ))}
Date Result React
{d.date} {dollars(d.total)}
{d.picks.map((p) => { const lineDetail = formatPickLineDetail(p); return ( ); })}
Bet Result React
{p.selection || p.event || (Array.isArray(p.legs) && p.legs.length > 0 ? `Parlay (${p.legs.length} legs)` : "")} {Array.isArray(p.legs) && p.legs.length > 0 ? (
{p.legs.map((l, i) => formatLegDetail(l, { showResult: true })).join("\n")}
) : lineDetail ? (
{lineDetail}
) : null}
{p.result || p.outcome || p.status || ""} {dollars(profitOnly(p))}
)}

Stake trend ({summaryDaysLabel.toLowerCase()})

) : view === "bookie" ? ( isAdmin ? ( ) : (
Admin access required.
) ) : view === "board" ? ( isAuthed ? ( <>

Open Bets & Balances - All Bettors

{messageModal.open && (
{messageModal.mode === "single" && ( )} {messageModal.mode === "multiple" && ( )} {messageModal.mode !== "single" && (
{(messageModal.mode === "everyone") ? "Broadcast sends to every other user in this group." : `${Array.isArray(messageModal.targets) ? messageModal.targets.length : 0} user(s) selected.`}
)}

Broadcast Daily Summary (All Bettors)

{pushForm.supports_schedule && (
Auto-Send Daily
Server will send at the configured local time.
)} {pushForm.result && (
Result
{pushForm.result}
)}
) : (

Admin only

You must be an admin to view this page.

) ) : (
{lastParlayPreview ? ( <>

Last Parlay Placed

Stake: {dollars(lastParlayPreview.stake)}
Combined Odds: {lastParlayPreview.combinedOdds ?? '-'}
Potential To Win: {lastParlayPreview.potential != null ? dollars(lastParlayPreview.potential) : '-'}
) : ( <>

Not found

Choose a view from the navigation.

)}
)}
{/* Betting drawer */} {drawerOpen && typeof document !== "undefined" && ReactDOM.createPortal((
setDrawerOpen(false)} />

Place Wager

{bet && ( <>
@
{bet.selection} {bet.odds != null && ({signOdds(bet.odds)})}
{bet.isLive && ( <> {bet.market === "spread" && ( )} {bet.market === "total" && ( )} )} {bet.market === "moneyline" && (
To Win:{" "} {moneylineToWin != null ? ( {dollars(moneylineToWin)} ) : ( - )}
)}
Ready to submit {Number(bet.stake) > 0 ? dollars(Number(bet.stake)) : "$0.00"}

By placing a wager, you confirm this ticket and stake amount are final.

)}
), document.body)} { if (t) { const r = Array.isArray(roles) ? roles : roles ? [roles] : ["member"]; save(t); setMe({ handle, roles: r }); } setJoinVisible(false); }} /> setProfileVisible(false)} onHandleChange={(h) => setMe((m) => (m ? { ...m, handle: h } : m))} /> setSettingsVisible(false)} theme={theme} onThemeChange={setTheme} /> {viewPick && ( )} {/* MOBILE NAV */} {!drawerOpen && } {/* FOOTER DISCLAIMER */}

Blackbook is provided for entertainment purposes only. No real money is exchanged through this application; any monetary figures shown are illustrative and should not be relied upon for real-world wagering or financial decisions.

); } ReactDOM.createRoot(document.getElementById("root")).render(); // Optional: surface real app errors window.addEventListener("unhandledrejection", (e) => console.error("Unhandled promise rejection:", e.reason)); window.addEventListener("error", (e) => console.error("Window error:", e.error || e.message)); window.notifyNewPick = window.notifyNewPick || function () {};