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>
This commit is contained in:
DevOps Engineer
2026-04-25 18:04:04 +02:00
parent ed07c8a3d1
commit 41a9e36166
9 changed files with 1545 additions and 42 deletions

177
account.html Normal file
View File

@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mon Compte — Turf IA</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--green:#00c853;--green-d:#009624;--blue:#1e88e5;--dark:#0d1117;--dark2:#161b22;--dark3:#21262d;--text:#e6edf3;--muted:#8b949e;--border:#30363d;--radius:10px;--error:#f85149;--gold:#ffd600}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--dark);color:var(--text);min-height:100vh;display:flex;flex-direction:column}
a{color:inherit;text-decoration:none}
nav{display:flex;align-items:center;justify-content:space-between;padding:14px 5%;border-bottom:1px solid var(--border)}
.nav-logo{font-weight:700;font-size:1.1rem}
.nav-back{color:var(--muted);font-size:.9rem}
main{flex:1;padding:40px 5%;max-width:900px;margin:0 auto;width:100%}
h1{font-size:1.6rem;font-weight:800;margin-bottom:28px}
.tabs{display:flex;gap:4px;border-bottom:1px solid var(--border);margin-bottom:28px}
.tab-btn{padding:10px 18px;border:none;background:transparent;color:var(--muted);font-size:.9rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:all .2s;margin-bottom:-1px}
.tab-btn:hover{color:var(--text)}
.tab-btn.active{color:var(--green);border-bottom-color:var(--green)}
.tab-panel{display:none}
.tab-panel.active{display:block}
.card{background:var(--dark2);border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin-bottom:20px}
.card-title{font-size:1rem;font-weight:700;margin-bottom:18px;padding-bottom:12px;border-bottom:1px solid var(--border)}
.form-group{margin-bottom:16px}
label{display:block;font-size:.83rem;font-weight:600;color:var(--muted);margin-bottom:6px}
input{width:100%;padding:10px 14px;background:var(--dark3);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.9rem;outline:none;transition:border-color .2s}
input:focus{border-color:var(--green)}
input:disabled{opacity:.5;cursor:not-allowed}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.btn{display:inline-flex;align-items:center;gap:6px;padding:9px 20px;border-radius:8px;font-size:.88rem;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-primary:disabled{opacity:.6;cursor:not-allowed}
.btn-ghost{background:transparent;border:1px solid var(--border);color:var(--text)}
.btn-ghost:hover{border-color:var(--muted)}
.btn-danger{background:transparent;border:1px solid var(--error);color:var(--error)}
.btn-danger:hover{background:rgba(248,81,73,.1)}
.btn-upgrade{background:linear-gradient(135deg,var(--gold),#ff6d00);color:#000}
.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin-bottom:24px}
.plan-card{background:var(--dark3);border:2px solid var(--border);border-radius:var(--radius);padding:20px;position:relative;transition:border-color .2s}
.plan-card.current{border-color:var(--green)}
.plan-current-badge{position:absolute;top:-10px;left:16px;background:var(--green);color:#000;padding:2px 10px;border-radius:10px;font-size:.7rem;font-weight:700}
.plan-name{font-weight:800;font-size:1rem;margin-bottom:6px}
.plan-price{font-size:1.5rem;font-weight:800;color:var(--green)}
.plan-price span{font-size:.85rem;font-weight:400;color:var(--muted)}
.plan-features-mini{list-style:none;margin-top:12px}
.plan-features-mini li{font-size:.8rem;color:var(--muted);padding:3px 0}
.plan-features-mini li::before{content:"✓ ";color:var(--green)}
.plan-card .btn{width:100%;justify-content:center;margin-top:14px;font-size:.82rem;padding:8px}
.alert{padding:12px 16px;border-radius:8px;font-size:.88rem;margin-bottom:14px;display:none}
.alert.show{display:block}
.alert-success{background:rgba(0,200,83,.1);border:1px solid rgba(0,200,83,.3);color:var(--green)}
.alert-error{background:rgba(248,81,73,.1);border:1px solid rgba(248,81,73,.3);color:var(--error)}
.danger-card{border-color:rgba(248,81,73,.3)}
#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.error{background:var(--error);color:#fff}
#toast.info{background:var(--blue);color:#fff}
@media(max-width:600px){.form-row{grid-template-columns:1fr}main{padding:20px 4%}}
</style>
</head>
<body>
<nav>
<a href="/" class="nav-logo">🏇 Turf IA</a>
<a href="/dashboard" class="nav-back">← Retour au dashboard</a>
</nav>
<main>
<h1>⚙️ Mon compte</h1>
<div class="tabs">
<button class="tab-btn active" data-tab="profile">Profil</button>
<button class="tab-btn" data-tab="security">Sécurité</button>
<button class="tab-btn" data-tab="upgrade">Mon plan</button>
<button class="tab-btn" data-tab="notifications">Notifications</button>
</div>
<div class="tab-panel active" id="tab-profile">
<div class="card">
<div class="card-title">Informations personnelles</div>
<div class="alert alert-success" id="profile-success">Profil mis à jour.</div>
<div class="alert alert-error" id="profile-error"></div>
<form id="profile-form">
<div class="form-row">
<div class="form-group"><label>Prénom</label><input type="text" id="p-firstname" placeholder="Jean"></div>
<div class="form-group"><label>Nom</label><input type="text" id="p-lastname" placeholder="Dupont"></div>
</div>
<div class="form-group"><label>Email</label><input type="email" id="p-email" placeholder="vous@exemple.fr"></div>
<button type="submit" class="btn btn-primary" id="profile-btn">Enregistrer</button>
</form>
</div>
<div class="card">
<div class="card-title">Informations de compte</div>
<div class="form-group"><label>Plan actuel</label><input type="text" id="p-plan-display" disabled></div>
<div class="form-group"><label>Membre depuis</label><input type="text" id="p-created" disabled></div>
</div>
</div>
<div class="tab-panel" id="tab-security">
<div class="card">
<div class="card-title">Changer le mot de passe</div>
<div class="alert alert-success" id="pwd-success">Mot de passe mis à jour.</div>
<div class="alert alert-error" id="pwd-error"></div>
<form id="password-form">
<div class="form-group"><label>Mot de passe actuel</label><input type="password" id="s-current-pwd" placeholder="••••••••" required></div>
<div class="form-group"><label>Nouveau mot de passe</label><input type="password" id="s-new-pwd" placeholder="8 caractères minimum" required></div>
<div class="form-group"><label>Confirmer</label><input type="password" id="s-confirm-pwd" placeholder="••••••••" required></div>
<button type="submit" class="btn btn-primary" id="pwd-btn">Mettre à jour</button>
</form>
</div>
<div class="card danger-card">
<div class="card-title">Zone dangereuse</div>
<p style="font-size:.9rem;color:var(--muted);margin-bottom:16px">La suppression de votre compte est irréversible.</p>
<button class="btn btn-danger" id="delete-account-btn">Supprimer mon compte</button>
</div>
</div>
<div class="tab-panel" id="tab-upgrade">
<div class="card">
<div class="card-title">Votre plan actuel</div>
<div class="plan-grid">
<div class="plan-card" id="plan-card-free"><div class="plan-name">Free</div><div class="plan-price">0€ <span>/mois</span></div><ul class="plan-features-mini"><li>1 course/jour</li><li>Aperçu Top-3</li></ul><button class="btn btn-ghost" id="select-free-btn">Plan actuel</button></div>
<div class="plan-card" id="plan-card-premium"><div class="plan-name">Premium ⭐</div><div class="plan-price">9,90€ <span>/mois</span></div><ul class="plan-features-mini"><li>Toutes les courses</li><li>Alertes Telegram</li><li>Value bets</li></ul><button class="btn btn-upgrade" id="select-premium-btn">Choisir Premium</button></div>
<div class="plan-card" id="plan-card-pro"><div class="plan-name">Pro 🚀</div><div class="plan-price">24,90€ <span>/mois</span></div><ul class="plan-features-mini"><li>Tout Premium</li><li>Export CSV</li><li>API REST</li></ul><button class="btn btn-ghost" id="select-pro-btn">Choisir Pro</button></div>
</div>
<p style="font-size:.82rem;color:var(--muted)">💳 Paiement Stripe disponible dans le Sprint 5-6.</p>
</div>
</div>
<div class="tab-panel" id="tab-notifications">
<div class="card">
<div class="card-title">Alertes Telegram</div>
<div class="alert alert-success" id="notif-success">Préférences enregistrées.</div>
<form id="notif-form">
<div class="form-group"><label>Chat ID Telegram</label><input type="text" id="n-telegram-id" placeholder="123456789"><p style="font-size:.78rem;color:var(--muted);margin-top:5px">Envoyez /start à @TurfIABot pour obtenir votre Chat ID.</p></div>
<div class="form-group">
<p style="font-size:.88rem;font-weight:600;margin-bottom:10px;color:var(--muted)">Recevoir des alertes pour :</p>
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem"><input type="checkbox" id="n-alert-vb" style="width:auto;accent-color:var(--green)"> Value bets identifiés</label>
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem"><input type="checkbox" id="n-alert-top1" style="width:auto;accent-color:var(--green)"> Favori IA Top-1</label>
<label style="display:flex;gap:10px;align-items:center;cursor:pointer;font-size:.9rem"><input type="checkbox" id="n-alert-quinte" style="width:auto;accent-color:var(--green)"> Quinté+ uniquement</label>
</div>
<button type="submit" class="btn btn-primary">Enregistrer</button>
</form>
</div>
</div>
</main>
<div id="toast"></div>
<script>
const API='/api/v1';
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){location.href='/login';return null}return res.json().catch(()=>null)}
function showToast(msg,type='success'){const t=document.getElementById('toast');t.textContent=msg;t.className='show '+type;setTimeout(()=>t.className='',3500)}
function showAlert(id,show=true){document.getElementById(id)?.classList.toggle('show',show)}
function setAlertMsg(id,msg,show=true){const el=document.getElementById(id);if(el){el.textContent=msg;el.classList.toggle('show',show)}}
document.querySelectorAll('.tab-btn').forEach(btn=>{btn.addEventListener('click',()=>{document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));btn.classList.add('active');document.getElementById('tab-'+btn.dataset.tab)?.classList.add('active')})});
(function(){const tab=new URLSearchParams(location.search).get('tab');if(tab){document.querySelectorAll('.tab-btn').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));document.querySelectorAll('.tab-panel').forEach(p=>p.classList.toggle('active',p.id==='tab-'+tab))}})();
async function loadUser(){
if(!getToken()){location.href='/login';return}
const data=await fetchJson(API+'/auth/me');if(!data)return;
const user=data.user||data;
document.getElementById('p-firstname').value=user.firstname||'';
document.getElementById('p-lastname').value=user.lastname||'';
document.getElementById('p-email').value=user.email||'';
document.getElementById('p-plan-display').value={free:'Free',premium:'Premium',pro:'Pro'}[user.plan]||'Free';
document.getElementById('p-created').value=user.created_at?new Date(user.created_at).toLocaleDateString('fr-FR'):'—';
document.getElementById('n-telegram-id').value=user.telegram_chat_id||'';
document.getElementById('n-alert-vb').checked=user.alert_value_bets!==false;
document.getElementById('n-alert-top1').checked=user.alert_top1!==false;
document.getElementById('n-alert-quinte').checked=!!user.alert_quinte_only;
const plan=user.plan||'free';
['free','premium','pro'].forEach(p=>{const card=document.getElementById('plan-card-'+p);if(!card)return;card.classList.toggle('current',p===plan);const ex=card.querySelector('.plan-current-badge');if(p===plan&&!ex){const b=document.createElement('div');b.className='plan-current-badge';b.textContent='✓ Actuel';card.prepend(b)}else if(p!==plan&&ex)ex.remove()});
localStorage.setItem('turf_user',JSON.stringify(user));
}
document.getElementById('profile-form').addEventListener('submit',async e=>{e.preventDefault();showAlert('profile-success',false);showAlert('profile-error',false);const btn=document.getElementById('profile-btn');btn.disabled=true;btn.textContent='Enregistrement…';const res=await fetchJson(API+'/auth/update-profile',{method:'POST',body:JSON.stringify({firstname:document.getElementById('p-firstname').value.trim(),lastname:document.getElementById('p-lastname').value.trim(),email:document.getElementById('p-email').value.trim()})});btn.disabled=false;btn.textContent='Enregistrer';if(res&&res.ok!==false){showAlert('profile-success');showToast('Profil mis à jour.')}else setAlertMsg('profile-error',res?.error||'Erreur.')});
document.getElementById('password-form').addEventListener('submit',async e=>{e.preventDefault();showAlert('pwd-success',false);showAlert('pwd-error',false);const np=document.getElementById('s-new-pwd').value,cp=document.getElementById('s-confirm-pwd').value;if(np!==cp){setAlertMsg('pwd-error','Les mots de passe ne correspondent pas.');return}if(np.length<8){setAlertMsg('pwd-error','Minimum 8 caractères.');return}const btn=document.getElementById('pwd-btn');btn.disabled=true;const res=await fetchJson(API+'/auth/change-password',{method:'POST',body:JSON.stringify({current_password:document.getElementById('s-current-pwd').value,new_password:np})});btn.disabled=false;if(res&&res.ok!==false){showAlert('pwd-success');showToast('Mot de passe mis à jour.');document.getElementById('password-form').reset()}else setAlertMsg('pwd-error',res?.error||'Mot de passe actuel incorrect.')});
document.getElementById('notif-form').addEventListener('submit',async e=>{e.preventDefault();showAlert('notif-success',false);const res=await fetchJson(API+'/auth/update-preferences',{method:'POST',body:JSON.stringify({telegram_chat_id:document.getElementById('n-telegram-id').value.trim(),alert_value_bets:document.getElementById('n-alert-vb').checked,alert_top1:document.getElementById('n-alert-top1').checked,alert_quinte_only:document.getElementById('n-alert-quinte').checked})});if(res&&res.ok!==false){showAlert('notif-success');showToast('Préférences enregistrées.')}else showToast('Erreur lors de la sauvegarde.','error')});
['free','premium','pro'].forEach(p=>{document.getElementById('select-'+p+'-btn')?.addEventListener('click',()=>{showToast(p==='free'?'Vous êtes sur le plan Free.':'Paiement Stripe disponible dans le Sprint 5-6.','info')})});
document.getElementById('delete-account-btn').addEventListener('click',()=>{if(confirm('Supprimer votre compte définitivement ?')){if(confirm('Confirmez la suppression.'))fetchJson(API+'/auth/delete-account',{method:'DELETE'}).then(()=>{localStorage.clear();location.href='/'})}});
loadUser();
</script>
</body>
</html>

