);
}
/* ──────────────────────────────────────────────────────────
DUPLICATE BANNER
────────────────────────────────────────────────────────── */
function QDuplicateBanner({ duplicate, onKickOthers }) {
if (!duplicate) return null;
return (
!
Session multiple détectée — ton compte est ouvert sur un autre
onglet ou appareil.
peer #{shortId(duplicate.otherSessionId)} · enregistré dans le journal
session #{shortId(session.sessionId)} · kelly /{profile.kellyDivisor}
{session.role === "admin" && (
)}
)}
);
}
/* ──────────────────────────────────────────────────────────
USER PROFILE PANEL (slide-in)
────────────────────────────────────────────────────────── */
function QProfilePanel({ open, onClose, session, profile, updateProfile, visibleBMs, toggleVisibleBM, bipeEnabled, setBipeEnabled }) {
const closeRef = useRefA(null);
useEffectA(() => {
if (!open) return;
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Focus trap: transfer focus to the close button when the panel opens
// so keyboard / screen-reader users land inside the dialog.
useEffectA(() => {
if (open && closeRef.current) closeRef.current.focus();
}, [open]);
if (!session || !profile) return null;
const sinceMin = Math.round((Date.now() - session.since) / 60000);
return (
<>
>
);
}
/* ──────────────────────────────────────────────────────────
PER-USER BOOKMAKER PREFERENCES
Two distinct settings, two storage paths on purpose:
* Telegram-notification filter: persisted server-side in
`user_notif.bookmakers` (CSV). Survives device changes, shared
across devices logged in as the same account. Edited via
POST /api/notif/me.
* Dashboard visibility: persisted in localStorage, keyed by
username. Per-device by design — most operators sit at the same
laptop and may want to hide books they never use without affecting
their account-wide notification filter. A separate "Dashboard
bookmakers" section in the profile panel surfaces the toggles.
Both settings share the same constant ALL_BMS_LIST so the UI stays
consistent — adding a new BM requires touching this single line.
────────────────────────────────────────────────────────── */
const ALL_BMS_LIST = ["Betclic", "Winamax", "Yonibet", "PMU", "Unibet"];
const ALL_SPORTS_LIST = [
{ value: "tennis", label: "Tennis" },
{ value: "football", label: "Football" },
];
function _localKey(username) {
return username ? `quanta-visible-bms-${username}` : null;
}
function useVisibleBMs(username) {
const key = _localKey(username);
const [visible, setVisible] = useStateA(() => {
if (!key) return new Set(ALL_BMS_LIST);
try {
const raw = localStorage.getItem(key);
if (!raw) return new Set(ALL_BMS_LIST);
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return new Set(ALL_BMS_LIST);
const known = arr.filter(b => ALL_BMS_LIST.includes(b));
// An empty selection is almost always a mistake — restore all BMs
// rather than render an empty dashboard.
return new Set(known.length ? known : ALL_BMS_LIST);
} catch { return new Set(ALL_BMS_LIST); }
});
const toggle = (bm) => {
setVisible(prev => {
const next = new Set(prev);
if (next.has(bm)) next.delete(bm); else next.add(bm);
if (key) {
try { localStorage.setItem(key, JSON.stringify([...next])); } catch {}
}
return next;
});
};
return [visible, toggle];
}
/* DashboardBookmakers — toggle list rendered inside QProfilePanel.
No network calls: state is owned by useVisibleBMs at the
QuantaAppAuthed level and threaded down here for editing. */
function DashboardBookmakers({ visibleBMs, toggleVisibleBM }) {
return (
<>
Décoche les bookmakers que tu ne veux PAS voir dans les cartes / tableaux du dashboard.
Préférence locale (par appareil) — n'affecte pas les notifications.
>
);
}
/* ──────────────────────────────────────────────────────────
NOTIF SETTINGS — Telegram link UI inside the profile panel.
Gracefully degrades when the /api/notif endpoints aren't available
(the backend worker is shipping in parallel).
────────────────────────────────────────────────────────── */
function NotifSettings({ session }) {
const EMPTY = {
loading: true, available: true, linked: false,
chat_id: null, threshold: null,
sports: "", bookmakers: "",
};
const [state, setState] = useStateA(EMPTY);
const [linking, setLinking] = useStateA(false);
const [linkPayload, setLinkPayload] = useStateA(null); // { bot_url, token }
const _unavailable = () =>
setState({ ...EMPTY, loading: false, available: false });
// Server wraps the row in `{notif: {...}}`. Be defensive: a future
// un-wrapping or partial response shouldn't blank the UI.
const _absorbNotif = (rawJson) => {
const notif = (rawJson && rawJson.notif) || rawJson || {};
setState({
loading: false,
available: true,
linked: !!notif.telegram_chat_id,
chat_id: notif.telegram_chat_id || null,
threshold: notif.threshold ?? null,
sports: notif.sports ?? "",
bookmakers: notif.bookmakers ?? "",
});
};
const refresh = async () => {
try {
const r = await fetch("/api/notif/me", { credentials: "include" });
if (r.status === 404 || r.status === 501 || !r.ok) {
_unavailable();
return;
}
_absorbNotif(await r.json());
} catch {
_unavailable();
}
};
useEffectA(() => { refresh(); }, [session && session.user]);
const startLink = async () => {
setLinking(true);
try {
const r = await fetch("/api/notif/telegram/link", { method: "POST", credentials: "include" });
if (!r.ok) { setLinking(false); return; }
const data = await r.json();
setLinkPayload({ bot_url: data.bot_url, token: data.token });
} catch { /* swallow */ }
setLinking(false);
};
const unlink = async () => {
try {
await fetch("/api/notif/telegram/unlink", { method: "POST", credentials: "include" });
} catch {}
setLinkPayload(null);
refresh();
};
/* Persist a partial settings patch (`{bookmakers: "..."}` or
`{sports: "..."}`) via POST /api/notif/me.
Optimistic UI: we set local state immediately so the checkbox flips
without waiting on the round-trip, then reconcile with the row the
server returns. A failed POST silently leaves the optimistic value
in place — next `refresh()` will correct it. */
const persist = async (patch) => {
setState(prev => ({ ...prev, ...patch }));
try {
const r = await fetch("/api/notif/me", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(patch),
});
if (r.ok) {
_absorbNotif(await r.json());
}
} catch { /* keep optimistic state */ }
};
const _csvToSet = (s) =>
new Set(String(s || "").split(",").map(x => x.trim()).filter(Boolean));
const _setToCsv = (set) => [...set].join(",");
const toggleBm = (bm) => {
const next = _csvToSet(state.bookmakers);
if (next.has(bm)) next.delete(bm); else next.add(bm);
persist({ bookmakers: _setToCsv(next) });
};
const toggleSport = (sp) => {
const next = _csvToSet(state.sports);
if (next.has(sp)) next.delete(sp); else next.add(sp);
persist({ sports: _setToCsv(next) });
};
if (state.loading) {
return
Chargement…
;
}
if (!state.available) {
return (
Telegram pas encore configuré côté serveur.
Cette section s'activera dès que les notifs seront déployées.
);
}
const bmSet = _csvToSet(state.bookmakers);
const spSet = _csvToSet(state.sports);
/* When linked, surface the per-account filters (BMs + sports) so the
user can curate which spike events the Telegram bot pushes to them.
These are persisted server-side so they're shared across devices —
a deliberate split from the dashboard-visibility toggle below, which
is local-only. */
const filtersBlock = (
<>