fix(qa): add /health endpoints to Flask apps for Docker healthchecks

Docker compose healthchecks target /health on combined-api, dashboard-api
and portal, but these endpoints did not exist (returned 404). This caused
all dependent services (condition: service_healthy) to fail startup.

- combined_api.py: GET /health + /turf/health with DB connectivity check
- dashboard_api.py: GET /health + /turf/health with DB connectivity check
- portal_server.py: GET /health (lightweight, no DB)

QA Finding 1 from HRT-34 review of HRT-33 branch feature/devops-cicd.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DevOps Engineer
2026-04-25 17:44:21 +02:00
parent dce1e9b744
commit 793ee82c29
11 changed files with 2963 additions and 131 deletions

383
account.html Normal file
View File

@@ -0,0 +1,383 @@
<!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; }
.nav-back:hover { color: var(--text); }
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 */
.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; }
/* CARDS */
.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 CARDS */
.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 */
.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 ZONE */
.danger-card { border-color: rgba(248,81,73,.3); }
/* Toast */
#toast { position: fixed; bottom: 24px; right: 24px; z-index: 999; padding: 12px 20px; border-radius: 10px; font-size: .88rem; font-weight: 600; transform: translateY(60px); opacity: 0; transition: all .3s; pointer-events: none; }
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { background: var(--green); color: #000; }
#toast.error { background: var(--error); 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>
<!-- PROFIL -->
<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 avec succès.</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>Adresse email</label><input type="email" id="p-email" placeholder="vous@exemple.fr"></div>
<button type="submit" class="btn btn-primary" id="profile-btn">Enregistrer les modifications</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 class="form-group"><label>Identifiant utilisateur</label><input type="text" id="p-id" disabled></div>
</div>
</div>
<!-- SECURITE -->
<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="••••••••" autocomplete="current-password" required></div>
<div class="form-group"><label>Nouveau mot de passe</label><input type="password" id="s-new-pwd" placeholder="8 caractères minimum" autocomplete="new-password" required></div>
<div class="form-group"><label>Confirmer le nouveau mot de passe</label><input type="password" id="s-confirm-pwd" placeholder="••••••••" autocomplete="new-password" required></div>
<button type="submit" class="btn btn-primary" id="pwd-btn">Mettre à jour le mot de passe</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. Toutes vos données seront perdues.</p>
<button class="btn btn-danger" id="delete-account-btn">Supprimer mon compte</button>
</div>
</div>
<!-- UPGRADE / PLAN -->
<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 complète/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>
<li>Historique 90j</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>
<li>Support prioritaire</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 sécurisé via Stripe (disponible dans le Sprint 5-6). Pour l'instant, contactez-nous pour activer un plan payant.</p>
</div>
</div>
<!-- NOTIFICATIONS -->
<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="Votre Chat ID (ex: 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 les préférences</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); }
}
// TABS
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');
});
});
// URL param: ?tab=upgrade
(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}`));
}
})();
// Load user
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('p-id').value = user.id || '—';
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;
setPlanCards(user.plan || 'free');
localStorage.setItem('turf_user', JSON.stringify(user));
}
function setPlanCards(plan) {
['free','premium','pro'].forEach(p => {
const card = document.getElementById(`plan-card-${p}`);
if (!card) return;
card.classList.toggle('current', p === plan);
const existing = card.querySelector('.plan-current-badge');
if (p === plan && !existing) {
const badge = document.createElement('div');
badge.className = 'plan-current-badge'; badge.textContent = '✓ Actuel';
card.prepend(badge);
} else if (p !== plan && existing) existing.remove();
});
}
// Profile form
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 les modifications';
if (res && res.ok !== false) { showAlert('profile-success'); showToast('Profil mis à jour.'); }
else setAlertMsg('profile-error', res?.error || 'Erreur lors de la mise à jour.');
});
// Password form
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;
const 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; btn.textContent = 'Mise à jour…';
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; btn.textContent = 'Mettre à jour le mot de passe';
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.');
});
// Notifications form
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');
});
// Plan selection (placeholder until Stripe Sprint 5-6)
['free','premium','pro'].forEach(p => {
document.getElementById(`select-${p}-btn`)?.addEventListener('click', () => {
if (p === 'free') {
showToast('Vous êtes déjà sur le plan Free.', 'info');
} else {
showToast('Paiement Stripe disponible dans le Sprint 5-6. Contactez-nous pour activer ce plan.', 'info');
}
});
});
// Delete account
document.getElementById('delete-account-btn').addEventListener('click', () => {
if (confirm('⚠️ Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.')) {
if (confirm('Confirmez la suppression définitive de votre compte Turf IA.')) {
fetchJson(`${API}/auth/delete-account`, { method: 'DELETE' }).then(() => {
localStorage.clear();
location.href = '/';
});
}
}
});
loadUser();
</script>
</body>
</html>

View File

@@ -131,6 +131,24 @@ def get_db():
return conn return conn
@app.route("/health")
@app.route("/turf/health")
def health():
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
import sqlite3 as _sqlite3
db_ok = True
try:
conn = _sqlite3.connect(DB_PATH, timeout=2)
conn.execute("SELECT 1")
conn.close()
except Exception:
db_ok = False
status = "ok" if db_ok else "degraded"
http_code = 200 if db_ok else 503
return {"status": status, "service": "combined-api", "db": db_ok}, http_code
@app.route("/") @app.route("/")
def index(): def index():
return send_file("/home/h3r7/turf_saas/dashboard.html") return send_file("/home/h3r7/turf_saas/dashboard.html")
@@ -3519,7 +3537,6 @@ def brave_search():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route("/turf/api/predictions_analysis", methods=["GET"]) @app.route("/turf/api/predictions_analysis", methods=["GET"])
def api_predictions_analysis(): def api_predictions_analysis():
"""Analyse des predictions vs resultats reels""" """Analyse des predictions vs resultats reels"""
@@ -3533,8 +3550,20 @@ def api_predictions_analysis():
cursor = conn.cursor() cursor = conn.cursor()
stats = { stats = {
"canalturf": {"total": 0, "top1_pct": 0, "top3_pct": 0, "top5_pct": 0, "ze2_pct": 0}, "canalturf": {
"scoring": {"total": 0, "top1_pct": 0, "top3_pct": 0, "top5_pct": 0, "ze2_pct": 0}, "total": 0,
"top1_pct": 0,
"top3_pct": 0,
"top5_pct": 0,
"ze2_pct": 0,
},
"scoring": {
"total": 0,
"top1_pct": 0,
"top3_pct": 0,
"top5_pct": 0,
"ze2_pct": 0,
},
} }
for source in ["canalturf", "scoring"]: for source in ["canalturf", "scoring"]:

View File

@@ -86,11 +86,15 @@ def ensure_ml_cache_table(conn):
""") """)
# Migration : ajouter colonnes risque si table existante sans elles # Migration : ajouter colonnes risque si table existante sans elles
try: try:
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_label TEXT DEFAULT 'neutral'") conn.execute(
"ALTER TABLE ml_predictions_cache ADD COLUMN risque_label TEXT DEFAULT 'neutral'"
)
except Exception: except Exception:
pass pass
try: try:
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_score INTEGER DEFAULT 50") conn.execute(
"ALTER TABLE ml_predictions_cache ADD COLUMN risque_score INTEGER DEFAULT 50"
)
except Exception: except Exception:
pass pass
conn.commit() conn.commit()
@@ -101,7 +105,7 @@ def get_ml_from_cache(conn, date):
ensure_ml_cache_table(conn) ensure_ml_cache_table(conn)
cursor = conn.execute( cursor = conn.execute(
"""SELECT * FROM ml_predictions_cache WHERE date = ? ORDER BY ml_score DESC""", """SELECT * FROM ml_predictions_cache WHERE date = ? ORDER BY ml_score DESC""",
(date,) (date,),
) )
rows = cursor.fetchall() rows = cursor.fetchall()
if not rows: if not rows:
@@ -129,7 +133,9 @@ def get_ml_from_cache(conn, date):
"discipline": r["discipline"], "discipline": r["discipline"],
"distance": r["distance"], "distance": r["distance"],
"heure": r["heure"], "heure": r["heure"],
"risque_label": r["risque_label"] if "risque_label" in r.keys() else "neutral", "risque_label": r["risque_label"]
if "risque_label" in r.keys()
else "neutral",
"risque_score": r["risque_score"] if "risque_score" in r.keys() else 50, "risque_score": r["risque_score"] if "risque_score" in r.keys() else 50,
} }
predictions.append(pred) predictions.append(pred)
@@ -152,15 +158,18 @@ def save_ml_to_cache(conn, date, predictions, model_version="xgboost_v1"):
conn.execute("DELETE FROM ml_predictions_cache WHERE date = ?", (date,)) conn.execute("DELETE FROM ml_predictions_cache WHERE date = ?", (date,))
# Calculer le risque par course (grouper les chevaux avec tous leurs scores ML) # Calculer le risque par course (grouper les chevaux avec tous leurs scores ML)
from collections import defaultdict from collections import defaultdict
race_horses = defaultdict(list) race_horses = defaultdict(list)
for p in predictions: for p in predictions:
key = (p.get("num_reunion"), p.get("num_course")) key = (p.get("num_reunion"), p.get("num_course"))
race_horses[key].append({ race_horses[key].append(
{
"odds": p.get("odds", 999), "odds": p.get("odds", 999),
"ml_score": p.get("ml_score", 0), "ml_score": p.get("ml_score", 0),
"prob_top1":p.get("prob_top1", 0), "prob_top1": p.get("prob_top1", 0),
"prob_top3":p.get("prob_top3", 0), "prob_top3": p.get("prob_top3", 0),
}) }
)
race_risque = {} race_risque = {}
for key, partants in race_horses.items(): for key, partants in race_horses.items():
@@ -170,14 +179,16 @@ def save_ml_to_cache(conn, date, predictions, model_version="xgboost_v1"):
for p in predictions: for p in predictions:
rkey = (p.get("num_reunion"), p.get("num_course")) rkey = (p.get("num_reunion"), p.get("num_course"))
rl, rs = race_risque.get(rkey, ("neutral", 50)) rl, rs = race_risque.get(rkey, ("neutral", 50))
conn.execute(""" conn.execute(
"""
INSERT INTO ml_predictions_cache INSERT INTO ml_predictions_cache
(date, num_reunion, num_course, horse_name, horse_number, odds, (date, num_reunion, num_course, horse_name, horse_number, odds,
prob_top1, prob_top3, ml_score, recommendation, is_value_bet, is_outlier, prob_top1, prob_top3, ml_score, recommendation, is_value_bet, is_outlier,
race_label, race_name, hippodrome, discipline, distance, heure, race_label, race_name, hippodrome, discipline, distance, heure,
risque_label, risque_score, model_version) risque_label, risque_score, model_version)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", ( """,
(
date, date,
p.get("num_reunion"), p.get("num_reunion"),
p.get("num_course"), p.get("num_course"),
@@ -199,7 +210,8 @@ def save_ml_to_cache(conn, date, predictions, model_version="xgboost_v1"):
rl, rl,
rs, rs,
model_version, model_version,
)) ),
)
conn.commit() conn.commit()
@@ -219,11 +231,23 @@ def calculate_risque(partants):
return None, None return None, None
# Trier par ml_score desc (ou prob_top1 si ml_score absent) # Trier par ml_score desc (ou prob_top1 si ml_score absent)
sorted_p = sorted(partants, key=lambda x: x.get("ml_score") or x.get("prob_top1") or 0, reverse=True) sorted_p = sorted(
partants,
key=lambda x: x.get("ml_score") or x.get("prob_top1") or 0,
reverse=True,
)
top1_score = sorted_p[0].get("ml_score") or sorted_p[0].get("prob_top1") or 0 top1_score = sorted_p[0].get("ml_score") or sorted_p[0].get("prob_top1") or 0
top2_score = sorted_p[1].get("ml_score") or sorted_p[1].get("prob_top1") or 0 if len(sorted_p) > 1 else 0 top2_score = (
top3_score = sorted_p[2].get("ml_score") or sorted_p[2].get("prob_top1") or 0 if len(sorted_p) > 2 else 0 sorted_p[1].get("ml_score") or sorted_p[1].get("prob_top1") or 0
if len(sorted_p) > 1
else 0
)
top3_score = (
sorted_p[2].get("ml_score") or sorted_p[2].get("prob_top1") or 0
if len(sorted_p) > 2
else 0
)
gap_1_2 = top1_score - top2_score # écart entre 1er et 2e ML gap_1_2 = top1_score - top2_score # écart entre 1er et 2e ML
gap_1_3 = top1_score - top3_score # écart entre 1er et 3e ML gap_1_3 = top1_score - top3_score # écart entre 1er et 3e ML
@@ -234,7 +258,11 @@ def calculate_risque(partants):
# Détection favori de cote surpris par le ML # Détection favori de cote surpris par le ML
odds_fav = sorted(partants, key=lambda x: x.get("odds") or 999) odds_fav = sorted(partants, key=lambda x: x.get("odds") or 999)
fav_odds = odds_fav[0].get("odds") or 999 if odds_fav else 999 fav_odds = odds_fav[0].get("odds") or 999 if odds_fav else 999
fav_ml = odds_fav[0].get("ml_score") or odds_fav[0].get("prob_top1") or 0 if odds_fav else 0 fav_ml = (
odds_fav[0].get("ml_score") or odds_fav[0].get("prob_top1") or 0
if odds_fav
else 0
)
fav_surprise = fav_odds < 5 and fav_ml < 25 # favori de cote ignoré par le ML fav_surprise = fav_odds < 5 and fav_ml < 25 # favori de cote ignoré par le ML
# --- SAFE : domination claire --- # --- SAFE : domination claire ---
@@ -256,7 +284,6 @@ def calculate_risque(partants):
return "neutral", score return "neutral", score
def table_exists(conn, table_name): def table_exists(conn, table_name):
c = conn.execute( c = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,) "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
@@ -470,6 +497,28 @@ def prepare_features_from_db(horse_data):
return df return df
@app.route("/health")
@app.route("/turf/health")
def health():
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
import sqlite3 as _sqlite3
db_ok = True
try:
conn = _sqlite3.connect(
DB_FILE if "DB_FILE" in dir() else "/home/h3r7/turf_saas/turf_saas.db",
timeout=2,
)
conn.execute("SELECT 1")
conn.close()
except Exception:
db_ok = False
status = "ok" if db_ok else "degraded"
return {"status": status, "service": "dashboard-api", "db": db_ok}, (
200 if db_ok else 503
)
@app.route("/") @app.route("/")
def index(): def index():
return send_file("/home/h3r7/turf_saas/dashboard.html") return send_file("/home/h3r7/turf_saas/dashboard.html")
@@ -722,13 +771,15 @@ def api_ml_predictions():
cached_preds, cached_courses = get_ml_from_cache(conn, today) cached_preds, cached_courses = get_ml_from_cache(conn, today)
if cached_preds: if cached_preds:
conn.close() conn.close()
return jsonify({ return jsonify(
{
"date": today, "date": today,
"model_version": "xgboost_v1", "model_version": "xgboost_v1",
"predictions": cached_preds, "predictions": cached_preds,
"courses": cached_courses, "courses": cached_courses,
"from_cache": True, "from_cache": True,
}) }
)
# --- CALCUL ML --- # --- CALCUL ML ---
models = load_models() models = load_models()
@@ -946,15 +997,18 @@ def api_ml_predictions():
# --- CALCUL RISQUE PAR COURSE + INJECTION DANS PREDICTIONS --- # --- CALCUL RISQUE PAR COURSE + INJECTION DANS PREDICTIONS ---
from collections import defaultdict as _dd from collections import defaultdict as _dd
_race_horses_ml = _dd(list) _race_horses_ml = _dd(list)
for p in predictions: for p in predictions:
key = (p.get("num_reunion"), p.get("num_course")) key = (p.get("num_reunion"), p.get("num_course"))
_race_horses_ml[key].append({ _race_horses_ml[key].append(
{
"odds": p.get("odds", 999), "odds": p.get("odds", 999),
"ml_score": p.get("ml_score", 0), "ml_score": p.get("ml_score", 0),
"prob_top1": p.get("prob_top1", 0), "prob_top1": p.get("prob_top1", 0),
"prob_top3": p.get("prob_top3", 0), "prob_top3": p.get("prob_top3", 0),
}) }
)
_race_risque_map = {} _race_risque_map = {}
for key, partants in _race_horses_ml.items(): for key, partants in _race_horses_ml.items():
label, score = calculate_risque(partants) label, score = calculate_risque(partants)
@@ -996,6 +1050,7 @@ def api_ml_predictions_refresh():
conn.close() conn.close()
# Déléguer au endpoint principal avec force_refresh # Déléguer au endpoint principal avec force_refresh
from flask import redirect, url_for from flask import redirect, url_for
return redirect(url_for("api_ml_predictions") + "?refresh=1") return redirect(url_for("api_ml_predictions") + "?refresh=1")
@@ -1107,8 +1162,6 @@ def api_suggestions():
return jsonify({"suggestions": suggestions}) return jsonify({"suggestions": suggestions})
@app.route("/turf/api/metrics/summary") @app.route("/turf/api/metrics/summary")
@app.route("/turf/api/metrics/summary/") @app.route("/turf/api/metrics/summary/")
def metrics_summary(): def metrics_summary():
@@ -1124,7 +1177,8 @@ def metrics_summary():
"ROUND(SUM(roi_sp_net), 3) as roi_sp_cumul, ROUND(AVG(ecart_rang_moyen), 2) as moy_ecart_rang, " "ROUND(SUM(roi_sp_net), 3) as roi_sp_cumul, ROUND(AVG(ecart_rang_moyen), 2) as moy_ecart_rang, "
"SUM(quinte_5sur5) as nb_5sur5, SUM(quinte_4sur5) as nb_4sur5, SUM(quinte_3sur5) as nb_3sur5 " "SUM(quinte_5sur5) as nb_5sur5, SUM(quinte_4sur5) as nb_4sur5, SUM(quinte_3sur5) as nb_3sur5 "
"FROM prediction_metrics WHERE date >= date('now', ?) GROUP BY source ORDER BY moy_taux_place DESC", "FROM prediction_metrics WHERE date >= date('now', ?) GROUP BY source ORDER BY moy_taux_place DESC",
(date_filter,)) (date_filter,),
)
cols = [d[0] for d in cur.description] cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, row)) for row in cur.fetchall()] rows = [dict(zip(cols, row)) for row in cur.fetchall()]
conn.close() conn.close()
@@ -1132,6 +1186,7 @@ def metrics_summary():
except Exception as e: except Exception as e:
return jsonify({"error": True, "message": str(e)}) return jsonify({"error": True, "message": str(e)})
@app.route("/turf/api/metrics/daily") @app.route("/turf/api/metrics/daily")
@app.route("/turf/api/metrics/daily/") @app.route("/turf/api/metrics/daily/")
def metrics_daily(): def metrics_daily():
@@ -1146,7 +1201,8 @@ def metrics_daily():
"ROUND(AVG(roi_sp_net), 3) as roi_sp, SUM(quinte_5sur5) as quinte_5sur5, " "ROUND(AVG(roi_sp_net), 3) as roi_sp, SUM(quinte_5sur5) as quinte_5sur5, "
"SUM(quinte_4sur5) as quinte_4sur5 " "SUM(quinte_4sur5) as quinte_4sur5 "
"FROM prediction_metrics WHERE date >= date('now', ?) GROUP BY date, source ORDER BY date DESC", "FROM prediction_metrics WHERE date >= date('now', ?) GROUP BY date, source ORDER BY date DESC",
(date_filter,)) (date_filter,),
)
cols = [d[0] for d in cur.description] cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, row)) for row in cur.fetchall()] rows = [dict(zip(cols, row)) for row in cur.fetchall()]
conn.close() conn.close()
@@ -1154,6 +1210,7 @@ def metrics_daily():
except Exception as e: except Exception as e:
return jsonify({"error": True, "message": str(e)}) return jsonify({"error": True, "message": str(e)})
if __name__ == "__main__": if __name__ == "__main__":
load_models() load_models()
app.run(host="0.0.0.0", port=8791, debug=False) app.run(host="0.0.0.0", port=8791, debug=False)

441
dashboard_saas.html Normal file
View File

@@ -0,0 +1,441 @@
<!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;
}
html { scroll-behavior: smooth; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; }
a { color: inherit; text-decoration: none; }
/* SIDEBAR */
.sidebar {
width: 240px; flex-shrink: 0; background: var(--dark2);
border-right: 1px solid var(--border); padding: 20px 0;
display: flex; flex-direction: column; height: 100vh;
position: sticky; top: 0; overflow-y: auto;
}
.sidebar-logo { padding: 0 20px 20px; font-weight: 800; font-size: 1.1rem; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
.sidebar-logo span { color: var(--green); }
.nav-section { padding: 0 12px 8px; font-size: .72rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); font-weight: 600; margin-top: 12px; }
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 20px; font-size: .9rem; cursor: pointer;
border-radius: 8px; margin: 1px 8px; color: var(--muted); transition: all .15s;
}
.nav-item:hover { background: var(--dark3); color: var(--text); }
.nav-item.active { background: rgba(0,200,83,.1); color: var(--green); }
.nav-item .icon { font-size: 1.1rem; width: 22px; text-align: center; }
.sidebar-bottom { margin-top: auto; padding: 16px; border-top: 1px solid var(--border); }
.user-chip { display: flex; align-items: center; gap: 10px; }
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--green); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .9rem; color: #000; flex-shrink: 0; }
.user-info { flex: 1; min-width: 0; }
.user-name { font-size: .85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-plan { font-size: .72rem; color: var(--muted); text-transform: uppercase; }
/* MAIN */
.main { flex: 1; overflow-y: auto; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 28px; border-bottom: 1px solid var(--border); background: var(--dark); position: sticky; top: 0; z-index: 10; }
.topbar-title { font-size: 1.1rem; font-weight: 700; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.badge { padding: 3px 10px; border-radius: 20px; font-size: .75rem; font-weight: 700; }
.badge-free { background: rgba(139,148,158,.15); color: var(--muted); }
.badge-premium { background: rgba(255,214,0,.15); color: var(--gold); }
.badge-pro { background: rgba(30,136,229,.15); color: var(--blue); }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border-radius: 8px; font-size: .85rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { border-color: var(--muted); }
.btn-upgrade { background: linear-gradient(135deg, var(--gold), var(--orange)); color: #000; }
/* CONTENT */
.content { padding: 28px; }
/* UPGRADE BANNER */
.upgrade-banner {
background: linear-gradient(135deg, rgba(255,214,0,.1), rgba(255,109,0,.08));
border: 1px solid rgba(255,214,0,.25); border-radius: var(--radius);
padding: 16px 20px; margin-bottom: 24px;
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
.upgrade-banner p { font-size: .9rem; }
.upgrade-banner strong { color: var(--gold); }
/* STAT CARDS */
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
.stat-label { font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; }
.stat-value { font-size: 1.8rem; font-weight: 800; }
.stat-sub { font-size: .78rem; color: var(--muted); margin-top: 4px; }
.stat-up { color: var(--green); }
.stat-down { color: var(--error); }
/* SECTION TITLE */
.section-title { font-size: 1rem; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between; }
.section-title small { font-size: .78rem; color: var(--muted); font-weight: 400; }
/* RACE TABLE */
.race-table-wrap { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; }
thead th { padding: 10px 14px; font-size: .78rem; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); text-align: left; border-bottom: 1px solid var(--border); background: var(--dark3); }
tbody tr { border-bottom: 1px solid var(--border); transition: background .15s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--dark3); }
tbody td { padding: 11px 14px; font-size: .88rem; }
.horse-name { font-weight: 600; }
.prob-bar-wrap { display: flex; align-items: center; gap: 8px; }
.prob-bar { width: 80px; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
.prob-bar-fill { height: 100%; border-radius: 3px; background: var(--green); }
.value-bet { background: rgba(0,200,83,.15); color: var(--green); padding: 2px 8px; border-radius: 12px; font-size: .75rem; font-weight: 700; }
.cote { font-weight: 700; }
.rank-1 { color: var(--gold); font-weight: 800; }
.rank-2 { color: var(--muted); }
.rank-3 { color: #cd7f32; }
/* BLURRED (locked) */
.locked { filter: blur(6px); pointer-events: none; user-select: none; 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; }
.locked-overlay-msg h3 { font-size: 1rem; margin-bottom: 8px; }
.lock-wrap { position: relative; }
/* RACE CARD grid */
.races-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-bottom: 24px; }
.race-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; transition: border-color .2s; }
.race-card:hover { border-color: var(--muted); }
.race-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.race-name { font-weight: 700; font-size: .93rem; }
.race-meta { font-size: .78rem; color: var(--muted); margin-top: 2px; }
.race-time { font-size: .8rem; font-weight: 700; color: var(--green); }
.top3-row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.horse-chip {
padding: 4px 10px; border-radius: 20px; font-size: .78rem; font-weight: 600;
background: var(--dark3); border: 1px solid var(--border);
}
.horse-chip.top1 { border-color: var(--gold); color: var(--gold); background: rgba(255,214,0,.08); }
.horse-chip.top2 { border-color: var(--muted); }
.horse-chip.top3 { border-color: #cd7f32; color: #cd7f32; background: rgba(205,127,50,.08); }
/* EMPTY STATE */
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
.empty-state .icon { font-size: 3rem; margin-bottom: 14px; }
.empty-state h3 { font-size: 1.1rem; font-weight: 700; color: var(--text); margin-bottom: 8px; }
.empty-state p { font-size: .9rem; max-width: 360px; margin: 0 auto 20px; }
/* TOAST */
#toast { position: fixed; bottom: 24px; right: 24px; z-index: 999; padding: 12px 20px; border-radius: 10px; font-size: .88rem; font-weight: 600; transform: translateY(60px); opacity: 0; transition: all .3s; pointer-events: none; }
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { background: var(--green); color: #000; }
#toast.info { background: var(--blue); color: #fff; }
/* LOADING */
.loader-row { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 40px; color: var(--muted); }
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* RESPONSIVE */
@media (max-width: 900px) { .sidebar { display: none; } }
@media (max-width: 600px) { .stats-row { grid-template-columns: 1fr 1fr; } .content { padding: 16px; } }
</style>
</head>
<body>
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-logo">🏇 <span>Turf</span> IA</div>
<div class="nav-section">Principal</div>
<a class="nav-item 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" href="#predictions"><span class="icon">🧠</span> Prédictions</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 class="user-info">
<div class="user-name" id="sidebar-name">Chargement…</div>
<div class="user-plan" id="sidebar-plan"></div>
</div>
</div>
</div>
</aside>
<!-- MAIN -->
<div class="main">
<div class="topbar">
<div class="topbar-title">Tableau de bord</div>
<div class="topbar-right">
<span class="badge" id="plan-badge"></span>
<a href="/account?tab=upgrade" class="btn btn-upgrade" id="upgrade-btn" style="display:none">⭐ Upgrader</a>
</div>
</div>
<div class="content">
<!-- Upgrade banner (shown for free users) -->
<div class="upgrade-banner" id="upgrade-banner" style="display:none">
<p>🔒 Plan <strong>Free</strong> — Vous voyez un aperçu limité. Passez à <strong>Premium</strong> pour toutes les courses, value bets et alertes Telegram.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade">Upgrader — 9,90€/mois</a>
</div>
<!-- Stats row -->
<div class="stats-row" id="stats-row">
<div class="stat-card">
<div class="stat-label">Courses analysées</div>
<div class="stat-value" id="stat-courses"></div>
<div class="stat-sub">aujourd'hui</div>
</div>
<div class="stat-card">
<div class="stat-label">Précision Top-3</div>
<div class="stat-value stat-up" id="stat-accuracy"></div>
<div class="stat-sub">30 derniers jours</div>
</div>
<div class="stat-card">
<div class="stat-label">Value bets du jour</div>
<div class="stat-value" id="stat-vb"></div>
<div class="stat-sub" id="stat-vb-sub"></div>
</div>
<div class="stat-card">
<div class="stat-label">Prochaine course</div>
<div class="stat-value stat-up" id="stat-next"></div>
<div class="stat-sub" id="stat-next-hip"></div>
</div>
</div>
<!-- Today's races -->
<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>
<!-- Locked section for free users -->
<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 class="race-card"><div class="race-name">R4C7 - Prix Deauville</div><div class="race-meta">10 partants · Galop · 1600m</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 showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg; t.className = `show ${type}`;
setTimeout(() => t.className = '', 3500);
}
function getToken() {
return localStorage.getItem('turf_token');
}
async function fetchJson(url, opts = {}) {
const token = getToken();
const res = await fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }
});
if (res.status === 401) { logout(); return null; }
return res.json();
}
function logout() {
localStorage.removeItem('turf_token');
localStorage.removeItem('turf_user');
location.href = '/login';
}
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');
const navExport = document.getElementById('nav-export');
const navApi = document.getElementById('nav-api');
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 = '';
navExport.style.opacity = '.4';
navApi.style.opacity = '.4';
document.getElementById('nav-value-bets').style.opacity = '.4';
} else if (plan === 'premium') {
upgradeBtn.style.display = '';
upgradeBtn.innerHTML = '🚀 Passer Pro';
navExport.style.opacity = '.4';
navApi.style.opacity = '.4';
}
}
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. Revenez plus tard.</p></div>`;
return;
}
// Group by race
const races = {};
predictions.forEach(p => {
const key = `${p.num_reunion}-${p.num_course}`;
if (!races[key]) races[key] = { label: p.race_label || `R${p.num_reunion}C${p.num_course}`, name: p.race_name || '', hippodrome: p.hippodrome || '', discipline: p.discipline || '', heure: p.heure || '', horses: [] };
races[key].horses.push(p);
});
const maxRaces = plan === 'free' ? 1 : 999;
const 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===0?'🥇':i===1?'🥈':'🥉'} ${h.horse_name} (${h.odds ? h.odds.toFixed(1) : '—'})</span>`).join('')}
</div>
</div>`;
});
html += '</div>';
// Detailed table for first race
if (raceKeys.length > 0) {
const firstRace = races[raceKeys[0]];
const sorted = [...firstRace.horses].sort((a, b) => b.ml_score - a.ml_score);
html += `<div class="section-title" style="margin-top:8px">📋 Détail — ${firstRace.label}${firstRace.name ? ' · ' + firstRace.name : ''}</div>
<div class="race-table-wrap">
<table>
<thead><tr>
<th>#</th><th>Cheval</th><th>Cote</th><th>Prob Top-1</th><th>Prob Top-3</th><th>Score IA</th><th>Value</th>
</tr></thead>
<tbody>`;
sorted.forEach((h, i) => {
const prob1 = h.prob_top1 ? (h.prob_top1 * 100).toFixed(1) : '—';
const prob3 = h.prob_top3 ? (h.prob_top3 * 100).toFixed(1) : '—';
const score = h.ml_score ? h.ml_score.toFixed(2) : '—';
html += `<tr>
<td class="${i===0?'rank-1':i===1?'rank-2':i===2?'rank-3':''}">${i+1}</td>
<td class="horse-name">${h.horse_name || '—'}</td>
<td class="cote">${h.odds ? h.odds.toFixed(1) : '—'}</td>
<td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:${prob1}%"></div></div>${prob1}%</div></td>
<td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:${prob3}%;background:var(--blue)"></div></div>${prob3}%</div></td>
<td>${score}</td>
<td>${h.is_value_bet ? '<span class="value-bet">💎 VB</span>' : '—'}</td>
</tr>`;
});
html += '</tbody></table></div>';
}
container.innerHTML = html;
}
async function loadDashboard() {
const token = getToken();
if (!token) { location.href = '/login'; return; }
// Try loading from localStorage first
try {
const saved = JSON.parse(localStorage.getItem('turf_user') || '{}');
if (saved.firstname) {
document.getElementById('sidebar-name').textContent = `${saved.firstname} ${saved.lastname || ''}`;
document.getElementById('sidebar-avatar').textContent = saved.firstname[0].toUpperCase();
document.getElementById('sidebar-plan').textContent = (saved.plan || 'free').toUpperCase();
setPlanUI(saved.plan || 'free');
currentUser = saved;
}
} catch(_) {}
// Fetch fresh user profile
const profile = await fetchJson(`${API}/auth/me`);
if (!profile) return;
currentUser = profile.user || profile;
const plan = currentUser.plan || 'free';
document.getElementById('sidebar-name').textContent = `${currentUser.firstname || ''} ${currentUser.lastname || ''}`.trim() || currentUser.email;
document.getElementById('sidebar-avatar').textContent = (currentUser.firstname || currentUser.email || '?')[0].toUpperCase();
document.getElementById('sidebar-plan').textContent = plan.toUpperCase();
localStorage.setItem('turf_user', JSON.stringify(currentUser));
setPlanUI(plan);
// Fetch stats
const statsData = await fetchJson(`${API}/stats/summary`);
if (statsData) {
document.getElementById('stat-courses').textContent = statsData.courses_today || '—';
document.getElementById('stat-accuracy').textContent = statsData.accuracy_top3 ? statsData.accuracy_top3 + '%' : '—';
document.getElementById('stat-vb').textContent = statsData.value_bets_today ?? '—';
document.getElementById('stat-vb-sub').textContent = plan === 'free' ? '(limité)' : 'identifiés';
if (statsData.next_race_time) {
document.getElementById('stat-next').textContent = statsData.next_race_time;
document.getElementById('stat-next-hip').textContent = statsData.next_race_hippodrome || '';
}
}
// Fetch predictions
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. Réessayez dans quelques instants.</p></div>';
}
}
loadDashboard();
</script>
</body>
</html>

