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(window.BBMoney.formatMoney(stake, { decimals: 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, window.BBMoney.formatMoney(stake, { decimals: 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 adminParlayDetail(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");
}
// "To win: $181.82" (open) / "Won $181.82" · "Lost $200.00" · "Push · $200.00 returned" (graded),
// from the shared helper (utils/towin.js). Module level, like the pick cards below.
function AdminToWin({ p }) {
const line = window.BBToWin.wagerResult(p);
const tone = line.tone === "win" ? " text-green-600" : line.tone === "loss" ? " text-red-600" : "";
return
{line.label}
;
}
// Pick cards are defined at module level (not inside AdminDashboard) so the dashboard's
// frequent re-renders (emulator ticks, reaction blink, refreshes) never create a new
// component type and remount them. A remount wiped in-progress edits and dismissed the
// iPhone keyboard, which made the Admin tab "jump" while typing.
const AdminQueuePick = React.memo(function AdminQueuePick({ p, actions, counterOn }) {
const isParlay = Array.isArray(p.legs) && p.legs.length > 0;
// Counter offer (api #275): only on single bets, only when the server has the counter routes.
const [countering, setCountering] = useState(false);
const CU = window.BBCounterUI;
const canCounter = !!(counterOn && CU && !isParlay);
const title = isParlay ? `Parlay (${p.legs.length} legs)` : p.selection;
const detail = isParlay ? adminParlayDetail(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}
)}
{CU && !isParlay &&
}
actions.current.adminApprove(p)}>Approve
actions.current.adminReject(p)}>Reject
{canCounter && (
setCountering((v) => !v)}>Counter
)}
actions.current.handleReact(p)}>React
{canCounter && countering && (
setCountering(false)}
onSent={() => { setCountering(false); actions.current.counterSent(p); }}
/>
)}
);
});
const AdminOpenPick = React.memo(function AdminOpenPick({ p, actions }) {
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 ? adminParlayDetail(p) : `${p.league} - ${p.event}`;
const score = useLiveScore(p.league, p.event, p.start);
const resolvedRef = useRef(false);
// In-progress edits live with the mounted card, not on the pick object, so a
// refresh that swaps in fresh pick objects cannot drop them.
const draftRef = useRef({});
useEffect(() => {
const autoResolve = actions.current.autoResolve;
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, actions, 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}
)}
);
});
// Live betting closes late in games: per-group server setting (GET /settings/live-cutoff, PUT
// /admin/settings/live-cutoff; admin only, every change logged on the server). Default ON with 60 s buffers.
function LiveCutoffSetting({ apiBase, token, withCode }) {
const LC = typeof window !== "undefined" ? window.BBLiveCutoff : null;
const [setting, setSetting] = useState({ state: "loading" });
const [draft, setDraft] = useState(null);
const [saving, setSaving] = useState(false);
const [note, setNote] = useState("");
useEffect(() => {
if (!LC || !token) return;
let cancelled = false;
LC.fetchSetting(apiBase, token, withCode).then((s) => {
if (cancelled) return;
setSetting(s);
if (s.state === "ready") setDraft(Object.assign({}, s.buffers));
});
return () => { cancelled = true; };
}, [apiBase, token, withCode]);
if (!LC) return null;
const save = async (patch, okText) => {
setSaving(true);
setNote("");
const r = await LC.saveSetting(apiBase, token, withCode, patch);
setSaving(false);
if (r.state === "ready") {
setSetting(r);
setDraft(Object.assign({}, r.buffers));
setNote(okText);
try { window.dispatchEvent(new CustomEvent("bb-live-cutoff-changed", { detail: r })); } catch (e) {}
} else {
setNote(r.error || "Could not save");
}
};
const ready = setting.state === "ready";
const dirty = ready && draft && LC.BUFFER_SPORTS.some(({ key }) => Number(draft[key]) !== setting.buffers[key]);
const closes = ready ? LC.describeBuffers(draft || setting.buffers) : null;
return (
save({ enabled: e.target.checked }, e.target.checked ? "Live betting now closes late in games." : "Late-game cutoff is off.")}
/>
Close live betting late in games
{setting.state === "loading" ? "Loading…"
: setting.state === "unavailable" ? "Not available on this server yet"
: saving ? "Saving…"
: setting.enabled ? "On" : "Off"}
Closes each sport a safety buffer early, because scores reach us 1-2 minutes behind TV. Pending live bets
stay in the queue and can still be approved.
{ready && draft && (
<>
{LC.BUFFER_SPORTS.map(({ key, label }) => (
{label}
setDraft(Object.assign({}, draft, { [key]: e.target.value }))}
/>
sec early: {closes[key]}
))}
Baseball (no clock): {closes.mlb}
save({ buffers: LC.normalizeBuffers(draft) }, "Buffers saved.")}
>
Save buffers
setDraft(Object.assign({}, LC.DEFAULT_BUFFERS))}
>
Use defaults (60 s)
{setting.updatedBy && (
Last changed by {setting.updatedBy}
)}
>
)}
{note && {note}
}
);
}
// Leagues this group offers (utils/group-leagues.js, tracker #59). A group admin sets their own group; a super
// admin also gets a group selector (GET /admin/settings/leagues/groups answers only for them). Turning a league
// off hides it from the group's Games list and stops new bets on it; existing bets are never touched.
function LeaguesSetting({ apiBase, token, withCode }) {
const GL = typeof window !== "undefined" ? window.BBGroupLeagues : null;
const [groups, setGroups] = useState({ state: "loading" });
const [target, setTarget] = useState("");
const [setting, setSetting] = useState({ state: "loading" });
const [toggles, setToggles] = useState(null);
const [saving, setSaving] = useState(false);
const [note, setNote] = useState(null); // { kind: "ok" | "error", text }
useEffect(() => {
if (!GL || !token) return;
let cancelled = false;
GL.fetchGroups(apiBase, token, withCode).then((g) => { if (!cancelled) setGroups(g); });
return () => { cancelled = true; };
}, [apiBase, token, withCode]);
useEffect(() => {
if (!GL || !token) return;
let cancelled = false;
setSetting({ state: "loading" });
setNote(null);
GL.fetchAdmin(apiBase, token, withCode, target).then((s) => {
if (cancelled) return;
setSetting(s);
setToggles(s.state === "ready" ? GL.togglesFrom(s.leagues) : null);
});
return () => { cancelled = true; };
}, [apiBase, token, withCode, target]);
if (!GL) return null;
const ready = setting.state === "ready" && toggles;
const superAdmin = groups.state === "ready";
const dirty = ready && GL.disabledFromToggles(toggles).join(",") !== setting.disabled.join(",");
const save = async (disabled) => {
setSaving(true);
setNote(null);
const r = await GL.saveAdmin(apiBase, token, withCode, { disabled, groupCode: superAdmin ? (target || setting.groupCode) : "" });
setSaving(false);
if (r.state === "ready") {
setSetting(r);
setToggles(GL.togglesFrom(r.leagues));
const off = r.disabled.length;
setNote({ kind: "ok", text: `Saved for ${r.groupName || r.groupCode}. ${off ? `${off} league${off === 1 ? "" : "s"} off` : "Every league on"}.` });
if (superAdmin) {
GL.fetchGroups(apiBase, token, withCode).then((g) => setGroups(g));
}
try { window.dispatchEvent(new CustomEvent("bb-group-leagues-changed", { detail: r })); } catch (e) {}
} else {
setNote({ kind: "error", text: `Not saved: ${r.error || "Could not save"}` });
}
};
const groupLabel = (g) => `${g.name ? `${g.name} (${g.code})` : g.code}${g.disabled_count ? ` · ${g.disabled_count} off` : ""}`;
return (
Leagues
{setting.state === "loading" ? "Loading…"
: setting.state === "unavailable" ? "Not available on this server yet"
: setting.state === "error" ? "Could not load"
: saving ? "Saving…" : GL.summary(toggles)}
Choose which leagues {superAdmin ? "a group" : "your group"} can see and bet on. A league that's off disappears
from the Games list and takes no new bets. Bets already placed stay put and still settle.
{superAdmin && (
Group
setTarget(e.target.value)}
>
{groups.groups.map((g) => (
{groupLabel(g)}
))}
Super admin: you can change any group.
)}
{setting.state === "error" && {setting.error}
}
{ready && (
<>
{GL.groupBySport(setting.leagues).map((sport) => (
{sport.label}
{sport.leagues.map((l) => (
{ setNote(null); setToggles(Object.assign({}, toggles, { [l.key]: e.target.checked })); }}
/>
{l.label}
{l.sport === "soccer" && l.soccer_public === false && (
Admins only for now
)}
))}
))}
save(GL.disabledFromToggles(toggles))}>
{saving ? "Saving…" : "Save leagues"}
{ setNote(null); setToggles(GL.togglesFrom(setting.leagues.map((l) => Object.assign({}, l, { enabled: true })))); }}>
Turn all on
{setting.updatedBy && Last changed by {setting.updatedBy} }
>
)}
{note && (
{note.text}
)}
);
}
function AdminDashboard({ admin, setAdmin, gradeOpenBets, loadAdmin, adminEditPick, adminApprove, adminReject, adminAdjust, 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 {
// in-page picker (utils/react-picker.js): window.prompt() raised the iPhone keyboard and stranded the bottom nav
const emoji = window.BBReactPicker ? await window.BBReactPicker.open({ title: "React to bet", current: p.emoji || "🔥", text: p.emoji || "🔥" }) : null;
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 }, 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 = async (u) => {
const emoji = window.BBReactPicker ? await window.BBReactPicker.open({ title: `React to ${u.handle}'s balance`, current: "\uD83D\uDD25", text: "\uD83D\uDD25" }) : null;
if (!emoji) return;
const bal = typeof u.balance === "number" ? window.BBMoney.formatMoney(u.balance, { decimals: 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
);
};
// Per-user credit controls. blackbook-api serves exactly one route for these:
// PATCH /admin/users/contact body { handle, credit_hold, credit_hold_reason, credit_hold_message,
// credit_hold_amount, max_bet } (admin bearer token; ?code= group)
// and answers { ok, handle, credit_hold, credit_hold_reason, credit_hold_message, credit_hold_amount, max_bet }
// read back from the store bets are enforced against. Only send keys that change: an omitted key is left alone,
// and never send reason/message as null (the api stores str(None) == "None"); clearing the hold clears them.
const applyUserControls = (handle, controls) => {
const key = String(handle || "").toLowerCase();
if (typeof setAdmin !== "function") return;
setAdmin((prev) => {
const prevUsers = Array.isArray(prev?.users) ? prev.users : [];
return {
...prev,
users: prevUsers.map((x) => String(x?.handle || "").toLowerCase() !== key ? x : {
...x,
credit_hold: !!controls.credit_hold,
credit_hold_reason: controls.credit_hold_reason ?? null,
credit_hold_message: controls.credit_hold_message ?? null,
credit_hold_amount: controls.credit_hold_amount ?? null,
max_bet: controls.max_bet ?? null,
}),
};
});
};
const saveUserControls = async (handle, changes) => {
const res = await fetch(withCode(`${apiBase}/admin/users/contact`), {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ handle, ...changes }),
});
let body = null;
try { body = await res.json(); } catch { body = null; }
if (!res.ok || !body || body.ok === false) {
const detail = body?.error || (typeof body?.detail === "string" ? body.detail : "");
throw new Error(detail || `HTTP ${res.status}`);
}
if (!Object.prototype.hasOwnProperty.call(body, "credit_hold")) {
throw new Error("Server did not return the saved controls");
}
applyUserControls(body.handle || handle, body);
setMaxBetOverride(body.handle || handle, parseMaxBetAmount(body.max_bet));
return body;
};
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;
const parsedAmount = Number(holdAmountInput);
const holdAmount = nextHold && String(holdAmountInput ?? "").trim() !== "" && 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;
}
setActionStatus(user.handle, "busy", nextHold ? "Applying hold..." : "Removing hold...");
const changes = nextHold
? {
credit_hold: true,
credit_hold_reason: "manual",
...(holdMessage != null ? { credit_hold_message: holdMessage } : {}),
...(holdAmount != null ? { credit_hold_amount: holdAmount } : {}),
}
: { credit_hold: false }; // server clears reason, message and threshold with the hold
if (maxBetRequested) changes.max_bet = nextMaxBet;
try {
const saved = await saveUserControls(user.handle, changes);
if (!!saved.credit_hold !== nextHold) throw new Error(nextHold ? "Hold was not applied" : "Hold is still active");
setActionStatus(user.handle, "ok", nextHold ? "Credit hold applied." : "Credit hold removed.");
return true;
} catch (e) {
setActionStatus(user.handle, "error", 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)})...`);
try {
const saved = await saveUserControls(user.handle, { max_bet: nextMaxBet });
const maxBet = parseMaxBetAmount(saved.max_bet);
setActionStatus(user.handle, "ok", maxBet == null ? "Max bet cleared." : `Max bet set to ${dollars(maxBet)}.`);
return { ok: true, maxBet };
} catch (e) {
setActionStatus(user.handle, "error", 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 };
}
const wasAutoHeld = !!user?.credit_hold && user?.credit_hold_reason === "auto";
setActionStatus(user.handle, "busy", nextThreshold == null ? "Clearing hold threshold..." : `Saving hold threshold (${dollars(nextThreshold)})...`);
try {
// Clearing the threshold also releases an "auto" hold server-side; no extra fields needed.
const saved = await saveUserControls(user.handle, { credit_hold_amount: nextThreshold });
const threshold = parseHoldThresholdAmount(saved.credit_hold_amount);
setActionStatus(
user.handle,
"ok",
threshold == null
? (wasAutoHeld ? (saved.credit_hold ? "Hold threshold cleared. Hold still active; remove it manually if needed." : "Hold threshold cleared and auto-hold released.") : "Hold threshold cleared.")
: `Hold threshold set to ${dollars(threshold)}.`
);
return { ok: true, threshold };
} catch (e) {
setActionStatus(user.handle, "error", e?.message || "Unable to save hold threshold");
return { ok: false, threshold: null };
}
};
const [queueCollapsed, setQueueCollapsed] = useState(true);
// The switch lives only on the server, per group (GET/PUT .../settings/live-approval), default REQUIRED;
// POST /picks enforces it. Nothing is read from or written to the browser. Until the server answers (or if
// it can't be reached) the box shows REQUIRED and nothing is saved; only an explicit click saves.
const [requireLiveApproval, setRequireLiveApproval] = useState(true);
const [liveApprovalState, setLiveApprovalState] = useState("loading"); // loading | ready | unavailable | saving
useEffect(() => {
let cancelled = false;
setRequireLiveApproval(true);
setLiveApprovalState("loading");
if (!token || !window.BBLiveApproval) { setLiveApprovalState("unavailable"); return; }
window.BBLiveApproval.fetchSetting(apiBase, token).then((s) => {
if (cancelled) return;
if (s) { setRequireLiveApproval(s.required); setLiveApprovalState("ready"); }
else { setRequireLiveApproval(true); setLiveApprovalState("unavailable"); }
});
return () => { cancelled = true; };
}, [apiBase, token]);
const [openCollapsed, setOpenCollapsed] = useState(true);
const [closedCollapsed, setClosedCollapsed] = useState(true);
const [balancesCollapsed, setBalancesCollapsed] = useState(true);
const [closedSort, setClosedSort] = useState("date_desc");
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 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]);
// The emulator only needs to tick while it is on screen. Ticking while hidden
// re-rendered the whole Admin tab every second for nothing.
const emulatorVisible = displaySubmenu === "emulator";
useEffect(() => {
if (!emulatorVisible) return;
const intervalMs = Math.max(900, Math.round(pageSpeedToHoldSeconds(displaySpeed.page) * 1000));
const id = setInterval(() => setEmulatorStep((v) => v + 1), intervalMs);
return () => clearInterval(id);
}, [displaySpeed.page, emulatorVisible]);
useEffect(() => {
const persistMs = Math.max(15000, Number(displayDraft?.reaction_persist_seconds || 600) * 1000);
const prune = () => {
setReactionQueue((prev) => {
if (!prev.length) return prev;
const next = prev.filter((item) => Date.now() < (item.expiresAt || Date.now() + persistMs));
// Keep the same array when nothing expired so React skips the re-render.
return next.length === prev.length ? prev : next;
});
};
prune();
const id = setInterval(prune, 1000);
return () => clearInterval(id);
}, [displayDraft?.reaction_persist_seconds]);
useEffect(() => {
if (!hasActiveReaction || !emulatorVisible) {
setReactionBlinkOn(false);
return;
}
const id = setInterval(() => setReactionBlinkOn((prev) => !prev), 320);
return () => clearInterval(id);
}, [hasActiveReaction, emulatorVisible]);
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)} ${window.BBMoney.formatMoney(wager.stake, { decimals: 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 = window.BBMoney.formatMoney(item.stake, { decimals: 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]);
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
);
// Counter bets (api #275): the countered list doubles as the feature check (404 = older API, no Counter button).
const counterCtx = useRef({});
counterCtx.current = { apiBase, token, withCode };
const CounterUI = window.BBCounterUI;
const countered = CounterUI ? CounterUI.useCountered("admin", { apiBase, token, withCode }, { intervalMs: 15000 }) : { state: "unavailable", proposals: [], refresh: () => {} };
const counterOn = countered.state === "ready";
const counterSent = useCallback(() => {
countered.refresh();
if (loadAdmin) loadAdmin();
}, [countered.refresh, loadAdmin]);
// Latest handlers for the memoized pick cards, read at call time so the cards
// don't re-render (or remount) when these closures change identity.
const pickActionsRef = useRef({});
pickActionsRef.current = { adminApprove, adminReject, adminEditPick, autoResolve, handleReact, counterCtx, counterSent };
return (
<>
Admin Dashboard
{canInstall && (
Install App
)}
{/* Only on the first load: showing this line on every background refresh pushed the whole tab down and back up. */}
{admin.loading && !admin.users.length && !admin.picks.length && !admin.queue.length && 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.
loadDisplayConfig(displayDeviceId)}>Reload
{displayBusy ? "Saving..." : "Save + Push"}
setDisplaySubmenu("configurator")}
>
Configurator
setDisplaySubmenu("emulator")}
>
Emulator
Device Id
setDisplayDeviceId(e.target.value)}
placeholder="pi-main"
/>
Mode
setDisplayDraft((prev) => ({ ...prev, display_mode: e.target.value }))}
>
Toggle Pages
Ticker
Sports Board
Scoreboard
Vertical Scroll Speed (1=slowest, 35=fastest)
setDisplaySpeed("vertical", Number(e.target.value))}
/>
{displaySpeed.vertical}
Horizontal Scroll Speed (1=slowest, 35=fastest)
setDisplaySpeed("horizontal", Number(e.target.value))}
/>
{displaySpeed.horizontal}
Page Change Speed (1=slowest, 35=fastest)
setDisplaySpeed("page", Number(e.target.value))}
/>
{displaySpeed.page} ({pageSpeedToHoldSeconds(displaySpeed.page).toFixed(2)}s hold)
Brightness
setDisplayDraft((prev) => ({ ...prev, brightness: Number(e.target.value) }))}
/>
{Number(displayDraft.brightness || 90)}%
Font Stack
setDisplayDraft((prev) => ({ ...prev, font_family: e.target.value }))}
placeholder="Bebas Neue, Rajdhani, Inter, sans-serif"
/>
Font Scale
setDisplayDraft((prev) => ({ ...prev, font_scale: Number(e.target.value) || 1 }))}
/>
Poll Seconds
setDisplayDraft((prev) => ({ ...prev, poll_seconds: Number(e.target.value) || 8 }))}
/>
Reaction Timer (sec)
setDisplayDraft((prev) => ({ ...prev, reaction_persist_seconds: Number(e.target.value) || 600 }))}
/>
Reaction Screen Time (sec)
setDisplayDraft((prev) => ({ ...prev, reaction_display_seconds: Number(e.target.value) || 20 }))}
/>
Accent
setDisplayDraft((prev) => deepMergeDisplay(prev, { theme: { accent: e.target.value } }))} />
Background
setDisplayDraft((prev) => deepMergeDisplay(prev, { theme: { background: e.target.value } }))} />
Text
setDisplayDraft((prev) => deepMergeDisplay(prev, { theme: { text: e.target.value } }))} />
Title
setDisplayDraft((prev) => deepMergeDisplay(prev, { theme: { title: e.target.value } }))} />
Presets
{DISPLAY_PRESETS.map((preset) => (
applyPreset(preset)}>{preset.label}
))}
{ setDisplayDraft(deepMergeDisplay(DISPLAY_DEFAULTS, displayConfig)); setDisplayStatus("Draft reset to last saved config."); }}>Revert Draft
Logo + Theme Pack
applyBrandPack(e.target.value)}
>
{DISPLAY_BRAND_PACKS.map((pack) => (
{pack.label}
))}
{(DISPLAY_BRAND_PACKS.find((pack) => pack.key === selectedBrandPack)?.logos || []).slice(0, 2).map((logo) => (
))}
{((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 (
{label}
{
if (isNotif) {
setDisplayDraft((prev) => deepMergeDisplay(prev, { notifications: { [notifKey]: e.target.checked } }));
} else {
setDisplayDraft((prev) => ({ ...prev, [field]: e.target.checked }));
}
}}
/>
);
})}
{displayStatus &&
{displayStatus}
}
Live Emulator (true 128x64 LED matrix)
setEmulatorMode(e.target.value)}>
Auto Cycle
Toggle Pages
Ticker
Board
Settlement
Finals
Reaction
{/* === 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} [${window.BBMoney.formatMoney(p.stake, { decimals: 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 ? window.BBMoney.formatMoney(pend, { decimals: 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}
) : (
{window.BBMoney.formatMoney(p.stake, { decimals: 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.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)` : ""}
)}
{/* The api has no global credit-hold threshold (no route, no setting); the per-user "Hold Threshold" in
Bettor Balances is the enforced control (PATCH /admin/users/contact credit_hold_amount). */}
Bettors
{admin.users.length}
Group admin Balance
{admin.resume.totalBalance != null ? dollars(-admin.resume.totalBalance) : "-"}
{/* LIVE BET QUEUE */}
setQueueCollapsed(q => !q)}
>
Live Bet Queue
{queueCollapsed && ({admin.queue.length}) }
{
const v = e.target.checked;
const prev = requireLiveApproval;
if (!window.BBLiveApproval) return;
setRequireLiveApproval(v);
setLiveApprovalState("saving");
window.BBLiveApproval.saveSetting(apiBase, token, v).then((j) => {
setRequireLiveApproval(typeof j.required === "boolean" ? j.required : v);
setLiveApprovalState("ready");
}).catch((err) => {
setRequireLiveApproval(prev);
setLiveApprovalState("ready");
alert(`Could not save the live-approval setting: ${err.message || err}`);
});
}}
/>
Require approval for live bets
{liveApprovalState === "unavailable" && (couldn't load; showing the default) }
{!queueCollapsed && (
{admin.queue.map((p) => (
))}
{admin.queue.length === 0 &&
No live bet proposals.
}
{CounterUI &&
}
)}
{/* OPEN PICKS EDITOR */}
setOpenCollapsed(o => !o)}
>
Open Picks
{openCollapsed && (
<>
({admin.picks.length}, {dollars(totalOpenAmount)})
{pastOpenCount > 0 && (
({pastOpenCount} overdue, {dollars(pastOpenAmount)})
)}
>
)}
{!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) */}
setClosedCollapsed(c => !c)}
>
Closed Picks (Log)
{closedCollapsed && ({admin.log.length}) }
{!closedCollapsed && (
setClosedSort(e.target.value)}
>
Newest
Oldest
User A-Z
User Z-A
)}
{!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 ? adminParlayDetail(p) : `${p.league} - ${p.event}`;
return (
{p.handle} - {title} {!isParlay && p.odds != null && ({signOdds(Number(p.odds))}) }
{detail}
{
const val = e.target.value;
if (!val) return;
const confirmed = window.confirm(`Override result to ${val.toUpperCase()}?`);
if (!confirmed) {
e.target.value = "";
return;
}
adminEditPick(p, { result: val, __skipOptimistic: true });
e.target.value = "";
}}
>
Override result...
Won
Lost
Void
Push
handleReact(p)}
>
React
);
})}
)}
{/* CONTACT + BALANCES TABLE */}
setBalancesCollapsed(b => !b)}
>
Balances & Contact
{balancesCollapsed && ({admin.users.length}) }
{!balancesCollapsed && Balances exclude pending wagers }
{!balancesCollapsed && (
<>
Bettor
Open
Pending $
Balance
React
Contact
Hold
Balance Adjustment
{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 (
{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) : "-"}
handleReactBalance(u)}>React
{u.contact || (u.email || u.phone || "-")}
{
e.preventDefault();
const amt = Number(e.currentTarget.amount.value);
if (!amt) return;
const direction = amt > 0 ? "increase" : "decrease";
const confirmed = window.confirm(
`Confirm balance ${direction} for ${u.handle} by ${dollars(Math.abs(amt))}?`
);
if (!confirmed) return;
adminAdjust(u.handle, amt);
e.currentTarget.reset();
}}>
Adjust Balance
);
})}
{admin.users.length === 0 && (
No users
)}
{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 (
{u.handle}
{isHeld && (
Credit hold ({holdReason})
)}
{isHeld && u.credit_hold_message && (
"{u.credit_hold_message}"
)}
Open: {u.openCount ?? "-"}
Pending: {u.pending != null ? dollars(pending) : "-"}
Balance: {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
Default: "Your account is on credit hold. Please contact an administrator for support."
>
)}
{isHeld ? "Remove Hold" : "Hold"}
Hold Threshold
{holdThreshold != null ? dollars(holdThreshold) : "Not set"}
{
const res = await submitHoldThreshold({
user: u,
holdAmountInput: evt.currentTarget?.form?.hold_amount?.value,
});
if (res.ok && evt.currentTarget?.form?.hold_amount) {
evt.currentTarget.form.hold_amount.value = res.threshold == null ? "" : String(res.threshold);
}
}}
>
Save Threshold
Negative blocks below loss floor. Positive requires minimum bankroll.
Max Bet
{maxBet != null ? dollars(maxBet) : "No limit"}
{
const res = await submitMaxBet({
user: u,
maxBetInput: evt.currentTarget?.form?.max_bet?.value,
});
if (res.ok && evt.currentTarget?.form?.max_bet) {
evt.currentTarget.form.max_bet.value = res.maxBet == null ? "" : String(res.maxBet);
}
}}
>
Save
{actionStatus?.message && (
{actionStatus.message}
)}
);
})}
{admin.users.length === 0 &&
No users
}
>
)}
>
);
}
// Expose globally
function exposeReadonlyGlobal(name, value) {
const descriptor = Object.getOwnPropertyDescriptor(window, name);
if (descriptor) return;
Object.defineProperty(window, name, {
value,
writable: false,
configurable: false,
enumerable: true,
});
}
exposeReadonlyGlobal("DISPLAY_DEFAULTS", DISPLAY_DEFAULTS);
exposeReadonlyGlobal("deepMergeDisplay", deepMergeDisplay);
exposeReadonlyGlobal("LEAGUE_LOGO_PATHS", LEAGUE_LOGO_PATHS);
exposeReadonlyGlobal("AdminDashboard", AdminDashboard);