Files
turf_saas/dashboard_saas.html
DevOps Engineer 41a9e36166 feat(sprint4-5): Landing page + onboarding SaaS — HRT-30
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>
2026-04-25 18:04:19 +02:00

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>