Frontend pages: - landing.html: marketing page — hero, pricing (Free/9.90e/24.90e), features, FAQ, footer, mobile-first responsive, LCP < 2.5s friendly - login.html: JWT auth login with JS validation, error handling, redirect-after-login - register.html: registration with plan selection preview sidebar, password strength meter - dashboard_saas.html: role-based dashboard (Free/Premium/Pro) with locked sections, race prediction cards, detailed table, stats row - onboarding.html: 3-step wizard — plan confirm + Telegram alerts config + first prediction preview - account.html: tabbed account management — profile, security (change-password, delete), plan upgrade, notification preferences Backend: - saas_auth.py: Flask Blueprint /api/v1/auth/* — register, login, token auth, profile/password/plan/preferences update, logout, delete-account - saas_api_v1.py: Flask Blueprint /api/v1/* — stats/summary, predictions/today (plan-gated), value-bets (Premium+), CSV export (Pro) Server: - portal_server.py: register blueprints, serve all new SaaS routes at /login /register /dashboard /onboarding /account Co-Authored-By: Paperclip <noreply@paperclip.ing>
231 lines
18 KiB
HTML
231 lines
18 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}
|
|
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{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}
|
|
.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-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{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}
|
|
.content{padding:28px}
|
|
.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)}
|
|
.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)}
|
|
.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-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}
|
|
.rank-1{color:var(--gold);font-weight:800}
|
|
.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.top3{border-color:#cd7f32;color:#cd7f32;background:rgba(205,127,50,.08)}
|
|
.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}
|
|
.lock-wrap{position:relative}
|
|
.locked-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(13,17,23,.7);border-radius:var(--radius);z-index:2}
|
|
.locked-overlay-msg{text-align:center}
|
|
.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)}}
|
|
#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}
|
|
@media(max-width:900px){.sidebar{display:none}}
|
|
@media(max-width:600px){.stats-row{grid-template-columns:1fr 1fr}.content{padding:16px}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<aside class="sidebar">
|
|
<div class="sidebar-logo">🏇 <span>Turf</span> IA</div>
|
|
<div class="nav-section">Principal</div>
|
|
<a class="nav-item active" href="/dashboard"><span class="icon">📊</span> Dashboard</a>
|
|
<a class="nav-item" href="#races"><span class="icon">🏁</span> Courses du jour</a>
|
|
<a class="nav-item" id="nav-value-bets" href="#value-bets"><span class="icon">💎</span> Value Bets</a>
|
|
<div class="nav-section">Analyse</div>
|
|
<a class="nav-item" id="nav-history" href="#history"><span class="icon">📅</span> Historique</a>
|
|
<a class="nav-item" id="nav-export" href="#export"><span class="icon">📤</span> Export CSV</a>
|
|
<a class="nav-item" id="nav-api" href="/docs/api"><span class="icon">⚡</span> API Docs</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>
|
|
<div class="user-name" id="sidebar-name">Chargement…</div>
|
|
<div class="user-plan" id="sidebar-plan">—</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
<div class="main">
|
|
<div class="topbar">
|
|
<div class="topbar-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">
|
|
<div class="upgrade-banner" id="upgrade-banner" style="display:none">
|
|
<p>🔒 Plan <strong>Free</strong> — 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>
|
|
<div class="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>
|
|
<div class="section-title">🏁 Prédictions du jour<small id="race-count-label">Chargement…</small></div>
|
|
<div id="races-container"><div class="loader-row"><div class="spinner"></div> Chargement des prédictions…</div></div>
|
|
<div id="locked-section" style="display:none">
|
|
<div class="lock-wrap" style="position:relative;min-height:200px;">
|
|
<div style="filter:blur(5px)">
|
|
<div class="races-grid">
|
|
<div class="race-card"><div class="race-name">R3C5 - Quinté+</div><div class="race-meta">16 partants · Plat · 2400m</div></div>
|
|
<div class="race-card"><div class="race-name">R2C3 - Prix du Président</div><div class="race-meta">12 partants · Trot · 2100m</div></div>
|
|
</div>
|
|
</div>
|
|
<div class="locked-overlay">
|
|
<div class="locked-overlay-msg">
|
|
<div style="font-size:2rem;margin-bottom:10px">🔒</div>
|
|
<h3>+120 courses cachées</h3>
|
|
<p style="color:var(--muted);font-size:.88rem;margin-bottom:14px">Passez à Premium pour débloquer toutes les courses, les value bets et les alertes Telegram.</p>
|
|
<a href="/account?tab=upgrade" class="btn btn-primary">Débloquer — 9,90€/mois</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="toast"></div>
|
|
<script>
|
|
const API='/api/v1';
|
|
let currentUser=null;
|
|
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'}
|
|
document.getElementById('logout-btn').addEventListener('click',e=>{e.preventDefault();logout()});
|
|
function setPlanUI(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('locked-section').style.display=''}
|
|
else if(plan==='premium'){upgradeBtn.style.display='';upgradeBtn.innerHTML='🚀 Passer Pro'}
|
|
}
|
|
function renderRaceCards(predictions,plan){
|
|
const container=document.getElementById('races-container');
|
|
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.</p></div>';return}
|
|
const races={};
|
|
predictions.forEach(p=>{const k=p.num_reunion+'-'+p.num_course;if(!races[k])races[k]={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[k].horses.push(p)});
|
|
const maxRaces=plan==='free'?1:999;
|
|
const raceKeys=Object.keys(races).slice(0,maxRaces);
|
|
document.getElementById('race-count-label').textContent=raceKeys.length+' course'+(raceKeys.length>1?'s':'')+' affichée'+(raceKeys.length>1?'s':'');
|
|
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]+' '+h.horse_name+' ('+(h.odds?h.odds.toFixed(1):'—')+')</span>').join('')+'</div></div>'
|
|
});
|
|
html+='</div>';
|
|
if(raceKeys.length>0){
|
|
const fr=races[raceKeys[0]];
|
|
const sorted=[...fr.horses].sort((a,b)=>b.ml_score-a.ml_score);
|
|
html+='<div class="section-title" style="margin-top:8px">📋 Détail — '+fr.label+(fr.name?' · '+fr.name:'')+'</div><div class="race-table-wrap"><table><thead><tr><th>#</th><th>Cheval</th><th>Cote</th><th>Prob Top-3</th><th>Score IA</th><th>Value</th></tr></thead><tbody>';
|
|
sorted.forEach((h,i)=>{const p3=h.prob_top3?(h.prob_top3*100).toFixed(1):'—';html+='<tr><td class="'+(i===0?'rank-1':'')+'">'+( i+1)+'</td><td class="horse-name">'+(h.horse_name||'—')+'</td><td>'+(h.odds?h.odds.toFixed(1):'—')+'</td><td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:'+p3+'%"></div></div>'+p3+'%</div></td><td>'+(h.ml_score?h.ml_score.toFixed(2):'—')+'</td><td>'+(h.is_value_bet?'<span class="value-bet">💎 VB</span>':'—')+'</td></tr>'});
|
|
html+='</tbody></table></div'+'>';
|
|
}
|
|
container.innerHTML=html;
|
|
}
|
|
async function loadDashboard(){
|
|
const token=getToken();
|
|
if(!token){location.href='/login';return}
|
|
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(_){}
|
|
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||'');
|
|
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);
|
|
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||''}
|
|
}
|
|
const predsData=await fetchJson(API+'/predictions/today');
|
|
if(predsData&&predsData.predictions){renderRaceCards(predsData.predictions,plan)}
|
|
else{document.getElementById('races-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.</p></div>'}
|
|
}
|
|
loadDashboard();
|
|
</script>
|
|
</body>
|
|
</html>
|