/* QUANTA · App — interactive prototype
Wires the list ↔ detail routing, sport switch, light/dark theme, mobile menu.
Pure React state — no real WS, mock data from window.MOCK. */
const { useState, useEffect, useRef, useMemo } = React;
/* ──────────────────────────────────────────────────────────
HELPERS
────────────────────────────────────────────────────────── */
function shortName(s) {
// "Alcaraz, C." -> "ALCA"; "Arsenal" -> "ARS"; "Man City" -> "MAC"
const part = (s || "").split(",")[0].trim();
const words = part.split(/\s+/);
if (words.length > 1) {
return words.map(w => w[0]).join("").toUpperCase().slice(0, 4);
}
return part.replace(/[^A-Za-z]/g, "").slice(0, 4).toUpperCase();
}
function pct(n, digits = 1) {
return (n > 0 ? "+" : "") + (n * 100).toFixed(digits) + "%";
}
function heatClass(e) {
if (e == null) return "";
if (e >= 0.05) return " h-pos-3";
if (e >= 0.025) return " h-pos-2";
if (e > 0) return " h-pos-1";
if (e <= -0.03) return " h-neg-2";
if (e < 0) return " h-neg-1";
return "";
}
function edgeHeat(e) {
// for cmp table edge cell
if (e == null) return "";
if (e >= 0.05) return " h-pos-3";
if (e >= 0.025) return " h-pos-2";
if (e > 0) return " h-pos-1";
if (e <= -0.03) return " h-neg-2";
if (e < 0) return " h-neg-1";
return "";
}
/* Null-safe formatters
─────────────────────
Betfair LAY can be missing (the upstream BF stream hasn't matched the
fixture). Components must render "—" in that case — never a synthesised
substitute, which would silently hide the absence of a reference. */
function fmtOdds(v) { return v != null ? v.toFixed(2) : "—"; }
function fmtImpliedPct(v) { return v != null ? (100 / v).toFixed(1) + "%" : "—"; }
/* 3-way (football) helpers
─────────────────────────
QUANTA's design is 2-way (p1/p2). We extend it so football fixtures
also surface the draw cell. These helpers centralise the three
places that need to know: "is this match 3-way?", "which side has the
best edge across {p1, p2, draw}?", and "what label should I display
for that side?". */
function isThreeWay(match) {
return match && match.sport === "football";
}
function bestSideOf(b, threeWay) {
let side = "p1", best = b.e1 != null ? b.e1 : -Infinity;
if (b.e2 != null && b.e2 > best) { side = "p2"; best = b.e2; }
if (threeWay && b.eDraw != null && b.eDraw > best) { side = "draw"; best = b.eDraw; }
return side;
}
/* Same as bestSideOf, but returns null when the best edge is < 0 — used
to gate the green "value" highlight: a side should only be greened when
the edge is actually non-negative against Betfair LAY, not just "least
bad" among negative options. */
function bestPositiveSideOf(b, threeWay) {
const side = bestSideOf(b, threeWay);
const e = side === "draw" ? b.eDraw : (side === "p1" ? b.e1 : b.e2);
return (e != null && e >= 0) ? side : null;
}
function pickLabelFor(match, side) {
if (side === "draw") return "Match nul";
return side === "p1" ? match.p1 : match.p2;
}
function oddsForSide(b, side) {
if (side === "draw") return b.draw;
return side === "p1" ? b.p1 : b.p2;
}
function edgeForSide(b, side) {
if (side === "draw") return b.eDraw;
return side === "p1" ? b.e1 : b.e2;
}
function fmtAge(seconds) {
// Returns {text, level} where level is 'fresh' | 'mid' | 'stale'
if (seconds == null) return { text: "—", level: "stale" };
if (seconds < 1) return { text: "now", level: "fresh" };
if (seconds < 5) return { text: `${seconds.toFixed(1)}s`, level: "fresh" };
if (seconds < 30) return { text: `${Math.round(seconds)}s`, level: "mid" };
if (seconds < 90) return { text: `${Math.round(seconds)}s`, level: "stale" };
return { text: `${Math.round(seconds/60)}m`, level: "stale" };
}
function fmtLiquidity(n) {
if (n == null) return "—";
if (n >= 1000) return `£${(n/1000).toFixed(1)}k`;
return `£${n}`;
}
/* Market-level "total matched" — typically £k → £M range. Compact
notation (`£1.2M`, `£45k`) so it fits in the cards/toolbar without
blowing up the line height. Returns null when the BF stream hasn't
reported a total yet (first few ticks after subscribe). */
function fmtTotalMatched(n) {
if (n == null) return null;
if (n >= 1_000_000) return `£${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `£${(n / 1_000).toFixed(1)}k`;
return `£${Math.round(n)}`;
}
/* BfDepth — small badge shown next to the Betfair LAY odds.
Combines two metrics into a single compact span:
* at-best liquidity (how much is tradeable RIGHT NOW at this LAY)
* volume already matched AT this exact LAY price (not the runner
total — that mixes volume across many historical prices)
Format: "£12k / £3.4k" with the slash distinguishing the two.
The `title` attribute spells out which is which on hover.
Falls back to the runner-total `matched` (less precise) when the
per-price value isn't available — keeps the badge populated during
the first WS ticks before EX_TRADED has been pushed. */
function BfDepth({ lay, matched, layMatched }) {
const layStr = lay != null ? fmtLiquidity(lay) : "—";
const atPrice = (layMatched != null) ? layMatched : null;
const fallback = (atPrice == null && matched != null) ? matched : null;
const display = atPrice != null ? atPrice : fallback;
const totStr = fmtTotalMatched(display);
if (totStr == null) {
return {layStr};
}
const tooltip = atPrice != null
? "Liquidité au meilleur LAY : " + layStr +
"\nDéjà matché à cette cote : " + totStr
: "Liquidité au meilleur LAY : " + layStr +
"\nTotal matché sur la sélection (runner) : " + totStr +
"\n(EX_TRADED pas encore reçu — fallback runner total)";
return (
{layStr} / {totStr}
);
}
function FreshBadge({ lastSeen, tick }) {
// tick is a number that re-renders the badge on each clock tick
const age = lastSeen ? (Date.now() / 1000 - lastSeen) : null;
const f = fmtAge(age);
return {f.text};
}
/* HelpTip — small inline help bubble for technical jargon.
Tap the (?) to toggle a one-line definition. */
function HelpTip({ children, label }) {
const [open, setOpen] = useState(false);
// Best-effort aria-label: only concatenate text-like children so we
// don't leak `[object Object]` into the accessible name.
const ariaTerm = (typeof children === "string" || typeof children === "number")
? `Aide: ${children}` : "Aide";
return (
{children}
{open && {label}}
);
}
/* QOnboarding — 4-step modal explaining the dashboard on first login.
Skipping or completing persists `quanta-onboarded-{username}=1` in
localStorage so the user never sees it again. */
function QOnboarding({ username, onClose }) {
const [step, setStep] = useState(0);
const steps = [
{ title: "Bienvenue sur QUANTA", body: "5 bookmakers comparés en temps réel à Betfair LAY pour repérer les value-bets live tennis + football.", icon: "⚡" },
{ title: "C'est quoi un edge ?", body: "Quand un bookmaker affiche une cote plus généreuse que Betfair LAY (la référence sharp), tu as un avantage. Au-dessus de +3 %, ça vaut le coup.", icon: "📊" },
{ title: "Combien miser ?", body: "Le critère de Kelly suggère la mise optimale. On affiche Kelly /3 par défaut (conservateur). Tu peux changer le diviseur dans ton profil.", icon: "🎯" },
{ title: "Reçois les alertes", body: "Active les notifs Telegram dans ton profil pour recevoir les value-bets en push, sans garder l'écran ouvert.", icon: "🔔" },
];
const s = steps[step];
return (
{s.icon}
{s.title}
{s.body}
{steps.map((_, i) => )}
{step > 0
?
:
}
{step < steps.length - 1
?
:
}
);
}
function Star({ on, onClick }) {
return (
);
}
function useFavorites() {
const [favs, setFavs] = useState(() => {
try { return new Set(JSON.parse(localStorage.getItem("quanta-favs") || "[]")); }
catch { return new Set(); }
});
const toggle = (key) => {
setFavs(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
try { localStorage.setItem("quanta-favs", JSON.stringify([...next])); } catch {}
return next;
});
};
return { favs, toggle, isFav: (k) => favs.has(k) };
}
/* Canonical BM column order. Single source of truth for every grid +
the comparison table — adding a new bookmaker means touching this
list (and the equivalent on the server side). */
const BM_DISPLAY_ORDER = ["Betclic", "Winamax", "Yonibet", "PMU", "Unibet"];
/* Filter the canonical order against the user's per-device "visible
bookmakers" set. When `visibleBMs` is undefined (no auth context,
or an older code path that doesn't pass it through), keep every
BM — that's the legacy behaviour and a safe default. */
function applyVisibleBMs(visibleBMs) {
if (!visibleBMs || typeof visibleBMs.has !== "function") return BM_DISPLAY_ORDER;
return BM_DISPLAY_ORDER.filter(name => visibleBMs.has(name));
}
/* Stale threshold — past this many seconds since last_seen, we mark the
bookmaker row as STALE (grayed + line-through + STALE tag). */
const STALE_SECONDS = 30;
function isStale(lastSeen) {
if (!lastSeen) return false;
return (Date.now() / 1000 - lastSeen) > STALE_SECONDS;
}
/* BM deep-link wrapper. Click stops propagation so the card click handler
doesn't fire and navigate to detail at the same time. Always opens in
a new tab. */
function BmLink({ url, name, children }) {
if (!url) return {children};
return (
e.stopPropagation()}
title={`Ouvrir ${name} dans un nouvel onglet`}
>
{children}
);
}
/* ──────────────────────────────────────────────────────────
CHROME — top strip + header
────────────────────────────────────────────────────────── */
function QClock() {
const [t, setT] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setT(new Date()), 1000);
return () => clearInterval(id);
}, []);
const pad = (n) => String(n).padStart(2, "0");
return (
EU {pad(t.getHours())}:{pad(t.getMinutes())}:{pad(t.getSeconds())} CEST
);
}
function QTopStrip({ sport }) {
const counts = {
tennis: window.MOCK.TENNIS_MATCHES.length,
football: window.MOCK.FOOT_MATCHES.length,
baseball: (window.MOCK.BASEBALL_MATCHES || []).length,
};
const total = counts.tennis + counts.football + counts.baseball;
// Read fresh on every render — parent re-renders every 1s via tick
const status = (window.MOCK.STREAM && window.MOCK.STREAM[sport]) || "connecting";
const stats = (window.MOCK.getStats && window.MOCK.getStats(sport)) || { lastTickAgeS: null };
const lastTick = stats.lastTickAgeS;
const statusLabel =
status === "live" ? "LIVE" :
status === "rest" ? "REST FALLBACK" :
status === "down" ? "DOWN" : "CONNECTING";
const cls =
status === "live" ? "live" :
status === "rest" ? "muted" :
status === "down" ? "err" : "muted";
const lastTickLabel = lastTick == null ? "—" : `${lastTick.toFixed(1)}s`;
return (
);
}
/* ──────────────────────────────────────────────────────────
STATS RIBBON (computed from current sport)
────────────────────────────────────────────────────────── */
function QStatsRibbon({ matches, sport, visibleBMs }) {
const stats = useMemo(() => {
// Filter to matches that have at least one VISIBLE bookmaker.
// Otherwise hiding Yonibet (which has many unique fixtures) would
// leave the ribbon counting matches the user can't actually see.
const allowed = (visibleBMs && typeof visibleBMs.has === "function") ? visibleBMs : null;
const isVisible = (m) => {
if (!allowed) return true;
if (!m || !m.bm) return false;
for (const name of Object.keys(m.bm)) {
if (allowed.has(name)) return true;
}
return false;
};
const visible = matches.filter(isVisible);
// When computing edges we restrict to visible bookmakers too — a
// hidden BM's edge shouldn't move the average the user sees.
const edges = visible.flatMap(m =>
Object.entries(m.bm)
.filter(([name]) => !allowed || allowed.has(name))
.flatMap(([, b]) => [b.e1, b.e2])
.filter(e => e != null)
);
const best = visible.reduce((a, m) => Math.max(a, m.bestEdge), 0);
const avg = edges.length ? edges.reduce((a, b) => a + b, 0) / edges.length : 0;
return {
best, avg, count: visible.length,
bms: new Set(
visible.flatMap(m => Object.keys(m.bm).filter(n => !allowed || allowed.has(n)))
).size,
};
}, [matches, visibleBMs]);
// Read live tick stats fresh on every render — the parent re-renders
// every 1s via its `tick` interval, which keeps "Last tick" counting up
// smoothly without an extra timer.
const live = (window.MOCK.getStats && window.MOCK.getStats(sport)) || { ticksPerMin: 0, lastTickAgeS: null };
const lastTickText = live.lastTickAgeS != null ? live.lastTickAgeS.toFixed(1) : "—";
return (
Best edge live
{pct(stats.best)}
Avg edge live
{pct(stats.avg)}
Live matches
{stats.count}
Bookmakers
{stats.bms} +BF
Ticks / min
{live.ticksPerMin}
Last tick
{lastTickText}s
);
}
/* ──────────────────────────────────────────────────────────
SCORE FORMATTING
────────────────────────────────────────────────────────── */
function ScoreInline({ match }) {
if (match.sport === "tennis") {
return (
{match.score.p1.map((v,i) => {v} )}
/
{match.score.p2.map((v,i) => {v} )}
);
}
return (
{match.scoreLine}{match.minute}
);
}
/* ──────────────────────────────────────────────────────────
TOP OPPORTUNITIES PANEL
────────────────────────────────────────────────────────── */
/* QTopsPanel — Top 3 opportunities with hysteresis.
Without hysteresis, a match whose edge oscillates around the 3% line
would appear/disappear on every snapshot — and because the Top panel
sits at the top of the page, the rest of the layout would shake
underneath it (especially on phone, where everything is stacked).
Rules:
* ENTER: an opportunity must hold edge ≥ THRESHOLD for ENTER_MS
(2s) before joining the visible Top. Filters point-by-point
blips and stale-tick artefacts.
* STICK: once visible, it stays for STICK_MS (5s) after the edge
last touched THRESHOLD. Even if it dips to +1.2% mid-window, the
card keeps its slot — the user sees a number falling, not a card
vanishing under their finger.
State persists across renders via a ref so it survives the parent's
re-render-every-1s tick without thrashing. */
const TOP_THRESHOLD = 0.03;
const TOP_ENTER_MS = 2000;
const TOP_STICK_MS = 5000;
function QTopsPanel({ matches, onOpen, favorites, flashKey, tick, visibleBMs }) {
const stateRef = useRef(new Map()); // matchKey → {firstAbove, lastAboveAt, shown}
// useMemo recomputes whenever `matches` reference changes OR the parent
// tick bumps (1s interval). The tick dep matters: between WS pushes
// `matches` may keep the same reference, but the STICK timer still
// needs to age — without `tick`, an opportunity that's only sticky
// (no longer above threshold) would freeze in the visible list.
const top = useMemo(() => {
const now = Date.now();
const live = new Set();
// Filter to matches whose best edge belongs to a VISIBLE bookmaker.
// If the only BM hitting >3% on this match is one the user hid,
// surfacing it in "Top opportunités" is misleading — they can't
// actually act on it without re-enabling the BM.
const allowed = (visibleBMs && typeof visibleBMs.has === "function") ? visibleBMs : null;
const candidates = !allowed ? matches : matches.filter(m => {
if (!m || !m.bm) return false;
// Must have at least one visible BM with data
let hasVisible = false;
for (const name of Object.keys(m.bm)) {
if (allowed.has(name)) { hasVisible = true; break; }
}
if (!hasVisible) return false;
// AND the bestEdge must come from a visible BM
return !m.bestEdgeBm || allowed.has(m.bestEdgeBm);
});
// Tick every match's hysteresis state once.
for (const m of candidates) {
live.add(m.key);
let entry = stateRef.current.get(m.key);
if (!entry) {
entry = { firstAbove: null, lastAboveAt: null, shown: false };
stateRef.current.set(m.key, entry);
}
if (m.bestEdge >= TOP_THRESHOLD) {
if (entry.firstAbove == null) entry.firstAbove = now;
entry.lastAboveAt = now;
} else {
entry.firstAbove = null; // reset the enter timer; STICK timer keeps decaying
}
}
// Drop entries that aren't in the current snapshot anymore (match ended).
for (const k of [...stateRef.current.keys()]) {
if (!live.has(k)) stateRef.current.delete(k);
}
// Decide visibility per match — only walk visible candidates so a
// BM-hidden match drops out of Top immediately instead of lingering
// until its STICK window expires.
const visible = [];
for (const m of candidates) {
const entry = stateRef.current.get(m.key);
if (!entry) continue;
if (entry.shown) {
if (entry.lastAboveAt != null && (now - entry.lastAboveAt) < TOP_STICK_MS) {
visible.push(m);
} else {
entry.shown = false; // exhausted the stick window — drop it
}
} else if (entry.firstAbove != null && (now - entry.firstAbove) >= TOP_ENTER_MS) {
entry.shown = true;
visible.push(m);
}
}
return visible.sort((a, b) => b.bestEdge - a.bestEdge).slice(0, 3);
}, [matches, tick, visibleBMs]);
return (
vs BF LAY {bfRef != null ? bfRef.toFixed(2) : "—"}
{odd != null ? odd.toFixed(2) : "—"}
);
})}
)}
);
}
/* ──────────────────────────────────────────────────────────
GRID (desktop table) + CARDS (mobile)
Each BM cell shows BOTH P1 and P2 odds so the user can see
which player is being compared — fixes the previous ambiguity.
────────────────────────────────────────────────────────── */
function BmCell({ match, bmName }) {
const b = match.bm[bmName];
if (!b) return
{/* BF total-matched meta strip — separate from the LAY row
on purpose. The price row already carries per-side
liquidity; this slot carries the market-wide depth so
the user can sanity-check "£12k matched" vs "£200". */}
BETFAIR LIVE
{fmtTotalMatched(m.bf.total_matched) || —}
matched
{/* Bookmaker rows */}
{bmOrder.map(name => {
const b = m.bm[name]; if (!b) return null;
// greenSide = the cote that should be highlighted, ONLY if its
// edge is non-negative. Returns null when this BM has no value
// anywhere — those rows stay neutral (no green tint).
const greenSide = bestPositiveSideOf(b, threeWay);
const e = greenSide ? edgeForSide(b, greenSide) : null;
const isBest = greenSide != null && m.bestEdgeBm === name;
const stale = isStale(b.last_seen);
const color = (window.MOCK.BMS[name] || {}).color;
return (
);
})}
{/* Polymarket reference row — only when prediction-market data
is actually present. Football has no Polymarket; some tennis
fixtures aren't matched either. Skip the row instead of
crashing on null fields. */}
{m.pm && m.pm.p1Buy != null && m.pm.p2Buy != null && (
{/* Polymarket row only when prediction-market quotes are
actually present. Football has none; tennis sometimes has
none. Skipping is cleaner than rendering "0.00 / 0.00". */}
{match.pm && match.pm.p1Buy != null && match.pm.p2Buy != null && (
REFPolymarket
BUY{match.pm.p1Buy.toFixed(2)}
{match.pm.p1Sell != null &&
SELL{match.pm.p1Sell.toFixed(2)}
}
{threeWay &&
—
}
BUY{match.pm.p2Buy.toFixed(2)}
{match.pm.p2Sell != null &&
SELL{match.pm.p2Sell.toFixed(2)}
}
{(100/match.pm.p1Buy).toFixed(1)}%
{threeWay &&
—
}
{(100/match.pm.p2Buy).toFixed(1)}%
—
{threeWay &&
—
}
—
)}
{bmNames.map(name => {
const b = match.bm[name];
const stale = isStale(b.last_seen);
return (
>
);
}
/* ──────────────────────────────────────────────────────────
LIST VIEW
────────────────────────────────────────────────────────── */
function QListView({ matches, sport, onOpen, favorites, flashKey, tick, search, visibleBMs }) {
const [filter, setFilter] = useState("all");
const [view, setView] = useState(() => {
try { return localStorage.getItem("quanta-view") || "cards"; }
catch { return "cards"; }
});
useEffect(() => {
try { localStorage.setItem("quanta-view", view); } catch {}
}, [view]);
return (
<>
>
);
}
/* ──────────────────────────────────────────────────────────
APP
────────────────────────────────────────────────────────── */
function QuantaApp() {
injectQuantaCSS();
// Auth lives at the outer component so the LoginScreen can render
// without instantiating any of the dashboard hooks. We keep theme at
// this level too so the login screen respects dark/light.
const auth = useAuth();
const [theme, setTheme] = useState(() => {
try { return localStorage.getItem("quanta-theme") || "dark"; }
catch { return "dark"; }
});
useEffect(() => {
try {
localStorage.setItem("quanta-theme", theme);
// Keep the data-theme attribute in sync so the pre-paint
// script's value matches the React tree (prevents a flash on toggle).
document.documentElement.dataset.theme = theme;
document.documentElement.style.background =
theme === "dark" ? "#0b0e13" : "#ffffff";
} catch {}
}, [theme]);
if (!auth.bootDone) {
return ;
}
// Invite flow: `?invite=` short-circuits both the login screen and
// the existing auth state. The friend opens the URL from WhatsApp/Telegram,
// sets their own password via QSetupScreen, and the POST response sets a
// session cookie that replaces any previous one. We re-evaluate auth on
// success by hitting the /api/auth/me endpoint via the existing useAuth boot,
// which means the simplest "did it work" signal is a hard reload of the
// root URL once `?invite` has been stripped.
let inviteToken = "";
try {
inviteToken = new URLSearchParams(location.search).get("invite") || "";
} catch {}
if (inviteToken) {
return (
{
// Reload from root so useAuth re-boots and reads the fresh cookie.
// `?invite=` is already stripped from the URL by QSetupScreen.
location.href = "/";
}}
/>
);
}
if (!auth.isAuthed) {
return (
);
}
return ;
}
function QuantaAppAuthed({ auth, theme, setTheme }) {
const { session, profile, logout, updateProfile } = auth;
const { duplicate, kickOthers } = useSessionWatcher(session, () => logout());
const [sport, setSport] = useState("tennis");
const [openKey, setOpenKey] = useState(null);
const favorites = useFavorites();
const [profilePanel, setProfilePanel] = useState(false);
const [logPanel, setLogPanel] = useState(false);
const [adminPanel, setAdminPanel] = useState(false);
// Dashboard-visibility BM filter (per-device, per-username).
// Lives at this scope because every grid + comparison table needs to
// read it. Edits flow through QProfilePanel.
// The hook lives in quanta-auth.jsx and is exposed on window.
const [visibleBMs, toggleVisibleBM] = window.useVisibleBMs(session && session.user);
// Onboarding: show the 4-step modal on first login for this username.
// Persists via localStorage so a returning user never sees it twice.
const onboardKey = session ? `quanta-onboarded-${session.user}` : null;
const [showOnboarding, setShowOnboarding] = useState(() => {
if (!onboardKey) return false;
try { return !localStorage.getItem(onboardKey); } catch { return false; }
});
const dismissOnboarding = () => {
if (onboardKey) {
try { localStorage.setItem(onboardKey, "1"); } catch {}
}
setShowOnboarding(false);
};
// Tick — re-renders every 1s so freshness badges stay live.
// Also bumps on `quanta-data-update` (fired by live-data.jsx on WS push)
// so new edges and odds appear instantly instead of waiting for the 1s tick.
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
const onData = () => setTick(t => t + 1);
window.addEventListener("quanta-data-update", onData);
return () => {
clearInterval(id);
window.removeEventListener("quanta-data-update", onData);
};
}, []);
// Session loss — live-data.jsx dispatches "quanta-auth-lost" on WS close
// 1008 or REST 401. Force a local logout so the login screen appears.
useEffect(() => {
const onAuthLost = () => {
auth.logout();
};
window.addEventListener("quanta-auth-lost", onAuthLost);
return () => window.removeEventListener("quanta-auth-lost", onAuthLost);
}, [auth]);
// Alerts queue + flash key. Uses profile.alertThreshold.
const [alerts, setAlerts] = useState([]);
const [flashKey, setFlashKey] = useState(null);
const [alertsOpen, setAlertsOpen] = useState(false);
// Bipe toggle (per-device, localStorage). Defaults to ON because the
// 2s persistence check already filters out the ephemeral false
// positives between tennis points — the bipes that survive are real.
const [bipeEnabled, setBipeEnabled] = useState(() => {
try { return localStorage.getItem("quanta-bipe") !== "0"; }
catch { return true; }
});
useEffect(() => {
try { localStorage.setItem("quanta-bipe", bipeEnabled ? "1" : "0"); } catch {}
}, [bipeEnabled]);
const bipeEnabledRef = useRef(bipeEnabled);
useEffect(() => { bipeEnabledRef.current = bipeEnabled; }, [bipeEnabled]);
// Audio bipe — generated via Web Audio API so we don't ship a sound
// file. Two short tones (880Hz → 1320Hz) so it's audible without
// being abrasive. The AudioContext is created lazily on first bipe
// because most browsers block AC creation until the user has
// interacted with the page at least once (login click counts).
const audioCtxRef = useRef(null);
const playBipe = () => {
if (!bipeEnabledRef.current) return;
try {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
let ctx = audioCtxRef.current;
if (!ctx || ctx.state === "closed") {
ctx = new AC();
audioCtxRef.current = ctx;
}
if (ctx.state === "suspended") { ctx.resume().catch(() => {}); }
const now = ctx.currentTime;
const blip = (freq, start, dur) => {
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = "sine";
o.frequency.value = freq;
// Short attack + decay so it sounds like a "blip" not a buzz.
g.gain.setValueAtTime(0.0001, start);
g.gain.exponentialRampToValueAtTime(0.20, start + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, start + dur);
o.connect(g).connect(ctx.destination);
o.start(start);
o.stop(start + dur + 0.02);
};
blip(880, now, 0.12);
blip(1320, now + 0.14, 0.12);
} catch { /* audio unavailable — swallow */ }
};
// Pending spike events that haven't passed the 2s persistence check.
// Held in a ref so the effect doesn't tear them down on every render.
// `firedRef` dedupes already-alerted ids across the lifetime of this
// tab — the alerts list itself caps at 25 entries so we can't rely on
// it for dedup of fresh events.
const pendingRef = useRef(new Map()); // alertId → setTimeout handle
const firedRef = useRef(new Set()); // alertId already alerted
/* Real alerts pipeline with persistence filter.
The server fires `edge_spike` events on the transition <3% → ≥3%.
Between tennis points the BF LAY price can briefly oscillate
(suspension / re-open), which historically triggered alerts that
vanished a second later — the "bipe entre les points" annoyance.
Fix: hold each event for 2 seconds, then re-check the LIVE match
state. If the BM still shows an edge ≥ threshold on the same side,
it's a real opportunity → fire alert + flash card + audio bipe.
If it has retreated, drop silently. */
useEffect(() => {
const PERSISTENCE_MS = 2000;
const onDataUpdate = () => {
const bySport = (window.MOCK && window.MOCK._lastSnapshotBySport) || {};
const threshold = profile?.alertThreshold || 0.03;
const allEvents = [];
for (const [sportKey, snap] of Object.entries(bySport)) {
const evs = (snap && snap.recent_events) || [];
for (const ev of evs) allEvents.push({ ev, sportKey });
}
if (!allEvents.length) return;
const findMatch = (key) =>
window.MOCK.ALL_MATCHES.find(m => m.key === key);
for (const { ev } of allEvents) {
if (ev.type !== "edge_spike") continue;
if (typeof ev.edge === "number" && ev.edge < threshold) continue;
const matchKey = ev.match_key;
const alertId = `${matchKey}-${ev.ts}-${ev.bm}-${ev.side}`;
if (firedRef.current.has(alertId)) continue;
if (pendingRef.current.has(alertId)) continue;
const handle = setTimeout(() => {
pendingRef.current.delete(alertId);
// Persistence check: is the opportunity STILL valid right now?
const live = findMatch(matchKey);
if (!live || !live.bm || !live.bm[ev.bm]) return;
const liveEdge = edgeForSide(live.bm[ev.bm], ev.side);
if (liveEdge == null || liveEdge < threshold) return;
// Survived the 2s window → fire.
firedRef.current.add(alertId);
// Trim firedRef so it doesn't grow unbounded across hours.
if (firedRef.current.size > 500) {
const keep = [...firedRef.current].slice(-250);
firedRef.current = new Set(keep);
}
const m = live;
const bmEntry = m.bm[ev.bm];
const oddsResolved = (ev.odds != null && ev.odds > 0)
? ev.odds
: (bmEntry && ev.side ? (oddsForSide(bmEntry, ev.side) || 0) : 0);
const alertObj = {
id: alertId,
ts: ev.ts || (Date.now() / 1000),
matchKey,
sport: ev.sport || m.sport,
tournament: m.tournament || matchKey,
p1: ev.home || m.p1,
p2: ev.away || m.p2,
bm: ev.bm,
side: ev.side,
pick: ev.side === "p1" ? (ev.home || m.p1)
: ev.side === "p2" ? (ev.away || m.p2)
: "Match nul",
odds: oddsResolved,
edge: liveEdge, // use the CURRENT edge, not the firing one
};
setAlerts(prev => {
if (prev.some(a => a.id === alertObj.id)) return prev;
return [alertObj, ...prev].slice(0, 25);
});
setFlashKey(matchKey);
playBipe();
setTimeout(() => setFlashKey(k => k === matchKey ? null : k), 3200);
}, PERSISTENCE_MS);
pendingRef.current.set(alertId, handle);
}
};
window.addEventListener("quanta-data-update", onDataUpdate);
return () => {
window.removeEventListener("quanta-data-update", onDataUpdate);
// Clear pending timers when the effect re-runs (e.g. threshold change).
for (const h of pendingRef.current.values()) clearTimeout(h);
pendingRef.current.clear();
};
}, [profile?.alertThreshold]);
// Hash-based routing
useEffect(() => {
const apply = () => {
const h = location.hash.replace(/^#\/?/, "");
const parts = h.split("/").filter(Boolean);
if (parts[0] === "tennis" || parts[0] === "football" || parts[0] === "baseball") setSport(parts[0]);
if (parts[1] === "match" && parts[2]) setOpenKey(decodeURIComponent(parts[2]));
else setOpenKey(null);
};
apply();
window.addEventListener("hashchange", apply);
return () => window.removeEventListener("hashchange", apply);
}, []);
const navigateMatch = (key) => { location.hash = `#/${sport}/match/${encodeURIComponent(key)}`; };
const navigateBack = () => { location.hash = `#/${sport}`; };
const navigateSport = (newSport) => { location.hash = `#/${newSport}`; };
// Search query lives at this level so the input in QHeader stays mounted
// when the user navigates into a match detail (the input value persists
// across views). The actual filtering is applied inside QGridPanel.
const [searchQuery, setSearchQuery] = useState("");
const matches =
sport === "tennis" ? window.MOCK.TENNIS_MATCHES :
sport === "baseball" ? (window.MOCK.BASEBALL_MATCHES || []) :
window.MOCK.FOOT_MATCHES;
const openMatch = openKey ? matches.find(m => m.key === openKey) : null;
const alertBadge = alerts.length;
return (
setAlertsOpen(true)}
search={searchQuery} onSearch={setSearchQuery}
session={session} profile={profile}
onOpenProfile={() => setProfilePanel(true)}
onOpenLog={() => setLogPanel(true)}
onOpenAdmin={() => setAdminPanel(true)}
onLogout={logout}
/>
{/* Dedicated mobile/tablet search row — the header .q-cmd is hidden
below 1024px because the header gets cramped (sport tabs + user
menu + theme toggle already eat the width). We surface the same
search state on a slim row right under the header instead, only
when we're on the list view (a detail page has no list to filter). */}
{!openMatch && (
setSearchQuery(e.target.value)}
placeholder="Équipe · joueur · tournoi…"
aria-label="Rechercher"
spellCheck={false}
autoComplete="off"
/>