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>
1552 lines
84 KiB
HTML
1552 lines
84 KiB
HTML
<!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 > 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>
|