const { useState, useRef, useEffect, useMemo, useCallback } = React; const MAX_BET_OVERRIDES_KEY = "bb_max_bet_overrides_v1"; const DISPLAY_DEFAULTS = { display_mode: "toggle_pages", scroll_speed: 18, brightness: 90, show_logos: true, show_graph: true, show_scores: true, show_open_bets: true, show_balance_strip: true, rotation: "Rotate:180", font_family: "Bebas Neue, Rajdhani, Inter, sans-serif", font_scale: 1, layouts: ["ticker", "scores", "balances"], logo_pack: "nfl", poll_seconds: 8, reaction_persist_seconds: 600, reaction_display_seconds: 20, theme: { title: "BLACKBOOK LIVE", accent: "#ff7a18", background: "#000000", text: "#f6f7ff", }, notifications: { reactions: true, payins: true, payouts: true, settlements: true, live_bets: true, }, motion: { main_page_hold_seconds: 1.22, marquee_scroll_speed: 18, card_scroll_speed: 18, wagers_scroll_speed: 18, results_scroll_speed: 18, board_scroll_speed: 18, reaction_footer_speed: 18, board_hold_seconds: 1.22, finals_hold_seconds: 1.22, }, }; const DISPLAY_PRESETS = [ { key: "sportsbar", label: "Sports Bar", patch: { display_mode: "toggle_pages", scroll_speed: 20, brightness: 95, font_family: "Bebas Neue, Rajdhani, Inter, sans-serif", font_scale: 1.1, theme: { title: "BLACKBOOK PRIME", accent: "#ff7a18", background: "#07070d", text: "#fff7ef" }, }, }, { key: "retro", label: "Retro Matrix", patch: { display_mode: "toggle_pages", scroll_speed: 16, brightness: 82, font_family: "Share Tech Mono, IBM Plex Mono, monospace", font_scale: 0.96, theme: { title: "BLACKBOOK CLASSIC", accent: "#ffb703", background: "#09060d", text: "#fff4d6" }, }, }, { key: "broadcast", label: "Broadcast", patch: { display_mode: "toggle_pages", scroll_speed: 14, brightness: 92, font_family: "Oswald, Rajdhani, sans-serif", font_scale: 1.04, theme: { title: "BLACKBOOK BROADCAST", accent: "#ff5ec4", background: "#090d17", text: "#fef7ff" }, }, }, ]; const DISPLAY_BRAND_PACKS = [ { key: "nfl", label: "NFL Prime", patch: { logo_pack: "nfl", theme: { title: "BLACKBOOK NFL", accent: "#ff7a18", background: "#05060a", text: "#fff8f3" }, show_logos: true, }, logos: ["/images/league-logos/nfl.png", "/images/league-logos/nfl.svg"], }, { key: "nba", label: "NBA Neon", patch: { logo_pack: "nba", theme: { title: "BLACKBOOK NBA", accent: "#ff5ec4", background: "#07080d", text: "#fff7ff" }, show_logos: true, }, logos: ["/images/league-logos/nba.png", "/images/league-logos/nba.svg"], }, { key: "mlb", label: "MLB Classic", patch: { logo_pack: "mlb", theme: { title: "BLACKBOOK MLB", accent: "#ffb703", background: "#09070b", text: "#fff4d6" }, show_logos: true, }, logos: ["/images/league-logos/mlb.png", "/images/league-logos/mlb.svg"], }, { key: "minimal", label: "Minimal Pro", patch: { logo_pack: "minimal", show_logos: false, theme: { title: "BLACKBOOK PRO", accent: "#ff7a18", background: "#05070b", text: "#f9fafb" }, }, logos: [], }, ]; const LEAGUE_LOGO_PATHS = { nfl: "/images/league-logos/nfl.png", nba: "/images/league-logos/nba.png", mlb: "/images/league-logos/mlb.png", nhl: "/images/league-logos/nhl.png", cfb: "/images/league-logos/cfb.svg", ncaab: "/images/league-logos/ncaab.svg", ufc: "/images/league-logos/ufc.png", }; const TEAM_LOGO_PATHS = { browns: "/images/team-logos/browns.png", buckeyes: "/images/team-logos/buckeyes.png", bulls: "/images/team-logos/bulls.png", chiefs: "/images/team-logos/chiefs.png", cowboys: "/images/team-logos/cowboys.png", eagles: "/images/team-logos/eagles.png", giants: "/images/team-logos/giants.png", lakers: "/images/team-logos/lakers.png", michigan: "/images/team-logos/michigan.png", packers: "/images/team-logos/packers.png", patriots: "/images/team-logos/patriots.png", raiders: "/images/team-logos/raiders.png", steelers: "/images/team-logos/steelers.png", yankees: "/images/team-logos/yankees.png", }; const TEAM_LOGO_ALIASES = { "new york giants": "giants", giants: "giants", "kansas city chiefs": "chiefs", chiefs: "chiefs", "philadelphia eagles": "eagles", eagles: "eagles", "green bay packers": "packers", packers: "packers", "new england patriots": "patriots", patriots: "patriots", "las vegas raiders": "raiders", "oakland raiders": "raiders", raiders: "raiders", "dallas cowboys": "cowboys", cowboys: "cowboys", "pittsburgh steelers": "steelers", steelers: "steelers", "cleveland browns": "browns", browns: "browns", "los angeles lakers": "lakers", lakers: "lakers", "chicago bulls": "bulls", bulls: "bulls", yankees: "yankees", "new york yankees": "yankees", michigan: "michigan", wolverines: "michigan", buckeyes: "buckeyes", "ohio state": "buckeyes", }; function deepMergeDisplay(base, patch) { const root = { ...(base || {}) }; const extra = patch || {}; const mergedTheme = { ...(root.theme || {}), ...(extra.theme || {}) }; const mergedMotion = { ...((root.motion && typeof root.motion === "object") ? root.motion : {}), ...((extra.motion && typeof extra.motion === "object") ? extra.motion : {}), }; const mergedNotifications = { ...((root.notifications && typeof root.notifications === "object") ? root.notifications : {}), ...((extra.notifications && typeof extra.notifications === "object") ? extra.notifications : {}), }; const out = { ...root, ...extra, theme: mergedTheme, motion: mergedMotion, notifications: mergedNotifications }; if (!Array.isArray(out.layouts) || out.layouts.length === 0) out.layouts = [...DISPLAY_DEFAULTS.layouts]; return out; } const SPEED_MIN = 1; const SPEED_MAX = 35; const SPEED_DEFAULT = 18; const PAGE_HOLD_MAX_SECONDS = 22.0; const PAGE_HOLD_MIN_SECONDS = 0.63; function clampDisplaySpeed(value, fallback = SPEED_DEFAULT) { const num = Number(value); if (!Number.isFinite(num)) return fallback; return Math.max(SPEED_MIN, Math.min(SPEED_MAX, Math.round(num))); } function pageSpeedToHoldSeconds(speedValue) { const speed = clampDisplaySpeed(speedValue); const hold = PAGE_HOLD_MAX_SECONDS / speed; return Math.max(PAGE_HOLD_MIN_SECONDS, Number(hold.toFixed(2))); } function holdSecondsToPageSpeed(holdSeconds) { const hold = Number(holdSeconds); if (!Number.isFinite(hold) || hold <= 0) return SPEED_DEFAULT; return clampDisplaySpeed(PAGE_HOLD_MAX_SECONDS / hold); } function readDisplaySpeedSettings(config) { const safe = config && typeof config === "object" ? config : {}; const motion = safe.motion && typeof safe.motion === "object" ? safe.motion : {}; const fallback = clampDisplaySpeed(safe.scroll_speed, SPEED_DEFAULT); const vertical = clampDisplaySpeed( motion.card_scroll_speed ?? motion.board_scroll_speed ?? motion.wagers_scroll_speed ?? motion.results_scroll_speed ?? fallback, fallback ); const horizontal = clampDisplaySpeed(motion.marquee_scroll_speed ?? motion.reaction_footer_speed ?? fallback, fallback); const page = holdSecondsToPageSpeed( motion.main_page_hold_seconds ?? motion.board_hold_seconds ?? motion.finals_hold_seconds ?? pageSpeedToHoldSeconds(fallback) ); return { vertical, horizontal, page }; } function applyDisplaySpeedSettings(config, speedPatch) { const current = readDisplaySpeedSettings(config); const vertical = clampDisplaySpeed(speedPatch?.vertical, current.vertical); const horizontal = clampDisplaySpeed(speedPatch?.horizontal, current.horizontal); const page = clampDisplaySpeed(speedPatch?.page, current.page); const holdSeconds = pageSpeedToHoldSeconds(page); const existingMotion = config?.motion && typeof config.motion === "object" ? config.motion : {}; return { ...(config || {}), scroll_speed: vertical, motion: { ...existingMotion, card_scroll_speed: vertical, wagers_scroll_speed: vertical, results_scroll_speed: vertical, board_scroll_speed: vertical, marquee_scroll_speed: horizontal, reaction_footer_speed: horizontal, main_page_hold_seconds: holdSeconds, board_hold_seconds: holdSeconds, finals_hold_seconds: holdSeconds, }, }; } function tickerScorelineFromPick(pick) { if (!pick || typeof pick !== "object") return ""; if (pick.final_score) return String(pick.final_score); if (pick.event) return String(pick.event); return ""; } function hexToRgb(hex, fallback = { r: 34, g: 197, b: 94 }) { const raw = String(hex || "").trim(); const normalized = raw.startsWith("#") ? raw.slice(1) : raw; if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return fallback; return { r: parseInt(normalized.slice(0, 2), 16), g: parseInt(normalized.slice(2, 4), 16), b: parseInt(normalized.slice(4, 6), 16), }; } function normalizeLogoToken(value) { return String(value || "") .toLowerCase() .replace(/[^a-z0-9\s]/g, " ") .replace(/\s+/g, " ") .trim(); } function extractTeamsFromEvent(eventText) { const raw = String(eventText || "").trim(); if (!raw) return []; const parts = raw.split(/\s+@\s+|\s+vs\.?\s+|\s+v\.?\s+/i).map((x) => x.trim()).filter(Boolean); return parts.slice(0, 2); } function stripDateNoise(label) { return String(label || "") .replace(/\b(?:mon|tue|wed|thu|fri|sat|sun)(?:day)?\b/gi, " ") .replace(/\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\b\.?/gi, " ") .replace(/\b\d{1,2}[:.]\d{2}(?:\s?[ap]m)?\b/gi, " ") .replace(/\b(?:et|est|edt|ct|cst|cdt|pt|pst|pdt|gmt|utc)\b/gi, " ") .replace(/\b\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?\b/g, " ") .replace(/\b\d{4}-\d{2}-\d{2}\b/g, " ") .replace(/[,\-]+$/g, " ") .replace(/\s{2,}/g, " ") .trim(); } function compactTeamTag(teamName) { const clean = stripDateNoise(teamName); if (!clean) return ""; const words = clean.split(/\s+/).filter(Boolean); if (words.length === 1) return words[0].slice(0, 3).toUpperCase(); const initials = words.map((w) => w[0]).join("").toUpperCase(); return initials.slice(0, 4); } function summarizeFinalsEvent(eventText) { const raw = String(eventText || "").trim(); if (!raw) return { matchup: "Final", secondTeam: "" }; const teams = extractTeamsFromEvent(raw); if (teams.length >= 2) { const first = stripDateNoise(teams[0]); const second = stripDateNoise(teams[1]); return { matchup: `${first.slice(0, 12)} @ ${second.slice(0, 12)}`, secondTeam: second.slice(0, 16), compactMatchup: `${compactTeamTag(first)}@${compactTeamTag(second)}`.replace(/^@|@$/g, ""), }; } return { matchup: stripDateNoise(raw).slice(0, 24), secondTeam: "", compactMatchup: "" }; } function summarizeWagerDetails(pick) { const market = String(pick?.market || "").trim().toUpperCase(); const oddsRaw = pick?.odds; const oddsNum = Number(oddsRaw); const odds = Number.isFinite(oddsNum) ? `${oddsNum > 0 ? "+" : ""}${Math.trunc(oddsNum)}` : (typeof oddsRaw === "string" && oddsRaw.trim() ? oddsRaw.trim() : ""); const stake = Number(pick?.stake || 0); const selection = String(pick?.selection || "Open bet").trim(); const marketShort = market === "MONEYLINE" ? "ML" : market === "SPREAD" ? "SPR" : market === "TOTAL" ? "TOT" : market; const bits = [selection]; if (marketShort) bits.push(marketShort); if (odds) bits.push(`ODDS ${odds}`); bits.push(`$${stake.toFixed(0)}`); return bits.join(" • "); } function summarizeWagerCompactDetails(pick) { const market = String(pick?.market || "").trim().toUpperCase(); const oddsRaw = pick?.odds; const oddsNum = Number(oddsRaw); const odds = Number.isFinite(oddsNum) ? `${oddsNum > 0 ? "+" : ""}${Math.trunc(oddsNum)}` : (typeof oddsRaw === "string" && oddsRaw.trim() ? oddsRaw.trim() : ""); const stake = Number(pick?.stake || 0); const selection = String(pick?.selection || "").toLowerCase(); let pickPart = ""; if (market === "TOTAL") { const side = selection.includes("under") ? "U" : selection.includes("over") ? "O" : "T"; const totalMatch = String(pick?.selection || "").match(/([0-9]+(?:\.[0-9]+)?)/); pickPart = `${side}${totalMatch ? totalMatch[1] : ""}`; } else if (market === "SPREAD") { const lineMatch = String(pick?.selection || "").match(/([+-]\d+(?:\.\d+)?)/); pickPart = lineMatch ? `S${lineMatch[1]}` : "SPR"; } else if (market === "MONEYLINE") { pickPart = "ML"; } else { pickPart = market.slice(0, 3) || "BET"; } return [pickPart, odds, `$${stake.toFixed(0)}`].filter(Boolean).join(" "); } function resolveLeagueLogoKey(league, logoPack) { const direct = normalizeLogoToken(league); if (direct && LEAGUE_LOGO_PATHS[direct]) return direct; const pack = normalizeLogoToken(logoPack); if (pack && LEAGUE_LOGO_PATHS[pack]) return pack; return "nfl"; } function resolveTeamLogoPath(teamName) { const normalized = normalizeLogoToken(teamName); if (!normalized) return ""; if (TEAM_LOGO_ALIASES[normalized]) { return TEAM_LOGO_PATHS[TEAM_LOGO_ALIASES[normalized]] || ""; } const tokens = normalized.split(" ").filter(Boolean); for (let i = tokens.length - 1; i >= 0; i -= 1) { const token = tokens[i]; if (TEAM_LOGO_ALIASES[token] && TEAM_LOGO_PATHS[TEAM_LOGO_ALIASES[token]]) { return TEAM_LOGO_PATHS[TEAM_LOGO_ALIASES[token]]; } } return ""; } function shortTeamLabel(teamName) { const normalized = normalizeLogoToken(teamName); if (!normalized) return "TM"; const parts = normalized.split(" ").filter(Boolean); if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return `${parts[0][0] || ""}${parts[parts.length - 1][0] || ""}`.toUpperCase(); } function parseMaxBetAmount(value) { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : null; } function parseHoldThresholdAmount(value) { if (value == null) return null; if (typeof value === "string" && !value.trim()) return null; const n = Number(value); return Number.isFinite(n) ? 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 writeMaxBetOverrides(map) { try { localStorage.setItem(MAX_BET_OVERRIDES_KEY, JSON.stringify(map || {})); } catch {} } 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 setMaxBetOverride(handle, amount) { const key = String(handle || "").trim().toLowerCase(); if (!key) return; const map = readMaxBetOverrides(); if (amount == null) { delete map[key]; } else { map[key] = Number(amount); } writeMaxBetOverrides(map); } function AdminDashboard({ admin, setAdmin, gradeOpenBets, loadAdmin, adminEditPick, adminApprove, adminReject, adminAdjust, adminSetHold, adminSetThreshold, apiBase, token, withCode, canInstall, triggerInstall, autoResolve, assignEmoji }) { const rawAdmin = admin && typeof admin === "object" ? admin : {}; admin = { ...rawAdmin, loading: !!rawAdmin.loading, error: String(rawAdmin.error || ""), users: Array.isArray(rawAdmin.users) ? rawAdmin.users : [], picks: Array.isArray(rawAdmin.picks) ? rawAdmin.picks : [], queue: Array.isArray(rawAdmin.queue) ? rawAdmin.queue : [], log: Array.isArray(rawAdmin.log) ? rawAdmin.log : [], resume: rawAdmin.resume && typeof rawAdmin.resume === "object" ? rawAdmin.resume : {}, }; // Local fallback for react/emoji: prefer parent `assignEmoji` if provided, // otherwise prompt locally and POST to the notify endpoint. This keeps // the React button working even if the parent didn't pass the handler. const enqueueReaction = useCallback((emoji, targetHandle, contextLabel, contextType) => { const safeEmoji = String(emoji || "🔥").trim() || "🔥"; const safeTarget = String(targetHandle || "bettor").trim() || "bettor"; const safeLabel = String(contextLabel || "reaction").trim() || "reaction"; const safeType = String(contextType || "bet"); const reactor = String(reactorHandle || localStorage.getItem("bb_reactor_handle") || "Admin").trim() || "Admin"; const persistMs = Math.max(15000, Number(displayDraft?.reaction_persist_seconds || 600) * 1000); const now = Date.now(); setReactionQueue((prev) => { const next = [...prev, { id: `${now}-${Math.random().toString(36).slice(2, 8)}`, emoji: safeEmoji, reactor, targetHandle: safeTarget, contextLabel: safeLabel, contextType: safeType, createdAt: now, expiresAt: now + persistMs, }]; return next.slice(-12); }); setReactionFlashEnd(Date.now() + 10000); setEmulatorStep(0); }, [displayDraft?.reaction_persist_seconds, reactorHandle]); const handleReact = async (p) => { try { const emoji = window.prompt("React to bet", p.emoji || "🔥"); if (!emoji) return; const target = String(p.handle || "bettor"); const event = String(p.event || p.game || ""); const sel = String(p.selection || p.bet || "bet"); const contextLabel = event ? `${sel} on ${event}` : sel; enqueueReaction(emoji, target, contextLabel, "bet"); if (typeof assignEmoji === "function") { try { return await assignEmoji({ ...p, emoji }); } catch (e) { console.error("emoji push failed", e); } } await fetch(withCode(`${apiBase}/notify/push/emoji`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ handle: "", emoji, bet: text }), }); } catch (e) { console.error("emoji push failed", e); } }; const handleReactBalance = (u) => { const emoji = window.prompt(`React to ${u.handle}'s balance`, "\uD83D\uDD25"); if (!emoji) return; const bal = typeof u.balance === "number" ? `$${Number(u.balance).toFixed(0)}` : "unknown"; enqueueReaction(emoji, String(u.handle), `balance of ${bal}`, "balance"); }; const [userActionStatus, setUserActionStatus] = useState({}); const setActionStatus = (handle, tone, message) => { if (!handle) return; setUserActionStatus((prev) => ({ ...prev, [handle]: { tone, message }, })); }; const getUserMaxBet = (user) => { const override = getMaxBetOverride(user?.handle); if (override != null) return override; return parseMaxBetAmount( user?.max_bet ?? user?.max_bet_threshold ?? user?.bet_limit ?? user?.bet_limit_amount ?? null ); }; const submitCreditHold = async ({ user, isHeld, holdAmountInput, holdMessageInput, maxBetInput }) => { if (!user?.handle) return false; const nextHold = !isHeld; const confirmed = window.confirm(nextHold ? `Place ${user.handle} on credit hold?` : `Remove credit hold for ${user.handle}?`); if (!confirmed) return false; setActionStatus(user.handle, "busy", nextHold ? "Applying hold..." : "Removing hold..."); const parsedAmount = Number(holdAmountInput); const holdAmount = nextHold && Number.isFinite(parsedAmount) && parsedAmount !== 0 ? parsedAmount : undefined; const holdMessage = nextHold && typeof holdMessageInput === "string" && holdMessageInput.trim() ? holdMessageInput.trim() : undefined; const maxBetRaw = typeof maxBetInput === "string" ? maxBetInput.trim() : ""; const maxBetRequested = maxBetRaw !== ""; const nextMaxBet = maxBetRequested ? parseMaxBetAmount(maxBetRaw) : null; if (maxBetRequested && nextMaxBet == null) { setActionStatus(user.handle, "error", "Enter a valid positive max bet threshold."); return false; } const holdPayload = { on_hold: nextHold, credit_hold: nextHold, hold: nextHold, ...(nextHold ? { reason: "manual", credit_hold_reason: "manual" } : { reason: null, credit_hold_reason: null, message: null, credit_hold_message: null, amount: null, credit_hold_amount: null, }), ...(holdAmount != null ? { amount: holdAmount } : {}), ...(holdMessage != null ? { message: holdMessage } : {}), ...(maxBetRequested ? { max_bet: nextMaxBet } : {}), }; const legacyPayload = { handle: user.handle, on_hold: nextHold, credit_hold: nextHold, ...(nextHold ? { reason: "manual", credit_hold_reason: "manual" } : {}), ...(holdAmount != null ? { amount: holdAmount, credit_hold_amount: holdAmount } : {}), ...(holdMessage != null ? { message: holdMessage, credit_hold_message: holdMessage } : {}), ...(maxBetRequested ? { max_bet: nextMaxBet, max_bet_threshold: nextMaxBet, bet_limit: nextMaxBet, bet_limit_amount: nextMaxBet } : {}), }; const contactEndpoint = { url: withCode(`${apiBase}/admin/users/contact`), payload: legacyPayload, name: "legacy contact endpoint", }; const fallbackEndpoints = [ { url: withCode(`${apiBase}/admin/users/${encodeURIComponent(user.handle)}/hold`), payload: holdPayload, name: "hold endpoint" }, ]; if (!nextHold) { fallbackEndpoints.unshift({ url: withCode(`${apiBase}/admin/users/${encodeURIComponent(user.handle)}/unhold`), payload: holdPayload, name: "unhold endpoint", }); } const methods = nextHold ? ["PATCH", "POST", "PUT"] : ["PATCH", "POST", "PUT", "DELETE"]; try { let result = null; let resultBody = null; let lastUnavailableStatus = null; let verificationFailure = null; const tryNotes = []; const verifyHoldState = (snapshot) => { const users = Array.isArray(snapshot?.users) ? snapshot.users : []; const target = users.find((x) => String(x?.handle || "").toLowerCase() === String(user.handle || "").toLowerCase()); if (!target) return false; const hasHoldField = Object.prototype.hasOwnProperty.call(target, "credit_hold") || Object.prototype.hasOwnProperty.call(target, "on_hold") || Object.prototype.hasOwnProperty.call(target, "hold"); if (!hasHoldField) { // Older backends may not include hold fields in admin overview. // In that case, don't treat verification as failed. return true; } const holdValue = target.credit_hold ?? target.on_hold ?? target.hold ?? false; const holdMatches = !!holdValue === nextHold; if (!holdMatches) return false; if (!maxBetRequested) return true; const hasMaxField = Object.prototype.hasOwnProperty.call(target, "max_bet") || Object.prototype.hasOwnProperty.call(target, "max_bet_threshold") || Object.prototype.hasOwnProperty.call(target, "bet_limit") || Object.prototype.hasOwnProperty.call(target, "bet_limit_amount"); if (!hasMaxField) { // Same compatibility handling for max-bet fields. return true; } const refreshedMax = parseMaxBetAmount( target.max_bet ?? target.max_bet_threshold ?? target.bet_limit ?? target.bet_limit_amount ?? null ); if (nextMaxBet == null) return refreshedMax == null; return refreshedMax != null && Math.abs(refreshedMax - nextMaxBet) < 0.0001; }; // First prefer /admin/users/contact; only fall back to legacy hold routes // when contact is unavailable in this environment. let contactUnavailable = false; for (const method of methods) { const res = await fetch(contactEndpoint.url, { method, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, ...(method === "DELETE" ? {} : { body: JSON.stringify(contactEndpoint.payload) }), }); let body; try { body = await res.json(); } catch { body = { ok: res.ok }; } if (res.ok && body.ok !== false) { const refreshed = await loadAdmin(); if (verifyHoldState(refreshed)) { result = res; resultBody = body; break; } verificationFailure = `${method} ${contactEndpoint.name} returned OK but hold state did not update`; tryNotes.push(verificationFailure); continue; } if (res.status === 404 || res.status === 405) { contactUnavailable = true; lastUnavailableStatus = `HTTP ${res.status} (${method} ${contactEndpoint.name})`; tryNotes.push(lastUnavailableStatus); continue; } const errLabel = body?.error || `HTTP ${res.status} (${method} ${contactEndpoint.name})`; tryNotes.push(errLabel); } if (!result && contactUnavailable) { outer: for (const endpoint of fallbackEndpoints) { for (const method of methods) { const res = await fetch(endpoint.url, { method, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, ...(method === "DELETE" ? {} : { body: JSON.stringify(endpoint.payload) }), }); let body; try { body = await res.json(); } catch { body = { ok: res.ok }; } if (res.ok && body.ok !== false) { const refreshed = await loadAdmin(); if (verifyHoldState(refreshed)) { result = res; resultBody = body; break outer; } verificationFailure = `${method} ${endpoint.name} returned OK but hold state did not update`; tryNotes.push(verificationFailure); continue; } if (res.status === 404 || res.status === 405) { lastUnavailableStatus = `HTTP ${res.status} (${method} ${endpoint.name})`; tryNotes.push(lastUnavailableStatus); continue; } const errLabel = body?.error || `HTTP ${res.status} (${method} ${endpoint.name})`; tryNotes.push(errLabel); continue; } } } if (!result) { throw new Error(verificationFailure || lastUnavailableStatus || tryNotes[tryNotes.length - 1] || "Credit hold endpoint is unavailable"); } if (!(result.ok && resultBody?.ok !== false)) { setActionStatus(user.handle, "error", resultBody?.error || `HTTP ${result.status}`); alert(resultBody?.error || `HTTP ${result.status} ${result.statusText}`); return false; } if (maxBetRequested) { setMaxBetOverride(user.handle, nextMaxBet); } setActionStatus(user.handle, "ok", nextHold ? "Credit hold applied." : "Credit hold removed."); return true; } catch (e) { await loadAdmin(); setActionStatus(user.handle, "error", e?.message || "Network error"); alert(e?.message || "Network error"); return false; } }; const submitMaxBet = async ({ user, maxBetInput }) => { if (!user?.handle) return { ok: false, maxBet: null }; const raw = String(maxBetInput ?? "").trim(); const nextMaxBet = raw === "" ? null : parseMaxBetAmount(raw); if (raw !== "" && nextMaxBet == null) { setActionStatus(user.handle, "error", "Enter a valid positive max bet or leave blank to clear."); return { ok: false, maxBet: null }; } setActionStatus( user.handle, "busy", nextMaxBet == null ? "Clearing max bet..." : `Saving max bet (${dollars(nextMaxBet)})...` ); const primaryPayload = { max_bet: nextMaxBet }; const legacyPayload = { handle: user.handle, max_bet: nextMaxBet, max_bet_threshold: nextMaxBet, bet_limit: nextMaxBet, bet_limit_amount: nextMaxBet, }; const endpoints = [ { url: withCode(`${apiBase}/admin/users/${encodeURIComponent(user.handle)}/max-bet`), payload: primaryPayload, name: "max-bet endpoint" }, { url: withCode(`${apiBase}/admin/users/${encodeURIComponent(user.handle)}/limits`), payload: primaryPayload, name: "limits endpoint" }, { url: withCode(`${apiBase}/admin/users/contact`), payload: legacyPayload, name: "legacy contact endpoint" }, ]; const methods = ["PATCH", "POST", "PUT"]; try { let result = null; let resultBody = null; let lastUnavailableStatus = null; outer: for (const endpoint of endpoints) { for (const method of methods) { const res = await fetch(endpoint.url, { method, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify(endpoint.payload), }); let body; try { body = await res.json(); } catch { body = { ok: res.ok }; } if (res.ok && body.ok !== false) { result = res; resultBody = body; break outer; } if (res.status === 404 || res.status === 405) { lastUnavailableStatus = `HTTP ${res.status} (${method} ${endpoint.name})`; continue; } result = res; resultBody = body; break outer; } } if (!result) { throw new Error(lastUnavailableStatus || "Max bet endpoint is unavailable"); } if (!(result.ok && resultBody?.ok !== false)) { setActionStatus(user.handle, "error", resultBody?.error || `HTTP ${result.status}`); alert(resultBody?.error || `HTTP ${result.status} ${result.statusText}`); return { ok: false, maxBet: null }; } setMaxBetOverride(user.handle, nextMaxBet); await loadAdmin(); setActionStatus( user.handle, "ok", nextMaxBet == null ? "Max bet cleared." : `Max bet set to ${dollars(nextMaxBet)}.` ); return { ok: true, maxBet: nextMaxBet }; } catch (e) { setActionStatus(user.handle, "error", e?.message || "Network error"); alert(e?.message || "Network error"); return { ok: false, maxBet: null }; } }; const submitHoldThreshold = async ({ user, holdAmountInput }) => { if (!user?.handle) return { ok: false, threshold: null }; const raw = String(holdAmountInput ?? "").trim(); const nextThreshold = raw === "" ? null : parseHoldThresholdAmount(raw); if (raw !== "" && nextThreshold == null) { setActionStatus(user.handle, "error", "Enter a valid threshold amount or leave blank to clear."); return { ok: false, threshold: null }; } setActionStatus( user.handle, "busy", nextThreshold == null ? "Clearing hold threshold..." : `Saving hold threshold (${dollars(nextThreshold)})...` ); const thresholdPayloadValue = nextThreshold == null ? "" : nextThreshold; const payload = { handle: user.handle, amount: thresholdPayloadValue, credit_hold_amount: thresholdPayloadValue, }; const shouldReleaseAutoHold = nextThreshold == null && !!user?.credit_hold && user?.credit_hold_reason === "auto"; if (shouldReleaseAutoHold) { payload.on_hold = false; payload.credit_hold = false; payload.reason = null; payload.credit_hold_reason = null; payload.message = null; payload.credit_hold_message = null; } const methods = ["PATCH", "POST", "PUT"]; let lastErr = null; const matchesAutoRelease = (snapshot) => { if (!shouldReleaseAutoHold) return true; const users = Array.isArray(snapshot?.users) ? snapshot.users : []; const target = users.find((x) => String(x?.handle || "").toLowerCase() === String(user.handle || "").toLowerCase()); if (!target) return false; const hasHoldField = Object.prototype.hasOwnProperty.call(target, "credit_hold") || Object.prototype.hasOwnProperty.call(target, "on_hold") || Object.prototype.hasOwnProperty.call(target, "hold"); if (!hasHoldField) return true; const held = !!(target.credit_hold ?? target.on_hold ?? target.hold ?? false); return !held; }; for (const method of methods) { try { const res = await fetch(withCode(`${apiBase}/admin/users/contact`), { method, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify(payload), }); let body; try { body = await res.json(); } catch { body = { ok: res.ok }; } if (res.ok && body?.ok !== false) { const refreshed = await loadAdmin(); const autoReleaseApplied = matchesAutoRelease(refreshed); setActionStatus( user.handle, "ok", nextThreshold == null ? (shouldReleaseAutoHold ? (autoReleaseApplied ? "Hold threshold cleared and auto-hold released." : "Hold threshold cleared. Auto-hold still active; remove hold manually if needed.") : "Hold threshold cleared.") : `Hold threshold set to ${dollars(nextThreshold)}.` ); return { ok: true, threshold: nextThreshold }; } if (res.status === 404 || res.status === 405) { lastErr = `HTTP ${res.status} (${method} contact endpoint)`; continue; } lastErr = body?.error || `HTTP ${res.status}`; } catch (e) { lastErr = e?.message || "Network error"; } } setActionStatus(user.handle, "error", lastErr || "Unable to save hold threshold"); return { ok: false, threshold: null }; }; const [queueCollapsed, setQueueCollapsed] = useState(true); const [requireLiveApproval, setRequireLiveApproval] = useState(() => localStorage.getItem("bb_require_live_approval") !== "0"); const [openCollapsed, setOpenCollapsed] = useState(true); const [closedCollapsed, setClosedCollapsed] = useState(true); const [balancesCollapsed, setBalancesCollapsed] = useState(true); const [closedSort, setClosedSort] = useState("date_desc"); const [thresholdInput, setThresholdInput] = useState(""); const [displayDeviceId, setDisplayDeviceId] = useState("pi-main"); const [displayConfig, setDisplayConfig] = useState(() => deepMergeDisplay(DISPLAY_DEFAULTS, {})); const [displayDraft, setDisplayDraft] = useState(() => deepMergeDisplay(DISPLAY_DEFAULTS, {})); const [displayPayload, setDisplayPayload] = useState(null); const [displayBusy, setDisplayBusy] = useState(false); const [displayStatus, setDisplayStatus] = useState(""); const [displayCloneSource, setDisplayCloneSource] = useState("pi-main"); const [displayCloneTargets, setDisplayCloneTargets] = useState("pi-lobby, pi-bar"); const [selectedBrandPack, setSelectedBrandPack] = useState("nfl"); const [displaySubmenu, setDisplaySubmenu] = useState("main"); const [emulatorMode, setEmulatorMode] = useState("auto"); const [emulatorStep, setEmulatorStep] = useState(0); const [reactionQueue, setReactionQueue] = useState([]); const [reactionBlinkOn, setReactionBlinkOn] = useState(false); const [reactionFlashEnd, setReactionFlashEnd] = useState(0); const [reactorHandle, setReactorHandle] = useState(() => String(localStorage.getItem("bb_reactor_handle") || "")); const [logoRenderVersion, setLogoRenderVersion] = useState(0); const logoImageCacheRef = useRef(new Map()); const ledCanvasRef = useRef(null); const savedThreshold = admin.credit_hold_threshold ?? admin.resume?.credit_hold_threshold ?? null; const getHoldReasonLabel = (reason) => (reason === "auto" ? "auto-triggered" : "manual"); const displaySpeed = useMemo(() => readDisplaySpeedSettings(displayDraft), [displayDraft]); const setDisplaySpeed = useCallback((axis, nextValue) => { setDisplayDraft((prev) => { const current = readDisplaySpeedSettings(prev); if (!Object.prototype.hasOwnProperty.call(current, axis)) return prev; return applyDisplaySpeedSettings(prev, { ...current, [axis]: nextValue }); }); }, []); const loadDisplayConfig = useCallback(async (targetDeviceId) => { const rawId = String(targetDeviceId || displayDeviceId || "pi-main").trim(); if (!rawId) return; const deviceId = encodeURIComponent(rawId); const localCode = String(localStorage.getItem("bb_group_code") || "").trim(); setDisplayBusy(true); setDisplayStatus("Loading display settings..."); try { await fetch(withCode(`${apiBase}/display/devices/${deviceId}/register`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ device_id: rawId, name: `Ticker ${rawId}`, group_code: localCode }), }); const res = await fetch(withCode(`${apiBase}/display/devices/${deviceId}/config`), { headers: { Authorization: `Bearer ${token}` }, }); const data = await res.json(); if (!res.ok || data?.ok === false) { throw new Error(data?.error || `HTTP ${res.status}`); } const merged = deepMergeDisplay(DISPLAY_DEFAULTS, data?.config || {}); setDisplayConfig(merged); setDisplayDraft(merged); setDisplayStatus(`Loaded config for ${rawId}.`); } catch (e) { setDisplayStatus(`Config load failed: ${e?.message || "network error"}`); } finally { setDisplayBusy(false); } }, [apiBase, token, withCode, displayDeviceId]); const loadDisplayPayload = useCallback(async (targetDeviceId) => { const rawId = String(targetDeviceId || displayDeviceId || "pi-main").trim(); if (!rawId || !token) return; const deviceId = encodeURIComponent(rawId); const localCode = String(localStorage.getItem("bb_group_code") || "").trim(); try { await fetch(withCode(`${apiBase}/display/devices/${deviceId}/register`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ device_id: rawId, name: `Ticker ${rawId}`, group_code: localCode }), }); const res = await fetch(withCode(`${apiBase}/display/devices/${deviceId}/payload`), { headers: { Authorization: `Bearer ${token}` }, }); const data = await res.json(); if (!res.ok || data?.ok === false) { throw new Error(data?.error || `HTTP ${res.status}`); } setDisplayPayload(data || null); if (data?.config) { const merged = deepMergeDisplay(DISPLAY_DEFAULTS, data.config); setDisplayConfig(merged); setDisplayDraft((prev) => deepMergeDisplay(prev, merged)); } } catch (e) { setDisplayPayload(null); } }, [apiBase, token, withCode, displayDeviceId]); useEffect(() => { if (!token) return; loadDisplayConfig(displayDeviceId); loadDisplayPayload(displayDeviceId); }, [token, loadDisplayConfig, loadDisplayPayload, displayDeviceId]); useEffect(() => { const intervalMs = Math.max(900, Math.round(pageSpeedToHoldSeconds(displaySpeed.page) * 1000)); const id = setInterval(() => setEmulatorStep((v) => v + 1), intervalMs); return () => clearInterval(id); }, [displaySpeed.page]); useEffect(() => { const persistMs = Math.max(15000, Number(displayDraft?.reaction_persist_seconds || 600) * 1000); const prune = () => { setReactionQueue((prev) => prev.filter((item) => Date.now() < (item.expiresAt || Date.now() + persistMs))); }; prune(); const id = setInterval(prune, 1000); return () => clearInterval(id); }, [displayDraft?.reaction_persist_seconds]); useEffect(() => { if (!hasActiveReaction) { setReactionBlinkOn(false); return; } const id = setInterval(() => setReactionBlinkOn((prev) => !prev), 320); return () => clearInterval(id); }, [hasActiveReaction]); const saveDisplayConfig = async () => { const rawId = String(displayDeviceId || "pi-main").trim(); if (!rawId) { setDisplayStatus("Device id is required."); return; } const deviceId = encodeURIComponent(rawId); const payload = deepMergeDisplay(DISPLAY_DEFAULTS, displayDraft); setDisplayBusy(true); setDisplayStatus("Saving display settings..."); try { const res = await fetch(withCode(`${apiBase}/display/devices/${deviceId}/config`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(payload), }); const data = await res.json(); if (!res.ok || data?.ok === false) { throw new Error(data?.error || `HTTP ${res.status}`); } const merged = deepMergeDisplay(DISPLAY_DEFAULTS, data?.config || payload); setDisplayConfig(merged); setDisplayDraft(merged); setDisplayStatus("Saved. Device will pick this up on next poll."); } catch (e) { setDisplayStatus(`Save failed: ${e?.message || "network error"}`); } finally { setDisplayBusy(false); } }; const applyPreset = (preset) => { if (!preset || typeof preset !== "object") return; setDisplayDraft((prev) => { const patch = preset.patch || {}; const merged = deepMergeDisplay(prev, patch); if (patch.scroll_speed != null && !(patch.motion && typeof patch.motion === "object")) { const speed = clampDisplaySpeed(patch.scroll_speed); return applyDisplaySpeedSettings(merged, { vertical: speed, horizontal: speed, page: speed }); } return merged; }); setDisplayStatus(`Applied preset: ${preset.label}. Review then save.`); }; const applyBrandPack = (packKey) => { const pack = DISPLAY_BRAND_PACKS.find((entry) => entry.key === packKey); if (!pack) return; setSelectedBrandPack(packKey); setDisplayDraft((prev) => deepMergeDisplay(prev, pack.patch || {})); setDisplayStatus(`Applied logo/theme pack: ${pack.label}.`); }; const cloneDisplayProfile = async () => { const source = String(displayCloneSource || "").trim(); const rawTargets = String(displayCloneTargets || ""); const targets = Array.from(new Set(rawTargets.split(/[\s,]+/).map((x) => x.trim()).filter(Boolean))); if (!source || targets.length === 0) { setDisplayStatus("Clone requires a source device and at least one target device."); return; } setDisplayBusy(true); setDisplayStatus("Cloning display profile..."); try { const srcRes = await fetch(withCode(`${apiBase}/display/devices/${encodeURIComponent(source)}/config`), { headers: { Authorization: `Bearer ${token}` }, }); const srcData = await srcRes.json(); if (!srcRes.ok || srcData?.ok === false) { throw new Error(srcData?.error || `Failed to load source (${srcRes.status})`); } const profile = deepMergeDisplay(DISPLAY_DEFAULTS, srcData?.config || {}); const localCode = String(localStorage.getItem("bb_group_code") || "").trim(); for (const target of targets) { await fetch(withCode(`${apiBase}/display/devices/${encodeURIComponent(target)}/register`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ device_id: target, name: `Ticker ${target}`, group_code: localCode }), }); const saveRes = await fetch(withCode(`${apiBase}/display/devices/${encodeURIComponent(target)}/config`), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(profile), }); const saveData = await saveRes.json().catch(() => ({})); if (!saveRes.ok || saveData?.ok === false) { throw new Error(saveData?.error || `Failed clone target ${target}`); } } setDisplayStatus(`Cloned profile from ${source} to ${targets.length} device(s).`); if (targets.includes(displayDeviceId)) { setDisplayDraft(profile); setDisplayConfig(profile); } } catch (e) { setDisplayStatus(`Clone failed: ${e?.message || "network error"}`); } finally { setDisplayBusy(false); } }; const themeStyle = useMemo(() => { const t = displayDraft.theme || {}; const fontScale = Number(displayDraft.font_scale || 1); return { background: `radial-gradient(circle at 20% 10%, ${t.accent || "#22c55e"}25 0%, ${(t.background || "#050816")} 40%, #000 100%)`, color: t.text || "#e2fef0", fontFamily: displayDraft.font_family || DISPLAY_DEFAULTS.font_family, fontSize: `${Math.max(0.8, Math.min(1.35, fontScale))}rem`, boxShadow: `0 0 0 1px ${(t.accent || "#22c55e")}44 inset, 0 12px 30px #00000099`, }; }, [displayDraft]); const board = displayPayload?.board || {}; const emulatorRows = useMemo(() => { // Use admin.users for balances — matches the Board page exactly const adminUsers = Array.isArray(admin.users) ? admin.users : []; const payloadBalances = Array.isArray(board?.balances) ? board.balances : []; const pnlMap = {}; payloadBalances.forEach(b => { pnlMap[b.handle] = { today_pnl: Number(b.today_pnl || 0), ytd_pnl: Number(b.ytd_pnl || 0) }; }); const src = adminUsers.length > 0 ? adminUsers.map(u => ({ handle: u.handle, balance: Number(u.balance || 0), pending: Number(u.pending || 0), ...(pnlMap[u.handle] || { today_pnl: 0, ytd_pnl: 0 }) })) : payloadBalances.map(r => ({ handle: r.handle, balance: Number(r.balance || 0), pending: Number(r.pending || 0), today_pnl: Number(r.today_pnl || 0), ytd_pnl: Number(r.ytd_pnl || 0) })); return src.filter(r => r.handle).slice(0, 8); }, [admin.users, board?.balances]); const emulatorOpenPicks = useMemo(() => { const rawOpen = Array.isArray(board?.open_bets) ? board.open_bets : []; return rawOpen.map((p) => ({ handle: p.handle || p.user || "bettor", league: p.league || "", selection: p.selection || p.bet || p.title || "Open bet", stake: Number(p.stake || p.amount || p.size || 0), event: p.event || p.game || p.matchup || "", market: p.market || "", odds: p.odds, line: p.line, total: p.total, ...summarizeFinalsEvent(p.event || p.game || p.matchup || ""), })).slice(0, 4); }, [board?.open_bets]); const emulatorSettlements = useMemo(() => { const rawEvents = Array.isArray(board?.events) ? board.events : []; return rawEvents.map((event) => ({ handle: event.handle || event.user || "bettor", result: String(event.result || event.status || event.kind || "SETTLED").toUpperCase(), score: event.score || event.summary || event.body || "", bet: event.selection || event.bet || event.title || event.body || "", event: event.event || event.game || event.matchup || event.body || "", ...summarizeFinalsEvent(event.event || event.game || event.matchup || event.body || ""), })).slice(0, 4); }, [board?.events]); const emulatorDailySettlements = useMemo(() => { const raw = Array.isArray(board?.daily_settlements) ? board.daily_settlements : []; return raw .map((entry) => ({ league: String(entry?.league || "").toUpperCase(), event: String(entry?.event || "Final"), score: String(entry?.score || "FINAL"), ...summarizeFinalsEvent(entry?.event || "Final"), bettors: Array.isArray(entry?.bettors) ? entry.bettors .map((bettor) => ({ handle: String(bettor?.handle || "bettor").slice(0, 10), net: Number(bettor?.net || 0), })) .slice(0, 4) : [], })) .filter((entry) => entry.bettors.length > 0); }, [board?.daily_settlements]); const activeEmulatorMode = useMemo(() => { if (emulatorMode === "auto" || emulatorMode === "toggle_pages") { const now = Date.now(); const activeRxs = reactionQueue.filter((i) => Number.isFinite(i.expiresAt) && i.expiresAt > now); const pages = [ // reactions shown via footer ticker + flash — not as a cycle page { mode: "ticker", count: Math.max(1, emulatorOpenPicks.length) }, { mode: "board", count: Math.max(1, emulatorRows.length) }, { mode: "settlement", count: Math.max(1, emulatorSettlements.length) }, ]; if (emulatorDailySettlements.length > 0) pages.push({ mode: "finals", count: emulatorDailySettlements.length }); const total = pages.reduce((s, p) => s + p.count, 0); const step = emulatorStep % Math.max(1, total); let acc = 0; for (const p of pages) { if (step < acc + p.count) return p.mode; acc += p.count; } return "ticker"; } if (emulatorMode === "finals" && emulatorDailySettlements.length === 0) return "board"; return emulatorMode; }, [emulatorDailySettlements.length, emulatorMode, emulatorStep, emulatorOpenPicks.length, emulatorRows.length, emulatorSettlements.length, reactionQueue]); const activeSubStep = useMemo(() => { if (emulatorMode === "auto" || emulatorMode === "toggle_pages") { const now = Date.now(); const activeRxs = reactionQueue.filter((i) => Number.isFinite(i.expiresAt) && i.expiresAt > now); const pages = [ // reactions shown via footer ticker + flash — not as a cycle page { mode: "ticker", count: Math.max(1, emulatorOpenPicks.length) }, { mode: "board", count: Math.max(1, emulatorRows.length) }, { mode: "settlement", count: Math.max(1, emulatorSettlements.length) }, ]; if (emulatorDailySettlements.length > 0) pages.push({ mode: "finals", count: emulatorDailySettlements.length }); const total = pages.reduce((s, p) => s + p.count, 0); const step = emulatorStep % Math.max(1, total); let acc = 0; for (const p of pages) { if (step < acc + p.count) return step - acc; acc += p.count; } return 0; } const count = Math.max(1, emulatorMode === "board" ? emulatorRows.length : emulatorMode === "ticker" ? emulatorOpenPicks.length : emulatorMode === "settlement" ? emulatorSettlements.length : emulatorMode === "finals" ? emulatorDailySettlements.length : 1 ); return emulatorStep % count; }, [emulatorDailySettlements.length, emulatorMode, emulatorStep, emulatorOpenPicks.length, emulatorRows.length, emulatorSettlements.length, reactionQueue]); const getLogoImage = useCallback((src) => { const path = String(src || "").trim(); if (!path) return null; const cache = logoImageCacheRef.current; const existing = cache.get(path); if (existing) return existing; const img = new Image(); img.decoding = "async"; img.onload = () => setLogoRenderVersion((v) => v + 1); img.onerror = () => setLogoRenderVersion((v) => v + 1); img.src = path; cache.set(path, img); return img; }, []); const activeLogoContext = useMemo(() => { if (activeEmulatorMode === "finals" && emulatorDailySettlements.length > 0) { const card = emulatorDailySettlements[activeSubStep % emulatorDailySettlements.length] || emulatorDailySettlements[0]; return { league: card?.league || "", event: card?.event || "" }; } if ((activeEmulatorMode === "settlement" || activeEmulatorMode === "ticker") && emulatorOpenPicks.length > 0) { const pick = emulatorOpenPicks[activeSubStep % emulatorOpenPicks.length] || emulatorOpenPicks[0]; return { league: pick?.league || "", event: pick?.event || "" }; } return { league: displayDraft.logo_pack || "", event: "" }; }, [activeEmulatorMode, activeSubStep, displayDraft.logo_pack, emulatorDailySettlements, emulatorOpenPicks]); const stackedNotificationQueue = useMemo(() => { const now = Date.now(); const persistMs = Math.max(15000, Number(displayDraft?.reaction_persist_seconds || 600) * 1000); const liveEvents = Array.isArray(board?.events) ? board.events : []; const wagerEvents = Array.isArray(board?.open_bets) ? board.open_bets : []; const toggles = displayDraft?.notifications || {}; const queue = [ ...liveEvents.map((event, idx) => ({ id: `event-${event.id || idx}`, type: String(event.kind || event.type || "event").toLowerCase(), handle: String(event.handle || event.user || "bettor").slice(0, 10), label: String(event.kind || event.type || "event").toUpperCase(), text: String(event.body || event.summary || event.result || event.status || "update").slice(0, 18), createdAt: now - idx * 1000, expiresAt: now + persistMs + idx * 1500, })), ...wagerEvents.map((wager, idx) => ({ id: `wager-${wager.id || idx}`, type: "wager", handle: String(wager.handle || wager.user || "bettor").slice(0, 10), label: "WAGER", text: `${String(wager.selection || wager.bet || "pick").slice(0, 16)} $${Number(wager.stake || 0).toFixed(0)}`, createdAt: now - idx * 1000, expiresAt: now + persistMs + idx * 1500, })), ...reactionQueue.map((item) => ({ id: item.id, type: "reaction", handle: String(item.handle || "bettor").slice(0, 10), label: "REACTION", text: `${String(item.emoji || "🔥")} ${String(item.text || "reaction").slice(0, 12)}`, createdAt: item.createdAt, expiresAt: item.expiresAt, })), ].filter((item) => { if (!item || !Number.isFinite(item.expiresAt) || item.expiresAt <= now) return false; if (item.type === "reaction" && !toggles.reactions) return false; if (item.type === "wager" && !toggles.live_bets) return false; if (item.type === "payin" && !toggles.payins) return false; if (item.type === "payout" && !toggles.payouts) return false; if ((item.type === "settlement" || item.type === "event") && !toggles.settlements) return false; return true; }); return queue.slice(-16); }, [board?.events, board?.open_bets, displayDraft?.notifications, displayDraft?.reaction_persist_seconds, reactionQueue]); const hasActiveReaction = useMemo(() => { const now = Date.now(); return stackedNotificationQueue.some((i) => i.type === "reaction" && Number.isFinite(i.expiresAt) && i.expiresAt > now); }, [stackedNotificationQueue]); useEffect(() => { const canvas = ledCanvasRef.current; if (!canvas) return; const width = 128; const height = 64; const scale = 6; canvas.width = width * scale; canvas.height = height * scale; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.imageSmoothingEnabled = false; const frame = document.createElement("canvas"); frame.width = width; frame.height = height; const fctx = frame.getContext("2d"); if (!fctx) return; fctx.imageSmoothingEnabled = false; const theme = displayDraft.theme || {}; const accent = hexToRgb(theme.accent || "#22c55e"); const text = hexToRgb(theme.text || "#e2fef0", { r: 220, g: 255, b: 235 }); const bg = hexToRgb(theme.background || "#050816", { r: 5, g: 8, b: 22 }); const brightness = Math.max(0.25, Math.min(1, Number(displayDraft.brightness || 90) / 100)); fctx.fillStyle = `rgb(${bg.r},${bg.g},${bg.b})`; fctx.fillRect(0, 0, width, height); fctx.strokeStyle = `rgba(${accent.r},${accent.g},${accent.b},0.2)`; fctx.strokeRect(0.5, 0.5, width - 1, height - 1); fctx.fillStyle = `rgb(${accent.r},${accent.g},${accent.b})`; fctx.font = "bold 7px monospace"; fctx.fillText(String((theme.title || "BLACKBOOK LIVE")).slice(0, 18), 3, 8); const mode = activeEmulatorMode; const visibleNotifications = stackedNotificationQueue.filter((item) => Date.now() < (item.expiresAt || Date.now())); const activeNotification = visibleNotifications.length > 0 ? (visibleNotifications[activeSubStep % visibleNotifications.length] || visibleNotifications[0]) : null; const reactionAttentionActive = !!(displayDraft.notifications?.reactions && activeNotification?.type === "reaction"); const reactionInvertActive = reactionAttentionActive && reactionBlinkOn; if (displayDraft.show_logos) { const leagueKey = resolveLeagueLogoKey(activeLogoContext.league, displayDraft.logo_pack); const leaguePath = LEAGUE_LOGO_PATHS[leagueKey] || ""; const teams = extractTeamsFromEvent(activeLogoContext.event); const drawFallbackBadge = (label, x, y, w = 8, h = 8) => { fctx.fillStyle = `rgba(${accent.r},${accent.g},${accent.b},0.24)`; fctx.fillRect(x, y, w, h); fctx.strokeStyle = `rgba(${accent.r},${accent.g},${accent.b},0.7)`; fctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); fctx.fillStyle = `rgb(${text.r},${text.g},${text.b})`; fctx.font = "bold 4px monospace"; fctx.fillText(String(label || "LG").slice(0, 2), x + 1, y + h - 2); }; const drawLogo = (path, x, y, w, h, fallbackLabel) => { const img = getLogoImage(path); if (img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) { fctx.drawImage(img, x, y, w, h); return; } drawFallbackBadge(fallbackLabel, x, y, w, h); }; drawLogo(leaguePath, width - 10, 2, 8, 8, leagueKey.toUpperCase().slice(0, 2)); const topTeam = teams[0] || ""; const bottomTeam = teams[1] || ""; drawLogo(resolveTeamLogoPath(topTeam), width - 20, 2, 8, 8, shortTeamLabel(topTeam)); drawLogo(resolveTeamLogoPath(bottomTeam), width - 30, 2, 8, 8, shortTeamLabel(bottomTeam)); } if (mode === "board") { const graphValues = Array.isArray(board?.graph) && board.graph.length > 0 ? board.graph.map((entry) => ({ handle: entry.handle || entry.user || "bettor", balance: Number(entry.balance || entry.total || 0) })) : [...emulatorRows].sort((a, b) => b.balance - a.balance).slice(0, 6); const cleaned = graphValues .filter((entry) => Number.isFinite(Number(entry.balance))) .slice(0, 5) .sort((a, b) => b.balance - a.balance); const maxAbs = Math.max(1, ...cleaned.map((entry) => Math.abs(Number(entry.balance || 0)))); const plotLeft = 10; const plotTop = 16; const plotWidth = width - plotLeft - 8; const plotHeight = height - plotTop - 18; const zeroY = plotTop + plotHeight / 2; const barCount = cleaned.length || 1; const innerWidth = plotWidth / barCount; const barWidth = Math.max(4, innerWidth * 0.62); fctx.fillStyle = `rgba(${accent.r},${accent.g},${accent.b},0.08)`; fctx.fillRect(plotLeft, plotTop, plotWidth, plotHeight); fctx.strokeStyle = `rgba(${text.r},${text.g},${text.b},0.22)`; fctx.beginPath(); fctx.moveTo(plotLeft, zeroY); fctx.lineTo(plotLeft + plotWidth, zeroY); fctx.stroke(); fctx.fillStyle = `rgb(${accent.r},${accent.g},${accent.b})`; fctx.font = "bold 6px monospace"; fctx.fillText("BALANCE", 4, 12); cleaned.forEach((row, idx) => { const value = Number(row.balance || 0); const x = plotLeft + idx * innerWidth + (innerWidth - barWidth) / 2; const barHeight = Math.abs(value) / maxAbs * (plotHeight / 2 - 2); const barTop = value >= 0 ? zeroY - barHeight : zeroY; const barBottom = value >= 0 ? zeroY : zeroY + barHeight; fctx.fillStyle = value >= 0 ? `rgba(${accent.r},${accent.g},${accent.b},0.9)` : `rgba(${text.r},${text.g},${text.b},0.7)`; fctx.fillRect(x, barTop, barWidth, Math.max(2, Math.abs(barBottom - barTop))); fctx.fillStyle = `rgb(${text.r},${text.g},${text.b})`; fctx.font = "5px monospace"; const label = String(row.handle || "bettor").slice(0, 7); fctx.fillText(label, x - 1, height - 7); }); const topLine = cleaned[0] ? `${String(cleaned[0].handle || "bettor").slice(0, 8)} ${Number(cleaned[0].balance || 0).toFixed(0)}` : "LIVE"; fctx.fillStyle = `rgb(${accent.r},${accent.g},${accent.b})`; fctx.font = "5px monospace"; fctx.fillText(topLine.slice(0, 18), 4, height - 4); if (reactionAttentionActive && activeNotification) { const rxBg = reactionBlinkOn ? accent : bg; const rxFg = reactionBlinkOn ? bg : accent; fctx.fillStyle = `rgb(${rxBg.r},${rxBg.g},${rxBg.b})`; fctx.fillRect(0, height - 11, width, 11); fctx.fillStyle = `rgb(${rxFg.r},${rxFg.g},${rxFg.b})`; fctx.font = "bold 5px monospace"; fctx.fillText(`${activeNotification.handle}: ${String(activeNotification.text).slice(0, 18)}`, 2, height - 3); } } else if (mode === "reaction") { // Dedicated reaction takeover page const now = Date.now(); const activeRxs = reactionQueue.filter((i) => Number.isFinite(i.expiresAt) && i.expiresAt > now); const rx = activeRxs[activeSubStep % Math.max(1, activeRxs.length)] || activeRxs[0]; if (rx) { const reactor = rx.reactor || rx.handle || "Admin"; const target = rx.targetHandle || rx.handle || "bettor"; const cType = rx.contextType || "bet"; const cLabel = rx.contextLabel || rx.text || ""; const flashBg = reactionBlinkOn ? accent : bg; const flashFg = reactionBlinkOn ? bg : accent; fctx.fillStyle = `rgb(${flashBg.r},${flashBg.g},${flashBg.b})`; fctx.fillRect(0, 0, width, height); fctx.fillStyle = `rgb(${flashFg.r},${flashFg.g},${flashFg.b})`; fctx.font = "bold 8px monospace"; fctx.fillText("REACTION", 4, 10); fctx.font = "bold 7px monospace"; fctx.fillStyle = reactionBlinkOn ? `rgb(${bg.r},${bg.g},${bg.b})` : `rgb(${text.r},${text.g},${text.b})`; fctx.fillText(`${reactor} ${rx.emoji || "\uD83D\uDD25"}`.slice(0, 20), 4, 22); fctx.font = "5px monospace"; fctx.fillStyle = `rgb(${flashFg.r},${flashFg.g},${flashFg.b})`; if (cType === "balance") { fctx.fillText(`reacted to ${target}'s`.slice(0, 22), 4, 34); fctx.fillText(cLabel.slice(0, 22), 4, 44); } else { fctx.fillText(`about ${target}'s`.slice(0, 22), 4, 34); fctx.fillText(cLabel.slice(0, 22), 4, 44); if (cLabel.length > 22) fctx.fillText(cLabel.slice(22, 44), 4, 54); } } } else if (mode === "finals") { if (emulatorDailySettlements.length > 0) { const card = emulatorDailySettlements[activeSubStep % emulatorDailySettlements.length] || emulatorDailySettlements[0]; fctx.fillStyle = `rgb(${accent.r},${accent.g},${accent.b})`; fctx.font = "bold 6px monospace"; fctx.fillText((card.league || "FINAL").slice(0, 8), 4, 12); fctx.fillStyle = `rgb(${text.r},${text.g},${text.b})`; fctx.font = "5px monospace"; fctx.fillText(card.score.slice(0, 21), 4, 22); fctx.fillText((card.secondTeam || card.matchup || card.event).slice(0, 21), 4, 30); card.bettors.forEach((bettor, idx) => { const y = 40 + idx * 6; const net = Number(bettor.net || 0); const sign = net > 0 ? "+" : ""; fctx.fillStyle = net < 0 ? `rgba(${text.r},${text.g},${text.b},0.82)` : `rgb(${accent.r},${accent.g},${accent.b})`; fctx.fillText(`${bettor.handle} ${sign}${Math.round(net)}`, 4, y); }); if (reactionAttentionActive && activeNotification) { const rxBg = reactionBlinkOn ? accent : bg; const rxFg = reactionBlinkOn ? bg : accent; fctx.fillStyle = `rgb(${rxBg.r},${rxBg.g},${rxBg.b})`; fctx.fillRect(0, height - 11, width, 11); fctx.fillStyle = `rgb(${rxFg.r},${rxFg.g},${rxFg.b})`; fctx.font = "bold 5px monospace"; fctx.fillText(`${activeNotification.handle}: ${String(activeNotification.text).slice(0, 18)}`, 2, height - 3); } } else { fctx.fillStyle = `rgb(${accent.r},${accent.g},${accent.b})`; fctx.font = "bold 6px monospace"; fctx.fillText("NO FINALS", 4, 12); } } else if (mode === "settlement") { if (emulatorOpenPicks.length > 0) { const item = emulatorOpenPicks[activeSubStep % emulatorOpenPicks.length] || emulatorOpenPicks[0]; const handle = String(item.handle || "bettor").slice(0, 8); const pick = String(item.selection || item.bet || "OPEN BET").slice(0, 16); const stake = `$${Number(item.stake || 0).toFixed(0)}`; fctx.font = "bold 6px monospace"; fctx.fillStyle = `rgb(${accent.r},${accent.g},${accent.b})`; fctx.fillText("WAGERS", 4, 12); fctx.font = "5px monospace"; fctx.fillStyle = `rgb(${text.r},${text.g},${text.b})`; fctx.fillText(handle, 4, 26); fctx.fillStyle = `rgb(${accent.r},${accent.g},${accent.b})`; fctx.fillText(pick, 42, 26); fctx.fillStyle = `rgb(${text.r},${text.g},${text.b})`; fctx.fillText(stake, 4, 40); fctx.fillStyle = `rgba(${text.r},${text.g},${text.b},0.8)`; fctx.fillText(String(item.event || "LIVE").slice(0, 20), 4, 54); } else { fctx.font = "bold 6px monospace"; fctx.fillStyle = `rgb(${accent.r},${accent.g},${accent.b})`; fctx.fillText("NO WAGERS", 4, 12); } if (displayDraft.notifications?.reactions && activeNotification) { const item = activeNotification; const emoji = item.type === "reaction" ? String(item.text || "🔥").slice(0, 2) : ""; fctx.fillStyle = `rgba(${accent.r},${accent.g},${accent.b},0.85)`; fctx.font = "bold 7px monospace"; if (emoji) fctx.fillText(emoji, width - 20, 12); fctx.font = "5px monospace"; fctx.fillStyle = `rgb(${text.r},${text.g},${text.b})`; fctx.fillText(`${String(item.handle || "bettor").slice(0, 8)} ${String(item.text || "update").slice(0, 11)}`, 4, 62); } } else { if (emulatorOpenPicks.length > 0) { const item = emulatorOpenPicks[activeSubStep % emulatorOpenPicks.length] || emulatorOpenPicks[0]; const baseMessage = `${String(item.selection || "OPEN BET")} STK ${Number(item.stake || 0).toFixed(0)} ${String(item.event || "")}`; fctx.font = "6px monospace"; fctx.fillStyle = `rgb(${text.r},${text.g},${text.b})`; const textWidth = Math.max(1, Math.ceil(fctx.measureText(baseMessage).width)); // scroll resets each time this page starts (activeSubStep=0) so text always completes before page advances const scroll = Math.floor(activeSubStep * Math.max(8, displaySpeed.horizontal * 2)); const travel = textWidth + width + 10; const x = width - (scroll % travel); fctx.fillText(baseMessage, x, 34); fctx.fillStyle = `rgba(${accent.r},${accent.g},${accent.b},0.95)`; fctx.fillText("TICKER MODE", 3, 52); } if (activeNotification) { const item = activeNotification; const label = item.type === "reaction" ? String(item.text || "🔥").slice(0, 2) : item.label || "EVENT"; fctx.fillStyle = `rgba(${accent.r},${accent.g},${accent.b},0.9)`; fctx.font = "bold 7px monospace"; fctx.fillText(label, width - 22, 58); fctx.font = "5px monospace"; fctx.fillStyle = `rgb(${text.r},${text.g},${text.b})`; fctx.fillText(`${String(item.handle || "bettor").slice(0, 8)} ${String(item.text || "update").slice(0, 12)}`, 4, 58); } } const image = fctx.getImageData(0, 0, width, height).data; ctx.fillStyle = "#000"; ctx.fillRect(0, 0, canvas.width, canvas.height); const radius = Math.max(0.8, scale * 0.17); const offR = Math.round(bg.r * 0.84); const offG = Math.round(bg.g * 0.84); const offB = Math.round(bg.b * 0.84); const offColor = reactionInvertActive ? `rgba(${255 - offR},${255 - offG},${255 - offB},0.28)` : `rgba(${offR},${offG},${offB},0.28)`; for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { if ((x + y) % 5 !== 0) continue; const px = x * scale + scale / 2; const py = y * scale + scale / 2; ctx.beginPath(); ctx.arc(px, py, radius, 0, Math.PI * 2); ctx.fillStyle = offColor; ctx.fill(); } } for (let y = 0; y < height; y += 1) { for (let x = 0; x < width; x += 1) { const idx = (y * width + x) * 4; const a = image[idx + 3] / 255; if (a < 0.05) continue; const r = Math.min(255, Math.round(image[idx] * brightness)); const g = Math.min(255, Math.round(image[idx + 1] * brightness)); const b = Math.min(255, Math.round(image[idx + 2] * brightness)); const renderR = reactionInvertActive ? 255 - r : r; const renderG = reactionInvertActive ? 255 - g : g; const renderB = reactionInvertActive ? 255 - b : b; const px = x * scale + scale / 2; const py = y * scale + scale / 2; ctx.beginPath(); ctx.arc(px, py, radius, 0, Math.PI * 2); ctx.fillStyle = `rgba(${renderR},${renderG},${renderB},${Math.max(0.25, a)})`; ctx.fill(); } } }, [activeEmulatorMode, activeLogoContext, activeSubStep, displayDraft, emulatorDailySettlements, emulatorOpenPicks, emulatorRows, emulatorSettlements, getLogoImage, logoRenderVersion, reactionBlinkOn, stackedNotificationQueue]); useEffect(() => { const threshold = savedThreshold; if (threshold == null || threshold === "") { setThresholdInput(""); return; } setThresholdInput(String(Number(threshold))); }, [savedThreshold]); const sortedLog = useMemo(() => { const getDate = (p) => new Date( p.graded_at || p.settled_at || p.updated_at || p.placed_at || p.at || p.created_at || 0 ).getTime(); const arr = [...(admin.log || [])]; switch (closedSort) { case "user_asc": return arr.sort((a, b) => String(a.handle || "").localeCompare(String(b.handle || ""))); case "user_desc": return arr.sort((a, b) => String(b.handle || "").localeCompare(String(a.handle || ""))); case "date_asc": return arr.sort((a, b) => getDate(a) - getDate(b)); case "date_desc": default: return arr.sort((a, b) => getDate(b) - getDate(a)); } }, [admin.log, closedSort]); const totalOpenAmount = (admin.picks || []).reduce((sum, p) => sum + Number(p.stake || 0), 0); const pastOpenCount = (admin.picks || []).reduce((sum, p) => sum + (hasPickEnded(p) ? 1 : 0), 0); const pastOpenAmount = (admin.picks || []).reduce( (sum, p) => sum + (hasPickEnded(p) ? Number(p.stake || 0) : 0), 0 ); function parlayDetail(p) { if (!Array.isArray(p.legs) || p.legs.length === 0) return null; return p.legs.map((l, i) => { const sel = String(l.selection || "").trim(); const mkt = String(l.market || "").trim().toLowerCase(); const line = l.line ?? null; const total = l.total ?? null; const odds = l.odds != null ? `(${signOdds(Number(l.odds))})` : ""; let pick = sel; if (mkt === "spread" && line != null) pick = `${sel} ${Number(line) >= 0 ? "+" : ""}${line}`; else if (mkt === "total" && total != null) pick = `${l.side === "over" ? "O" : "U"} ${total}`; const game = String(l.event || "").trim(); const league = String(l.league || "").toUpperCase(); const result = l.result ? ` — ${String(l.result).toUpperCase()}` : ""; return `Leg ${i+1}: ${pick} ${odds}${game ? ` · ${league ? league+": " : ""}${game}` : ""}${result}`; }).join("\n"); } function QueuePick({ p }) { const isParlay = Array.isArray(p.legs) && p.legs.length > 0; const title = isParlay ? `Parlay (${p.legs.length} legs)` : p.selection; const detail = isParlay ? parlayDetail(p) : `${p.league} - ${p.event}`; const score = useLiveScore(p.league, p.event, p.start); return (
{p.handle} - {title} {!isParlay && p.odds != null && ( ({signOdds(Number(p.odds))}) )}
{detail}
{score && (
{score.awayName} {score.awayScore}-{score.homeScore} {score.homeName}
)}
{dollars(p.stake)}
); } function OpenPick({ p }) { const started = hasPickStarted(p); const ended = hasPickEnded(p); const rawStatus = String(p.status || p.state || "").trim().toLowerCase(); const closedStatuses = ["won","lost","void","push","graded","settled","closed","canceled","cancelled"]; const openish = ["open","pending","in_progress","ungraded","live","active","pending_approval","proposed"]; const overdue = !closedStatuses.includes(rawStatus) && ended; let statusText; if (closedStatuses.includes(rawStatus)) statusText = p.status || rawStatus; else if (ended) statusText = "overdue"; else if (started) statusText = "pending"; else if (openish.includes(rawStatus) || rawStatus === "") statusText = "open"; else statusText = p.status || p.state || "open"; const isParlay = Array.isArray(p.legs) && p.legs.length > 0; const title = isParlay ? `Parlay (${p.legs.length} legs)` : p.selection; const detail = isParlay ? parlayDetail(p) : `${p.league} - ${p.event}`; const score = useLiveScore(p.league, p.event, p.start); const resolvedRef = useRef(false); useEffect(() => { if (!autoResolve || resolvedRef.current) return; if (!score) return; if (closedStatuses.includes(rawStatus)) return; if (!hasPickEnded(p)) return; const result = resolveMoneyline(p, score); if (!result) return; resolvedRef.current = true; const finalScore = `${score.awayName} ${score.awayScore}-${score.homeScore} ${score.homeName}`; autoResolve(p, result, finalScore); }, [score, autoResolve, p, rawStatus]); return (
{p.handle} - {title} {!isParlay && p.odds != null && ( ({signOdds(Number(p.odds))}) )}
{detail} - {statusText}
{score && (
{score.awayName} {score.awayScore}-{score.homeScore} {score.homeName}
)}
{dollars(p.stake)}
(p.__stake = Number(e.target.value))} placeholder="Stake" /> {!isParlay && ( <> (p.__selection = e.target.value)} placeholder="Selection text" /> (p.__odds = e.target.value === "" ? null : Number(e.target.value))} placeholder="Odds (e.g., -110)" /> )} {isParlay &&
}
{!isParlay && ( )}
); } return ( <>

Admin Dashboard

{canInstall && ( )}
{admin.loading &&
Loading admin data...
} {admin.error &&
{admin.error}
} {displaySubmenu !== "main" && (
Ticker Configurator + Emulator
Tune the display centrally and preview what each Raspberry Pi board will render.
Presets
{DISPLAY_PRESETS.map((preset) => ( ))}
{(DISPLAY_BRAND_PACKS.find((pack) => pack.key === selectedBrandPack)?.logos || []).slice(0, 2).map((logo) => ( logo pack preview ))} {((DISPLAY_BRAND_PACKS.find((pack) => pack.key === selectedBrandPack)?.logos || []).length === 0) && ( No league logo overlay in this pack. )}
{[ ["show_open_bets", "Open Bets"], ["show_graph", "Balance Graph"], ["show_scores", "Final Scores"], ["show_balance_strip", "Balance Strip"], ["show_logos", "Team Logos"], ["notifications.reactions", "Reactions"], ["notifications.payins", "Payins"], ["notifications.payouts", "Payouts"], ["notifications.settlements", "Settlements"], ["notifications.live_bets", "Live Bets"], ].map(([field, label]) => { const isNotif = String(field).startsWith("notifications."); const notifKey = String(field).split(".")[1]; const checked = isNotif ? !!(displayDraft.notifications && displayDraft.notifications[notifKey]) : !!displayDraft[field]; return ( ); })}
{displayStatus &&
{displayStatus}
}
Live Emulator (true 128x64 LED matrix)
{/* === EMULATOR v3 === */} {/* Inject CSS animations */} {(() => { const t = displayDraft?.theme || {}; const accent = t.accent || "#22c55e"; const mode = activeEmulatorMode; const now = Date.now(); const hex2rgb = (h) => { const s = String(h||"#22c55e").replace("#",""); return [parseInt(s.slice(0,2)||"22",16), parseInt(s.slice(2,4)||"c5",16), parseInt(s.slice(4,6)||"5e",16)]; }; const [ar,ag,ab] = hex2rgb(accent); const winCol = accent; const lossCol = "#ff4466"; const pushCol = "#8899aa"; const textGlow = (col) => ({ color: col, textShadow: `0 0 6px ${col}88` }); const accentDim = `rgba(${ar},${ag},${ab},0.55)`; const rxNow = reactionQueue.filter((i) => Number.isFinite(i.expiresAt) && i.expiresAt > now); const rxActive = rxNow.length > 0; // Flash phase: invert panel colors for 10s after any reaction fires const isFlashing = rxActive && now < reactionFlashEnd; const flashPulse = isFlashing && reactionBlinkOn; // Panel style — inverts on flash const panelBg = isFlashing ? (flashPulse ? `rgba(${ar},${ag},${ab},1)` : "#000000") : "#000000"; const panelBorder = isFlashing ? `2px solid rgba(${ar},${ag},${ab},${flashPulse ? "1" : "0.6"})` : `1px solid rgba(${ar},${ag},${ab},0.28)`; const panelShadow = isFlashing ? `0 0 ${flashPulse ? "36" : "16"}px rgba(${ar},${ag},${ab},${flashPulse ? "0.8" : "0.4"}), 0 4px 16px #000c` : `0 0 8px rgba(${ar},${ag},${ab},0.12), 0 4px 16px #000c`; const panelStyle = { background: panelBg, borderRadius: "8px", border: panelBorder, boxShadow: panelShadow, fontFamily: "'Share Tech Mono','IBM Plex Mono','Courier New',monospace", overflow: "hidden", transition: "background 0.08s, box-shadow 0.08s, border-color 0.08s", }; const scanlines = { position:"absolute", inset:0, pointerEvents:"none", zIndex:0, background:"repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,0,0,0.1) 3px,rgba(0,0,0,0.1) 4px)", }; // Text color flips during flash const fgMain = isFlashing && flashPulse ? "#030c1a" : "#c8e8d0"; const fgAccent = isFlashing && flashPulse ? "#030c1a" : accent; const hdrPrimary = isFlashing && flashPulse ? "#030c1a" : "#ff7af6"; const hdrSecondary = isFlashing && flashPulse ? "#030c1a" : "#ffe45e"; // ── Wager ticker text ── const allWagers = emulatorOpenPicks; const horizontalFactor = Math.max(0.45, 18 / Math.max(1, displaySpeed.horizontal)); const tickerText = allWagers.length > 0 ? allWagers.map(p => { const sc2 = p.live_score; const scoreStr = sc2 ? ` [${sc2.isLive ? "LIVE" : "FINAL"}: ${sc2.label}]` : ""; return `${p.handle.toUpperCase()} ▸ ${p.selection} [$${Number(p.stake||0).toFixed(0)}]${scoreStr}`; }).join(" ◆ ") : "NO LIVE WAGERS"; const tickerDuration = `${(Math.max(8, tickerText.length * 0.14) * horizontalFactor).toFixed(2)}s`; // ── Reaction footer ticker text ── const rxFooterText = rxNow.map(rx => { const reactor = rx.reactor || rx.handle || "Admin"; const target = rx.targetHandle || rx.handle || "bettor"; const cType = rx.contextType || "bet"; const cLabel = rx.contextLabel || rx.text || ""; const ctx = cType === "balance" ? `reacted to ${target}'s ${cLabel}` : `said ${rx.emoji || "\uD83D\uDD25"} about ${target}'s ${cLabel}`; return `${rx.emoji || "\uD83D\uDD25"} ${reactor} ${ctx}`; }).join(" ★ "); const rxDuration = `${(Math.max(6, rxFooterText.length * 0.12) * horizontalFactor).toFixed(2)}s`; // ── Balance graph data ── const graphData = emulatorRows.length > 0 ? emulatorRows : []; // ── Bettor status helpers ── const getBettorStatus = (pick) => pick.bettor_status || null; const teamThemeColor = (pick) => { const sc = pick.live_score; if (!sc) return null; const sel = (pick.selection || "").toLowerCase(); const isHome = sc.homeName && sel.includes((sc.homeName.split(" ").pop() || "").toLowerCase()); return isHome ? sc.homeColor : sc.awayColor; }; const scoreLabel = (pick) => { const sc = pick.live_score; if (!sc) return null; return { label: sc.label, sc, isLive: sc.isLive, isFinal: sc.isFinal }; }; // ── Settlement filter (no reaction events) ── const realSettlements = emulatorSettlements.filter(s => !["REACTION","WAGER","EVENT"].includes(s.result)); // ── League logo ── const getLeagueLogo = (league) => LEAGUE_LOGO_PATHS[(league||"").toLowerCase().trim()] || null; const teamColors = ["#22c55e","#60a5fa","#f59e0b","#a78bfa","#34d399","#fb923c","#e879f9","#38bdf8"]; const userColor = (i) => teamColors[i % teamColors.length]; return (
{/* ── WAGER TICKER (always top) ── */}
{tickerText}
{isFlashing ? (flashPulse ? "⚡ REACTION ⚡" : (t.title || "BLACKBOOK LIVE")) : (t.title || "BLACKBOOK LIVE")} {isFlashing ? "REACT" : mode.toUpperCase()} {rxActive && ●}
{/* ── BOARD MODE ── */} {mode === "board" && (
▌ BALANCE BOARD
{graphData.length === 0 &&
No balance data
} {graphData.length > 0 && (
BETTOR TODAY YTD PEND BAL
)} {graphData.map((row, i) => { const bal = Number(row.balance || 0); const today = Number(row.today_pnl || 0); const ytd = Number(row.ytd_pnl || 0); const pend = Number(row.pending || 0); const todayCol = today > 0 ? winCol : today < 0 ? lossCol : pushCol; const ytdCol = ytd > 0 ? winCol : ytd < 0 ? lossCol : pushCol; const balCol = bal > 0 ? winCol : bal < 0 ? lossCol : pushCol; const pendCol = pend > 0 ? "#facc15" : pushCol; const userPick = emulatorOpenPicks.find(p => p.handle === row.handle); const status = userPick ? getBettorStatus(userPick) : null; const nameCol = status === "winning" ? winCol : status === "losing" ? lossCol : bal > 0 ? winCol : bal < 0 ? lossCol : pushCol; const tColor = (userPick && teamThemeColor(userPick)) || nameCol; return (
{row.handle} {today>=0?"+":""}{today.toFixed(2)} {ytd>=0?"+":""}{ytd.toFixed(2)} {pend > 0 ? `$${pend.toFixed(0)}` : "—"} {bal>=0?"+":""}{bal.toFixed(2)}
); })} {graphData.length > 1 && graphData.some(r => r.today_pnl !== 0) && (() => { const vals = graphData.map(r => Number(r.today_pnl||0)); const minV = Math.min(0,...vals), maxV = Math.max(0.01,...vals); const h=36, w=200, pad=4; const yOf = v => h - pad - ((v-minV)/(maxV-minV))*(h-2*pad); const pts = vals.map((v,i)=>`${pad+(i/(vals.length-1))*(w-2*pad)},${yOf(v)}`).join(" "); return (
▌ TODAY P&L
{vals.map((v,i)=>{ const x=pad+(i/(vals.length-1))*(w-2*pad), y=yOf(v), c2=v>0?winCol:v<0?lossCol:pushCol; return ; })}
); })()}
)} {/* ── TICKER MODE ── */} {mode === "ticker" && (
▌ ALL WAGERS
{emulatorOpenPicks.length === 0 &&
No open wagers
} {emulatorOpenPicks.map((p, i) => { const status = getBettorStatus(p); const col = status === "winning" ? winCol : status === "losing" ? lossCol : userColor(i); const sc2 = scoreLabel(p); return (
{p.handle} {p.compactMatchup || p.matchup || p.secondTeam || stripDateNoise(p.event || "")} {summarizeWagerCompactDetails(p)} • {summarizeWagerDetails(p)} {sc2 ? ( {sc2.isLive?"🔴":""}{sc2.label} ) : ( ${Number(p.stake||0).toFixed(0)} )}
); })}
)} {/* ── SETTLEMENT MODE ── */} {mode === "settlement" && (
▌ GAME RESULTS
{realSettlements.length === 0 &&
No settlements yet
} {realSettlements.map((s, i) => { const col = s.result==="WON"?winCol:s.result==="LOST"?lossCol:pushCol; return (
{s.handle} {s.secondTeam || s.matchup || stripDateNoise(s.event || s.bet)} {s.result}
); })}
)} {/* ── FINALS MODE ── */} {mode === "finals" && (
{emulatorDailySettlements.length === 0 &&
No finals today
} {emulatorDailySettlements.map((card, ci) => { const logo = getLeagueLogo(card.league); return (
{logo && {card.league}}
{card.score}
{card.matchup}
{card.secondTeam && (
{card.secondTeam}
)}
{card.league.toUpperCase()} ✓
{card.bettors.map((b, bi) => { const net=Number(b.net||0), col=net>0?winCol:net<0?lossCol:pushCol; return (
{b.handle} {net>=0?"+":""}{net.toFixed(2)}
); })}
); })}
)}
{/* ── REACTION FOOTER TICKER (shown whenever reactions are active) ── */} {rxActive && (
{rxFooterText}
)}
); })()}
Speed V:{displaySpeed.vertical} • H:{displaySpeed.horizontal} • Page:{displaySpeed.page} • {activeEmulatorMode.toUpperCase()} • group: {board?.group_code || "(connect device)"}{reactionQueue.filter(i => i.expiresAt > Date.now()).length > 0 ? ` • ${reactionQueue.filter(i => i.expiresAt > Date.now()).length} reaction(s)` : ""}
)}
Credit Hold Threshold
Users below this balance can be automatically placed on credit hold.
{ e.preventDefault(); if (typeof adminSetThreshold !== "function") return; adminSetThreshold(thresholdInput); }} > setThresholdInput(e.target.value)} placeholder="-500" />
Current: {savedThreshold == null ? "-" : dollars(Number(savedThreshold))}
Bettors
{admin.users.length}
Bookies Balance
{admin.resume.totalBalance != null ? dollars(-admin.resume.totalBalance) : "-"}
{/* LIVE BET QUEUE */}
{!queueCollapsed && (
{admin.queue.map((p) => ( ))} {admin.queue.length === 0 &&
No live bet proposals.
}
)}
{/* OPEN PICKS EDITOR */}
{!openCollapsed && Edit stake/selection/odds or close with result}
{!openCollapsed && (
{admin.picks.map((p) => ( ))} {admin.picks.length === 0 &&
No picks to edit.
}
)}
{/* CLOSED PICKS LOG (Historical) */}
{!closedCollapsed && ( )}
{!closedCollapsed && Final scores shown when available}
{!closedCollapsed && (
{sortedLog.length === 0 &&
No closed picks yet.
} {sortedLog.slice(0, 100).map((p) => { const isParlay = Array.isArray(p.legs) && p.legs.length > 0; const title = isParlay ? `Parlay (${p.legs.length} legs)` : p.selection; const detail = isParlay ? parlayDetail(p) : `${p.league} - ${p.event}`; return (
{p.handle} - {title} {!isParlay && p.odds != null && ({signOdds(Number(p.odds))})}
{detail}
{dollars(p.stake)}
{p.result || p.status || "closed"}
); })}
)}
{/* CONTACT + BALANCES TABLE */}
{!balancesCollapsed && Balances exclude pending wagers}
{!balancesCollapsed && ( <>
{admin.users.map((u, i) => { const pending = Number(u.pending ?? 0); const available = typeof u.balance === "number" ? Number(u.balance) : null; const maxBet = getUserMaxBet(u); const actionStatus = userActionStatus[u.handle]; const holdThreshold = parseHoldThresholdAmount(u.credit_hold_amount); const changed = (!!u.__push_opt_in !== !!u.push_opt_in); const isHeld = !!u.credit_hold; const holdReason = getHoldReasonLabel(u.credit_hold_reason); return (
Bettor Open Pending $ Balance React Contact Hold Balance Adjustment
{u.handle} {isHeld && ( Held ({holdReason}) )}
{isHeld && u.credit_hold_message && (
"{u.credit_hold_message}"
)}
{u.openCount ?? "-"} {u.pending != null ? dollars(pending) : "-"} {available != null ? dollars(available) : "-"}
{u.contact || (u.email || u.phone || "-")}
{ e.preventDefault(); const form = e.currentTarget; const holdAmountInput = form.hold_amount?.value; const holdMessageInput = form.hold_message?.value; const maxBetInput = form.max_bet?.value; const ok = await submitCreditHold({ user: u, isHeld, holdAmountInput, holdMessageInput, maxBetInput, }); if (ok) { if (form.hold_amount) form.hold_amount.value = ""; if (form.hold_message) form.hold_message.value = ""; } }} > {!isHeld && ( <>
Manual Hold