Files
turf_saas/dashboard_saas.html
DevOps Engineer 31db3a8260 fix(HRT-84): maxDays historique Pro — 365j au lieu de 30j (inversion corrigée)
Pro = 365j (historique le plus long), Premium = 90j, Free = 7j
Corrigé suite au point d'attention CTO dans revue de code.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-29 15:49:25 +02:00

1552 lines
84 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard — Turf IA</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
--gold: #ffd600; --orange: #ff6d00;
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
--radius: 10px; --error: #f85149; --purple: #7c3aed;
}
html { scroll-behavior: smooth; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; }
a { color: inherit; text-decoration: none; }
/* SIDEBAR */
.sidebar {
width: 240px; flex-shrink: 0; background: var(--dark2);
border-right: 1px solid var(--border); padding: 20px 0;
display: flex; flex-direction: column; height: 100vh;
position: sticky; top: 0; overflow-y: auto;
}
.sidebar-logo { padding: 0 20px 20px; font-weight: 800; font-size: 1.1rem; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
.sidebar-logo span { color: var(--green); }
.nav-section { padding: 0 12px 8px; font-size: .72rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); font-weight: 600; margin-top: 12px; }
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 20px; font-size: .9rem; cursor: pointer;
border-radius: 8px; margin: 1px 8px; color: var(--muted); transition: all .15s;
}
.nav-item:hover { background: var(--dark3); color: var(--text); }
.nav-item.active { background: rgba(0,200,83,.1); color: var(--green); }
.nav-item .icon { font-size: 1.1rem; width: 22px; text-align: center; }
.nav-item .plan-lock { font-size: .65rem; margin-left: auto; opacity: .6; }
.sidebar-bottom { margin-top: auto; padding: 16px; border-top: 1px solid var(--border); }
.user-chip { display: flex; align-items: center; gap: 10px; }
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--green); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .9rem; color: #000; flex-shrink: 0; }
.user-info { flex: 1; min-width: 0; }
.user-name { font-size: .85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-plan { font-size: .72rem; color: var(--muted); text-transform: uppercase; }
/* MAIN */
.main { flex: 1; overflow-y: auto; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 28px; border-bottom: 1px solid var(--border); background: var(--dark); position: sticky; top: 0; z-index: 10; }
.topbar-title { font-size: 1.1rem; font-weight: 700; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.badge { padding: 3px 10px; border-radius: 20px; font-size: .75rem; font-weight: 700; }
.badge-free { background: rgba(139,148,158,.15); color: var(--muted); }
.badge-premium { background: rgba(255,214,0,.15); color: var(--gold); }
.badge-pro { background: rgba(30,136,229,.15); color: var(--blue); }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border-radius: 8px; font-size: .85rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { border-color: var(--muted); }
.btn-upgrade { background: linear-gradient(135deg, var(--gold), var(--orange)); color: #000; }
.btn-upgrade-pro { background: linear-gradient(135deg, var(--blue), var(--purple)); color: #fff; }
.btn-danger { background: rgba(248,81,73,.15); color: var(--error); border: 1px solid rgba(248,81,73,.3); }
.btn-danger:hover { background: rgba(248,81,73,.25); }
.btn-sm { padding: 5px 12px; font-size: .8rem; }
/* CONTENT */
.content { padding: 28px; }
/* UPGRADE BANNER */
.upgrade-banner {
background: linear-gradient(135deg, rgba(255,214,0,.1), rgba(255,109,0,.08));
border: 1px solid rgba(255,214,0,.25); border-radius: var(--radius);
padding: 16px 20px; margin-bottom: 24px;
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
.upgrade-banner p { font-size: .9rem; }
.upgrade-banner strong { color: var(--gold); }
/* STAT CARDS */
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
.stat-label { font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; }
.stat-value { font-size: 1.8rem; font-weight: 800; }
.stat-sub { font-size: .78rem; color: var(--muted); margin-top: 4px; }
.stat-up { color: var(--green); }
.stat-down { color: var(--error); }
/* SECTION TITLE */
.section-title { font-size: 1rem; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between; }
.section-title small { font-size: .78rem; color: var(--muted); font-weight: 400; }
/* RACE TABLE */
.race-table-wrap { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; }
thead th { padding: 10px 14px; font-size: .78rem; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); text-align: left; border-bottom: 1px solid var(--border); background: var(--dark3); }
tbody tr { border-bottom: 1px solid var(--border); transition: background .15s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--dark3); }
tbody td { padding: 11px 14px; font-size: .88rem; }
.horse-name { font-weight: 600; }
.prob-bar-wrap { display: flex; align-items: center; gap: 8px; }
.prob-bar { width: 80px; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
.prob-bar-fill { height: 100%; border-radius: 3px; background: var(--green); }
.value-bet { background: rgba(0,200,83,.15); color: var(--green); padding: 2px 8px; border-radius: 12px; font-size: .75rem; font-weight: 700; }
.cote { font-weight: 700; }
.rank-1 { color: var(--gold); font-weight: 800; }
.rank-2 { color: var(--muted); }
.rank-3 { color: #cd7f32; }
/* LOCK / GATING */
.lock-wrap { position: relative; }
.plan-gate {
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 40px 24px; text-align: center; margin-bottom: 24px;
}
.plan-gate .gate-icon { font-size: 2.5rem; margin-bottom: 12px; }
.plan-gate h3 { font-size: 1rem; margin-bottom: 8px; }
.plan-gate p { font-size: .88rem; color: var(--muted); max-width: 400px; margin: 0 auto 16px; line-height: 1.5; }
.plan-gate.gate-premium { border-color: rgba(255,214,0,.3); }
.plan-gate.gate-pro { border-color: rgba(30,136,229,.3); }
/* RACE CARD grid */
.races-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-bottom: 24px; }
.race-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; transition: border-color .2s; }
.race-card:hover { border-color: var(--muted); }
.race-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.race-name { font-weight: 700; font-size: .93rem; }
.race-meta { font-size: .78rem; color: var(--muted); margin-top: 2px; }
.race-time { font-size: .8rem; font-weight: 700; color: var(--green); }
.top3-row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.horse-chip {
padding: 4px 10px; border-radius: 20px; font-size: .78rem; font-weight: 600;
background: var(--dark3); border: 1px solid var(--border);
}
.horse-chip.top1 { border-color: var(--gold); color: var(--gold); background: rgba(255,214,0,.08); }
.horse-chip.top2 { border-color: var(--muted); }
.horse-chip.top3 { border-color: #cd7f32; color: #cd7f32; background: rgba(205,127,50,.08); }
/* VALUE BET CARDS */
.vb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; margin-bottom: 24px; }
.vb-card {
background: var(--dark2); border: 1px solid rgba(0,200,83,.2);
border-radius: var(--radius); padding: 16px 18px;
transition: border-color .2s, transform .15s;
}
.vb-card:hover { border-color: var(--green); transform: translateY(-2px); }
.vb-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.vb-horse { font-weight: 700; font-size: .95rem; }
.vb-cote { font-size: 1.1rem; font-weight: 800; color: var(--green); }
.vb-race { font-size: .78rem; color: var(--muted); margin-bottom: 8px; }
.vb-stats { display: flex; gap: 12px; font-size: .78rem; color: var(--muted); }
.vb-stat-item { display: flex; flex-direction: column; gap: 2px; }
.vb-stat-label { font-size: .68rem; text-transform: uppercase; letter-spacing: .5px; }
.vb-stat-val { font-weight: 700; color: var(--text); }
.vb-risk { padding: 2px 8px; border-radius: 10px; font-size: .72rem; font-weight: 700; }
.vb-risk-low { background: rgba(0,200,83,.15); color: var(--green); }
.vb-risk-med { background: rgba(255,214,0,.15); color: var(--gold); }
.vb-risk-high { background: rgba(248,81,73,.15); color: var(--error); }
/* FORM CARDS */
.form-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; margin-bottom: 20px; }
.form-card h3 { font-size: .95rem; font-weight: 700; margin-bottom: 18px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group label { font-size: .8rem; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .4px; }
.form-group input, .form-group select, .form-group textarea {
background: var(--dark3); border: 1px solid var(--border);
border-radius: 8px; padding: 9px 12px; color: var(--text); font-size: .88rem;
outline: none; transition: border-color .2s; font-family: inherit;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color: var(--green); }
.form-group input.mono { font-family: monospace; letter-spacing: .5px; }
.form-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.status-ok { background: var(--green); }
.status-off { background: var(--muted); }
.status-err { background: var(--error); }
.inline-status { font-size: .82rem; color: var(--muted); }
.token-display {
background: var(--dark3); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 14px; font-family: monospace; font-size: .82rem;
word-break: break-all; color: var(--green); letter-spacing: .5px;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.copy-btn { cursor: pointer; opacity: .6; font-size: .75rem; white-space: nowrap; }
.copy-btn:hover { opacity: 1; }
/* HISTORY */
.history-filters { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; align-items: flex-end; }
.history-filters .form-group { min-width: 160px; }
.hist-vb-tag { background: rgba(0,200,83,.15); color: var(--green); padding: 1px 6px; border-radius: 8px; font-size: .72rem; font-weight: 700; }
/* MULTI-ACCOUNT */
.member-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); }
.member-row:last-child { border-bottom: none; }
.member-avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--dark3); display: flex; align-items: center; justify-content: center; font-weight: 700; color: var(--muted); flex-shrink: 0; }
.member-info { flex: 1; }
.member-name { font-size: .9rem; font-weight: 600; }
.member-email { font-size: .78rem; color: var(--muted); }
.member-plan-badge { font-size: .72rem; padding: 2px 8px; border-radius: 10px; }
/* EMPTY STATE */
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
.empty-state .icon { font-size: 3rem; margin-bottom: 14px; }
.empty-state h3 { font-size: 1.1rem; font-weight: 700; color: var(--text); margin-bottom: 8px; }
.empty-state p { font-size: .9rem; max-width: 360px; margin: 0 auto 20px; }
/* TABS */
.tab-bar { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 20px; overflow-x: auto; }
.tab-btn {
padding: 10px 18px; font-size: .88rem; font-weight: 600; cursor: pointer;
border-bottom: 2px solid transparent; color: var(--muted); white-space: nowrap;
transition: all .15s; background: none; border-top: none; border-left: none; border-right: none;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--text); border-bottom-color: var(--green); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* TOAST */
#toast { position: fixed; bottom: 24px; right: 24px; z-index: 999; padding: 12px 20px; border-radius: 10px; font-size: .88rem; font-weight: 600; transform: translateY(60px); opacity: 0; transition: all .3s; pointer-events: none; }
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { background: var(--green); color: #000; }
#toast.info { background: var(--blue); color: #fff; }
#toast.error { background: var(--error); color: #fff; }
/* LOADING */
.loader-row { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 40px; color: var(--muted); }
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* SECTION WRAPPER */
.dashboard-section { margin-bottom: 32px; }
/* RESPONSIVE */
@media (max-width: 900px) {
.sidebar { display: none; }
}
@media (max-width: 600px) {
.stats-row { grid-template-columns: 1fr 1fr; }
.content { padding: 16px; }
.form-row { grid-template-columns: 1fr; }
.history-filters { flex-direction: column; }
}
</style>
</head>
<body>
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-logo">🏇 <span>Turf</span> IA</div>
<div class="nav-section">Principal</div>
<a class="nav-item" id="nav-dashboard" href="#dashboard" onclick="showSection('dashboard',this)"><span class="icon">📊</span> Dashboard</a>
<a class="nav-item" id="nav-races" href="#races" onclick="showSection('races',this)"><span class="icon">🏁</span> Courses du jour</a>
<a class="nav-item" id="nav-value-bets" href="#value-bets" onclick="showSection('value-bets',this)"><span class="icon">💎</span> Value Bets <span class="plan-lock" id="lock-vb"></span></a>
<div class="nav-section">Analyse</div>
<a class="nav-item" id="nav-history" href="#history" onclick="showSection('history',this)"><span class="icon">📅</span> Historique <span class="plan-lock" id="lock-hist"></span></a>
<a class="nav-item" id="nav-export" href="#export" onclick="showSection('export',this)"><span class="icon">📤</span> Export CSV <span class="plan-lock" id="lock-export"></span></a>
<div class="nav-section">Paramètres</div>
<a class="nav-item" id="nav-telegram" href="#telegram" onclick="showSection('telegram',this)"><span class="icon">📱</span> Alertes Telegram <span class="plan-lock" id="lock-tg"></span></a>
<a class="nav-item" id="nav-api-token" href="#api-token" onclick="showSection('api-token',this)"><span class="icon"></span> API Token <span class="plan-lock" id="lock-api"></span></a>
<a class="nav-item" id="nav-webhook" href="#webhook" onclick="showSection('webhook',this)"><span class="icon">🔗</span> Webhook <span class="plan-lock" id="lock-wh"></span></a>
<a class="nav-item" id="nav-multi-account" href="#multi-account" onclick="showSection('multi-account',this)"><span class="icon">👥</span> Multi-compte <span class="plan-lock" id="lock-mc"></span></a>
<div class="nav-section">Compte</div>
<a class="nav-item" href="/account"><span class="icon">⚙️</span> Mon compte</a>
<a class="nav-item" href="#" id="logout-btn"><span class="icon">🚪</span> Déconnexion</a>
<div class="sidebar-bottom">
<div class="user-chip">
<div class="user-avatar" id="sidebar-avatar">?</div>
<div class="user-info">
<div class="user-name" id="sidebar-name">Chargement…</div>
<div class="user-plan" id="sidebar-plan"></div>
</div>
</div>
</div>
</aside>
<!-- MAIN -->
<div class="main">
<div class="topbar">
<div class="topbar-title" id="topbar-section-title">Tableau de bord</div>
<div class="topbar-right">
<span class="badge" id="plan-badge"></span>
<a href="/account?tab=upgrade" class="btn btn-upgrade" id="upgrade-btn" style="display:none">⭐ Upgrader</a>
</div>
</div>
<div class="content">
<!-- Upgrade banner (shown for free users) -->
<div class="upgrade-banner" id="upgrade-banner" style="display:none">
<p>🔒 Plan <strong>Free</strong> — Vous voyez un aperçu limité. Passez à <strong>Premium</strong> pour toutes les courses, value bets et alertes Telegram.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade">Upgrader — 9,90€/mois</a>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<!-- SECTION: DASHBOARD (accueil) -->
<!-- ═══════════════════════════════════════════════════════ -->
<div id="section-dashboard" class="dashboard-section">
<!-- Stats row -->
<div class="stats-row" id="stats-row">
<div class="stat-card">
<div class="stat-label">Courses analysées</div>
<div class="stat-value" id="stat-courses"></div>
<div class="stat-sub">aujourd'hui</div>
</div>
<div class="stat-card">
<div class="stat-label">Précision Top-3</div>
<div class="stat-value stat-up" id="stat-accuracy"></div>
<div class="stat-sub">30 derniers jours</div>
</div>
<div class="stat-card">
<div class="stat-label">Value bets du jour</div>
<div class="stat-value" id="stat-vb"></div>
<div class="stat-sub" id="stat-vb-sub"></div>
</div>
<div class="stat-card">
<div class="stat-label">Prochaine course</div>
<div class="stat-value stat-up" id="stat-next"></div>
<div class="stat-sub" id="stat-next-hip"></div>
</div>
</div>
<!-- Today's top races summary -->
<div class="section-title">
🏇 Résumé du jour
<small id="race-count-label">Chargement…</small>
</div>
<div id="races-summary-container">
<div class="loader-row"><div class="spinner"></div> Chargement…</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<!-- SECTION: COURSES DU JOUR -->
<!-- ═══════════════════════════════════════════════════════ -->
<div id="section-races" class="dashboard-section" style="display:none">
<div class="section-title">
🏁 Prédictions du jour
<small id="race-count-label-2">Chargement…</small>
</div>
<div id="races-container">
<div class="loader-row"><div class="spinner"></div> Chargement des prédictions…</div>
</div>
<!-- Locked section for free users -->
<div id="locked-section" style="display:none">
<div class="plan-gate gate-premium">
<div class="gate-icon">🔒</div>
<h3>+120 courses cachées</h3>
<p>Passez à <strong>Premium</strong> pour débloquer toutes les courses du jour, les value bets et les alertes Telegram.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade">Débloquer — 9,90€/mois</a>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<!-- SECTION: VALUE BETS — endpoint réel /api/v1/valuebets -->
<!-- ═══════════════════════════════════════════════════════ -->
<div id="section-value-bets" class="dashboard-section" style="display:none">
<div class="section-title">
💎 Value Bets du jour
<small id="vb-count-label"></small>
</div>
<!-- Gate: free users -->
<div id="vb-gate-free" class="plan-gate gate-premium" style="display:none">
<div class="gate-icon"></div>
<h3>Fonctionnalité Premium</h3>
<p>Les Value Bets identifient les chevaux dont la cote du marché sous-estime la vraie probabilité de victoire — votre avantage sur les bookmakers. Disponible dès le plan <strong>Premium</strong>.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade">Passer à Premium — 9,90€/mois</a>
</div>
<!-- Value bets content (Premium+) -->
<div id="vb-content" style="display:none">
<div class="history-filters" style="margin-bottom:16px">
<div class="form-group">
<label>Cote minimale</label>
<select id="vb-min-odds" onchange="loadValueBets()">
<option value="1.5">1.5+</option>
<option value="2.0" selected>2.0+</option>
<option value="3.0">3.0+</option>
<option value="4.0">4.0+</option>
</select>
</div>
</div>
<div id="vb-container">
<div class="loader-row"><div class="spinner"></div> Chargement des value bets…</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<!-- SECTION: HISTORIQUE — endpoint réel /api/v1/history (HRT-81) -->
<!-- ═══════════════════════════════════════════════════════ -->
<div id="section-history" class="dashboard-section" style="display:none">
<div class="section-title">
📅 Historique des prédictions
</div>
<!-- Gate: free (only 7 days) — partial lock -->
<div id="hist-gate-free" class="upgrade-banner" style="display:none;margin-bottom:16px">
<p>Plan <strong>Free</strong> — Historique limité aux <strong>7 derniers jours</strong>. Passez à <strong>Premium</strong> pour 90 jours, ou <strong>Pro</strong> pour un historique illimité.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade">Upgrader</a>
</div>
<div class="history-filters">
<div class="form-group">
<label>Date début</label>
<input type="date" id="hist-start" />
</div>
<div class="form-group">
<label>Date fin</label>
<input type="date" id="hist-end" />
</div>
<div style="display:flex;align-items:flex-end">
<button class="btn btn-ghost" onclick="loadHistory()">Appliquer</button>
</div>
<div style="display:flex;align-items:flex-end">
<button class="btn btn-ghost btn-sm" id="hist-export-btn" onclick="exportHistoryCsv()" style="display:none">⬇ CSV</button>
</div>
</div>
<div id="history-container">
<div class="loader-row"><div class="spinner"></div> Chargement de l'historique…</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<!-- SECTION: EXPORT CSV (Premium+) -->
<!-- ═══════════════════════════════════════════════════════ -->
<div id="section-export" class="dashboard-section" style="display:none">
<div class="section-title">📤 Export CSV</div>
<!-- Gate: free -->
<div id="export-gate-free" class="plan-gate gate-premium" style="display:none">
<div class="gate-icon"></div>
<h3>Fonctionnalité Premium</h3>
<p>Exportez toutes vos prédictions au format CSV pour analyse externe. Disponible dès le plan <strong>Premium</strong>.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade">Passer à Premium — 9,90€/mois</a>
</div>
<div id="export-content" style="display:none">
<div class="form-card">
<h3>Exporter les prédictions</h3>
<div class="form-row">
<div class="form-group">
<label>Date début</label>
<input type="date" id="export-start" />
</div>
<div class="form-group">
<label>Date fin</label>
<input type="date" id="export-end" />
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" onclick="doExportCsv()">⬇ Télécharger CSV</button>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<!-- SECTION: ALERTES TELEGRAM (Premium+) -->
<!-- TODO: replace mock — HRT-79 -->
<!-- ═══════════════════════════════════════════════════════ -->
<div id="section-telegram" class="dashboard-section" style="display:none">
<div class="section-title">
📱 Alertes Telegram
<small>Recevez les value bets et alertes en temps réel</small>
</div>
<!-- Gate: free -->
<div id="tg-gate-free" class="plan-gate gate-premium" style="display:none">
<div class="gate-icon">📱</div>
<h3>Fonctionnalité Premium</h3>
<p>Recevez les value bets du jour et les alertes courses directement sur Telegram. Disponible dès le plan <strong>Premium</strong>.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade">Passer à Premium — 9,90€/mois</a>
</div>
<!-- Telegram config (Premium+) -->
<div id="tg-content" style="display:none">
<div class="form-card">
<h3>Configuration du bot Telegram</h3>
<div id="tg-status-row" style="margin-bottom:16px; font-size:.85rem; color:var(--muted)">
<span class="status-dot status-off" id="tg-status-dot"></span>
<span id="tg-status-text">Chargement…</span>
</div>
<div class="form-row">
<div class="form-group">
<label>Bot Token</label>
<input type="password" id="tg-bot-token" class="mono" placeholder="123456:ABCdef..." autocomplete="off" />
</div>
<div class="form-group">
<label>Chat ID</label>
<input type="text" id="tg-chat-id" class="mono" placeholder="-100123456789" autocomplete="off" />
</div>
</div>
<div class="form-row" style="margin-top:4px">
<div class="form-group">
<label>Types d'alertes</label>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:4px">
<label style="display:flex;align-items:center;gap:8px;text-transform:none;font-size:.88rem;letter-spacing:0;color:var(--text);cursor:pointer">
<input type="checkbox" id="tg-alert-vb" checked style="accent-color:var(--green)"> Value Bets (cote &gt; 2.0)
</label>
<label style="display:flex;align-items:center;gap:8px;text-transform:none;font-size:.88rem;letter-spacing:0;color:var(--text);cursor:pointer">
<input type="checkbox" id="tg-alert-top1" style="accent-color:var(--green)"> Top-1 prédictions IA
</label>
<label style="display:flex;align-items:center;gap:8px;text-transform:none;font-size:.88rem;letter-spacing:0;color:var(--text);cursor:pointer">
<input type="checkbox" id="tg-alert-quinté" style="accent-color:var(--green)"> Quinté+ uniquement
</label>
</div>
</div>
<div class="form-group">
<label>Heure de résumé quotidien</label>
<input type="time" id="tg-summary-time" value="08:00" />
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" onclick="saveTelegramConfig()">💾 Enregistrer</button>
<button class="btn btn-ghost" onclick="testTelegramMessage()">📤 Envoyer message test</button>
<button class="btn btn-danger btn-sm" id="tg-delete-btn" onclick="deleteTelegramConfig()" style="display:none">🗑 Supprimer</button>
</div>
</div>
<div class="form-card" style="background:rgba(30,136,229,.05);border-color:rgba(30,136,229,.2)">
<h3 style="color:var(--blue)"> Comment configurer votre bot</h3>
<ol style="padding-left:20px;font-size:.88rem;color:var(--muted);line-height:1.8">
<li>Ouvrez Telegram et parlez à <strong style="color:var(--text)">@BotFather</strong></li>
<li>Envoyez <code style="background:var(--dark3);padding:2px 6px;border-radius:4px">/newbot</code> et suivez les instructions</li>
<li>Copiez le <strong style="color:var(--text)">Bot Token</strong> fourni</li>
<li>Ajoutez le bot à votre groupe ou canal et récupérez le <strong style="color:var(--text)">Chat ID</strong> via <code style="background:var(--dark3);padding:2px 6px;border-radius:4px">@userinfobot</code></li>
</ol>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<!-- SECTION: API TOKEN (Pro uniquement) -->
<!-- TODO: replace mock — HRT-80 -->
<!-- ═══════════════════════════════════════════════════════ -->
<div id="section-api-token" class="dashboard-section" style="display:none">
<div class="section-title">
⚡ API Token
<small>Accès programmatique à votre abonnement</small>
</div>
<!-- Gate: free -->
<div id="api-gate-free" class="plan-gate gate-premium" style="display:none">
<div class="gate-icon"></div>
<h3>Fonctionnalité Premium & Pro</h3>
<p>Intégrez les prédictions Turf IA dans vos propres outils via notre API REST. Disponible dès le plan <strong>Premium</strong>.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade">Passer à Premium — 9,90€/mois</a>
</div>
<!-- Gate: premium (show teaser, upgrade to Pro for full access) -->
<div id="api-gate-premium" style="display:none">
<div class="upgrade-banner" style="border-color:rgba(30,136,229,.3);background:rgba(30,136,229,.07)">
<p>Plan <strong>Premium</strong> — API Token disponible. Passez à <strong style="color:var(--blue)">Pro</strong> pour un accès illimité (rate limit 10 000 req/jour) et le support Webhook.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade-pro">Passer Pro — 24,90€/mois</a>
</div>
</div>
<!-- API Token content (Premium+) -->
<div id="api-token-content" style="display:none">
<div class="form-card">
<h3>Votre clé API personnelle</h3>
<div style="margin-bottom:16px">
<div class="form-group" style="margin-bottom:12px">
<label>Clé API</label>
<div class="token-display" id="api-token-display">
<span id="api-token-value">•••••••••••••••••••••••••••••••</span>
<span class="copy-btn" onclick="copyToken()">📋 Copier</span>
</div>
</div>
<div style="font-size:.8rem;color:var(--muted)">
<span id="api-token-meta">Aucune clé générée</span>
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" id="api-generate-btn" onclick="generateApiToken()">🔑 Générer une clé</button>
<button class="btn btn-danger btn-sm" id="api-revoke-btn" onclick="revokeApiToken()" style="display:none">🗑 Révoquer</button>
</div>
</div>
<!-- Rate limits info -->
<div class="form-card" id="api-rate-card">
<h3>Limites d'utilisation</h3>
<div class="stats-row" style="margin-bottom:0">
<div class="stat-card">
<div class="stat-label">Requêtes / jour</div>
<div class="stat-value" id="api-rate-limit"></div>
<div class="stat-sub">votre plan</div>
</div>
<div class="stat-card">
<div class="stat-label">Endpoints accessibles</div>
<div class="stat-value" id="api-endpoints-count"></div>
<div class="stat-sub">dont /valuebets, /history</div>
</div>
</div>
</div>
<div class="form-card" style="background:rgba(30,136,229,.05);border-color:rgba(30,136,229,.2)">
<h3 style="color:var(--blue)">Exemple d'utilisation</h3>
<pre style="background:var(--dark3);padding:14px;border-radius:8px;font-size:.8rem;color:var(--green);overflow-x:auto;line-height:1.6">curl -H "Authorization: Bearer YOUR_TOKEN" \
https://turf-saas-kolifee.duckdns.org/api/v1/valuebets</pre>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<!-- SECTION: WEBHOOK (Pro uniquement) -->
<!-- TODO: replace mock — HRT-80 -->
<!-- ═══════════════════════════════════════════════════════ -->
<div id="section-webhook" class="dashboard-section" style="display:none">
<div class="section-title">
🔗 Webhook
<small>Recevoir les prédictions en push sur votre serveur</small>
</div>
<!-- Gate: free -->
<div id="wh-gate-free" class="plan-gate gate-premium" style="display:none">
<div class="gate-icon">🔗</div>
<h3>Fonctionnalité Pro</h3>
<p>Configurez un webhook pour recevoir les prédictions en temps réel sur votre propre serveur. Disponible dès le plan <strong>Pro</strong>.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade-pro">Passer Pro — 24,90€/mois</a>
</div>
<!-- Gate: premium (locked for Pro) -->
<div id="wh-gate-premium" class="plan-gate gate-pro" style="display:none">
<div class="gate-icon">🔗</div>
<h3>Fonctionnalité Pro exclusif</h3>
<p>Les webhooks sont disponibles uniquement pour le plan <strong>Pro</strong>. Passez à Pro pour recevoir les prédictions en push sur votre serveur.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade-pro">Passer Pro — 24,90€/mois</a>
</div>
<!-- Webhook content (Pro only) -->
<div id="wh-content" style="display:none">
<div class="form-card">
<h3>Configuration du webhook</h3>
<div id="wh-status-row" style="margin-bottom:16px;font-size:.85rem;color:var(--muted)">
<span class="status-dot status-off" id="wh-status-dot"></span>
<span id="wh-status-text">Aucun webhook configuré</span>
</div>
<div class="form-row">
<div class="form-group" style="grid-column:1/-1">
<label>URL du webhook</label>
<input type="url" id="wh-url" placeholder="https://votre-serveur.com/webhook/turf" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Secret de signature (HMAC-SHA256)</label>
<input type="text" id="wh-secret" class="mono" placeholder="Généré automatiquement" readonly />
</div>
<div class="form-group">
<label>Événements à envoyer</label>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:4px">
<label style="display:flex;align-items:center;gap:8px;text-transform:none;font-size:.88rem;letter-spacing:0;color:var(--text);cursor:pointer">
<input type="checkbox" id="wh-ev-vb" checked style="accent-color:var(--blue)"> value_bet_detected
</label>
<label style="display:flex;align-items:center;gap:8px;text-transform:none;font-size:.88rem;letter-spacing:0;color:var(--text);cursor:pointer">
<input type="checkbox" id="wh-ev-pred" style="accent-color:var(--blue)"> predictions_ready
</label>
<label style="display:flex;align-items:center;gap:8px;text-transform:none;font-size:.88rem;letter-spacing:0;color:var(--text);cursor:pointer">
<input type="checkbox" id="wh-ev-result" style="accent-color:var(--blue)"> race_result
</label>
</div>
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" onclick="saveWebhook()">💾 Enregistrer</button>
<button class="btn btn-ghost" onclick="testWebhook()">🧪 Tester le webhook</button>
<button class="btn btn-danger btn-sm" id="wh-delete-btn" onclick="deleteWebhook()" style="display:none">🗑 Supprimer</button>
</div>
</div>
<!-- Last deliveries -->
<div class="form-card">
<h3>Dernières livraisons</h3>
<div id="wh-deliveries">
<div class="empty-state" style="padding:30px 20px">
<div class="icon">📭</div>
<p>Aucune livraison pour l'instant</p>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════ -->
<!-- SECTION: MULTI-COMPTE (Pro uniquement) -->
<!-- Endpoint non défini — gating UI uniquement (HRT-78) -->
<!-- ═══════════════════════════════════════════════════════ -->
<div id="section-multi-account" class="dashboard-section" style="display:none">
<div class="section-title">
👥 Multi-compte
<small>Gérez les accès de votre équipe</small>
</div>
<!-- Gate: free -->
<div id="mc-gate-free" class="plan-gate gate-premium" style="display:none">
<div class="gate-icon">👥</div>
<h3>Fonctionnalité Pro</h3>
<p>Invitez des membres dans votre espace et gérez les accès de votre équipe. Disponible dès le plan <strong>Pro</strong>.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade-pro">Passer Pro — 24,90€/mois</a>
</div>
<!-- Gate: premium -->
<div id="mc-gate-premium" class="plan-gate gate-pro" style="display:none">
<div class="gate-icon">👥</div>
<h3>Fonctionnalité Pro exclusif</h3>
<p>La gestion multi-compte est disponible uniquement en <strong>Pro</strong>. Passez à Pro pour inviter votre équipe et partager l'accès aux prédictions.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade-pro">Passer Pro — 24,90€/mois</a>
</div>
<!-- Multi-account content (Pro only) -->
<div id="mc-content" style="display:none">
<div class="form-card">
<h3>Membres de l'équipe</h3>
<div id="mc-members-list">
<div class="loader-row"><div class="spinner"></div> Chargement…</div>
</div>
</div>
<div class="form-card">
<h3>Inviter un membre</h3>
<div class="form-row">
<div class="form-group">
<label>Email</label>
<input type="email" id="mc-invite-email" placeholder="collaborateur@email.com" />
</div>
<div class="form-group">
<label>Rôle</label>
<select id="mc-invite-role">
<option value="viewer">Lecteur (view only)</option>
<option value="analyst">Analyste (predictions)</option>
<option value="admin">Admin (full access)</option>
</select>
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" onclick="inviteMember()">📧 Envoyer l'invitation</button>
</div>
<div style="margin-top:12px;padding:10px 14px;background:var(--dark3);border-radius:8px;font-size:.8rem;color:var(--muted)">
⚠️ Les membres invités utilisent les quotas de votre abonnement Pro. Limite: <strong style="color:var(--text)" id="mc-seat-limit"></strong> sièges inclus.
</div>
</div>
</div>
</div>
</div><!-- .content -->
</div><!-- .main -->
<div id="toast"></div>
<script>
const API = '/api/v1';
let currentUser = null;
let currentPlan = 'free';
// ────────────────────────────────────────────────────────
// Utilities
// ────────────────────────────────────────────────────────
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg; t.className = `show ${type}`;
setTimeout(() => t.className = '', 3500);
}
function getToken() {
return localStorage.getItem('turf_token');
}
async function fetchJson(url, opts = {}) {
const token = getToken();
const res = await fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }
});
if (res.status === 401) { logout(); return null; }
return res.json();
}
function logout() {
localStorage.removeItem('turf_token');
localStorage.removeItem('turf_user');
location.href = '/login';
}
function planLevel(plan) {
return { free: 0, premium: 1, pro: 2 }[plan] || 0;
}
function planAllows(plan, required) {
return planLevel(plan) >= planLevel(required);
}
function fmtDate(d) {
if (!d) return '—';
return new Date(d).toLocaleDateString('fr-FR', { day:'2-digit', month:'2-digit', year:'numeric' });
}
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
function daysAgoIso(n) {
const d = new Date();
d.setDate(d.getDate() - n);
return d.toISOString().slice(0, 10);
}
// ────────────────────────────────────────────────────────
// Section navigation
// ────────────────────────────────────────────────────────
const SECTION_TITLES = {
'dashboard': 'Tableau de bord',
'races': 'Courses du jour',
'value-bets': 'Value Bets',
'history': 'Historique',
'export': 'Export CSV',
'telegram': 'Alertes Telegram',
'api-token': 'API Token',
'webhook': 'Webhook',
'multi-account': 'Multi-compte',
};
function showSection(name, navEl) {
// Hide all sections
document.querySelectorAll('.dashboard-section').forEach(s => s.style.display = 'none');
// Remove active from all nav items
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
// Show target section
const sec = document.getElementById('section-' + name);
if (sec) sec.style.display = '';
// Mark nav item active
if (navEl) navEl.classList.add('active');
// Update topbar title
document.getElementById('topbar-section-title').textContent = SECTION_TITLES[name] || name;
// Lazy-load section data
onSectionShow(name);
return false;
}
function onSectionShow(name) {
if (name === 'value-bets' && currentPlan && planAllows(currentPlan, 'premium')) loadValueBets();
if (name === 'history') loadHistory();
if (name === 'telegram' && planAllows(currentPlan, 'premium')) loadTelegramConfig();
if (name === 'api-token' && planAllows(currentPlan, 'premium')) loadApiToken();
if (name === 'webhook' && planAllows(currentPlan, 'pro')) loadWebhook();
if (name === 'multi-account' && planAllows(currentPlan, 'pro')) loadMultiAccount();
}
// ────────────────────────────────────────────────────────
// Plan UI gating
// ────────────────────────────────────────────────────────
function setPlanUI(plan) {
currentPlan = plan;
const badge = document.getElementById('plan-badge');
const upgradeBtn = document.getElementById('upgrade-btn');
const upgradeBanner = document.getElementById('upgrade-banner');
badge.className = `badge badge-${plan}`;
badge.textContent = { free: 'Free', premium: 'Premium ⭐', pro: 'Pro 🚀' }[plan] || plan;
if (plan === 'free') {
upgradeBtn.style.display = '';
upgradeBanner.style.display = '';
document.getElementById('lock-vb').textContent = '🔒';
document.getElementById('lock-hist').textContent = '';
document.getElementById('lock-export').textContent = '🔒';
document.getElementById('lock-tg').textContent = '🔒';
document.getElementById('lock-api').textContent = '🔒';
document.getElementById('lock-wh').textContent = '🔒';
document.getElementById('lock-mc').textContent = '🔒';
} else if (plan === 'premium') {
upgradeBtn.style.display = '';
upgradeBtn.innerHTML = '🚀 Passer Pro';
upgradeBtn.className = 'btn btn-upgrade-pro';
document.getElementById('lock-wh').textContent = '🔒';
document.getElementById('lock-mc').textContent = '🔒';
}
// Pro: no locks
// Section-level gating
applyGates(plan);
}
function applyGates(plan) {
// Value Bets: premium+
setGate('vb', plan, 'premium', 'vb-content');
// History: available to all, partial lock message
if (plan === 'free') show('hist-gate-free'); else hide('hist-gate-free');
// Export: premium+
setGate('export', plan, 'premium', 'export-content');
// Telegram: premium+
setGate('tg', plan, 'premium', 'tg-content');
// API Token: premium+ (with Pro upgrade teaser)
setGateWithProTeaser('api', plan, 'api-token-content', 'api-gate-premium');
// Webhook: pro only
setGatePro('wh', plan, 'wh-content');
// Multi-account: pro only
setGatePro('mc', plan, 'mc-content');
// API rate limits
if (planAllows(plan, 'premium')) {
document.getElementById('api-rate-limit').textContent = plan === 'pro' ? '10 000' : '1 000';
document.getElementById('api-endpoints-count').textContent = plan === 'pro' ? '8' : '5';
}
// Multi-account seats
document.getElementById('mc-seat-limit').textContent = '5';
}
function setGate(prefix, plan, minPlan, contentId) {
const gateId = `${prefix}-gate-free`;
if (planAllows(plan, minPlan)) {
hide(gateId);
show(contentId);
} else {
show(gateId);
hide(contentId);
}
}
function setGateWithProTeaser(prefix, plan, contentId, premiumTeaserId) {
const gateId = `${prefix}-gate-free`;
if (plan === 'free') {
show(gateId);
hide(premiumTeaserId);
hide(contentId);
} else if (plan === 'premium') {
hide(gateId);
show(premiumTeaserId);
show(contentId);
} else {
hide(gateId);
hide(premiumTeaserId);
show(contentId);
}
}
function setGatePro(prefix, plan, contentId) {
const gatesFree = `${prefix}-gate-free`;
const gatesPremium = `${prefix}-gate-premium`;
if (plan === 'free') {
show(gatesFree);
hide(gatesPremium);
hide(contentId);
} else if (plan === 'premium') {
hide(gatesFree);
show(gatesPremium);
hide(contentId);
} else {
hide(gatesFree);
hide(gatesPremium);
show(contentId);
}
}
function show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
function hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
// ────────────────────────────────────────────────────────
// Render: Race Cards (summary on dashboard)
// ────────────────────────────────────────────────────────
function renderRaceCards(predictions, plan, containerId, detailId) {
const container = document.getElementById(containerId);
if (!container) return;
if (!predictions || predictions.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="icon">🏇</div><h3>Aucune prédiction disponible</h3><p>Les prédictions d'aujourd'hui ne sont pas encore disponibles. Revenez plus tard.</p></div>`;
return;
}
// Group by race
const races = {};
predictions.forEach(p => {
const key = `${p.num_reunion}-${p.num_course}`;
if (!races[key]) races[key] = { label: p.race_label || `R${p.num_reunion}C${p.num_course}`, name: p.race_name || '', hippodrome: p.hippodrome || '', discipline: p.discipline || '', heure: p.heure || '', horses: [] };
races[key].horses.push(p);
});
const maxRaces = plan === 'free' ? 1 : 999;
const allKeys = Object.keys(races);
const raceKeys = allKeys.slice(0, maxRaces);
if (detailId) {
const lbl1 = document.getElementById('race-count-label');
const lbl2 = document.getElementById('race-count-label-2');
const txt = `${allKeys.length} course${allKeys.length>1?'s':''} analysée${allKeys.length>1?'s':''}`;
if (lbl1) lbl1.textContent = txt;
if (lbl2) lbl2.textContent = txt;
}
let html = '<div class="races-grid">';
raceKeys.forEach(key => {
const race = races[key];
const sorted = [...race.horses].sort((a, b) => b.ml_score - a.ml_score).slice(0, 3);
const vbCount = race.horses.filter(h => h.is_value_bet).length;
html += `<div class="race-card">
<div class="race-header">
<div>
<div class="race-name">${race.label}${race.name ? ' — ' + race.name : ''}</div>
<div class="race-meta">${race.hippodrome ? race.hippodrome + ' · ' : ''}${race.discipline || ''}</div>
</div>
<div class="race-time">${race.heure || ''}</div>
</div>
${vbCount > 0 ? `<span class="value-bet">💎 ${vbCount} value bet${vbCount>1?'s':''}</span>` : ''}
<div class="top3-row">
${sorted.map((h, i) => `<span class="horse-chip top${i+1}">${i===0?'🥇':i===1?'🥈':'🥉'} ${h.horse_name} (${h.odds ? h.odds.toFixed(1) : '—'})</span>`).join('')}
</div>
</div>`;
});
html += '</div>';
// Detailed table for first race
if (raceKeys.length > 0 && detailId) {
const firstRace = races[raceKeys[0]];
const sorted = [...firstRace.horses].sort((a, b) => b.ml_score - a.ml_score);
html += `<div class="section-title" style="margin-top:8px">📋 Détail — ${firstRace.label}${firstRace.name ? ' · ' + firstRace.name : ''}</div>
<div class="race-table-wrap">
<table>
<thead><tr>
<th>#</th><th>Cheval</th><th>Cote</th><th>Prob Top-1</th><th>Prob Top-3</th><th>Score IA</th><th>Value</th>
</tr></thead>
<tbody>`;
sorted.forEach((h, i) => {
const prob1 = h.prob_top1 ? (h.prob_top1 * 100).toFixed(1) : '—';
const prob3 = h.prob_top3 ? (h.prob_top3 * 100).toFixed(1) : '—';
const score = h.ml_score ? h.ml_score.toFixed(2) : '—';
html += `<tr>
<td class="${i===0?'rank-1':i===1?'rank-2':i===2?'rank-3':''}">${i+1}</td>
<td class="horse-name">${h.horse_name || '—'}</td>
<td class="cote">${h.odds ? h.odds.toFixed(1) : '—'}</td>
<td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:${prob1}%"></div></div>${prob1}%</div></td>
<td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:${prob3}%;background:var(--blue)"></div></div>${prob3}%</div></td>
<td>${score}</td>
<td>${h.is_value_bet ? '<span class="value-bet">💎 VB</span>' : '—'}</td>
</tr>`;
});
html += '</tbody></table></div>';
}
container.innerHTML = html;
}
// ────────────────────────────────────────────────────────
// VALUE BETS — endpoint réel /api/v1/valuebets
// ────────────────────────────────────────────────────────
async function loadValueBets() {
if (!planAllows(currentPlan, 'premium')) return;
const container = document.getElementById('vb-container');
if (!container) return;
const minOdds = document.getElementById('vb-min-odds')?.value || '2.0';
container.innerHTML = '<div class="loader-row"><div class="spinner"></div> Chargement…</div>';
const data = await fetchJson(`${API}/valuebets?min_odds=${minOdds}`);
if (!data) return;
const label = document.getElementById('vb-count-label');
if (data.total !== undefined && label) label.textContent = `${data.total} trouvé${data.total>1?'s':''}`;
if (data.count !== undefined && label) label.textContent = `${data.count} trouvé${data.count>1?'s':''}`;
const vbs = data.valuebets || [];
if (vbs.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="icon">💎</div><h3>Aucun value bet pour l'instant</h3><p>Les value bets du jour n'ont pas encore été identifiés. Revenez plus tard.</p></div>`;
return;
}
let html = '<div class="vb-grid">';
vbs.forEach(vb => {
const prob3 = vb.prob_top3 ? (vb.prob_top3 * 100).toFixed(1) : '—';
const score = vb.ml_score ? vb.ml_score.toFixed(2) : '—';
const risk = vb.risque_label || 'moyen';
const riskClass = risk === 'faible' ? 'vb-risk-low' : risk === 'fort' ? 'vb-risk-high' : 'vb-risk-med';
html += `<div class="vb-card">
<div class="vb-card-header">
<span class="vb-horse">🐎 ${vb.horse_name || '—'}</span>
<span class="vb-cote">${vb.odds ? vb.odds.toFixed(1) : '—'}</span>
</div>
<div class="vb-race">${vb.race_label || ''}${vb.hippodrome || ''} ${vb.heure ? '· ' + vb.heure : ''}</div>
<div class="vb-stats">
<div class="vb-stat-item">
<span class="vb-stat-label">Prob Top-3</span>
<span class="vb-stat-val">${prob3}%</span>
</div>
<div class="vb-stat-item">
<span class="vb-stat-label">Score IA</span>
<span class="vb-stat-val">${score}</span>
</div>
<div class="vb-stat-item">
<span class="vb-stat-label">Risque</span>
<span class="vb-risk ${riskClass}">${risk}</span>
</div>
</div>
</div>`;
});
html += '</div>';
container.innerHTML = html;
}
// ────────────────────────────────────────────────────────
// HISTORIQUE — endpoint réel /api/v1/history (HRT-81)
// ────────────────────────────────────────────────────────
async function loadHistory() {
const container = document.getElementById('history-container');
if (!container) return;
// Init date pickers if empty
const startEl = document.getElementById('hist-start');
const endEl = document.getElementById('hist-end');
if (!endEl.value) endEl.value = todayIso();
if (!startEl.value) {
const maxDays = currentPlan === 'pro' ? 365 : currentPlan === 'premium' ? 90 : 7;
startEl.value = daysAgoIso(maxDays - 1);
}
container.innerHTML = '<div class="loader-row"><div class="spinner"></div> Chargement de l\'historique…</div>';
const params = new URLSearchParams({ start: startEl.value, end: endEl.value, limit: 100 });
const data = await fetchJson(`${API}/history?${params}`);
if (!data) return;
if (data.error) {
container.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div><h3>${data.error}</h3></div>`;
return;
}
const rows = data.history || [];
// Show export button for premium+
if (planAllows(currentPlan, 'premium')) show('hist-export-btn');
if (rows.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="icon">📅</div><h3>Aucun historique</h3><p>Pas de prédictions sur cette période.</p></div>`;
return;
}
let html = `<div class="race-table-wrap"><table>
<thead><tr>
<th>Date</th><th>Course</th><th>Cheval</th><th>Score IA</th><th>Prob Top-3</th><th>Hippodrome</th><th>Value Bet</th>
</tr></thead>
<tbody>`;
rows.forEach(r => {
const prob3 = r.prob_top3 ? (r.prob_top3 * 100).toFixed(1) : '—';
html += `<tr>
<td>${r.date || '—'}</td>
<td>${r.race_label || '—'}</td>
<td class="horse-name">${r.horse_name || '—'}</td>
<td>${r.ml_score ? r.ml_score.toFixed(2) : '—'}</td>
<td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:${prob3}%;background:var(--blue)"></div></div>${prob3}%</div></td>
<td>${r.hippodrome || '—'}</td>
<td>${r.is_value_bet ? '<span class="hist-vb-tag">💎 VB</span>' : '—'}</td>
</tr>`;
});
html += '</tbody></table></div>';
if (data.total > rows.length) {
html += `<p style="text-align:center;color:var(--muted);font-size:.82rem;margin-top:8px">${rows.length} / ${data.total} résultats affichés — affinez la période pour voir plus</p>`;
}
container.innerHTML = html;
}
async function exportHistoryCsv() {
if (!planAllows(currentPlan, 'premium')) { showToast('Export disponible dès le plan Premium', 'error'); return; }
const start = document.getElementById('hist-start')?.value || daysAgoIso(30);
const end = document.getElementById('hist-end')?.value || todayIso();
window.open(`${API}/export/csv?start=${start}&end=${end}&token=${getToken()}`, '_blank');
}
// ────────────────────────────────────────────────────────
// EXPORT CSV
// ────────────────────────────────────────────────────────
function initExportDates() {
const startEl = document.getElementById('export-start');
const endEl = document.getElementById('export-end');
if (endEl && !endEl.value) endEl.value = todayIso();
if (startEl && !startEl.value) startEl.value = daysAgoIso(29);
}
async function doExportCsv() {
if (!planAllows(currentPlan, 'premium')) { showToast('Export disponible dès le plan Premium', 'error'); return; }
const start = document.getElementById('export-start')?.value || daysAgoIso(29);
const end = document.getElementById('export-end')?.value || todayIso();
const token = getToken();
window.open(`${API}/export/csv?start=${start}&end=${end}&token=${token}`, '_blank');
}
// ────────────────────────────────────────────────────────
// ALERTES TELEGRAM
// TODO: replace mock — HRT-79
// Contrat d'interface attendu:
// GET /api/v1/telegram/config → { enabled, bot_token_masked, chat_id, alert_vb, alert_top1, alert_quinte, summary_time }
// POST /api/v1/telegram/config → { bot_token, chat_id, alert_vb, alert_top1, alert_quinte, summary_time }
// POST /api/v1/telegram/test → { ok, message }
// DELETE /api/v1/telegram/config → { ok }
// ────────────────────────────────────────────────────────
let _tgConfigExists = false;
async function loadTelegramConfig() {
if (!planAllows(currentPlan, 'premium')) return;
// TODO: replace mock — HRT-79 — endpoint GET /api/v1/telegram/config non encore disponible
const MOCK_CONFIG = null; // null = pas de config
applyTelegramConfig(MOCK_CONFIG);
}
function applyTelegramConfig(cfg) {
const dot = document.getElementById('tg-status-dot');
const statusTxt = document.getElementById('tg-status-text');
const deleteBtn = document.getElementById('tg-delete-btn');
if (cfg && cfg.enabled) {
_tgConfigExists = true;
dot.className = 'status-dot status-ok';
statusTxt.textContent = `Actif · Bot configuré · Chat ID: ${cfg.chat_id || '—'}`;
if (document.getElementById('tg-chat-id')) document.getElementById('tg-chat-id').value = cfg.chat_id || '';
if (cfg.alert_vb !== undefined) document.getElementById('tg-alert-vb').checked = cfg.alert_vb;
if (cfg.alert_top1 !== undefined) document.getElementById('tg-alert-top1').checked = cfg.alert_top1;
if (cfg.alert_quinte !== undefined) document.getElementById('tg-alert-quinté').checked = cfg.alert_quinte;
if (cfg.summary_time) document.getElementById('tg-summary-time').value = cfg.summary_time;
deleteBtn.style.display = '';
} else {
_tgConfigExists = false;
dot.className = 'status-dot status-off';
statusTxt.textContent = 'Non configuré';
deleteBtn.style.display = 'none';
}
}
async function saveTelegramConfig() {
if (!planAllows(currentPlan, 'premium')) { showToast('Fonctionnalité Premium requise', 'error'); return; }
const token = document.getElementById('tg-bot-token')?.value;
const chatId = document.getElementById('tg-chat-id')?.value;
if (!chatId) { showToast('Le Chat ID est requis', 'error'); return; }
if (!_tgConfigExists && !token) { showToast('Le Bot Token est requis pour la première configuration', 'error'); return; }
// TODO: replace mock — HRT-79 — POST /api/v1/telegram/config
// const data = await fetchJson(`${API}/telegram/config`, { method: 'POST', body: JSON.stringify({ bot_token: token, chat_id: chatId, alert_vb: ..., ... }) });
showToast('Configuration Telegram enregistrée (mock)', 'info');
applyTelegramConfig({ enabled: true, chat_id: chatId });
}
async function testTelegramMessage() {
if (!planAllows(currentPlan, 'premium')) { showToast('Fonctionnalité Premium requise', 'error'); return; }
// TODO: replace mock — HRT-79 — POST /api/v1/telegram/test
// const data = await fetchJson(`${API}/telegram/test`, { method: 'POST' });
showToast('Message test envoyé (mock)', 'info');
}
async function deleteTelegramConfig() {
if (!confirm('Supprimer la configuration Telegram ?')) return;
// TODO: replace mock — HRT-79 — DELETE /api/v1/telegram/config
applyTelegramConfig(null);
showToast('Configuration Telegram supprimée (mock)', 'info');
}
// ────────────────────────────────────────────────────────
// API TOKEN
// TODO: replace mock — HRT-80
// Contrat d'interface attendu:
// GET /api/v1/apikey → { exists, token_masked, created_at, last_used_at }
// POST /api/v1/apikey/generate → { token }
// DELETE /api/v1/apikey → { ok }
// ────────────────────────────────────────────────────────
let _apiTokenExists = false;
async function loadApiToken() {
if (!planAllows(currentPlan, 'premium')) return;
// TODO: replace mock — HRT-80 — GET /api/v1/apikey
const MOCK = { exists: false };
applyApiToken(MOCK);
}
function applyApiToken(data) {
const valEl = document.getElementById('api-token-value');
const metaEl = document.getElementById('api-token-meta');
const genBtn = document.getElementById('api-generate-btn');
const revokeBtn = document.getElementById('api-revoke-btn');
if (data && data.exists && data.token_masked) {
_apiTokenExists = true;
valEl.textContent = data.token_masked;
metaEl.textContent = `Créée le ${fmtDate(data.created_at)}${data.last_used_at ? ' · Dernière utilisation: ' + fmtDate(data.last_used_at) : ''}`;
genBtn.textContent = '🔄 Régénérer';
revokeBtn.style.display = '';
} else {
_apiTokenExists = false;
valEl.textContent = '•••••••••••••••••••••••••••••••';
metaEl.textContent = 'Aucune clé générée';
genBtn.textContent = '🔑 Générer une clé';
revokeBtn.style.display = 'none';
}
}
async function generateApiToken() {
if (!planAllows(currentPlan, 'premium')) { showToast('Fonctionnalité Premium requise', 'error'); return; }
if (_apiTokenExists && !confirm('Régénérer la clé API ? L\'ancienne clé sera invalidée immédiatement.')) return;
// TODO: replace mock — HRT-80 — POST /api/v1/apikey/generate
const MOCK_TOKEN = 'trf_' + Math.random().toString(36).slice(2, 12) + Math.random().toString(36).slice(2, 24);
const valEl = document.getElementById('api-token-value');
valEl.textContent = MOCK_TOKEN;
valEl.dataset.full = MOCK_TOKEN;
document.getElementById('api-token-meta').textContent = `Créée le ${fmtDate(new Date().toISOString())} · Copiez-la maintenant, elle ne sera plus visible`;
document.getElementById('api-revoke-btn').style.display = '';
document.getElementById('api-generate-btn').textContent = '🔄 Régénérer';
_apiTokenExists = true;
showToast('Clé API générée (mock — HRT-80)', 'info');
}
async function revokeApiToken() {
if (!confirm('Révoquer la clé API ? Toutes les intégrations utilisant cette clé seront immédiatement invalidées.')) return;
// TODO: replace mock — HRT-80 — DELETE /api/v1/apikey
applyApiToken({ exists: false });
showToast('Clé API révoquée (mock)', 'info');
}
function copyToken() {
const valEl = document.getElementById('api-token-value');
const text = valEl.dataset.full || valEl.textContent;
if (text.startsWith('•')) { showToast('Générez d\'abord une clé API', 'error'); return; }
navigator.clipboard.writeText(text).then(() => showToast('Clé copiée dans le presse-papier', 'success'));
}
// ────────────────────────────────────────────────────────
// WEBHOOK
// TODO: replace mock — HRT-80
// Contrat d'interface attendu:
// GET /api/v1/webhook/config → { exists, url, events, secret_masked }
// POST /api/v1/webhook/config → { url, events[] } → { ok, secret }
// POST /api/v1/webhook/test → { ok, status_code, duration_ms }
// DELETE /api/v1/webhook/config → { ok }
// ────────────────────────────────────────────────────────
async function loadWebhook() {
if (!planAllows(currentPlan, 'pro')) return;
// TODO: replace mock — HRT-80 — GET /api/v1/webhook/config
const MOCK = { exists: false };
applyWebhookConfig(MOCK);
}
function applyWebhookConfig(data) {
const dot = document.getElementById('wh-status-dot');
const statusTxt = document.getElementById('wh-status-text');
const deleteBtn = document.getElementById('wh-delete-btn');
const secretEl = document.getElementById('wh-secret');
if (data && data.exists) {
dot.className = 'status-dot status-ok';
statusTxt.textContent = `Actif · ${data.url || ''}`;
if (document.getElementById('wh-url')) document.getElementById('wh-url').value = data.url || '';
if (secretEl) secretEl.value = data.secret_masked || '••••••••••••';
if (data.events) {
if (data.events.includes('value_bet_detected')) document.getElementById('wh-ev-vb').checked = true;
if (data.events.includes('predictions_ready')) document.getElementById('wh-ev-pred').checked = true;
if (data.events.includes('race_result')) document.getElementById('wh-ev-result').checked = true;
}
deleteBtn.style.display = '';
} else {
dot.className = 'status-dot status-off';
statusTxt.textContent = 'Aucun webhook configuré';
if (secretEl) secretEl.value = '';
deleteBtn.style.display = 'none';
}
}
async function saveWebhook() {
if (!planAllows(currentPlan, 'pro')) { showToast('Fonctionnalité Pro requise', 'error'); return; }
const url = document.getElementById('wh-url')?.value;
if (!url || !url.startsWith('http')) { showToast('URL webhook invalide', 'error'); return; }
// TODO: replace mock — HRT-80 — POST /api/v1/webhook/config
const mockSecret = 'whsec_' + Math.random().toString(36).slice(2, 30);
document.getElementById('wh-secret').value = mockSecret;
applyWebhookConfig({ exists: true, url });
showToast('Webhook enregistré (mock — HRT-80)', 'info');
}
async function testWebhook() {
if (!planAllows(currentPlan, 'pro')) { showToast('Fonctionnalité Pro requise', 'error'); return; }
const url = document.getElementById('wh-url')?.value;
if (!url) { showToast('Configurez d\'abord votre webhook', 'error'); return; }
// TODO: replace mock — HRT-80 — POST /api/v1/webhook/test
showToast('Webhook testé : 200 OK (mock)', 'success');
}
async function deleteWebhook() {
if (!confirm('Supprimer le webhook ?')) return;
// TODO: replace mock — HRT-80 — DELETE /api/v1/webhook/config
applyWebhookConfig({ exists: false });
showToast('Webhook supprimé (mock)', 'info');
}
// ────────────────────────────────────────────────────────
// MULTI-COMPTE
// Endpoint non défini — gating UI uniquement
// ────────────────────────────────────────────────────────
async function loadMultiAccount() {
if (!planAllows(currentPlan, 'pro')) return;
// TODO: endpoint multi-compte non encore défini (HRT-78)
const MOCK_MEMBERS = [
{ name: 'Vous (propriétaire)', email: currentUser?.email || '—', plan: 'pro', role: 'owner', initials: (currentUser?.firstname || 'V')[0].toUpperCase() },
];
renderMembers(MOCK_MEMBERS);
}
function renderMembers(members) {
const container = document.getElementById('mc-members-list');
if (!container) return;
if (!members || members.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:24px"><p>Aucun membre pour l\'instant. Invitez votre équipe ci-dessous.</p></div>';
return;
}
let html = '';
members.forEach(m => {
const roleTxt = { owner: '👑 Propriétaire', admin: '🛡 Admin', analyst: '📊 Analyste', viewer: '👁 Lecteur' }[m.role] || m.role;
html += `<div class="member-row">
<div class="member-avatar">${m.initials || (m.name||'?')[0].toUpperCase()}</div>
<div class="member-info">
<div class="member-name">${m.name || '—'}</div>
<div class="member-email">${m.email || '—'}</div>
</div>
<span class="member-plan-badge badge badge-${m.plan || 'free'}">${roleTxt}</span>
</div>`;
});
container.innerHTML = html;
}
async function inviteMember() {
if (!planAllows(currentPlan, 'pro')) { showToast('Fonctionnalité Pro requise', 'error'); return; }
const email = document.getElementById('mc-invite-email')?.value;
if (!email || !email.includes('@')) { showToast('Adresse email invalide', 'error'); return; }
// TODO: endpoint multi-compte non encore défini (HRT-78)
showToast(`Invitation envoyée à ${email} (mock)`, 'info');
document.getElementById('mc-invite-email').value = '';
}
// ────────────────────────────────────────────────────────
// BOOTSTRAP
// ────────────────────────────────────────────────────────
document.getElementById('logout-btn').addEventListener('click', e => { e.preventDefault(); logout(); });
async function loadDashboard() {
const token = getToken();
if (!token) { location.href = '/login'; return; }
// Try loading from localStorage first for fast render
try {
const saved = JSON.parse(localStorage.getItem('turf_user') || '{}');
if (saved.firstname) {
document.getElementById('sidebar-name').textContent = `${saved.firstname} ${saved.lastname || ''}`;
document.getElementById('sidebar-avatar').textContent = saved.firstname[0].toUpperCase();
document.getElementById('sidebar-plan').textContent = (saved.plan || 'free').toUpperCase();
setPlanUI(saved.plan || 'free');
currentUser = saved;
}
} catch(_) {}
// Fetch fresh user profile
const profile = await fetchJson(`${API}/auth/me`);
if (!profile) return;
currentUser = profile.user || profile;
const plan = currentUser.plan || 'free';
document.getElementById('sidebar-name').textContent = `${currentUser.firstname || ''} ${currentUser.lastname || ''}`.trim() || currentUser.email;
document.getElementById('sidebar-avatar').textContent = (currentUser.firstname || currentUser.email || '?')[0].toUpperCase();
document.getElementById('sidebar-plan').textContent = plan.toUpperCase();
localStorage.setItem('turf_user', JSON.stringify(currentUser));
setPlanUI(plan);
// Fetch stats
const statsData = await fetchJson(`${API}/stats/summary`);
if (statsData) {
document.getElementById('stat-courses').textContent = statsData.courses_today || '—';
document.getElementById('stat-accuracy').textContent = statsData.accuracy_top3 ? statsData.accuracy_top3 + '%' : '—';
document.getElementById('stat-vb').textContent = statsData.value_bets_today ?? '—';
document.getElementById('stat-vb-sub').textContent = plan === 'free' ? '(limité)' : 'identifiés';
if (statsData.next_race_time) {
document.getElementById('stat-next').textContent = statsData.next_race_time;
document.getElementById('stat-next-hip').textContent = statsData.next_race_hippodrome || '';
}
}
// Fetch predictions (used for both dashboard summary and courses section)
const predsData = await fetchJson(`${API}/predictions/today`);
if (predsData && predsData.predictions) {
window._predictions = predsData.predictions;
renderRaceCards(predsData.predictions, plan, 'races-summary-container', true);
} else {
document.getElementById('races-summary-container').innerHTML = '<div class="empty-state"><div class="icon">🏇</div><h3>Prédictions non disponibles</h3><p>L\'API de prédictions est en cours de démarrage. Réessayez dans quelques instants.</p></div>';
}
// Show locked section for free
if (plan === 'free') show('locked-section');
// Init export dates
initExportDates();
// Set default active nav
const dashNav = document.getElementById('nav-dashboard');
if (dashNav) { dashNav.classList.add('active'); }
show('section-dashboard');
}
// Handle hash-based navigation on load
function initNavFromHash() {
const hash = window.location.hash.replace('#', '');
const sectionMap = {
'dashboard': 'nav-dashboard',
'races': 'nav-races',
'value-bets': 'nav-value-bets',
'history': 'nav-history',
'export': 'nav-export',
'telegram': 'nav-telegram',
'api-token': 'nav-api-token',
'webhook': 'nav-webhook',
'multi-account': 'nav-multi-account',
};
if (hash && sectionMap[hash]) {
setTimeout(() => {
const navEl = document.getElementById(sectionMap[hash]);
if (navEl) showSection(hash, navEl);
}, 100);
}
}
// Lazy-load races section when navigated to
const _origShowSection = showSection;
window.showSection = function(name, navEl) {
if (name === 'races' && window._predictions) {
setTimeout(() => {
renderRaceCards(window._predictions, currentPlan, 'races-container', true);
}, 10);
}
return _origShowSection(name, navEl);
};
loadDashboard().then(initNavFromHash);
</script>
</body>
</html>