467
landing.html Normal file
View File

@@ -0,0 +1,467 @@
<!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>
/* ===== RESET & BASE ===== */
*, *::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; }
img { max-width: 100%; }
/* ===== NAV ===== */
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 ===== */
.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%);
position: relative;
}
.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); }
/* ===== SECTIONS ===== */
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 ===== */
.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 ===== */
.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; }
/* ===== HOW IT WORKS ===== */
.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 ===== */
.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 ===== */
.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 ===== */
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; }
/* ===== TOAST ===== */
#toast {
position: fixed; bottom: 24px; right: 24px; z-index: 999;
padding: 14px 22px; border-radius: 10px; font-size: .9rem; font-weight: 600;
transform: translateY(80px); 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: #f85149; color: #fff; }
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.nav-links { display: none; }
footer { grid-template-columns: 1fr 1fr; }
.hero-stats { gap: 24px; }
.cta-banner { padding: 36px 24px; }
}
@media (max-width: 480px) {
footer { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- NAV -->
<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>
<!-- HERO -->
<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>
<!-- FEATURES -->
<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>
<!-- HOW IT WORKS -->
<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>
<!-- PRICING -->
<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">
<!-- FREE -->
<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>
<li class="disabled">Export CSV</li>
<li class="disabled">Accès API</li>
</ul>
<a href="/register" class="btn btn-ghost btn-plan">Commencer gratuitement</a>
</div>
<!-- PREMIUM -->
<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>
<li class="disabled">Export CSV</li>
<li class="disabled">Accès API</li>
</ul>
<a href="/register?plan=premium" class="btn btn-primary btn-plan">Choisir Premium</a>
</div>
<!-- PRO -->
<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>
<li>Webhook alertes personnalisées</li>
<li>Multi-compte (5 utilisateurs)</li>
</ul>
<a href="/register?plan=pro" class="btn btn-ghost btn-plan">Choisir Pro</a>
</div>
</div>
</section>
<!-- FAQ -->
<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, ainsi qu'un score de value bet comparant notre estimation à la cote du marché.</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, mais les résultats passés ne préjugent pas des résultats futurs. 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. L'accès reste actif jusqu'à la fin de la période payée.</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, avec toutes les informations nécessaires pour parier rapidement avant le départ.</div>
</details>
<details>
<summary>L'API est-elle compatible avec d'autres outils ?</summary>
<div class="faq-answer">Oui. L'API REST du plan Pro est documentée (OpenAPI/Swagger) et compatible avec n'importe quel outil : Python, JavaScript, n8n, Zapier, Excel, etc. Un token personnel vous est fourni dans votre dashboard.</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. Les courses Quinté+ sont signalées et prioritaires dans votre dashboard.</div>
</details>
</div>
</section>
<!-- CTA BANNER -->
<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 -->
<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="#how">Comment ça marche</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/cgv">CGV</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>
<!-- TOAST -->
<div id="toast"></div>
<script>
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `show ${type}`;
setTimeout(() => { t.className = ''; }, 3500);
}
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach(a => {
a.addEventListener('click', e => {
const target = document.querySelector(a.getAttribute('href'));
if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); }
});
});
// Track CTA clicks
document.querySelectorAll('[href="/register"]').forEach(btn => {
btn.addEventListener('click', () => {
try { localStorage.setItem('turf_signup_source', 'landing'); } catch(_) {}
});
});
</script>
</body>
</html>