230
dashboard_saas.html Normal file
View File

@@ -0,0 +1,230 @@
<!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>

226
landing.html Normal file
View File

@@ -0,0 +1,226 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Turf IA — Prédictions PMU par Intelligence Artificielle</title>
<meta name="description" content="Boostez vos paris PMU avec nos prédictions IA. Analyse XGBoost, value bets, alertes Telegram. Essai gratuit.">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green:#00c853; --green-d:#009624; --blue:#1565c0; --blue-l:#1e88e5;
--gold:#ffd600; --dark:#0d1117; --dark2:#161b22; --dark3:#21262d;
--text:#e6edf3; --muted:#8b949e; --border:#30363d; --radius:12px;
}
html { scroll-behavior: smooth; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); line-height: 1.6; overflow-x: hidden; }
a { color: inherit; text-decoration: none; }
nav { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; padding: 16px 5%; background: rgba(13,17,23,0.95); backdrop-filter: blur(10px); border-bottom: 1px solid var(--border); }
.nav-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.2rem; }
.nav-logo .badge { background: var(--green); color: #000; padding: 2px 8px; border-radius: 20px; font-size: .75rem; }
.nav-links { display: flex; align-items: center; gap: 28px; }
.nav-links a { color: var(--muted); font-size: .95rem; transition: color .2s; }
.nav-links a:hover { color: var(--text); }
.nav-cta { display: flex; gap: 10px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 20px; border-radius: 8px; font-size: .9rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { border-color: var(--muted); }
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); transform: translateY(-1px); }
.btn-lg { padding: 14px 32px; font-size: 1.05rem; border-radius: 10px; }
.hero { min-height: 90vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 80px 5% 60px; background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(0,200,83,.08) 0%, transparent 70%); }
.hero-eyebrow { display: inline-flex; align-items: center; gap: 8px; background: rgba(0,200,83,.1); border: 1px solid rgba(0,200,83,.3); padding: 6px 16px; border-radius: 20px; font-size: .85rem; color: var(--green); margin-bottom: 24px; }
.hero h1 { font-size: clamp(2rem,5vw,3.6rem); font-weight: 800; line-height: 1.15; max-width: 780px; margin-bottom: 20px; }
.hero h1 span { color: var(--green); }
.hero p { font-size: 1.15rem; color: var(--muted); max-width: 580px; margin-bottom: 36px; }
.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; justify-content: center; }
.hero-stats { display: flex; gap: 40px; margin-top: 60px; flex-wrap: wrap; justify-content: center; }
.stat { text-align: center; }
.stat strong { display: block; font-size: 2rem; font-weight: 800; color: var(--green); }
.stat span { font-size: .85rem; color: var(--muted); }
section { padding: 80px 5%; }
.section-label { color: var(--green); font-size: .85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
h2 { font-size: clamp(1.6rem,3vw,2.4rem); font-weight: 800; margin-bottom: 16px; }
.subtitle { color: var(--muted); font-size: 1.05rem; max-width: 560px; margin: 0 auto 50px; text-align: center; }
.section-center { text-align: center; }
.features-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(260px,1fr)); gap: 20px; max-width: 1100px; margin: 0 auto; }
.feature-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 28px; transition: border-color .2s,transform .2s; }
.feature-card:hover { border-color: var(--green); transform: translateY(-3px); }
.feature-icon { font-size: 2rem; margin-bottom: 14px; }
.feature-card h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: 8px; }
.feature-card p { color: var(--muted); font-size: .9rem; line-height: 1.6; }
.pricing-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(280px,1fr)); gap: 20px; max-width: 960px; margin: 0 auto; }
.plan-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 32px; position: relative; transition: transform .2s; }
.plan-card:hover { transform: translateY(-4px); }
.plan-card.popular { border-color: var(--green); }
.popular-badge { position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: var(--green); color: #000; padding: 3px 16px; border-radius: 20px; font-size: .75rem; font-weight: 700; }
.plan-name { font-size: 1.1rem; font-weight: 700; margin-bottom: 8px; }
.plan-price { font-size: 2.6rem; font-weight: 800; margin: 12px 0 4px; }
.plan-price sup { font-size: 1.2rem; vertical-align: top; margin-top: 10px; }
.plan-price span { font-size: 1rem; font-weight: 400; color: var(--muted); }
.plan-desc { color: var(--muted); font-size: .88rem; margin-bottom: 20px; }
.plan-features { list-style: none; margin-bottom: 28px; }
.plan-features li { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0; font-size: .9rem; }
.plan-features li::before { content: "✓"; color: var(--green); font-weight: 700; flex-shrink: 0; }
.plan-features li.disabled { color: var(--muted); }
.plan-features li.disabled::before { content: "×"; color: var(--border); }
.btn-plan { width: 100%; text-align: center; justify-content: center; }
.steps { display: flex; flex-direction: column; gap: 0; max-width: 700px; margin: 0 auto; }
.step { display: flex; gap: 24px; padding: 28px 0; border-bottom: 1px solid var(--border); }
.step:last-child { border-bottom: none; }
.step-num { width: 44px; height: 44px; border-radius: 50%; background: rgba(0,200,83,.1); border: 2px solid var(--green); display: flex; align-items: center; justify-content: center; font-weight: 800; color: var(--green); flex-shrink: 0; }
.step-body h3 { font-size: 1.05rem; font-weight: 700; margin-bottom: 6px; }
.step-body p { color: var(--muted); font-size: .9rem; }
.faq-list { max-width: 720px; margin: 0 auto; }
details { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 10px; overflow: hidden; }
details[open] { border-color: var(--green); }
summary { padding: 18px 22px; font-weight: 600; cursor: pointer; list-style: none; display: flex; justify-content: space-between; align-items: center; }
summary::after { content: "+"; color: var(--green); font-size: 1.3rem; }
details[open] summary::after { content: ""; }
.faq-answer { padding: 0 22px 18px; color: var(--muted); font-size: .93rem; line-height: 1.7; }
.cta-banner { background: linear-gradient(135deg,rgba(0,200,83,.12) 0%,rgba(21,101,192,.12) 100%); border: 1px solid var(--border); border-radius: 16px; padding: 60px; text-align: center; max-width: 820px; margin: 0 auto; }
.cta-banner h2 { margin-bottom: 12px; }
.cta-banner p { color: var(--muted); margin-bottom: 28px; }
footer { border-top: 1px solid var(--border); padding: 40px 5%; display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 40px; }
.footer-brand p { color: var(--muted); font-size: .88rem; margin-top: 10px; max-width: 240px; }
.footer-col h4 { font-size: .85rem; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 14px; color: var(--muted); }
.footer-col a { display: block; color: var(--muted); font-size: .88rem; margin-bottom: 8px; transition: color .2s; }
.footer-col a:hover { color: var(--text); }
.footer-bottom { border-top: 1px solid var(--border); padding: 20px 5%; text-align: center; color: var(--muted); font-size: .82rem; }
@media (max-width:768px) { .nav-links{display:none;} footer{grid-template-columns:1fr 1fr;} .cta-banner{padding:36px 24px;} }
@media (max-width:480px) { footer{grid-template-columns:1fr;} }
</style>
</head>
<body>
<nav>
<div class="nav-logo">🏇 Turf IA<span class="badge">BETA</span></div>
<div class="nav-links">
<a href="#features">Fonctionnalités</a>
<a href="#pricing">Tarifs</a>
<a href="#how">Comment ça marche</a>
<a href="#faq">FAQ</a>
</div>
<div class="nav-cta">
<a href="/login" class="btn btn-ghost">Connexion</a>
<a href="/register" class="btn btn-primary">S'inscrire gratuitement</a>
</div>
</nav>
<section class="hero">
<div class="hero-eyebrow">🤖 Intelligence Artificielle · XGBoost · Données PMU temps réel</div>
<h1>Pariez plus <span>intelligemment</span><br>grâce à l'IA</h1>
<p>Nos modèles XGBoost analysent chaque course PMU en temps réel — cotes, historique, jockeys, météo — pour vous donner les meilleures prédictions du marché.</p>
<div class="hero-actions">
<a href="/register" class="btn btn-primary btn-lg">Commencer gratuitement</a>
<a href="#how" class="btn btn-ghost btn-lg">Voir comment ça marche</a>
</div>
<div class="hero-stats">
<div class="stat"><strong>+73%</strong><span>précision Top-3</span></div>
<div class="stat"><strong>150+</strong><span>courses analysées/jour</span></div>
<div class="stat"><strong>2.4s</strong><span>temps de réponse moyen</span></div>
<div class="stat"><strong>3 plans</strong><span>adaptés à chaque profil</span></div>
</div>
</section>
<section id="features">
<div class="section-center">
<div class="section-label">Fonctionnalités</div>
<h2>Tout ce dont vous avez besoin pour gagner</h2>
<p class="subtitle">Un moteur IA complet, des alertes instantanées, et des analyses détaillées pour chaque parieur.</p>
</div>
<div class="features-grid">
<div class="feature-card"><div class="feature-icon">🧠</div><h3>Prédictions XGBoost</h3><p>Modèle entraîné sur des milliers de courses PMU. Probabilités Top-1 et Top-3 pour chaque partant, mis à jour en continu.</p></div>
<div class="feature-card"><div class="feature-icon">💎</div><h3>Value Bets identifiés</h3><p>Détection automatique des cotes sous-évaluées par le marché. Seulement les paris où l'espérance mathématique est positive.</p></div>
<div class="feature-card"><div class="feature-icon">📱</div><h3>Alertes Telegram</h3><p>Recevez les meilleures opportunités directement sur votre téléphone, avant le départ, avec toutes les infos clés.</p></div>
<div class="feature-card"><div class="feature-icon">📊</div><h3>Dashboard temps réel</h3><p>Tableau de bord complet : courses du jour, historique de performance, ROI, statistiques par hippodrome et discipline.</p></div>
<div class="feature-card"><div class="feature-icon">🌤️</div><h3>Analyse météo & terrain</h3><p>Impact des conditions météo et de l'état du terrain intégré dans chaque prédiction pour une précision maximale.</p></div>
<div class="feature-card"><div class="feature-icon">📤</div><h3>Export CSV & API</h3><p>Exportez vos données, intégrez nos prédictions dans vos propres outils via notre API documentée (plan Pro).</p></div>
</div>
</section>
<section id="how" style="background:var(--dark2);margin:0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<div class="section-center">
<div class="section-label">Comment ça marche</div>
<h2>En 3 étapes, prêt à parier</h2>
<p class="subtitle">De l'inscription à votre première prédiction en moins de 2 minutes.</p>
</div>
<div class="steps">
<div class="step"><div class="step-num">1</div><div class="step-body"><h3>Créez votre compte gratuitement</h3><p>Inscription en 30 secondes, sans carte bancaire. Accès immédiat au plan Free avec un aperçu des prédictions du jour.</p></div></div>
<div class="step"><div class="step-num">2</div><div class="step-body"><h3>Choisissez votre plan</h3><p>Free pour découvrir, Premium (9,90€/mois) pour toutes les courses et alertes, Pro (24,90€/mois) pour l'API et les exports.</p></div></div>
<div class="step"><div class="step-num">3</div><div class="step-body"><h3>Recevez vos premières prédictions</h3><p>Notre IA analyse les 150+ courses du jour. Accédez aux Top-3, value bets et probabilités depuis votre dashboard ou Telegram.</p></div></div>
</div>
</section>
<section id="pricing">
<div class="section-center">
<div class="section-label">Tarifs</div>
<h2>Des prix transparents</h2>
<p class="subtitle">Commencez gratuitement. Passez au niveau supérieur quand vous êtes prêt.</p>
</div>
<div class="pricing-grid">
<div class="plan-card">
<div class="plan-name">Free</div>
<div class="plan-price">0<sup></sup><span>/mois</span></div>
<p class="plan-desc">Pour découvrir la puissance de l'IA turf.</p>
<ul class="plan-features">
<li>Aperçu Top-3 du jour (limité)</li><li>1 course complète par jour</li><li>Statistiques basiques</li>
<li class="disabled">Alertes Telegram</li><li class="disabled">Toutes les courses</li><li class="disabled">Value bets</li>
</ul>
<a href="/register" class="btn btn-ghost btn-plan">Commencer gratuitement</a>
</div>
<div class="plan-card popular">
<div class="popular-badge">⭐ Le plus populaire</div>
<div class="plan-name">Premium</div>
<div class="plan-price">9<sup></sup>,90<span>/mois</span></div>
<p class="plan-desc">Pour les parieurs sérieux qui veulent un vrai avantage.</p>
<ul class="plan-features">
<li>Toutes les courses du jour</li><li>Prédictions Top-1 et Top-3</li><li>Value bets identifiés</li>
<li>Alertes Telegram configurables</li><li>Historique 90 jours</li><li>Analyse météo & terrain</li>
</ul>
<a href="/register?plan=premium" class="btn btn-primary btn-plan">Choisir Premium</a>
</div>
<div class="plan-card">
<div class="plan-name">Pro</div>
<div class="plan-price">24<sup></sup>,90<span>/mois</span></div>
<p class="plan-desc">Pour les professionnels et développeurs qui veulent tout.</p>
<ul class="plan-features">
<li>Tout du plan Premium</li><li>Export CSV illimité</li><li>Accès API REST documentée</li>
<li>Backtest personnalisé</li><li>Historique illimité</li><li>Support prioritaire</li>
</ul>
<a href="/register?plan=pro" class="btn btn-ghost btn-plan">Choisir Pro</a>
</div>
</div>
</section>
<section id="faq" style="background:var(--dark2);border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
<div class="section-center">
<div class="section-label">FAQ</div>
<h2>Questions fréquentes</h2>
</div>
<div class="faq-list">
<details><summary>Comment fonctionne le modèle IA ?</summary><div class="faq-answer">Notre modèle XGBoost est entraîné sur plusieurs années de données PMU : cotes, historique des chevaux et drivers, conditions météo, état du terrain, statistiques par hippodrome. Il calcule pour chaque partant une probabilité d'arriver dans le Top-1 et Top-3.</div></details>
<details><summary>Les prédictions garantissent-elles des gains ?</summary><div class="faq-answer">Non. Aucune prédiction ne garantit des gains. Le pari hippique reste un jeu de hasard. Notre IA améliore vos chances en identifiant les opportunités statistiquement favorables. Pariez de façon responsable.</div></details>
<details><summary>Puis-je annuler à tout moment ?</summary><div class="faq-answer">Oui, sans engagement. Vous pouvez annuler votre abonnement Premium ou Pro à tout moment depuis votre espace compte.</div></details>
<details><summary>Les alertes Telegram fonctionnent-elles sur mobile ?</summary><div class="faq-answer">Oui. Après activation dans vos paramètres, vous recevrez les alertes value bets et top picks directement dans votre application Telegram.</div></details>
<details><summary>Quelles disciplines sont couvertes ?</summary><div class="faq-answer">Nous couvrons toutes les disciplines PMU : Plat, Trot Attelé, Trot Monté, et Galop sur les hippodromes français.</div></details>
</div>
</section>
<section>
<div class="cta-banner">
<h2>Prêt à parier plus intelligemment ?</h2>
<p>Rejoignez des centaines de parieurs qui utilisent déjà Turf IA chaque jour. Essai gratuit, sans carte bancaire.</p>
<a href="/register" class="btn btn-primary btn-lg">Créer mon compte gratuit →</a>
</div>
</section>
<footer>
<div class="footer-brand"><div style="font-weight:700;font-size:1.1rem;">🏇 Turf IA</div><p>Prédictions PMU par intelligence artificielle. Analyse XGBoost, value bets, alertes temps réel.</p></div>
<div class="footer-col"><h4>Produit</h4><a href="#features">Fonctionnalités</a><a href="#pricing">Tarifs</a><a href="/dashboard">Dashboard</a></div>
<div class="footer-col"><h4>Compte</h4><a href="/login">Connexion</a><a href="/register">Inscription</a><a href="/account">Mon compte</a></div>
<div class="footer-col"><h4>Légal</h4><a href="/legal/cgu">CGU</a><a href="/legal/privacy">Confidentialité</a><a href="/legal/cookies">Cookies</a></div>
</footer>
<div class="footer-bottom"><p>© 2026 Turf IA — H3R7 Tech. Tous droits réservés. Le jeu peut être dangereux, jouez de façon responsable. <strong>18+</strong></p></div>
<script>
document.querySelectorAll('a[href^="#"]').forEach(a => {
a.addEventListener('click', e => {
const t = document.querySelector(a.getAttribute('href'));
if (t) { e.preventDefault(); t.scrollIntoView({behavior:'smooth'}); }
});
});
</script>
</body>
</html>

102
login.html Normal file
View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion — Turf IA</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--green:#00c853;--green-d:#009624;--dark:#0d1117;--dark2:#161b22;--dark3:#21262d;--text:#e6edf3;--muted:#8b949e;--border:#30363d;--error:#f85149;--radius:10px}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--dark);color:var(--text);min-height:100vh;display:flex;flex-direction:column}
a{color:inherit;text-decoration:none}
nav{display:flex;align-items:center;justify-content:space-between;padding:16px 5%;border-bottom:1px solid var(--border)}
.nav-logo{font-weight:700;font-size:1.1rem}
.nav-link{color:var(--muted);font-size:.9rem}
.nav-link:hover{color:var(--text)}
main{flex:1;display:flex;align-items:center;justify-content:center;padding:40px 20px}
.auth-card{width:100%;max-width:420px;background:var(--dark2);border:1px solid var(--border);border-radius:14px;padding:40px}
.auth-title{font-size:1.5rem;font-weight:800;margin-bottom:6px}
.auth-subtitle{color:var(--muted);font-size:.9rem;margin-bottom:28px}
.form-group{margin-bottom:18px}
label{display:block;font-size:.85rem;font-weight:600;color:var(--muted);margin-bottom:6px}
input{width:100%;padding:11px 14px;background:var(--dark3);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:.95rem;outline:none;transition:border-color .2s}
input:focus{border-color:var(--green)}
input.error{border-color:var(--error)}
.field-error{color:var(--error);font-size:.8rem;margin-top:4px;display:none}
.field-error.show{display:block}
.forgot{float:right;font-size:.82rem;color:var(--muted)}
.forgot:hover{color:var(--text)}
.btn{width:100%;padding:12px;border:none;border-radius:var(--radius);font-size:1rem;font-weight:700;cursor:pointer;transition:all .2s;margin-top:8px}
.btn-primary{background:var(--green);color:#000}
.btn-primary:hover{background:var(--green-d)}
.btn-primary:disabled{opacity:.6;cursor:not-allowed}
.divider{display:flex;align-items:center;gap:12px;margin:20px 0;color:var(--muted);font-size:.82rem}
.divider::before,.divider::after{content:'';flex:1;height:1px;background:var(--border)}
.auth-footer{text-align:center;margin-top:20px;color:var(--muted);font-size:.9rem}
.auth-footer a{color:var(--green);font-weight:600}
.alert{padding:12px 16px;border-radius:var(--radius);font-size:.88rem;margin-bottom:18px;display:none}
.alert.show{display:block}
.alert-error{background:rgba(248,81,73,.12);border:1px solid rgba(248,81,73,.3);color:#f85149}
.loader{display:inline-block;width:16px;height:16px;border:2px solid rgba(0,0,0,.3);border-top-color:#000;border-radius:50%;animation:spin .7s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
footer{text-align:center;padding:20px;color:var(--muted);font-size:.8rem;border-top:1px solid var(--border)}
</style>
</head>
<body>
<nav>
<a href="/" class="nav-logo">🏇 Turf IA</a>
<a href="/register" class="nav-link">Pas encore de compte ? S'inscrire →</a>
</nav>
<main>
<div class="auth-card">
<h1 class="auth-title">Bon retour !</h1>
<p class="auth-subtitle">Connectez-vous à votre compte Turf IA.</p>
<div class="alert alert-error" id="alert-error"></div>
<form id="login-form" novalidate>
<div class="form-group">
<label for="email">Adresse email</label>
<input type="email" id="email" name="email" placeholder="vous@exemple.fr" autocomplete="email" required>
<div class="field-error" id="email-error">Email invalide.</div>
</div>
<div class="form-group">
<label for="password">Mot de passe <a href="/forgot-password" class="forgot">Mot de passe oublié ?</a></label>
<input type="password" id="password" name="password" placeholder="••••••••" autocomplete="current-password" required>
<div class="field-error" id="password-error">Mot de passe requis.</div>
</div>
<button type="submit" class="btn btn-primary" id="submit-btn">Se connecter</button>
</form>
<div class="divider">ou</div>
<div class="auth-footer">Pas encore de compte ? <a href="/register">Créer un compte gratuit</a></div>
</div>
</main>
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
<script>
const API='/api/v1';
const form=document.getElementById('login-form');
const emailInput=document.getElementById('email');
const passInput=document.getElementById('password');
const submitBtn=document.getElementById('submit-btn');
const alertBox=document.getElementById('alert-error');
function showError(m){alertBox.textContent=m;alertBox.classList.add('show')}
function hideError(){alertBox.classList.remove('show')}
function setLoading(on){submitBtn.disabled=on;submitBtn.innerHTML=on?'<span class="loader"></span>Connexion…':'Se connecter'}
function validateField(input,errId,cond,msg){const e=document.getElementById(errId);if(cond){input.classList.add('error');e.textContent=msg;e.classList.add('show');return false}input.classList.remove('error');e.classList.remove('show');return true}
form.addEventListener('submit',async e=>{
e.preventDefault();hideError();
const email=emailInput.value.trim(),pass=passInput.value;
const v1=validateField(emailInput,'email-error',!email||!/^[^@]+@[^@]+\.[^@]+$/.test(email),'Adresse email invalide.');
const v2=validateField(passInput,'password-error',!pass,'Mot de passe requis.');
if(!v1||!v2)return;
setLoading(true);
try{
const res=await fetch(`${API}/auth/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password:pass})});
const data=await res.json();
if(!res.ok){showError(data.error||'Identifiants incorrects.')}
else{localStorage.setItem('turf_token',data.token);localStorage.setItem('turf_user',JSON.stringify(data.user));location.href=new URLSearchParams(location.search).get('next')||'/dashboard'}
}catch(_){showError('Erreur de connexion. Vérifiez votre réseau.')}
finally{setLoading(false)}
});
[emailInput,passInput].forEach(el=>el.addEventListener('input',()=>{el.classList.remove('error');document.getElementById(el.id+'-error')?.classList.remove('show');hideError()}));
(function(){if(localStorage.getItem('turf_token'))location.href='/dashboard'})();
</script>
</body>
</html>

150
onboarding.html Normal file
View File

@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenue — Turf IA</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--green:#00c853;--green-d:#009624;--dark:#0d1117;--dark2:#161b22;--dark3:#21262d;--text:#e6edf3;--muted:#8b949e;--border:#30363d;--radius:12px;--gold:#ffd600}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--dark);color:var(--text);min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px}
.onboarding-wrap{width:100%;max-width:580px}
.step-indicator{display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:36px}
.step-dot{width:32px;height:32px;border-radius:50%;border:2px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:.8rem;font-weight:700;color:var(--muted);background:var(--dark2);transition:all .3s;flex-shrink:0}
.step-dot.active{border-color:var(--green);color:var(--green);background:rgba(0,200,83,.1)}
.step-dot.done{border-color:var(--green);background:var(--green);color:#000}
.step-line{flex:1;height:2px;background:var(--border);max-width:80px;transition:background .3s}
.step-line.done{background:var(--green)}
.step-panel{display:none;animation:fadeIn .3s ease}
.step-panel.active{display:block}
@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
.card{background:var(--dark2);border:1px solid var(--border);border-radius:16px;padding:40px}
.step-eyebrow{font-size:.8rem;color:var(--green);font-weight:700;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px}
h2{font-size:1.6rem;font-weight:800;margin-bottom:10px}
.step-subtitle{color:var(--muted);font-size:.95rem;margin-bottom:28px;line-height:1.6}
.plan-options{display:flex;flex-direction:column;gap:12px;margin-bottom:28px}
.plan-option{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:16px 18px;border:2px solid var(--border);border-radius:12px;cursor:pointer;transition:all .2s;background:var(--dark3)}
.plan-option:hover{border-color:var(--muted)}
.plan-option.selected{border-color:var(--green);background:rgba(0,200,83,.06)}
.plan-option-left{display:flex;align-items:center;gap:14px}
.plan-icon{font-size:1.6rem}
.plan-info h3{font-size:1rem;font-weight:700}
.plan-info p{font-size:.82rem;color:var(--muted);margin-top:2px}
.plan-price-tag{font-weight:800;font-size:1rem;color:var(--green);text-align:right}
.plan-price-tag .sub{font-size:.72rem;font-weight:400;color:var(--muted)}
.plan-radio{width:18px;height:18px;border-radius:50%;border:2px solid var(--border);flex-shrink:0;transition:all .2s}
.plan-option.selected .plan-radio{border-color:var(--green);background:var(--green)}
.telegram-field{background:var(--dark3);border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:20px}
.telegram-field h3{font-size:.95rem;font-weight:700;margin-bottom:6px}
.telegram-field p{font-size:.83rem;color:var(--muted);margin-bottom:12px}
.telegram-input-row{display:flex;gap:10px}
input[type=text]{flex:1;padding:10px 14px;background:var(--dark);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:.9rem;outline:none;transition:border-color .2s}
input[type=text]:focus{border-color:var(--green)}
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:12px;font-size:1rem;font-weight:700;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(--muted);padding:12px 20px}
.btn-ghost:hover{color:var(--text);border-color:var(--muted)}
.btn-full{width:100%;justify-content:center}
.btn-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.success-icon{font-size:4rem;text-align:center;margin-bottom:20px}
.checklist{list-style:none;margin:20px 0}
.checklist li{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);font-size:.93rem}
.checklist li:last-child{border-bottom:none}
.checklist li::before{content:"✓";background:var(--green);color:#000;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:.75rem;font-weight:800;flex-shrink:0}
.first-pred-preview{background:var(--dark3);border:1px solid var(--border);border-radius:12px;padding:16px;margin:20px 0}
.pred-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);font-size:.88rem}
.pred-row:last-child{border-bottom:none}
.pred-rank{font-weight:700;color:var(--gold);width:24px}
.pred-name{flex:1;font-weight:600}
.pred-prob{color:var(--green);font-weight:700}
footer{margin-top:24px;color:var(--muted);font-size:.78rem;text-align:center}
</style>
</head>
<body>
<div class="onboarding-wrap">
<div class="step-indicator">
<div class="step-dot active" id="dot-1">1</div>
<div class="step-line" id="line-1"></div>
<div class="step-dot" id="dot-2">2</div>
<div class="step-line" id="line-2"></div>
<div class="step-dot" id="dot-3">3</div>
</div>
<div class="step-panel active" id="step-1">
<div class="card">
<div class="step-eyebrow">Étape 1 sur 3</div>
<h2>Bienvenue sur Turf IA 🏇</h2>
<p class="step-subtitle">Votre compte est créé. Confirmez votre plan de départ pour personnaliser votre expérience.</p>
<div class="plan-options" id="plan-options">
<div class="plan-option" data-plan="free"><div class="plan-option-left"><div class="plan-icon">🆓</div><div class="plan-info"><h3>Free</h3><p>Aperçu quotidien, 1 course complète</p></div></div><div><div class="plan-price-tag">0€<div class="sub">/mois</div></div></div><div class="plan-radio"></div></div>
<div class="plan-option" data-plan="premium"><div class="plan-option-left"><div class="plan-icon"></div><div class="plan-info"><h3>Premium</h3><p>Toutes les courses, alertes Telegram</p></div></div><div><div class="plan-price-tag">9,90€<div class="sub">/mois</div></div></div><div class="plan-radio"></div></div>
<div class="plan-option" data-plan="pro"><div class="plan-option-left"><div class="plan-icon">🚀</div><div class="plan-info"><h3>Pro</h3><p>API, export CSV, support prioritaire</p></div></div><div><div class="plan-price-tag">24,90€<div class="sub">/mois</div></div></div><div class="plan-radio"></div></div>
</div>
<div class="btn-row"><button class="btn btn-primary btn-full" id="step1-next">Continuer →</button></div>
</div>
</div>
<div class="step-panel" id="step-2">
<div class="card">
<div class="step-eyebrow">Étape 2 sur 3</div>
<h2>Configurez vos alertes</h2>
<p class="step-subtitle">Recevez les meilleures opportunités directement sur votre téléphone avant chaque départ.</p>
<div class="telegram-field">
<h3>📱 Alertes Telegram</h3>
<p>Envoyez <strong>/start</strong> à <strong>@TurfIABot</strong> et collez votre Chat ID ci-dessous.</p>
<div class="telegram-input-row">
<input type="text" id="telegram-id" placeholder="Chat ID (ex: 123456789)">
<button class="btn btn-ghost" onclick="window.open('https://t.me/TurfIABot','_blank')">Ouvrir le bot</button>
</div>
</div>
<div style="margin-bottom:24px">
<p style="font-size:.88rem;color:var(--muted);margin-bottom:12px">🔔 Quand recevoir les alertes :</p>
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem"><input type="checkbox" id="alert-vb" checked style="width:auto;accent-color:var(--green)"> Value bets identifiés</label>
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem"><input type="checkbox" id="alert-top1" checked style="width:auto;accent-color:var(--green)"> Favori IA Top-1</label>
<label style="display:flex;gap:10px;align-items:center;cursor:pointer;font-size:.9rem"><input type="checkbox" id="alert-quinte" style="width:auto;accent-color:var(--green)"> Quinté+ uniquement</label>
</div>
<div class="btn-row">
<button class="btn btn-ghost" id="step2-skip">Passer cette étape</button>
<button class="btn btn-primary" id="step2-next">Enregistrer & continuer →</button>
</div>
</div>
</div>
<div class="step-panel" id="step-3">
<div class="card">
<div class="step-eyebrow">Étape 3 sur 3</div>
<div class="success-icon">🎉</div>
<h2 style="text-align:center">Vous êtes prêt !</h2>
<p class="step-subtitle" style="text-align:center">Voici un aperçu de votre première prédiction du jour.</p>
<div class="first-pred-preview" id="first-pred">
<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION DU JOUR</div>
<div class="pred-row"><div class="pred-rank">🥇</div><div class="pred-name">Chargement…</div><div class="pred-prob"></div><div style="color:var(--muted);font-size:.82rem"></div></div>
</div>
<ul class="checklist">
<li>Compte créé et configuré</li>
<li id="check-alerts">Alertes Telegram configurées</li>
<li>Dashboard accessible 24h/24</li>
</ul>
<button class="btn btn-primary btn-full" id="step3-finish">Accéder à mon dashboard →</button>
</div>
</div>
</div>
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
<script>
const API='/api/v1';
let selectedPlan='free';
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){location.href='/login';return null}return res.ok?res.json():null}
(function(){if(!getToken()){location.href='/login';return}try{const user=JSON.parse(localStorage.getItem('turf_user')||'{}');selectedPlan=user.plan||'free';selectPlan(selectedPlan)}catch(_){selectPlan('free')}if(!document.querySelector('.plan-option.selected'))selectPlan('free')})();
function selectPlan(plan){selectedPlan=plan;document.querySelectorAll('.plan-option').forEach(el=>el.classList.toggle('selected',el.dataset.plan===plan))}
document.querySelectorAll('.plan-option').forEach(el=>el.addEventListener('click',()=>selectPlan(el.dataset.plan)));
function goToStep(n){
document.querySelectorAll('.step-panel').forEach((el,i)=>el.classList.toggle('active',i+1===n));
for(let i=1;i<=3;i++){const dot=document.getElementById('dot-'+i);dot.classList.toggle('done',i<n);dot.classList.toggle('active',i===n);if(i<3)document.getElementById('line-'+i)?.classList.toggle('done',i<n)}
if(n===3)loadFirstPrediction()
}
document.getElementById('step1-next').addEventListener('click',async()=>{await fetchJson(API+'/auth/update-plan',{method:'POST',body:JSON.stringify({plan:selectedPlan})});const user=JSON.parse(localStorage.getItem('turf_user')||'{}');user.plan=selectedPlan;localStorage.setItem('turf_user',JSON.stringify(user));goToStep(2)});
document.getElementById('step2-skip').addEventListener('click',()=>{document.getElementById('check-alerts').textContent='Alertes Telegram (non configurées)';goToStep(3)});
document.getElementById('step2-next').addEventListener('click',async()=>{const chatId=document.getElementById('telegram-id').value.trim();if(chatId){await fetchJson(API+'/auth/update-preferences',{method:'POST',body:JSON.stringify({telegram_chat_id:chatId,alert_value_bets:document.getElementById('alert-vb').checked,alert_top1:document.getElementById('alert-top1').checked,alert_quinte_only:document.getElementById('alert-quinte').checked})})}goToStep(3)});
document.getElementById('step3-finish').addEventListener('click',()=>{localStorage.removeItem('turf_onboarding');location.href='/dashboard'});
async function loadFirstPrediction(){const data=await fetchJson(API+'/predictions/today');if(!data||!data.predictions||!data.predictions.length)return;const sorted=[...data.predictions].sort((a,b)=>b.ml_score-a.ml_score).slice(0,3);const container=document.getElementById('first-pred');container.innerHTML='<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION DU JOUR</div>'+sorted.map((h,i)=>'<div class="pred-row"><div class="pred-rank">'+['🥇','🥈','🥉'][i]+'</div><div class="pred-name">'+(h.horse_name||'—')+'</div><div class="pred-prob">'+(h.prob_top3?(h.prob_top3*100).toFixed(1)+'%':'—')+'</div><div style="color:var(--muted);font-size:.82rem">'+(h.odds?h.odds.toFixed(1):'—')+'</div></div>').join('')}
</script>
</body>
</html>

View File

@@ -10,17 +10,64 @@ app = Flask(__name__)
DASHBOARD_API_URL = "http://localhost:8791" DASHBOARD_API_URL = "http://localhost:8791"
COMBINED_API_URL = "http://localhost:8790" COMBINED_API_URL = "http://localhost:8790"
COMBINED_API_URL = "http://localhost:8790" SAAS_DIR = "/home/h3r7/turf_saas"
# ─── SaaS Auth & API v1 blueprints (Sprint 4-5 HRT-30) ───────────────────────
try:
from saas_auth import auth_bp
from saas_api_v1 import api_v1_bp
app.register_blueprint(auth_bp)
app.register_blueprint(api_v1_bp)
print("[portal] SaaS auth & API v1 blueprints registered")
except Exception as e:
print(f"[portal] Warning: SaaS blueprints not loaded: {e}")
# ─── Landing & SaaS pages ─────────────────────────────────────────────────────
@app.route("/") @app.route("/")
def portal(): def landing():
return send_from_directory("/home/h3r7/turf_saas", "portail.html") """Marketing landing page (Sprint 4-5)."""
return send_from_directory(SAAS_DIR, "landing.html")
@app.route("/login")
def login_page():
return send_from_directory(SAAS_DIR, "login.html")
@app.route("/register")
def register_page():
return send_from_directory(SAAS_DIR, "register.html")
@app.route("/dashboard")
def dashboard_saas():
return send_from_directory(SAAS_DIR, "dashboard_saas.html")
@app.route("/onboarding")
def onboarding():
return send_from_directory(SAAS_DIR, "onboarding.html")
@app.route("/account")
def account():
return send_from_directory(SAAS_DIR, "account.html")
@app.route("/portal")
@app.route("/portail")
def portal_legacy():
return send_from_directory(SAAS_DIR, "portail.html")
@app.route("/favicon.ico") @app.route("/favicon.ico")
def favicon(): def favicon():
return send_from_directory("/home/h3r7/turf_saas", "favicon.ico") return send_from_directory(SAAS_DIR, "favicon.ico")
@app.route("/prompts", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @app.route("/prompts", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/prompts/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @app.route("/prompts/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/prompts/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @app.route("/prompts/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@@ -269,9 +316,7 @@ def niches_business():
@app.route("/template_restaurant_json.html") @app.route("/template_restaurant_json.html")
def template_restaurant(): def template_restaurant():
return send_from_directory( return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_json.html")
"/home/h3r7/turf_saas", "template_restaurant_json.html"
)
@app.route("/template_boulangerie_final.html") @app.route("/template_boulangerie_final.html")
@@ -288,9 +333,7 @@ def template_artisan():
@app.route("/template_restaurant_final.html") @app.route("/template_restaurant_final.html")
def template_restaurant_final(): def template_restaurant_final():
return send_from_directory( return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_final.html")
"/home/h3r7/turf_saas", "template_restaurant_final.html"
)
@app.route("/template_complet.html") @app.route("/template_complet.html")
@@ -300,9 +343,7 @@ def template_complet():
@app.route("/boite_a_idees_dashboard") @app.route("/boite_a_idees_dashboard")
def boite_a_idees_dashboard(): def boite_a_idees_dashboard():
return send_from_directory( return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
"/home/h3r7/turf_saas", "boite_a_idees_dashboard.html"
)
@app.route("/datagouv_explorer.html") @app.route("/datagouv_explorer.html")
@@ -345,13 +386,23 @@ def api_chat_workflows():
return jsonify([dict(w) for w in workflows]) return jsonify([dict(w) for w in workflows])
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route("/api/chat/nvidia-models", methods=["GET"]) @app.route("/api/chat/nvidia-models", methods=["GET"])
def api_nvidia_models(): def api_nvidia_models():
return jsonify([ return jsonify(
{"id": k, "name": v.split("/")[-1].replace("-instruct", "").replace("-", " ").title(), "full_id": v} [
{
"id": k,
"name": v.split("/")[-1]
.replace("-instruct", "")
.replace("-", " ")
.title(),
"full_id": v,
}
for k, v in NVIDIA_MODELS.items() for k, v in NVIDIA_MODELS.items()
]) ]
)
@app.route("/api/chat/sessions", methods=["GET"]) @app.route("/api/chat/sessions", methods=["GET"])
@@ -457,7 +508,9 @@ def api_chat_cleanup():
OPENCLAW_TOKEN = "szDd75yG15ZRADWEhGus3dfNoQve7ZhdxpGq9gudeEzoVhUqB0TomVJd90XcwkUz" OPENCLAW_TOKEN = "szDd75yG15ZRADWEhGus3dfNoQve7ZhdxpGq9gudeEzoVhUqB0TomVJd90XcwkUz"
OPENCLAW_CONTAINER = "openclaw-ow404080wwkkgkgc4oswwssc" OPENCLAW_CONTAINER = "openclaw-ow404080wwkkgkgc4oswwssc"
NVIDIA_API_KEY = "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" NVIDIA_API_KEY = (
"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"
)
NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions" NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
NVIDIA_MODEL = "meta/llama-3.1-8b-instruct" # Default model NVIDIA_MODEL = "meta/llama-3.1-8b-instruct" # Default model
NVIDIA_MODELS = { NVIDIA_MODELS = {
@@ -476,7 +529,6 @@ NVIDIA_MODELS = {
} }
@app.route("/webhook/telegram", methods=["POST"]) @app.route("/webhook/telegram", methods=["POST"])
def telegram_webhook(): def telegram_webhook():
try: try:
@@ -702,12 +754,17 @@ def api_proxy(api_path=""):
url = f"{DASHBOARD_API_URL}/turf/api" url = f"{DASHBOARD_API_URL}/turf/api"
try: try:
fwd_method = request.method fwd_method = request.method
fwd_json = request.get_json(silent=True) if fwd_method in ("POST", "PUT", "PATCH") else None fwd_json = (
request.get_json(silent=True)
if fwd_method in ("POST", "PUT", "PATCH")
else None
)
fwd_headers = {"Content-Type": "application/json"} fwd_headers = {"Content-Type": "application/json"}
if request.headers.get("Authorization"): if request.headers.get("Authorization"):
fwd_headers["Authorization"] = request.headers.get("Authorization") fwd_headers["Authorization"] = request.headers.get("Authorization")
resp = requests.request(method=fwd_method, url=url, json=fwd_json, timeout=30, resp = requests.request(
headers=fwd_headers) method=fwd_method, url=url, json=fwd_json, timeout=30, headers=fwd_headers
)
return resp.content, resp.status_code, {"Content-Type": "application/json"} return resp.content, resp.status_code, {"Content-Type": "application/json"}
except Exception as e: except Exception as e:
return jsonify({"error": str(e), "url": url}), 500 return jsonify({"error": str(e), "url": url}), 500
@@ -744,23 +801,26 @@ def opencode_api():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route("/candidatures/") @app.route("/candidatures/")
def candidatures_index(): def candidatures_index():
return send_from_directory("/home/h3r7/turf_saas", "crm_candidatures.html") return send_from_directory("/home/h3r7/turf_saas", "crm_candidatures.html")
@app.route("/candidatures/<path:filename>") @app.route("/candidatures/<path:filename>")
def candidatures_static(filename): def candidatures_static(filename):
return send_from_directory("/home/h3r7/turf_saas", filename) return send_from_directory("/home/h3r7/turf_saas", filename)
@app.route("/map") @app.route("/map")
def map_visual(): def map_visual():
return send_from_directory("/home/h3r7/turf_saas", "map_visual.html") return send_from_directory("/home/h3r7/turf_saas", "map_visual.html")
@app.route("/architecture.json") @app.route("/architecture.json")
def architecture_json(): def architecture_json():
return send_from_directory("/home/h3r7/turf_saas", "architecture.json") return send_from_directory("/home/h3r7/turf_saas", "architecture.json")
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=8792, debug=False) app.run(host="0.0.0.0", port=8792, debug=False)
@@ -827,5 +887,3 @@ def proxy_prompts_test():
return response return response
except Exception as e: except Exception as e:
return f"Erreur proxy prompts: {e}", 502 return f"Erreur proxy prompts: {e}", 502

129
register.html Normal file
View File

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inscription — Turf IA</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--green:#00c853;--green-d:#009624;--dark:#0d1117;--dark2:#161b22;--dark3:#21262d;--text:#e6edf3;--muted:#8b949e;--border:#30363d;--error:#f85149;--radius:10px}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--dark);color:var(--text);min-height:100vh;display:flex;flex-direction:column}
a{color:inherit;text-decoration:none}
nav{display:flex;align-items:center;justify-content:space-between;padding:16px 5%;border-bottom:1px solid var(--border)}
.nav-logo{font-weight:700;font-size:1.1rem}
.nav-link{color:var(--muted);font-size:.9rem}
main{flex:1;display:flex;align-items:flex-start;justify-content:center;padding:40px 20px;gap:40px;flex-wrap:wrap}
.auth-card{width:100%;max-width:440px;background:var(--dark2);border:1px solid var(--border);border-radius:14px;padding:40px}
.plan-preview{width:100%;max-width:300px}
.auth-title{font-size:1.5rem;font-weight:800;margin-bottom:6px}
.auth-subtitle{color:var(--muted);font-size:.9rem;margin-bottom:28px}
.form-group{margin-bottom:16px}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:14px}
label{display:block;font-size:.85rem;font-weight:600;color:var(--muted);margin-bottom:6px}
input,select{width:100%;padding:11px 14px;background:var(--dark3);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:.95rem;outline:none;transition:border-color .2s}
input:focus,select:focus{border-color:var(--green)}
input.error{border-color:var(--error)}
select option{background:var(--dark3)}
.field-error{color:var(--error);font-size:.8rem;margin-top:4px;display:none}
.field-error.show{display:block}
.btn{width:100%;padding:12px;border:none;border-radius:10px;font-size:1rem;font-weight:700;cursor:pointer;transition:all .2s;margin-top:4px}
.btn-primary{background:var(--green);color:#000}
.btn-primary:hover{background:var(--green-d)}
.btn-primary:disabled{opacity:.6;cursor:not-allowed}
.auth-footer{text-align:center;margin-top:20px;color:var(--muted);font-size:.9rem}
.auth-footer a{color:var(--green);font-weight:600}
.alert{padding:12px 16px;border-radius:10px;font-size:.88rem;margin-bottom:18px;display:none}
.alert.show{display:block}
.alert-error{background:rgba(248,81,73,.12);border:1px solid rgba(248,81,73,.3);color:#f85149}
.loader{display:inline-block;width:16px;height:16px;border:2px solid rgba(0,0,0,.3);border-top-color:#000;border-radius:50%;animation:spin .7s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
.terms{font-size:.8rem;color:var(--muted);line-height:1.6}
.terms a{color:var(--green)}
.password-strength{height:4px;border-radius:2px;margin-top:6px;background:var(--border);overflow:hidden}
.strength-bar{height:100%;width:0;transition:width .3s,background .3s}
.plan-card{background:var(--dark2);border:2px solid var(--border);border-radius:14px;padding:24px;margin-bottom:16px;transition:border-color .3s}
.plan-card.selected{border-color:var(--green)}
.plan-card-name{font-weight:700;font-size:1rem;display:flex;justify-content:space-between;align-items:center}
.plan-card-price{font-size:1.5rem;font-weight:800;margin:8px 0}
.plan-card-features{list-style:none;margin-top:12px}
.plan-card-features li{font-size:.83rem;color:var(--muted);padding:4px 0}
.plan-card-features li::before{content:"✓ ";color:var(--green)}
.plan-badge{background:var(--green);color:#000;padding:2px 8px;border-radius:10px;font-size:.7rem;font-weight:700}
footer{text-align:center;padding:20px;color:var(--muted);font-size:.8rem;border-top:1px solid var(--border)}
@media(max-width:768px){.plan-preview{display:none}}
</style>
</head>
<body>
<nav>
<a href="/" class="nav-logo">🏇 Turf IA</a>
<a href="/login" class="nav-link">Déjà un compte ? Se connecter →</a>
</nav>
<main>
<div class="auth-card">
<h1 class="auth-title">Créer votre compte</h1>
<p class="auth-subtitle">Commencez gratuitement, sans carte bancaire.</p>
<div class="alert alert-error" id="alert-error"></div>
<form id="register-form" novalidate>
<div class="form-row">
<div class="form-group"><label for="firstname">Prénom</label><input type="text" id="firstname" placeholder="Jean" required><div class="field-error" id="firstname-error">Requis.</div></div>
<div class="form-group"><label for="lastname">Nom</label><input type="text" id="lastname" placeholder="Dupont" required><div class="field-error" id="lastname-error">Requis.</div></div>
</div>
<div class="form-group"><label for="email">Email</label><input type="email" id="email" placeholder="vous@exemple.fr" required><div class="field-error" id="email-error">Email invalide.</div></div>
<div class="form-group"><label for="password">Mot de passe</label><input type="password" id="password" placeholder="8 caractères minimum" required><div class="password-strength"><div class="strength-bar" id="strength-bar"></div></div><div class="field-error" id="password-error">8 caractères minimum.</div></div>
<div class="form-group"><label for="plan">Plan de départ</label><select id="plan"><option value="free">Free — Gratuit</option><option value="premium">Premium — 9,90€/mois</option><option value="pro">Pro — 24,90€/mois</option></select></div>
<div class="form-group"><p class="terms">En vous inscrivant, vous acceptez nos <a href="/legal/cgu">CGU</a> et notre <a href="/legal/privacy">Politique de Confidentialité</a>. Vous devez avoir 18 ans ou plus.</p></div>
<button type="submit" class="btn btn-primary" id="submit-btn">Créer mon compte gratuit</button>
</form>
<div class="auth-footer">Déjà un compte ? <a href="/login">Se connecter</a></div>
</div>
<div class="plan-preview">
<div id="plan-card-free" class="plan-card selected"><div class="plan-card-name">Free</div><div class="plan-card-price">0€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div><ul class="plan-card-features"><li>Aperçu Top-3 du jour</li><li>1 course complète/jour</li></ul></div>
<div id="plan-card-premium" class="plan-card" style="display:none"><div class="plan-card-name">Premium <span class="plan-badge"></span></div><div class="plan-card-price">9,90€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div><ul class="plan-card-features"><li>Toutes les courses</li><li>Alertes Telegram</li><li>Value bets</li></ul></div>
<div id="plan-card-pro" class="plan-card" style="display:none"><div class="plan-card-name">Pro</div><div class="plan-card-price">24,90€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div><ul class="plan-card-features"><li>Tout Premium</li><li>Export CSV</li><li>API REST</li></ul></div>
<p style="font-size:.78rem;color:var(--muted);text-align:center;">⚡ Pas de carte bancaire pour le plan Free</p>
</div>
</main>
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
<script>
const API='/api/v1';
const form=document.getElementById('register-form');
const submitBtn=document.getElementById('submit-btn');
const alertBox=document.getElementById('alert-error');
const planSelect=document.getElementById('plan');
const urlPlan=new URLSearchParams(location.search).get('plan');
if(urlPlan&&['free','premium','pro'].includes(urlPlan))planSelect.value=urlPlan;
function updatePlanPreview(){['free','premium','pro'].forEach(p=>{const el=document.getElementById('plan-card-'+p);if(!el)return;if(planSelect.value===p){el.style.display='';el.classList.add('selected')}else{el.style.display='none';el.classList.remove('selected')}})}
planSelect.addEventListener('change',updatePlanPreview);updatePlanPreview();
function showError(m){alertBox.textContent=m;alertBox.classList.add('show')}
function hideError(){alertBox.classList.remove('show')}
function setLoading(on){submitBtn.disabled=on;submitBtn.innerHTML=on?'<span class="loader"></span>Création…':'Créer mon compte gratuit'}
function validateField(input,errId,cond,msg){const e=document.getElementById(errId);if(cond){input.classList.add('error');e.textContent=msg;e.classList.add('show');return false}input.classList.remove('error');e.classList.remove('show');return true}
const passInput=document.getElementById('password');
const bar=document.getElementById('strength-bar');
passInput.addEventListener('input',()=>{const v=passInput.value;let s=0;if(v.length>=8)s++;if(/[A-Z]/.test(v))s++;if(/[0-9]/.test(v))s++;if(/[^A-Za-z0-9]/.test(v))s++;const c=['#f85149','#e3b341','#58a6ff','#00c853'];bar.style.width=(s*25)+'%';bar.style.background=c[s-1]||'#30363d'});
form.addEventListener('submit',async e=>{
e.preventDefault();hideError();
const firstname=document.getElementById('firstname').value.trim();
const lastname=document.getElementById('lastname').value.trim();
const email=document.getElementById('email').value.trim();
const password=passInput.value;
const plan=planSelect.value;
let ok=true;
ok=validateField(document.getElementById('firstname'),'firstname-error',!firstname,'Prénom requis.')&&ok;
ok=validateField(document.getElementById('lastname'),'lastname-error',!lastname,'Nom requis.')&&ok;
ok=validateField(document.getElementById('email'),'email-error',!email||!email.includes('@'),'Email invalide.')&&ok;
ok=validateField(passInput,'password-error',password.length<8,'8 caractères minimum.')&&ok;
if(!ok)return;
setLoading(true);
try{
const res=await fetch(`${API}/auth/register`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({firstname,lastname,email,password,plan})});
const data=await res.json();
if(!res.ok){showError(data.error||'Erreur lors de la création du compte.')}
else{localStorage.setItem('turf_token',data.token);localStorage.setItem('turf_user',JSON.stringify(data.user));localStorage.setItem('turf_onboarding','pending');location.href='/onboarding'}
}catch(_){showError('Erreur de connexion. Vérifiez votre réseau.')}
finally{setLoading(false)}
});
document.querySelectorAll('input').forEach(el=>el.addEventListener('input',()=>{el.classList.remove('error');document.getElementById(el.id+'-error')?.classList.remove('show');hideError()}));
(function(){if(localStorage.getItem('turf_token'))location.href='/dashboard'})();
</script>
</body>
</html>

142
saas_api_v1.py Normal file
View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
SaaS API v1 Blueprint — /api/v1/*
Stats, prédictions, résumés pour le dashboard SaaS.
Sprint 4-5 — HRT-30
"""
from flask import Blueprint, request, jsonify, Response
import sqlite3
import csv
import io
import os
from datetime import datetime
from saas_auth import require_auth
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def plan_allows(user_plan, required):
order = {"free": 0, "premium": 1, "pro": 2}
return order.get(user_plan, 0) >= order.get(required, 0)
@api_v1_bp.route("/stats/summary", methods=["GET"])
@require_auth
def stats_summary():
today = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
try:
courses_today = conn.execute(
"SELECT COUNT(DISTINCT num_reunion||'-'||num_course) FROM ml_predictions_cache WHERE date=?",
(today,)
).fetchone()[0] or 0
value_bets_today = conn.execute(
"SELECT COUNT(*) FROM ml_predictions_cache WHERE date=? AND is_value_bet=1",
(today,)
).fetchone()[0] or 0
acc_row = conn.execute("""
SELECT CAST(SUM(CASE WHEN p.ordre_arrivee BETWEEN 1 AND 3 AND m.recommendation='top3' THEN 1 ELSE 0 END) AS FLOAT)
/ NULLIF(COUNT(CASE WHEN m.recommendation='top3' THEN 1 END), 0) * 100 AS acc
FROM ml_predictions_cache m
JOIN pmu_partants p ON m.horse_name=p.nom AND m.date=p.date_programme
WHERE m.date >= date('now', '-30 days')
""").fetchone()
accuracy_top3 = round(acc_row[0], 1) if acc_row and acc_row[0] else None
next_race = conn.execute(
"SELECT heure, hippodrome FROM ml_predictions_cache WHERE date=? AND heure IS NOT NULL ORDER BY heure LIMIT 1",
(today,)
).fetchone()
conn.close()
return jsonify({
"courses_today": courses_today,
"value_bets_today": value_bets_today,
"accuracy_top3": accuracy_top3,
"next_race_time": next_race["heure"] if next_race else None,
"next_race_hippodrome": next_race["hippodrome"] if next_race else None,
}), 200
except Exception as e:
conn.close()
return jsonify({"error": str(e), "courses_today": 0, "value_bets_today": 0}), 200
@api_v1_bp.route("/predictions/today", methods=["GET"])
@require_auth
def predictions_today():
user = request.current_user
plan = user.get("plan", "free")
today = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
try:
rows = conn.execute("""
SELECT horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, is_value_bet, is_outlier,
race_label, race_name, hippodrome, discipline, distance,
heure, risque_label, risque_score, num_reunion, num_course
FROM ml_predictions_cache
WHERE date=?
ORDER BY num_reunion, num_course, ml_score DESC
""", (today,)).fetchall()
conn.close()
predictions = [dict(r) for r in rows]
if plan == "free" and predictions:
first = predictions[0]
first_key = (first["num_reunion"], first["num_course"])
predictions = [p for p in predictions if (p["num_reunion"], p["num_course"]) == first_key]
for p in predictions:
p["is_value_bet"] = 0
return jsonify({"date": today, "plan": plan, "count": len(predictions), "predictions": predictions}), 200
except Exception as e:
conn.close()
return jsonify({"error": str(e), "predictions": []}), 200
@api_v1_bp.route("/value-bets/today", methods=["GET"])
@require_auth
def value_bets_today():
user = request.current_user
plan = user.get("plan", "free")
if not plan_allows(plan, "premium"):
return jsonify({"error": "Cette fonctionnalité requiert un plan Premium ou Pro.", "upgrade_required": True}), 403
today = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
rows = conn.execute("""
SELECT horse_name, race_label, race_name, hippodrome, odds, prob_top3, ml_score, risque_label, heure
FROM ml_predictions_cache WHERE date=? AND is_value_bet=1 ORDER BY ml_score DESC
""", (today,)).fetchall()
conn.close()
return jsonify({"value_bets": [dict(r) for r in rows], "count": len(rows)}), 200
@api_v1_bp.route("/export/csv", methods=["GET"])
@require_auth
def export_csv():
user = request.current_user
plan = user.get("plan", "free")
if not plan_allows(plan, "pro"):
return jsonify({"error": "L'export CSV requiert un plan Pro.", "upgrade_required": True}), 403
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
conn = get_db()
rows = conn.execute(
"SELECT * FROM ml_predictions_cache WHERE date=? ORDER BY num_reunion, num_course, ml_score DESC",
(date_param,)
).fetchall()
conn.close()
output = io.StringIO()
if rows:
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows([dict(r) for r in rows])
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": f"attachment; filename=turf_ia_{date_param}.csv"}
)

289
saas_auth.py Normal file
View File

@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""
SaaS Auth Blueprint — /api/v1/auth/*
Gestion des utilisateurs, JWT, plans, préférences.
Sprint 4-5 — HRT-30
"""
from flask import Blueprint, request, jsonify
import sqlite3
import hashlib
import secrets
import os
import time
from functools import wraps
from datetime import datetime
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
JWT_SECRET = os.environ.get("JWT_SECRET", secrets.token_hex(32))
TOKEN_TTL = int(os.environ.get("JWT_TTL_SECONDS", 30 * 24 * 3600)) # 30 days
auth_bp = Blueprint("auth_v1", __name__, url_prefix="/api/v1/auth")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_users_table():
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS saas_users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
firstname TEXT DEFAULT '',
lastname TEXT DEFAULT '',
password_hash TEXT NOT NULL,
plan TEXT DEFAULT 'free',
telegram_chat_id TEXT DEFAULT NULL,
alert_value_bets INTEGER DEFAULT 1,
alert_top1 INTEGER DEFAULT 1,
alert_quinte_only INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS saas_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
""")
conn.commit()
conn.close()
try:
init_users_table()
except Exception as e:
print(f"[auth_bp] DB init warning: {e}")
def generate_token(user_id):
token = secrets.token_urlsafe(48)
expires = int(time.time()) + TOKEN_TTL
conn = get_db()
conn.execute("INSERT INTO saas_tokens (token, user_id, expires_at) VALUES (?,?,?)", (token, user_id, expires))
conn.commit()
conn.close()
return token
def validate_token(token):
if not token:
return None
conn = get_db()
now = int(time.time())
row = conn.execute(
"SELECT t.user_id, u.* FROM saas_tokens t JOIN saas_users u ON t.user_id=u.id "
"WHERE t.token=? AND t.expires_at>?",
(token, now)
).fetchone()
conn.close()
return dict(row) if row else None
def hash_password(password):
return hashlib.sha256(password.encode("utf-8")).hexdigest()
def require_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.headers.get("Authorization", "")
token = auth[7:].strip() if auth.startswith("Bearer ") else None
user = validate_token(token)
if not user:
return jsonify({"error": "Non authentifié"}), 401
request.current_user = user
return f(*args, **kwargs)
return decorated
def user_to_dict(user):
if isinstance(user, sqlite3.Row):
user = dict(user)
return {
"id": user.get("id"),
"email": user.get("email"),
"firstname": user.get("firstname", ""),
"lastname": user.get("lastname", ""),
"plan": user.get("plan", "free"),
"telegram_chat_id": user.get("telegram_chat_id"),
"alert_value_bets": bool(user.get("alert_value_bets", 1)),
"alert_top1": bool(user.get("alert_top1", 1)),
"alert_quinte_only": bool(user.get("alert_quinte_only", 0)),
"created_at": user.get("created_at"),
}
@auth_bp.route("/register", methods=["POST"])
def register():
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
firstname = (data.get("firstname") or "").strip()
lastname = (data.get("lastname") or "").strip()
plan = data.get("plan", "free")
if not email or "@" not in email:
return jsonify({"error": "Adresse email invalide."}), 400
if len(password) < 8:
return jsonify({"error": "Mot de passe trop court (8 caractères minimum)."}), 400
if plan not in ("free", "premium", "pro"):
plan = "free"
uid = secrets.token_hex(16)
pw_hash = hash_password(password)
conn = get_db()
try:
conn.execute(
"INSERT INTO saas_users (id, email, firstname, lastname, password_hash, plan) VALUES (?,?,?,?,?,?)",
(uid, email, firstname, lastname, pw_hash, plan)
)
conn.commit()
except sqlite3.IntegrityError:
conn.close()
return jsonify({"error": "Cette adresse email est déjà utilisée."}), 409
conn.close()
token = generate_token(uid)
user_row = validate_token(token)
return jsonify({"token": token, "user": user_to_dict(user_row)}), 201
@auth_bp.route("/login", methods=["POST"])
def login():
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
if not email or not password:
return jsonify({"error": "Email et mot de passe requis."}), 400
pw_hash = hash_password(password)
conn = get_db()
user = conn.execute(
"SELECT * FROM saas_users WHERE email=? AND password_hash=?",
(email, pw_hash)
).fetchone()
conn.close()
if not user:
return jsonify({"error": "Identifiants incorrects."}), 401
token = generate_token(user["id"])
return jsonify({"token": token, "user": user_to_dict(user)}), 200
@auth_bp.route("/me", methods=["GET"])
@require_auth
def me():
return jsonify({"user": user_to_dict(request.current_user)}), 200
@auth_bp.route("/update-profile", methods=["POST"])
@require_auth
def update_profile():
data = request.get_json(silent=True) or {}
uid = request.current_user["id"]
fields = {}
if "firstname" in data: fields["firstname"] = data["firstname"].strip()
if "lastname" in data: fields["lastname"] = data["lastname"].strip()
if "email" in data:
email = data["email"].strip().lower()
if "@" not in email:
return jsonify({"error": "Email invalide."}), 400
fields["email"] = email
if not fields:
return jsonify({"ok": True}), 200
set_clause = ", ".join(f"{k}=?" for k in fields)
values = list(fields.values()) + [datetime.utcnow().isoformat(), uid]
conn = get_db()
try:
conn.execute(f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values)
conn.commit()
except sqlite3.IntegrityError:
conn.close()
return jsonify({"error": "Cet email est déjà utilisé."}), 409
conn.close()
return jsonify({"ok": True}), 200
@auth_bp.route("/change-password", methods=["POST"])
@require_auth
def change_password():
data = request.get_json(silent=True) or {}
uid = request.current_user["id"]
cur_pwd = data.get("current_password") or ""
new_pwd = data.get("new_password") or ""
if len(new_pwd) < 8:
return jsonify({"error": "Nouveau mot de passe trop court."}), 400
conn = get_db()
user = conn.execute("SELECT * FROM saas_users WHERE id=? AND password_hash=?",
(uid, hash_password(cur_pwd))).fetchone()
if not user:
conn.close()
return jsonify({"error": "Mot de passe actuel incorrect."}), 401
conn.execute("UPDATE saas_users SET password_hash=?, updated_at=? WHERE id=?",
(hash_password(new_pwd), datetime.utcnow().isoformat(), uid))
conn.commit()
conn.close()
return jsonify({"ok": True}), 200
@auth_bp.route("/update-plan", methods=["POST"])
@require_auth
def update_plan():
data = request.get_json(silent=True) or {}
plan = data.get("plan", "free")
if plan not in ("free", "premium", "pro"):
return jsonify({"error": "Plan invalide."}), 400
uid = request.current_user["id"]
conn = get_db()
conn.execute("UPDATE saas_users SET plan=?, updated_at=? WHERE id=?",
(plan, datetime.utcnow().isoformat(), uid))
conn.commit()
conn.close()
return jsonify({"ok": True, "plan": plan}), 200
@auth_bp.route("/update-preferences", methods=["POST"])
@require_auth
def update_preferences():
data = request.get_json(silent=True) or {}
uid = request.current_user["id"]
fields = {}
if "telegram_chat_id" in data: fields["telegram_chat_id"] = data["telegram_chat_id"] or None
if "alert_value_bets" in data: fields["alert_value_bets"] = 1 if data["alert_value_bets"] else 0
if "alert_top1" in data: fields["alert_top1"] = 1 if data["alert_top1"] else 0
if "alert_quinte_only" in data: fields["alert_quinte_only"] = 1 if data["alert_quinte_only"] else 0
if fields:
set_clause = ", ".join(f"{k}=?" for k in fields)
values = list(fields.values()) + [datetime.utcnow().isoformat(), uid]
conn = get_db()
conn.execute(f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values)
conn.commit()
conn.close()
return jsonify({"ok": True}), 200
@auth_bp.route("/logout", methods=["POST"])
@require_auth
def logout():
auth = request.headers.get("Authorization", "")
token = auth[7:].strip() if auth.startswith("Bearer ") else ""
conn = get_db()
conn.execute("DELETE FROM saas_tokens WHERE token=?", (token,))
conn.commit()
conn.close()
return jsonify({"ok": True}), 200
@auth_bp.route("/delete-account", methods=["DELETE"])
@require_auth
def delete_account():
uid = request.current_user["id"]
conn = get_db()
conn.execute("DELETE FROM saas_tokens WHERE user_id=?", (uid,))
conn.execute("DELETE FROM saas_users WHERE id=?", (uid,))
conn.commit()
conn.close()
return jsonify({"ok": True}), 200