/* 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 (
LIVE {total} MATCHES · {counts.tennis} TENNIS · {counts.football} FOOTBALL · {counts.baseball} BASEBALL
STREAM {statusLabel} {lastTickLabel}
); } function QStreamBanner({ sport }) { const status = (window.MOCK.STREAM && window.MOCK.STREAM[sport]) || "connecting"; if (status === "live") return null; const msg = { connecting: { icon: "…", text: "Connexion en cours…", cls: "" }, rest: { icon: "!", text: "Stream temps réel indisponible — fallback REST toutes les 5s.", cls: "warn" }, down: { icon: "!", text: "Connexion perdue — tentative de reconnexion…", cls: "err" }, }[status] || { icon: "…", text: "État inconnu", cls: "" }; return (
{msg.icon}
{msg.text}
); } function QHeader({ sport, onSport, theme, onTheme, alertCount, onAlertsOpen, search, onSearch, session, profile, onOpenProfile, onOpenLog, onOpenAdmin, onLogout }) { return (
Quanta
QUANTAODDS · LIVE
onSearch && onSearch(e.target.value)} placeholder="Équipe · joueur · tournoi…" aria-label="Rechercher" spellCheck={false} autoComplete="off" />
{session && ( )}
); } /* ────────────────────────────────────────────────────────── 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 (
Top opportunités · edge ≥ 3%
SORT EDGE ↓
{top.length === 0 ? (
Aucune opportunité ≥ 3 % actuellement
) : (
{top.map((m, i) => { const pickName = pickLabelFor(m, m.bestEdgeOn); const bmEntry = m.bm[m.bestEdgeBm]; const odd = bmEntry ? oddsForSide(bmEntry, m.bestEdgeOn) : null; const bfRef = m.bf ? oddsForSide(m.bf, m.bestEdgeOn) : null; const isFav = favorites && favorites.isFav(m.key); const isFlashing = flashKey === m.key; return (
onOpen(m.key)} >
#{String(i+1).padStart(2,"0")} · {m.tournament}
{favorites && favorites.toggle(m.key)} />} {pct(m.bestEdge)}
{m.p1}vs{m.p2}
{m.sport === "tennis" ? `${m.score.p1.join(" ")} / ${m.score.p2.join(" ")} · ${m.surface.toUpperCase()}` : `${m.scoreLine} · ${m.minute}`}
PICK · {m.bestEdgeBm.toUpperCase()}
{pickName}
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 —; const threeWay = isThreeWay(match); const greenSide = bestPositiveSideOf(b, threeWay); // null when no edge >= 0 const greenE = greenSide ? edgeForSide(b, greenSide) : null; const isBestBm = greenSide != null && match.bestEdgeBm === bmName; return (
{shortName(match.p1)} {b.p1 != null ? b.p1.toFixed(2) : "—"} {greenSide === "p1" ? {pct(b.e1)} : null}
{threeWay && (
X {b.draw != null ? b.draw.toFixed(2) : "—"} {greenSide === "draw" ? {pct(b.eDraw)} : null}
)}
{shortName(match.p2)} {b.p2 != null ? b.p2.toFixed(2) : "—"} {greenSide === "p2" ? {pct(b.e2)} : null}
); } function BfCell({ match }) { const threeWay = isThreeWay(match); return (
{shortName(match.p1)} {fmtOdds(match.bf.p1)}
{threeWay && (
X {fmtOdds(match.bf.draw)}
)}
{shortName(match.p2)} {fmtOdds(match.bf.p2)}
); } function QGridDesktop({ matches, onOpen, favorites, flashKey, visibleBMs }) { const bmOrder = applyVisibleBMs(visibleBMs); return (
{bmOrder.map(name => )} {matches.map(m => { const pickName = pickLabelFor(m, m.bestEdgeOn); const isFav = favorites && favorites.isFav(m.key); const isFlashing = flashKey === m.key; return ( onOpen(m.key)} > {bmOrder.map(name => )} ); })}
MATCH SCORE BF LAYBASELINE{name.toUpperCase()}BEST EDGE
{favorites && favorites.toggle(m.key)} />} {shortName(m.p1)}/{shortName(m.p2)}
{m.p1}vs{m.p2}
{m.tournament}{m.sport === "tennis" ? ` · ${m.surface.toUpperCase()}` : ""}
{pct(m.bestEdge)} {m.bestEdgeBm.toUpperCase().slice(0,4)} ON · {m.bestEdgeOn === "draw" ? "NUL" : shortName(pickName)}
); } function QGridMobile({ matches, onOpen, favorites, flashKey, visibleBMs }) { const bmOrder = applyVisibleBMs(visibleBMs); return (
{matches.map(m => { const pickName = pickLabelFor(m, m.bestEdgeOn); const isFav = favorites && favorites.isFav(m.key); const isFlashing = flashKey === m.key; return (
onOpen(m.key)} >
{m.p1}vs{m.p2}
{m.tournament}{m.sport === "tennis" ? ` · ${m.surface.toUpperCase()}` : ""}
{favorites && favorites.toggle(m.key)} />} {pct(m.bestEdge)}
{m.sport === "tennis" ? ( <> {m.score.p1.map((v,i) => {v} )} / {m.score.p2.map((v,i) => {v} )} · PICK {m.bestEdgeOn === "draw" ? "NUL" : shortName(pickName)} ) : ( <> {m.scoreLine} {m.minute} · PICK {m.bestEdgeOn === "draw" ? "NUL" : shortName(pickName)} )}
BF LAYBASELINE {shortName(m.p1)} {fmtOdds(m.bf.p1)} {isThreeWay(m) && ( <> · X {fmtOdds(m.bf.draw)} )} · {shortName(m.p2)} {fmtOdds(m.bf.p2)}
{bmOrder.map(name => { const b = m.bm[name]; if (!b) return null; const threeWay = isThreeWay(m); // Same gating as the desktop cards: green/ink highlight only // when the best edge is actually >= 0 against Betfair LAY. const greenSide = bestPositiveSideOf(b, threeWay); const e = greenSide ? edgeForSide(b, greenSide) : null; const isBest = greenSide != null && m.bestEdgeBm === name; return (
{name.slice(0,4).toUpperCase()} {b.p1 != null ? b.p1.toFixed(2) : "—"} {threeWay && ( <> · {b.draw != null ? b.draw.toFixed(2) : "—"} )} · {b.p2 != null ? b.p2.toFixed(2) : "—"} = 0 ? "" : "neg")}>{e != null ? pct(e) : "—"}
); })}
); })}
); } function QGridCardsDesktop({ matches, onOpen, favorites, flashKey, tick, visibleBMs }) { const bmOrder = applyVisibleBMs(visibleBMs); return (
{matches.map(m => { const threeWay = isThreeWay(m); const pickName = pickLabelFor(m, m.bestEdgeOn); const pickShort = m.bestEdgeOn === "draw" ? "NUL" : shortName(pickName); const isFav = favorites && favorites.isFav(m.key); const isFlashing = flashKey === m.key; // 3-way football grids need a 6th column for the draw. Inline style // keeps the change scoped to the affected card without forking the // base .q-mcard-grid stylesheet. const gridStyle = threeWay ? { gridTemplateColumns: "16px 56px 1fr 1fr 1fr 70px" } : undefined; return (
onOpen(m.key)} >
{shortName(m.p1)}/{shortName(m.p2)} · {m.tournament.toUpperCase()}
{m.p1}vs{m.p2}
{m.sport === "tennis" ? ( <> {m.score.p1.join(" ")} / {m.score.p2.join(" ")} · {m.surface.toUpperCase()} ) : ( <> {m.scoreLine} · {m.minute} )}
{favorites && favorites.toggle(m.key)} />}
{pct(m.bestEdge)}
ON · {pickShort}
BM
{shortName(m.p1)}
{threeWay &&
X
}
{shortName(m.p2)}
EDGE
{/* 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
{/* BF LAY baseline row with liquidity + per-runner matched */}
BF LAY
{fmtOdds(m.bf.p1)}
{threeWay && (
{fmtOdds(m.bf.draw)}
)}
{fmtOdds(m.bf.p2)}
{/* 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 (
{name.slice(0, 4).toUpperCase()} {stale ? ( ${STALE_SECONDS}s`}>STALE ) : ( )}
{b.p1 != null ? b.p1.toFixed(2) : "—"}
{threeWay && (
{b.draw != null ? b.draw.toFixed(2) : "—"}
)}
{b.p2 != null ? b.p2.toFixed(2) : "—"}
= 0 && !stale ? "pos" : "neg")}>{stale || e == null ? "—" : pct(e)}
); })} {/* 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 && (
PM REF
{m.pm.p1Buy.toFixed(2)} {m.pm.p1Sell != null && /{m.pm.p1Sell.toFixed(2)}}
{m.pm.p2Buy.toFixed(2)} {m.pm.p2Sell != null && /{m.pm.p2Sell.toFixed(2)}}
)}
); })}
); } function QGridPanel({ matches, onOpen, filter, onFilter, view, onView, favorites, flashKey, tick, search, visibleBMs }) { // Pre-filter: a match where NO visible bookmaker has odds is just BF + // Polymarket — a useless card. Drop them before the chip + search // filters so the "ALL · N" count reflects what the user actually sees. const visibleMatches = useMemo(() => { if (!visibleBMs || typeof visibleBMs.has !== "function") return matches; return matches.filter(m => { const bm = m && m.bm; if (!bm) return false; for (const name of Object.keys(bm)) { if (visibleBMs.has(name)) return true; } return false; }); }, [matches, visibleBMs]); const filtered = useMemo(() => { let list = visibleMatches; if (filter === "edge3") list = list.filter(m => m.bestEdge >= 0.03); else if (filter === "favs") list = list.filter(m => favorites.isFav(m.key)); const q = (search || "").trim().toLowerCase(); if (q) { list = list.filter(m => { const haystack = ( (m.p1 || "") + " " + (m.p2 || "") + " " + (m.tournament || "") ).toLowerCase(); return haystack.includes(q); }); } return list; }, [visibleMatches, filter, favorites.favs, search]); const favCount = visibleMatches.filter(m => favorites.isFav(m.key)).length; return (
SORT EDGE ↓ ·
{filtered.length === 0 ? (
{filter === "favs" ? "Aucun match favori. Étoile une carte pour l'ajouter." : "Aucun match"}
) : view === "cards" ? ( ) : ( <> )}
); } /* ────────────────────────────────────────────────────────── DETAIL VIEW ────────────────────────────────────────────────────────── */ function QSparkline({ data, color, height = 70 }) { if (!data || !data.length) return null; const w = 360, h = height; const min = Math.min(...data), max = Math.max(...data); const range = (max - min) || 1; const pts = data.map((v, i) => { const x = (i / (data.length - 1)) * w; const y = h - ((v - min) / range) * h * 0.85 - h * 0.075; return `${x.toFixed(1)},${y.toFixed(1)}`; }); return ( ); } function QDetailHead({ match, onBack }) { return ( <>
QUANTA > {match.sport === "tennis" ? "TENNIS" : "FOOTBALL"} > {match.tournament.toUpperCase()} > {shortName(match.p1)}/{shortName(match.p2)} · id {match.key} LIVE
{match.sport === "tennis" ? `${match.tournament.replace(/\s+/g, "-").toUpperCase()} · ${match.surface.toUpperCase()} · BO5` : `${match.tournament.replace(/\s+/g, "-").toUpperCase()} · 90′`}
{match.p1.split(",")[0]}vs{match.p2.split(",")[0]}
{match.sport === "tennis" ? `${match.p1.toUpperCase()} · ${match.p2.toUpperCase()}` : `${match.tournament} matchday · ${match.minute} played`}
{match.sport === "tennis" ? ( <>
SCOREBOARD · SET {match.score.p1.length}
{match.p1.split(",")[0]}
{match.score.p1.map((v,i) => (
{v}
))}
{match.p2.split(",")[0]}
{match.score.p2.map((v,i) => (
{v}
))}
) : ( <>
SCOREBOARD · {match.minute}
{match.p1}
HOME
{match.scoreLine.split("-")[0].trim()} {match.scoreLine.split("-")[1].trim()}
{match.minute}
{match.p2}
AWAY
)}
); } function QComparisonTable({ match, visibleBMs }) { // Order BMs by the canonical display order (so the table doesn't reshuffle // depending on dict-key insertion order), then filter against the user's // dashboard-visibility set when one is provided. const allowed = (visibleBMs && typeof visibleBMs.has === "function") ? new Set([...visibleBMs]) : null; const bmNames = BM_DISPLAY_ORDER .filter(name => match.bm[name]) .filter(name => allowed ? allowed.has(name) : true); const p1Name = match.p1.split(",")[0]; const p2Name = match.p2.split(",")[0]; const threeWay = isThreeWay(match); // Helper: render a single odds cell with strikethrough when stale. const oddsCell = (v, stale) => ( {v != null ? v.toFixed(2) : "—"} ); return (
Comparaison des cotes · {match.sport === "tennis" ? "BO5" : "1X2"}
EDGE vs BETFAIR LAY · HEATMAP ON · {bmNames.length} BOOKMAKERS {match.pm ? " + 2 RÉFÉRENCES" : " + 1 RÉFÉRENCE"} {fmtTotalMatched(match.bf.total_matched) && ( <> · BF MATCHED {fmtTotalMatched(match.bf.total_matched)} )}
{threeWay && } {threeWay && } {threeWay && } {threeWay && ( )} {threeWay && } {threeWay && } {/* 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 && ( {threeWay && } {threeWay && } {threeWay && } )} {bmNames.map(name => { const b = match.bm[name]; const stale = isStale(b.last_seen); return ( {oddsCell(b.p1, stale)} {threeWay && oddsCell(b.draw, stale)} {oddsCell(b.p2, stale)} {threeWay && } {threeWay && } ); })}
SOURCE {p1Name}{threeWay ? "DOMICILE" : "P1 ODDS"}XMATCH NUL{p2Name}{threeWay ? "EXTÉRIEUR" : "P2 ODDS"} IMPLIED {threeWay ? "1" : "P1"}IMPLIED XIMPLIED {threeWay ? "2" : "P2"} EDGE {threeWay ? "1" : "P1"}EDGE XEDGE {threeWay ? "2" : "P2"}
BASELINE Betfair LAY {fmtOdds(match.bf.p1)} {fmtOdds(match.bf.draw)} {fmtOdds(match.bf.p2)} {fmtImpliedPct(match.bf.p1)}{fmtImpliedPct(match.bf.draw)}{fmtImpliedPct(match.bf.p2)}
REF Polymarket
BUY{match.pm.p1Buy.toFixed(2)}
{match.pm.p1Sell != null &&
SELL{match.pm.p1Sell.toFixed(2)}
}
BUY{match.pm.p2Buy.toFixed(2)}
{match.pm.p2Sell != null &&
SELL{match.pm.p2Sell.toFixed(2)}
}
{(100/match.pm.p1Buy).toFixed(1)}%{(100/match.pm.p2Buy).toFixed(1)}%
{name} {stale ? ${STALE_SECONDS}s`}>STALE : } {b.p1 != null ? (100/b.p1).toFixed(1) + "%" : "—"}{b.draw != null ? (100/b.draw).toFixed(1) + "%" : "—"}{b.p2 != null ? (100/b.p2).toFixed(1) + "%" : "—"} 0?"pos":"neg") + (stale ? "" : edgeHeat(b.e1))}>{stale || b.e1 == null ? "—" : pct(b.e1)}0?"pos":"neg") + (stale ? "" : edgeHeat(b.eDraw))}>{stale || b.eDraw == null ? "—" : pct(b.eDraw)}0?"pos":"neg") + (stale ? "" : edgeHeat(b.e2))}>{stale || b.e2 == null ? "—" : pct(b.e2)}
); } function QDetailFooter({ match }) { // Real 24-tick history from match.history.p1/p2.Betfair (maintained by // live-data.jsx). Falls back to a single-value placeholder when the // stream hasn't produced any history yet for this fixture. const p1Series = (match.history && match.history.p1 && match.history.p1.Betfair) || []; const p2Series = (match.history && match.history.p2 && match.history.p2.Betfair) || []; const hasP1 = p1Series.length > 0; const hasP2 = p2Series.length > 0; const p1Cur = match.bf.p1, p2Cur = match.bf.p2; const sumImplied = (match.bf.p1 != null && match.bf.p2 != null) ? (100/match.bf.p1 + 100/match.bf.p2).toFixed(1) : null; const safeMin = (arr) => hasArr(arr) ? Math.min(...arr).toFixed(2) : "—"; const safeMax = (arr) => hasArr(arr) ? Math.max(...arr).toFixed(2) : "—"; function hasArr(a) { return Array.isArray(a) && a.length > 0; } return (
Mouvement {match.p1.split(",")[0]} · last 24 ticks
OPEN {hasP1 ? p1Series[0].toFixed(2) : "—"} HI {safeMax(p1Series)} LO {safeMin(p1Series)} NOW {p1Cur != null ? p1Cur.toFixed(2) : "—"}
{hasP1 ? :
en attente…
}
Mouvement {match.p2.split(",")[0]} · last 24 ticks
OPEN {hasP2 ? p2Series[0].toFixed(2) : "—"} HI {safeMax(p2Series)} LO {safeMin(p2Series)} NOW {p2Cur != null ? p2Cur.toFixed(2) : "—"}
{hasP2 ? :
en attente…
}
Market snapshot
Sum implied{sumImplied != null ? sumImplied + "%" : "—"}
Overround{sumImplied != null ? (sumImplied - 100).toFixed(1) + "%" : "—"}
Best edge{pct(match.bestEdge)}
On player{match.bestEdgeOn === "p1" ? match.p1.split(",")[0] : match.p2.split(",")[0]}
From bookmaker{match.bestEdgeBm}
Last tick{(() => { const s = window.MOCK.getStats && window.MOCK.getStats(match.sport); return s && s.lastTickAgeS != null ? s.lastTickAgeS.toFixed(1) + "s ago" : "—"; })()}
); } function QDetailView({ match, onBack, favorites, tick, profile, visibleBMs }) { 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" />
)} {openMatch ? : } setAlertsOpen(false)} alerts={alerts} tick={tick} onOpenMatch={(key) => { setAlertsOpen(false); const m = window.MOCK.ALL_MATCHES.find(x => x.key === key); if (m) location.hash = `#/${m.sport}/match/${encodeURIComponent(key)}`; }} /> setProfilePanel(false)} session={session} profile={profile} updateProfile={updateProfile} visibleBMs={visibleBMs} toggleVisibleBM={toggleVisibleBM} bipeEnabled={bipeEnabled} setBipeEnabled={setBipeEnabled} /> setLogPanel(false)} /> {session && session.role === "admin" && ( setAdminPanel(false)} /> )} {showOnboarding && session && ( )}
); } window.QuantaApp = QuantaApp;