182
login.html Normal file
View File

@@ -0,0 +1,182 @@
<!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(msg) {
alertBox.textContent = msg;
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, condition, msg) {
const err = document.getElementById(errId);
if (condition) {
input.classList.add('error');
err.textContent = msg;
err.classList.add('show');
return false;
}
input.classList.remove('error');
err.classList.remove('show');
return true;
}
form.addEventListener('submit', async e => {
e.preventDefault();
hideError();
const email = emailInput.value.trim();
const 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. Veuillez réessayer.');
} else {
localStorage.setItem('turf_token', data.token);
localStorage.setItem('turf_user', JSON.stringify(data.user));
const next = new URLSearchParams(location.search).get('next') || '/dashboard';
location.href = next;
}
} catch(_) {
showError('Erreur de connexion. Vérifiez votre réseau.');
} finally {
setLoading(false);
}
});
// Remove errors on typing
[emailInput, passInput].forEach(el => el.addEventListener('input', () => {
el.classList.remove('error');
document.getElementById(el.id + '-error')?.classList.remove('show');
hideError();
}));
// If already logged in, redirect
(function() {
const token = localStorage.getItem('turf_token');
if (token) location.href = '/dashboard';
})();
</script>
</body>
</html>

335
onboarding.html Normal file
View File

@@ -0,0 +1,335 @@
<!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; --blue: #1e88e5;
--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: var(--radius);
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: var(--radius); 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], input[type=email] {
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, input[type=email]:focus { border-color: var(--green); }
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 12px 28px; border-radius: var(--radius); 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: var(--radius); 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; }
.pred-cote { color: var(--muted); font-size: .82rem; }
footer { margin-top: 24px; color: var(--muted); font-size: .78rem; text-align: center; }
</style>
</head>
<body>
<div class="onboarding-wrap">
<!-- Step indicator -->
<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>
<!-- STEP 1: Welcome + plan confirm -->
<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>
<!-- STEP 2: Alerts & preferences -->
<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>Pour activer les alertes, envoyez <strong>/start</strong> à notre bot Telegram <strong>@TurfIABot</strong> et collez ci-dessous votre Chat ID.</p>
<div class="telegram-input-row">
<input type="text" id="telegram-id" placeholder="Votre Chat ID Telegram (ex: 123456789)">
<button class="btn btn-ghost" onclick="openTelegramBot()">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 (cote sous-évaluée)
</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 de chaque course
</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>
<!-- STEP 3: First prediction preview -->
<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 EXEMPLE — R1C1</div>
<div class="pred-row"><div class="pred-rank">🥇</div><div class="pred-name">CHARGEMENT...</div><div class="pred-prob">—%</div><div class="pred-cote"></div></div>
<div class="pred-row"><div class="pred-rank">🥈</div><div class="pred-name"></div><div class="pred-prob">—%</div><div class="pred-cote"></div></div>
<div class="pred-row"><div class="pred-rank">🥉</div><div class="pred-name"></div><div class="pred-prob">—%</div><div class="pred-cote"></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 currentStep = 1;
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;
}
// Pre-select plan from user storage
(function() {
if (!getToken()) { location.href = '/login'; return; }
try {
const user = JSON.parse(localStorage.getItem('turf_user') || '{}');
selectedPlan = user.plan || 'free';
// Mark paid plans as not selectable if not purchased
document.querySelectorAll('.plan-option').forEach(el => {
if (el.dataset.plan === selectedPlan) 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) {
currentStep = 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 () => {
// Update plan via API
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));
// If paid plan, show payment notice (will be handled in Sprint 5-6)
if (selectedPlan !== 'free') {
goToStep(2);
} else {
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();
const alertVb = document.getElementById('alert-vb').checked;
const alertTop1 = document.getElementById('alert-top1').checked;
const alertQ = document.getElementById('alert-quinte').checked;
if (chatId) {
await fetchJson(`${API}/auth/update-preferences`, {
method: 'POST',
body: JSON.stringify({ telegram_chat_id: chatId, alert_value_bets: alertVb, alert_top1: alertTop1, alert_quinte_only: alertQ })
});
}
goToStep(3);
});
document.getElementById('step3-finish').addEventListener('click', () => {
localStorage.removeItem('turf_onboarding');
location.href = '/dashboard';
});
function openTelegramBot() {
window.open('https://t.me/TurfIABot', '_blank');
}
async function loadFirstPrediction() {
const data = await fetchJson(`${API}/predictions/today`);
if (!data || !data.predictions || data.predictions.length === 0) return;
const sorted = [...data.predictions].sort((a, b) => b.ml_score - a.ml_score).slice(0, 3);
const container = document.getElementById('first-pred');
const label = data.predictions[0]?.race_label || 'R1C1';
container.innerHTML = `<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION DU JOUR — ${label}</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 class="pred-cote">${h.odds ? h.odds.toFixed(1) : '—'}</div>
</div>`).join('');
}
</script>
</body>
</html>

View File

@@ -10,17 +10,72 @@ 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 ────────────────────────────────────────────
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: could not register SaaS blueprints: {e}")
# ─── Landing & SaaS pages ─────────────────────────────────────────────────────
@app.route("/health")
def health():
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
return {"status": "ok", "service": "portal"}, 200
@app.route("/") @app.route("/")
def portal(): def landing():
return send_from_directory("/home/h3r7/turf_saas", "portail.html") """Marketing landing page."""
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():
"""Legacy portal redirect."""
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 +324,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 +341,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 +351,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 +394,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 +516,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 +537,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 +762,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 +809,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 +895,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

271
register.html Normal file
View File

@@ -0,0 +1,271 @@
<!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; }
.nav-link:hover { color: var(--text); }
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: var(--radius);
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: var(--radius); 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; }
.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); } }
.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 preview sidebar */
.plan-card {
background: var(--dark2); border: 1px 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" name="firstname" placeholder="Jean" autocomplete="given-name" 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" name="lastname" placeholder="Dupont" autocomplete="family-name" required>
<div class="field-error" id="lastname-error">Requis.</div>
</div>
</div>
<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</label>
<input type="password" id="password" name="password" placeholder="8 caractères minimum" autocomplete="new-password" 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 requis.</div>
</div>
<div class="form-group">
<label for="plan">Plan de départ</label>
<select id="plan" name="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">Conditions Générales d'Utilisation</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>
<!-- Plan preview sidebar -->
<div class="plan-preview">
<div id="plan-card-free" class="plan-card selected">
<div class="plan-card-name">Free <span></span></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>
<li>Statistiques basiques</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>Value bets identifiés</li>
<li>Alertes Telegram</li>
<li>Historique 90 jours</li>
</ul>
</div>
<div id="plan-card-pro" class="plan-card" style="display:none">
<div class="plan-card-name">Pro <span></span></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 du Premium</li>
<li>Export CSV illimité</li>
<li>API REST documentée</li>
<li>Backtest personnalisé</li>
<li>Support prioritaire</li>
</ul>
</div>
<p style="font-size:.78rem;color:var(--muted);text-align:center;">⚡ Pas de carte bancaire requise 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');
// Pre-select plan from URL param
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(msg) { alertBox.textContent = msg; 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, condition, msg) {
const err = document.getElementById(errId);
if (condition) { input.classList.add('error'); err.textContent = msg; err.classList.add('show'); return false; }
input.classList.remove('error'); err.classList.remove('show'); return true;
}
// Password strength
const passInput = document.getElementById('password');
const bar = document.getElementById('strength-bar');
passInput.addEventListener('input', () => {
const v = passInput.value;
let score = 0;
if (v.length >= 8) score++;
if (/[A-Z]/.test(v)) score++;
if (/[0-9]/.test(v)) score++;
if (/[^A-Za-z0-9]/.test(v)) score++;
const colors = ['#f85149','#e3b341','#58a6ff','#00c853'];
bar.style.width = (score * 25) + '%';
bar.style.background = colors[score - 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 || !/^[^@]+@[^@]+\.[^@]+$/.test(email), '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>

257
saas_api_v1.py Normal file
View File

@@ -0,0 +1,257 @@
#!/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
import sqlite3
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: str, required: str) -> bool:
order = {"free": 0, "premium": 1, "pro": 2}
return order.get(user_plan, 0) >= order.get(required, 0)
# ─── Stats ────────────────────────────────────────────────────────────────────
@api_v1_bp.route("/stats/summary", methods=["GET"])
@require_auth
def stats_summary():
"""GET /api/v1/stats/summary — résumé dashboard."""
today = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
try:
# Courses today
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
value_bets_today = (
conn.execute(
"SELECT COUNT(*) FROM ml_predictions_cache WHERE date=? AND is_value_bet=1",
(today,),
).fetchone()[0]
or 0
)
# Accuracy top3 (30 days)
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
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
# ─── Predictions ──────────────────────────────────────────────────────────────
@api_v1_bp.route("/predictions/today", methods=["GET"])
@require_auth
def predictions_today():
"""GET /api/v1/predictions/today — prédictions du jour selon le plan."""
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]
# Free plan: return only 1 race
if plan == "free":
if 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
]
# Mask value bet flag in free
for p in predictions:
p["is_value_bet"] = 0
# Premium/Pro: full predictions
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("/predictions/race/<race_label>", methods=["GET"])
@require_auth
def predictions_race(race_label):
"""GET /api/v1/predictions/race/<label> — prédictions d'une course."""
user = request.current_user
plan = user.get("plan", "free")
today = datetime.now().strftime("%Y-%m-%d")
# Parse label like R1C3 → num_reunion=1, num_course=3
import re
m = re.match(r"R(\d+)C(\d+)", race_label.upper())
if not m:
return jsonify({"error": "Format invalide, attendu: R{n}C{n}"}), 400
nr, nc = int(m.group(1)), int(m.group(2))
conn = get_db()
rows = conn.execute(
"""
SELECT * FROM ml_predictions_cache
WHERE date=? AND num_reunion=? AND num_course=?
ORDER BY ml_score DESC
""",
(today, nr, nc),
).fetchall()
conn.close()
predictions = [dict(r) for r in rows]
if plan == "free" and predictions:
# Only show first race
pass # allowed in detail view if they know the race label
return jsonify({"predictions": predictions, "count": len(predictions)}), 200
# ─── Value Bets ───────────────────────────────────────────────────────────────
@api_v1_bp.route("/value-bets/today", methods=["GET"])
@require_auth
def value_bets_today():
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
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
# ─── Export ───────────────────────────────────────────────────────────────────
@api_v1_bp.route("/export/csv", methods=["GET"])
@require_auth
def export_csv():
"""GET /api/v1/export/csv — export CSV (Pro only)."""
from flask import Response
import csv, io
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"
},
)

