Files
turf_saas/login.html
DevOps Engineer 41a9e36166 feat(sprint4-5): Landing page + onboarding SaaS — HRT-30
Frontend pages:
- landing.html: marketing page — hero, pricing (Free/9.90e/24.90e), features, FAQ, footer, mobile-first responsive, LCP < 2.5s friendly
- login.html: JWT auth login with JS validation, error handling, redirect-after-login
- register.html: registration with plan selection preview sidebar, password strength meter
- dashboard_saas.html: role-based dashboard (Free/Premium/Pro) with locked sections, race prediction cards, detailed table, stats row
- onboarding.html: 3-step wizard — plan confirm + Telegram alerts config + first prediction preview
- account.html: tabbed account management — profile, security (change-password, delete), plan upgrade, notification preferences

Backend:
- saas_auth.py: Flask Blueprint /api/v1/auth/* — register, login, token auth, profile/password/plan/preferences update, logout, delete-account
- saas_api_v1.py: Flask Blueprint /api/v1/* — stats/summary, predictions/today (plan-gated), value-bets (Premium+), CSV export (Pro)

Server:
- portal_server.py: register blueprints, serve all new SaaS routes at /login /register /dashboard /onboarding /account

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 18:04:19 +02:00

102 lines
6.7 KiB
HTML

<!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>