344
saas_auth.py Normal file
View File

@@ -0,0 +1,344 @@
#!/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, current_app
import sqlite3
import hashlib
import secrets
import os
import time
import json
from functools import wraps
from datetime import datetime
# ─── Config ───────────────────────────────────────────────────────────────────
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)
) # persist in env for prod
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")
# ─── DB helpers ───────────────────────────────────────────────────────────────
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_users_table():
"""Ensure users table exists."""
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}")
# ─── Token helpers ────────────────────────────────────────────────────────────
def generate_token(user_id: str) -> str:
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: str):
"""Returns user row dict or None."""
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: str) -> str:
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.removeprefix("Bearer ").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) -> dict:
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"),
}
# ─── Routes ───────────────────────────────────────────────────────────────────
@auth_bp.route("/register", methods=["POST"])
def register():
"""POST /api/v1/auth/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():
"""POST /api/v1/auth/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():
"""GET /api/v1/auth/me"""
return jsonify({"user": user_to_dict(request.current_user)}), 200
@auth_bp.route("/update-profile", methods=["POST"])
@require_auth
def update_profile():
"""POST /api/v1/auth/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():
"""POST /api/v1/auth/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():
"""POST /api/v1/auth/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():
"""POST /api/v1/auth/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():
"""POST /api/v1/auth/logout"""
auth = request.headers.get("Authorization", "")
token = auth.removeprefix("Bearer ").strip()
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():
"""DELETE /api/v1/auth/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