flask_app.py
100 lignes
1# ============================================================================== 2# SMARTLOCKER -- POINT D'ENTREE DE L'APPLICATION FLASK 3# ------------------------------------------------------------------------------ 4# Ce fichier est volontairement court : il se contente de 5# 1) creer l'application Flask et configurer la session / les cookies ; 6# 2) enregistrer les "blueprints" (un par domaine fonctionnel) ; 7# 3) declarer les gestionnaires d'erreurs globaux. 8# 9# Tout le code metier a ete decoupe dans des modules dedies pour faciliter la 10# lecture et la maintenance : 11# 12# extensions.py -> connexions partagees (Firebase, Stripe) + alias config 13# helpers.py -> fonctions utilitaires + decorateurs require_login/admin 14# routes_main.py -> PWA, dashboard, scan, settings, photo de profil 15# routes_auth.py -> login, signup, logout, connexion Google 16# routes_pairing.py -> appairage / dissociation des casiers, toggle Premium 17# routes_sport.py -> casier Sport (reserve Premium) 18# routes_school.py -> casier Ecole (reservation payante Stripe + webhook) 19# routes_api.py -> API JSON, notifications, historique + export CSV, FCM 20# routes_admin.py -> console d'administration 21# 22# IMPORTANT (PythonAnywhere) : le fichier WSGI importe `app` depuis ce 23# module (`from flask_app import app`). On garde donc bien la variable 24# `app` exposee ici -- ne pas la renommer. 25# ============================================================================== 26 27import os 28from datetime import timedelta 29 30from flask import Flask 31 32import config 33 34# Les blueprints (un module = un domaine fonctionnel) 35from routes_main import main_bp 36from routes_auth import auth_bp 37from routes_pairing import pairing_bp 38from routes_sport import sport_bp 39from routes_school import school_bp 40from routes_api import api_bp 41from routes_admin import admin_bp 42 43 44# ============================================================================== 45# CREATION & CONFIGURATION DE L'APPLICATION 46# ============================================================================== 47 48app = Flask(__name__) 49app.secret_key = config.SECRET_KEY 50 51# Securite cookies de session 52# SESSION_COOKIE_SECURE=True en production HTTPS (PythonAnywhere) 53# Desactive automatiquement en debug (localhost http) via FLASK_ENV 54_is_prod = os.environ.get('FLASK_ENV', 'production') != 'development' 55app.config.update( 56 SESSION_COOKIE_HTTPONLY=True, 57 SESSION_COOKIE_SAMESITE='Lax', 58 SESSION_COOKIE_SECURE=_is_prod, 59 PERMANENT_SESSION_LIFETIME=timedelta(days=7), 60 MAX_CONTENT_LENGTH=8 * 1024 * 1024, # 8MB max upload (photos profil) 61) 62 63 64# ============================================================================== 65# ENREGISTREMENT DES BLUEPRINTS 66# ============================================================================== 67 68app.register_blueprint(main_bp) 69app.register_blueprint(auth_bp) 70app.register_blueprint(pairing_bp) 71app.register_blueprint(sport_bp) 72app.register_blueprint(school_bp) 73app.register_blueprint(api_bp) 74app.register_blueprint(admin_bp) 75 76 77# ============================================================================== 78# GESTIONNAIRES D'ERREURS 79# ============================================================================== 80 81@app.errorhandler(403) 82def forbidden(e): 83 return ("<h1>403 -- Acces refuse</h1>" 84 "<p>Vous n'avez pas les permissions pour acceder a cette page.</p>" 85 "<a href='/'>Retour a l'accueil</a>"), 403 86 87 88@app.errorhandler(404) 89def not_found(e): 90 return ("<h1>404 -- Page introuvable</h1>" 91 "<p>La page demandee n'existe pas.</p>" 92 "<a href='/'>Retour a l'accueil</a>"), 404 93 94 95@app.errorhandler(500) 96def server_error(e): 97 print("[500] {}".format(e)) 98 return ("<h1>500 -- Erreur serveur</h1>" 99 "<p>Une erreur interne est survenue. Reessayez plus tard.</p>" 100 "<a href='/'>Retour a l'accueil</a>"), 500
extensions.py
59 lignes
1# ══════════════════════════════════════════════════════════════════════════════ 2# EXTENSIONS — Initialisation des services partagés (Firebase, Stripe) 3# ────────────────────────────────────────────────────────────────────────────── 4# Ce module centralise la connexion aux services externes pour qu'ils soient 5# importés une seule fois et réutilisés partout (helpers + blueprints), sans 6# dépendance circulaire avec flask_app.py. 7# 8# Usage dans les autres fichiers : 9# from extensions import db, auth, storage, admin_db, stripe 10# from extensions import LOCKERS, LOCKER_CODES, SCHOOL_HOURS, PRICE_PER_HOUR 11# ══════════════════════════════════════════════════════════════════════════════ 12 13import os 14import pyrebase 15import stripe 16 17import config 18 19 20# ── Stripe ──────────────────────────────────────────────────────────────────── 21stripe.api_key = config.STRIPE_SECRET_KEY 22 23 24# ── Firebase ────────────────────────────────────────────────────────────────── 25firebase = pyrebase.initialize_app(config.FIREBASE_CONFIG) 26auth = firebase.auth() 27db = firebase.database() 28storage = firebase.storage() 29 30 31# ── Firebase Admin SDK (optionnel, pour le webhook Stripe) ──────────────────── 32# Si la clé service-account est présente, on l'utilise pour écrire dans 33# Firebase depuis le webhook Stripe (qui n'a pas de session utilisateur). 34# Sinon on retombe sur pyrebase (nécessite des règles permissives). 35admin_db = None 36try: 37 import firebase_admin 38 from firebase_admin import credentials as _fa_creds, db as _fa_db 39 _cred_path = os.path.join(os.path.dirname(__file__), 'cle-firebase.json') 40 if os.path.exists(_cred_path): 41 _cred = _fa_creds.Certificate(_cred_path) 42 if not firebase_admin._apps: 43 firebase_admin.initialize_app(_cred, { 44 'databaseURL': config.FIREBASE_CONFIG['databaseURL'] 45 }) 46 admin_db = _fa_db 47 print("[firebase-admin] OK — webhook Stripe utilisera firebase-admin") 48 else: 49 print("[firebase-admin] cle-firebase.json introuvable — webhook utilisera pyrebase") 50except ImportError: 51 print("[firebase-admin] module non installé — webhook utilisera pyrebase") 52 53 54# ── Alias de config pour éviter les longs config.xxx partout ────────────────── 55LOCKERS = config.LOCKERS 56LOCKER_CODES = config.LOCKER_CODES 57SCHOOL_HOURS = config.SCHOOL_HOURS 58PRICE_PER_HOUR = config.PRICE_PER_HOUR 59STRIPE_PUB_KEY = config.STRIPE_PUBLISHABLE_KEY
helpers.py
376 lignes
1# ══════════════════════════════════════════════════════════════════════════════ 2# HELPERS — Fonctions utilitaires & décorateurs partagés 3# ────────────────────────────────────────────────────────────────────────────── 4# Logique métier réutilisée par plusieurs routes : contexte utilisateur, état 5# des casiers (Firebase), journalisation, index des propriétaires, détection 6# en-ligne (heartbeat Pi) et décorateurs d'accès require_login / require_admin. 7# Aucune route Flask ici : seulement des fonctions importées par les blueprints. 8# ══════════════════════════════════════════════════════════════════════════════ 9 10import os 11import time 12import tempfile 13from datetime import datetime 14from functools import wraps 15 16from flask import session, redirect, url_for, abort 17 18import config 19from extensions import db, auth, storage, LOCKERS 20 21OFFLINE_THRESHOLD_SEC = 90 # > 3 heartbeats manqués (1/30s) ⇒ casier hors-ligne 22 23 24# ── Petits utilitaires partagés ─────────────────────────────────────────────── 25 26def is_admin(email): 27 """True si l'email fait partie des administrateurs (config.ADMIN_EMAILS).""" 28 return (email or "").lower() in [e.lower() for e in config.ADMIN_EMAILS] 29 30 31def lockdown_message(): 32 """Message utilisateur standard affiché quand un casier est en lockdown.""" 33 admin = config.ADMIN_EMAILS[0] if config.ADMIN_EMAILS else "" 34 msg = "Intrusion detectee : un administrateur a ete alerte et intervient. Veuillez patienter." 35 if admin: 36 msg += " En cas d'urgence, contactez l'administrateur a l'adresse {}.".format(admin) 37 return msg 38 39 40def store_profile_image(data_bytes, local_id, token): 41 """Écrit les octets image dans Storage (profile_images/{uid}.jpg) et retourne 42 l'URL publique. Gère le fichier temporaire (création + nettoyage garanti).""" 43 tmp_path = None 44 try: 45 with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp: 46 tmp.write(data_bytes) 47 tmp_path = tmp.name 48 path = "profile_images/{}.jpg".format(local_id) 49 storage.child(path).put(tmp_path) 50 return storage.child(path).get_url(token) 51 finally: 52 if tmp_path and os.path.exists(tmp_path): 53 try: 54 os.remove(tmp_path) 55 except Exception: 56 pass 57 58 59def _normalize_paired(user_data): 60 """Lit la clé 'paired' (fallback legacy 'paired_lockers') ⇒ toujours un dict.""" 61 paired = user_data.get("paired", user_data.get("paired_lockers", {})) 62 if isinstance(paired, bool): # très ancien format booléen 63 return {"sport": True, "school": True} if paired else {} 64 return paired if isinstance(paired, dict) else {} 65 66 67def _is_online(data, now=None): 68 """(online: bool, secondes_depuis_dernier_heartbeat | None) d'après le Pi casier.""" 69 now = now or time.time() 70 last_raw = data.get("last_heartbeat", data.get("casier_derniere_activite", "")) 71 status_raw = (data.get("online_status", data.get("casier_statut", "")) or "").lower() 72 if not last_raw: 73 return False, None 74 try: 75 seconds = int(now - datetime.fromisoformat(last_raw).timestamp()) 76 except Exception: 77 return False, None 78 online = seconds <= OFFLINE_THRESHOLD_SEC and status_raw not in ("offline", "hors_ligne") 79 return online, seconds 80 81 82# ══════════════════════════════════════════════════════════════════════════════ 83# CONTEXTE UTILISATEUR & ÉTAT DES CASIERS 84# ══════════════════════════════════════════════════════════════════════════════ 85 86def get_user_context(token): 87 """Récupère les infos de l'utilisateur et enrichit la session.""" 88 user_info = auth.get_account_info(token)['users'][0] 89 local_id = user_info['localId'] 90 providers = [p['providerId'] for p in user_info.get('providerUserInfo', [])] 91 user_data = db.child("users").child(local_id).get(token).val() or {} 92 photo_url = session.get('photo') or \ 93 "https://ui-avatars.com/api/?name={}&background=00ff9d&color=000".format( 94 session.get('pseudo', 'U')) 95 return { 96 'local_id': local_id, 97 'user_info': user_info, 98 'user_data': user_data, 99 'is_google': 'google.com' in providers, 100 'photo_url': photo_url, 101 'is_verified': user_info.get('emailVerified', False), 102 'is_premium': user_data.get('premium', False), 103 'email': user_info.get('email', session.get('email', '')), 104 'paired': _normalize_paired(user_data), 105 } 106 107 108def get_locker_state(locker_key, token, current_user_id=None): 109 """ 110 Retourne l'état du casier en gérant automatiquement : 111 - expiration d'une session (unlocked → locked) 112 - ouverture automatique à l'heure prévue (reserved → unlocked) 113 """ 114 lid = LOCKERS[locker_key]["id"] 115 data = db.child("lockers").child(lid).get(token).val() or {} 116 status = data.get("status", "locked") 117 session_data = data.get("active_session") or {} 118 if isinstance(session_data, str): 119 session_data = {} 120 now = time.time() 121 122 def _expire(): 123 """Ferme la session expirée, l'archive dans l'historique et libère le casier.""" 124 db.child("lockers").child(lid).update({"status": "locked", "active_session": None}, token) 125 _log_session(locker_key, lid, session_data, token, "expired") 126 _unpair_after_session(locker_key, token, session_data.get("user_id")) 127 return {"status": "locked", "session": {}} 128 129 # 1) Unlocked mais session expirée → fermer + libérer le casier du compte 130 if session_data and status == "unlocked": 131 expires_at = session_data.get("expires_at", 0) 132 if expires_at and now > expires_at: 133 return _expire() 134 135 # 2) Reserved et l'heure est arrivée → passer à unlocked (ou expirer si dépassé) 136 if session_data and status == "reserved": 137 started_at = session_data.get("started_at", 0) 138 expires_at = session_data.get("expires_at", 0) 139 if started_at and now >= started_at and now < expires_at: 140 db.child("lockers").child(lid).update({"status": "unlocked"}, token) 141 status = "unlocked" 142 elif expires_at and now >= expires_at: # réservation passée sans ouverture 143 return _expire() 144 145 # 3) Lockdown (intrusion : un admin doit intervenir) 146 lockdown_until = data.get("lockdown_until", 0) 147 lockdown_active = lockdown_until > now 148 lockdown_remaining = max(0, int(lockdown_until - now)) if lockdown_active else 0 149 150 # 4) En-ligne / hors-ligne (Pi du casier allumé ?) 151 online, seconds_since_seen = _is_online(data, now) 152 153 return { 154 "status": status, 155 "session": session_data, 156 "physical_state": data.get("door_physical", data.get("etat_porte_physique", "Inconnue")), 157 "alert": data.get("alert_active", data.get("alerte_active", "")), 158 "alert_time": data.get("alert_time", data.get("heure_alerte", "")), 159 "lockdown": lockdown_active, 160 "lockdown_remaining": lockdown_remaining, 161 "lockdown_reason": data.get("lockdown_reason", "") if lockdown_active else "", 162 "online": online, 163 "last_seen_sec": seconds_since_seen, 164 "last_seen_raw": data.get("last_heartbeat", data.get("casier_derniere_activite", "")), 165 "reminder": data.get("reminder_active", ""), # porte ouverte trop longtemps 166 "reminder_time": data.get("reminder_time", ""), 167 "object_alert": data.get("object_alert_active", ""), # objet retiré (info) 168 "object_alert_time": data.get("object_alert_time", ""), 169 } 170 171 172def _log_session(locker_key, lid, sess, token, reason="closed"): 173 """Écrit une entrée dans l'historique utilisateur (users/{uid}/history/).""" 174 uid = (sess or {}).get("user_id") 175 if not uid: 176 return 177 start = sess.get("started_at", 0) 178 end = time.time() 179 try: 180 db.child("users").child(uid).child("history").push({ 181 "locker_key": locker_key, 182 "locker_id": lid, 183 "started_at": start, 184 "ended_at": end, 185 "duration_min": round((end - start) / 60), 186 "amount_eur": sess.get("amount_eur", 0), 187 "reason": reason, 188 }, token) 189 except Exception as e: 190 print("[_log_session] {}".format(e)) 191 192 193def _unpair_after_session(locker_key, token, user_id): 194 """ 195 Retire un casier du compte de l'utilisateur à la fin d'une session : supprime 196 locker_key de users/{uid}/paired ET vide l'index propriétaire, libérant le 197 casier pour le prochain utilisateur. Idempotent et tolérant aux erreurs. 198 """ 199 if not user_id: 200 return 201 try: 202 user_data = db.child("users").child(user_id).get(token).val() or {} 203 paired = _normalize_paired(user_data if isinstance(user_data, dict) else {}) 204 if locker_key in paired: 205 del paired[locker_key] 206 db.child("users").child(user_id).update({"paired": paired}, token) 207 clear_locker_owner(locker_key, token) # no-op si déjà vide 208 except Exception as e: 209 print("[_unpair_after_session] {}".format(e)) 210 211 212def get_user_stats(local_id, token): 213 """Calcule les stats d'usage depuis l'historique utilisateur.""" 214 try: 215 raw = db.child("users").child(local_id).child("history").get(token).val() or {} 216 if not isinstance(raw, dict): 217 raw = {} 218 now = time.time() 219 month_start = now - 30 * 86400 220 day_start_today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp() 221 sessions_month = total_minutes = 0 222 total_spent = 0.0 223 days_7 = [0] * 7 # index 0 = aujourd'hui … 6 = il y a 6 jours 224 225 for v in raw.values(): 226 if not isinstance(v, dict): 227 continue 228 started = v.get('started_at', 0) or 0 229 duration = v.get('duration_min', 0) or 0 230 if started >= month_start: 231 sessions_month += 1 232 total_minutes += duration 233 try: 234 total_spent += float(v.get('amount_eur', 0) or 0) 235 except (TypeError, ValueError): 236 pass 237 if started > 0: 238 diff_days = int((day_start_today - started) / 86400) 239 if 0 <= diff_days < 7: 240 days_7[diff_days] += duration 241 242 return { 243 'sessions_month': sessions_month, 244 'total_minutes': total_minutes, 245 'total_spent': round(total_spent, 2), 246 'usage_7days': list(reversed(days_7)), # plus ancien → plus récent 247 } 248 except Exception as e: 249 print("[get_user_stats] {}".format(e)) 250 return {'sessions_month': 0, 'total_minutes': 0, 'total_spent': 0.0, 'usage_7days': [0]*7} 251 252 253def _get_booked_slots(token, for_user_id=None): 254 """Retourne la liste des créneaux occupés (format YYYY-MM-DD-HH:00).""" 255 try: 256 lid = LOCKERS["school"]["id"] 257 data = db.child("lockers").child(lid).get(token).val() or {} 258 sess = data.get("active_session") or {} 259 if isinstance(sess, str): 260 sess = {} 261 if not (sess and sess.get("expires_at", 0) > time.time()): 262 return [] 263 booked = [] 264 cur, end = sess.get("started_at", 0), sess.get("expires_at", 0) 265 while cur < end: 266 dt = datetime.fromtimestamp(cur) 267 booked.append("{}-{:02d}:00".format(dt.strftime("%Y-%m-%d"), dt.hour)) 268 cur += 3600 269 return booked 270 except Exception as e: 271 print("[_get_booked_slots] {}".format(e)) 272 return [] 273 274 275def _maybe_refresh_token(): 276 """Rafraîchit le token Firebase (durée 1h) toutes les 50 min s'il y a un refresh_token.""" 277 refresh_tok = session.get('refresh_token') 278 if not refresh_tok or time.time() - session.get('token_refreshed_at', 0) < 50 * 60: 279 return 280 try: 281 new_data = auth.refresh(refresh_tok) 282 session['user'] = new_data['idToken'] 283 session['refresh_token'] = new_data['refreshToken'] 284 session['token_refreshed_at'] = time.time() 285 except Exception as e: 286 # Si le refresh échoue, on laisse la requête suivre son cours (l'utilisateur 287 # sera redirigé naturellement si le token est invalide). 288 print("[token_refresh] {}".format(e)) 289 290 291# ══════════════════════════════════════════════════════════════════════════════ 292# INDEX CASIERS — /lockers_index/{key} = uid du propriétaire (lecture O(1)) 293# ══════════════════════════════════════════════════════════════════════════════ 294 295def get_locker_owner_uid(locker_key, token): 296 """Retourne l'uid du propriétaire d'un casier (ou None).""" 297 try: 298 return db.child("lockers_index").child(locker_key).get(token).val() or None 299 except Exception: 300 return None 301 302 303def set_locker_owner_uid(locker_key, uid, token): 304 """Définit le propriétaire d'un casier dans l'index.""" 305 try: 306 db.child("lockers_index").child(locker_key).set(uid, token) 307 except Exception as e: 308 print("[set_locker_owner_uid] {}".format(e)) 309 310 311def clear_locker_owner(locker_key, token): 312 """Supprime l'entrée de l'index quand un casier est dissocié.""" 313 try: 314 db.child("lockers_index").child(locker_key).remove(token) 315 except Exception as e: 316 print("[clear_locker_owner] {}".format(e)) 317 318 319def _auto_start_sport_session(token, local_id, user_email): 320 """ 321 Démarre une session sport après appairage : le casier passe en 'unlocked' 322 (session active) mais la porte ne s'ouvre PAS physiquement — pas de 323 cmd_open_door ici. L'ouverture reste déclenchée par le bouton OUVRIR LA PORTE. 324 Aucune action si le casier est déjà en session. 325 """ 326 try: 327 lid = LOCKERS["sport"]["id"] 328 current = db.child("lockers").child(lid).get(token).val() or {} 329 if current.get("status") in ("unlocked", "reserved"): 330 return 331 db.child("lockers").child(lid).update({ 332 "status": "unlocked", 333 "active_session": { 334 "user_id": local_id, 335 "user_email": user_email, 336 "started_at": time.time(), 337 "expires_at": 0, 338 "amount_eur": 0, 339 }, 340 }, token) 341 except Exception as e: 342 print("[_auto_start_sport_session] {}".format(e)) 343 344 345def _is_locker_online(locker_key, token): 346 """True si le Pi du casier a envoyé un heartbeat il y a moins de 90 s.""" 347 try: 348 data = db.child("lockers").child(LOCKERS[locker_key]["id"]).get(token).val() or {} 349 return _is_online(data)[0] if isinstance(data, dict) else False 350 except Exception: 351 return False 352 353 354# ══════════════════════════════════════════════════════════════════════════════ 355# DÉCORATEURS D'ACCÈS 356# ══════════════════════════════════════════════════════════════════════════════ 357 358def require_login(f): 359 @wraps(f) 360 def decorated(*args, **kwargs): 361 if 'user' not in session: 362 return redirect(url_for('main.home')) 363 _maybe_refresh_token() 364 return f(*args, **kwargs) 365 return decorated 366 367 368def require_admin(f): 369 @wraps(f) 370 def decorated(*args, **kwargs): 371 if 'user' not in session: 372 return redirect(url_for('main.home')) 373 if not is_admin(session.get('email')): 374 abort(403) 375 return f(*args, **kwargs) 376 return decorated
routes_main.py
203 lignes
1# ══════════════════════════════════════════════════════════════════════════════ 2# ROUTES — PRINCIPALES (PWA, Dashboard, Scan, Settings, Profil) 3# ────────────────────────────────────────────────────────────────────────────── 4# Regroupe : 5# • Les fichiers de la PWA (service-worker.js, manifest.json, FCM SW) 6# • Le tableau de bord (page d'accueil) 7# • Le scanner de QR code 8# • Les paramètres du compte + mise à jour de la photo de profil 9# ══════════════════════════════════════════════════════════════════════════════ 10 11import base64 12 13from flask import ( 14 Blueprint, render_template, request, redirect, session, url_for, 15 flash, send_from_directory 16) 17 18from extensions import db, auth, LOCKERS 19from helpers import ( 20 get_user_context, get_locker_state, get_user_stats, 21 _maybe_refresh_token, require_login, is_admin, store_profile_image, 22) 23 24main_bp = Blueprint('main', __name__) 25 26 27# ══════════════════════════════════════════════════════════════════════════════ 28# ROUTES STATIQUES (PWA) 29# ══════════════════════════════════════════════════════════════════════════════ 30 31@main_bp.route('/service-worker.js') 32def service_worker(): 33 r = send_from_directory('static', 'service-worker.js') 34 r.headers['Content-Type'] = 'application/javascript' 35 r.headers['Service-Worker-Allowed'] = '/' 36 r.headers['Cache-Control'] = 'no-cache' 37 return r 38 39 40@main_bp.route('/manifest.json') 41def manifest(): 42 return send_from_directory('static', 'manifest.json') 43 44 45@main_bp.route('/firebase-messaging-sw.js') 46def firebase_service_worker(): 47 return send_from_directory('static', 'firebase-messaging-sw.js', mimetype='application/javascript') 48 49 50# Cache-Control agressif sur les icones (enregistré au niveau de l'app entière) 51@main_bp.after_app_request 52def add_cache_headers(response): 53 if request.path.startswith('/static/icons/'): 54 response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' 55 return response 56 57 58# ══════════════════════════════════════════════════════════════════════════════ 59# DASHBOARD 60# ══════════════════════════════════════════════════════════════════════════════ 61 62@main_bp.route('/') 63def home(): 64 if 'user' not in session: 65 return render_template('index.html', logged_in=False) 66 try: 67 # Refresh token si proche de l'expiration 68 _maybe_refresh_token() 69 70 # Si un code d'appairage est en attente (depuis un scan QR avant login) 71 if session.get('pending_pair_code'): 72 return redirect(url_for('pairing.pair_locker_from_qr')) 73 74 token = session['user'] 75 ctx = get_user_context(token) 76 77 # ── Redirection admin direct ────────────────────────────── 78 if is_admin(session.get('email')): 79 return redirect(url_for('admin.admin_panel')) 80 81 sport_state = get_locker_state("sport", token, ctx['local_id']) 82 school_state = get_locker_state("school", token, ctx['local_id']) 83 stats = get_user_stats(ctx['local_id'], token) 84 return render_template('index.html', 85 logged_in=True, 86 email=session.get('email'), 87 pseudo=session.get('pseudo'), 88 photo=ctx['photo_url'], 89 is_verified=ctx['is_verified'], 90 is_premium=ctx['is_premium'], 91 is_admin=False, 92 paired_lockers=ctx['paired'], 93 sport_state=sport_state, 94 school_state=school_state, 95 lockers=LOCKERS, 96 stats=stats) 97 except Exception as e: 98 print("[home] {}".format(e)) 99 session.clear() 100 return redirect(url_for('main.home')) 101 102 103# ══════════════════════════════════════════════════════════════════════════════ 104# SCANNER QR 105# ══════════════════════════════════════════════════════════════════════════════ 106 107@main_bp.route('/scan') 108@require_login 109def scan_page(): 110 return render_template('scan.html') 111 112 113# ══════════════════════════════════════════════════════════════════════════════ 114# SETTINGS 115# ══════════════════════════════════════════════════════════════════════════════ 116 117@main_bp.route('/settings') 118@require_login 119def settings(): 120 try: 121 token = session['user'] 122 ctx = get_user_context(token) 123 return render_template('settings.html', 124 email=session.get('email'), 125 pseudo=session.get('pseudo'), 126 photo=ctx['photo_url'], 127 is_google=ctx['is_google'], 128 is_verified=ctx['is_verified'], 129 is_premium=ctx['is_premium'], 130 is_admin=is_admin(ctx['email']), 131 paired_lockers=ctx['paired'], 132 lockers=LOCKERS) 133 except Exception as e: 134 print("[settings] {}".format(e)) 135 session.clear() 136 return redirect(url_for('main.home')) 137 138 139@main_bp.route('/update_settings', methods=['POST']) 140@require_login 141def update_settings(): 142 token = session['user'] 143 new_pseudo = (request.form.get('new_pseudo', '') or '').strip() 144 new_email = (request.form.get('new_email', '') or '').strip().lower() 145 new_pass = (request.form.get('new_password', '') or '').strip() 146 try: 147 ctx = get_user_context(token) 148 local_id = ctx['local_id'] 149 if new_pseudo: 150 if len(new_pseudo) < 2 or len(new_pseudo) > 30: 151 flash("Pseudo invalide (2 à 30 caractères).") 152 return redirect(url_for('main.settings')) 153 db.child("users").child(local_id).update({"pseudo": new_pseudo}, token) 154 session['pseudo'] = new_pseudo 155 if not ctx['is_google'] and new_email and new_email != session.get('email'): 156 if '@' not in new_email or '.' not in new_email.split('@')[-1]: 157 flash("Email invalide.") 158 return redirect(url_for('main.settings')) 159 auth.update_email(token, new_email) 160 auth.send_email_verification(token) 161 session['email'] = new_email 162 if new_pass: 163 if len(new_pass) < 6: 164 flash("Le nouveau mot de passe doit contenir au moins 6 caractères.") 165 return redirect(url_for('main.settings')) 166 auth.change_password(token, new_pass) 167 flash("Profil mis a jour !") 168 except Exception as e: 169 print("[update_settings] {}".format(e)) 170 flash("Erreur lors de la mise a jour.") 171 return redirect(url_for('main.settings')) 172 173 174@main_bp.route('/update_photo', methods=['POST']) 175@require_login 176def update_photo(): 177 token = session['user'] 178 photo_b64 = request.form.get('photo_b64') 179 if not photo_b64: 180 return redirect(url_for('main.settings')) 181 try: 182 # Validation du format data-URL 183 if ',' not in photo_b64 or not photo_b64.startswith('data:image/'): 184 flash("Format d'image invalide.") 185 return redirect(url_for('main.settings')) 186 try: 187 data = base64.b64decode(photo_b64.split(",", 1)[1]) 188 except Exception: 189 flash("Image corrompue.") 190 return redirect(url_for('main.settings')) 191 # Limite taille (env. 8 Mo de base64 décodé) 192 if len(data) > 8 * 1024 * 1024: 193 flash("Image trop lourde (max 8 Mo).") 194 return redirect(url_for('main.settings')) 195 local_id = auth.get_account_info(token)['users'][0]['localId'] 196 photo_url = store_profile_image(data, local_id, token) 197 db.child("users").child(local_id).update({"photoURL": photo_url}, token) 198 session['photo'] = photo_url 199 flash("Photo mise a jour !") 200 except Exception as e: 201 print("[update_photo] {}".format(e)) 202 flash("Erreur lors de l'upload.") 203 return redirect(url_for('main.settings'))
routes_auth.py
145 lignes
1# ══════════════════════════════════════════════════════════════════════════════ 2# ROUTES — AUTHENTIFICATION 3# ────────────────────────────────────────────────────────────────────────────── 4# Connexion email/mot de passe, inscription, déconnexion et connexion Google. 5# Toutes ces routes ouvrent / ferment la session Flask et n'exigent PAS d'être 6# déjà connecté (sauf logout qui se contente de vider la session). 7# ══════════════════════════════════════════════════════════════════════════════ 8 9import time 10import base64 11 12from flask import Blueprint, request, redirect, session, url_for, flash 13 14from extensions import db, auth 15from helpers import store_profile_image 16 17auth_bp = Blueprint('auth', __name__) 18 19 20@auth_bp.route('/login', methods=['POST']) 21def login(): 22 email = (request.form.get('email') or '').strip().lower() 23 password = request.form.get('password') or '' 24 if not email or not password: 25 flash("Email et mot de passe requis.") 26 return redirect(url_for('main.home')) 27 try: 28 user = auth.sign_in_with_email_and_password(email, password) 29 token = user['idToken'] 30 session.permanent = True 31 session['user'] = token 32 session['refresh_token'] = user.get('refreshToken', '') 33 session['token_refreshed_at'] = time.time() 34 session['email'] = email 35 user_db = db.child("users").child(user['localId']).get(token).val() or {} 36 session['pseudo'] = user_db.get('pseudo', user_db.get('username', email.split('@')[0])) 37 session['photo'] = user_db.get('photoURL', '') 38 return redirect(url_for('main.home')) 39 except Exception as e: 40 print("[login] {}".format(e)) 41 flash("Identifiants incorrects.") 42 return redirect(url_for('main.home')) 43 44 45@auth_bp.route('/signup', methods=['POST']) 46def signup(): 47 email = (request.form.get('email') or '').strip().lower() 48 password = request.form.get('password') or '' 49 pseudo = (request.form.get('pseudo') or '').strip() 50 photo_b64 = request.form.get('photo_b64', '') 51 # Validation côté serveur 52 if not email or '@' not in email or '.' not in email.split('@')[-1]: 53 flash("Adresse email invalide.") 54 return redirect(url_for('main.home')) 55 if len(password) < 6: 56 flash("Le mot de passe doit contenir au moins 6 caractères.") 57 return redirect(url_for('main.home')) 58 if not pseudo or len(pseudo) < 2 or len(pseudo) > 30: 59 flash("Pseudo invalide (2 à 30 caractères).") 60 return redirect(url_for('main.home')) 61 try: 62 user = auth.create_user_with_email_and_password(email, password) 63 token = user['idToken'] 64 local_id = user['localId'] 65 auth.send_email_verification(token) 66 photo_url = '' 67 if photo_b64: 68 try: 69 data = base64.b64decode(photo_b64.split(",", 1)[1]) 70 photo_url = store_profile_image(data, local_id, token) 71 except Exception as e: 72 print("[signup photo] {}".format(e)) 73 photo_url = '' 74 db.child("users").child(local_id).set({ 75 "pseudo": pseudo, 76 "email": email, 77 "photoURL": photo_url, 78 "premium": False, 79 "paired": {}, 80 "created_at": time.time(), 81 }, token) 82 session.permanent = True 83 session['user'] = token 84 session['refresh_token'] = user.get('refreshToken', '') 85 session['token_refreshed_at'] = time.time() 86 session['email'] = email 87 session['pseudo'] = pseudo 88 session['photo'] = photo_url 89 flash("Compte cree ! Verifiez vos emails.") 90 return redirect(url_for('main.home')) 91 except Exception as e: 92 print("[signup] {}".format(e)) 93 flash("Erreur d'inscription.") 94 return redirect(url_for('main.home')) 95 96 97@auth_bp.route('/logout') 98def logout(): 99 """Deconnecte l'utilisateur en vidant la session Flask.""" 100 session.clear() 101 flash("Vous avez ete deconnecte.") 102 return redirect(url_for('main.home')) 103 104 105@auth_bp.route('/google_login', methods=['POST']) 106def google_login(): 107 """Connexion via Google : recoit l'idToken Firebase issu du popup Google 108 cote client (Firebase Auth) et ouvre la session Flask.""" 109 id_token = (request.form.get('idToken') or '').strip() 110 email = (request.form.get('email') or '').strip().lower() 111 name = (request.form.get('name') or '').strip() 112 photo = (request.form.get('photo') or '').strip() 113 if not id_token or not email: 114 flash("Connexion Google echouee : informations manquantes.") 115 return redirect(url_for('main.home')) 116 try: 117 # On recupere les infos du compte via le token recu du client 118 info = auth.get_account_info(id_token)['users'][0] 119 local_id = info['localId'] 120 121 # Cree ou met a jour le profil dans la DB 122 existing = db.child("users").child(local_id).get(id_token).val() or {} 123 user_data = { 124 "email": email, 125 "pseudo": existing.get('pseudo', name or email.split('@')[0]), 126 "photoURL": existing.get('photoURL', photo), 127 "premium": existing.get('premium', False), 128 "paired": existing.get('paired', existing.get('paired_lockers', {})), 129 } 130 if 'created_at' not in existing: 131 user_data['created_at'] = time.time() 132 db.child("users").child(local_id).update(user_data, id_token) 133 134 # Ouvre la session 135 session.permanent = True 136 session['user'] = id_token 137 session['refresh_token'] = '' 138 session['token_refreshed_at'] = time.time() 139 session['email'] = email 140 session['pseudo'] = user_data['pseudo'] 141 session['photo'] = user_data['photoURL'] 142 except Exception as e: 143 print("[google_login] {}".format(e)) 144 flash("Erreur de connexion Google.") 145 return redirect(url_for('main.home'))
routes_pairing.py
153 lignes
1# ══════════════════════════════════════════════════════════════════════════════ 2# ROUTES — APPAIRAGE / DISSOCIATION DES CASIERS 3# ────────────────────────────────────────────────────────────────────────────── 4# Appairage d'un casier à un compte (via QR scanné nativement ou code saisi), 5# dissociation, et le bouton de démonstration Premium pour le jury du TFE. 6# 7# Règle métier : 1 seul casier appairé par utilisateur. L'appairage vérifie que 8# le casier n'est pas déjà pris (index O(1)) et qu'il est en ligne. 9# ══════════════════════════════════════════════════════════════════════════════ 10 11from flask import Blueprint, request, redirect, session, url_for, flash 12 13from extensions import db, LOCKERS, LOCKER_CODES 14from helpers import ( 15 get_user_context, require_login, 16 get_locker_owner_uid, set_locker_owner_uid, clear_locker_owner, 17 _auto_start_sport_session, _is_locker_online, 18) 19 20pairing_bp = Blueprint('pairing', __name__) 21 22 23def _attempt_pair(token, code): 24 """ 25 Tente d'appairer le casier identifié par `code` au compte courant et retourne 26 le message flash à afficher. Centralise toute la logique partagée entre 27 l'appairage par QR natif et par code saisi (1 seul casier/utilisateur, casier 28 non déjà pris, casier en ligne). 29 """ 30 ctx = get_user_context(token) 31 local_id = ctx['local_id'] 32 locker_key = LOCKER_CODES.get(code) 33 paired = ctx['paired'] 34 35 if not locker_key: 36 return "Code invalide. Verifiez le code inscrit sur votre casier." 37 if locker_key in paired: 38 return "Ce casier est deja appaire a votre compte." 39 if paired: 40 return "Vous avez deja un casier appaire. Un seul casier par utilisateur est autorise." 41 42 # Casier déjà pris par un autre utilisateur (O(1) via index) 43 owner_uid = get_locker_owner_uid(locker_key, token) 44 if owner_uid and owner_uid != local_id: 45 owner_data = db.child("users").child(owner_uid).get(token).val() or {} 46 owner_email = owner_data.get('email', 'un autre utilisateur') 47 return "Ce casier est deja utilise par le compte {}.".format(owner_email) 48 49 # Casier en ligne (Raspberry Pi allumé) ? 50 if not _is_locker_online(locker_key, token): 51 return ("Casier hors-ligne : impossible d'appairer un casier eteint ou deconnecte. " 52 "Verifiez qu'il soit allume puis reessayez.") 53 54 paired[locker_key] = True 55 db.child("users").child(local_id).update({"paired": paired}, token) 56 set_locker_owner_uid(locker_key, local_id, token) 57 # Démarrage auto de la session sport si premium 58 if locker_key == "sport" and ctx['is_premium']: 59 _auto_start_sport_session(token, local_id, ctx['email']) 60 return "Casier appaire avec succes !" 61 62 63@pairing_bp.route('/pair') 64def pair_redirect(): 65 """Redirige depuis un QR code scanné avec la caméra native du téléphone.""" 66 code = (request.args.get('code', '') or '').strip().upper() 67 if not code: 68 flash("Code de casier manquant.") 69 return redirect(url_for('main.home')) 70 # Mémoriser le code — sera traité après login si nécessaire 71 session['pending_pair_code'] = code 72 if 'user' not in session: 73 flash("Connectez-vous d'abord pour appairer ce casier.") 74 return redirect(url_for('main.home')) 75 return redirect(url_for('pairing.pair_locker_from_qr')) 76 77 78@pairing_bp.route('/pair_qr') 79@require_login 80def pair_locker_from_qr(): 81 """Traite l'appairage depuis un QR code scanné nativement.""" 82 code = session.pop('pending_pair_code', '') 83 if not code: 84 return redirect(url_for('main.home')) 85 try: 86 flash(_attempt_pair(session['user'], code)) 87 except Exception as e: 88 print("[pair_locker_from_qr] {}".format(e)) 89 flash("Erreur lors de l'appairage.") 90 return redirect(url_for('main.home')) 91 92 93@pairing_bp.route('/pair_locker', methods=['POST']) 94@require_login 95def pair_locker(): 96 """Traite l'appairage depuis le code saisi manuellement (formulaire).""" 97 code = request.form.get('locker_code', '').strip().upper() 98 try: 99 flash(_attempt_pair(session['user'], code)) 100 except Exception as e: 101 print("[pair_locker] {}".format(e)) 102 flash("Erreur lors de l'appairage.") 103 return redirect(url_for('main.home')) 104 105 106@pairing_bp.route('/unpair_locker', methods=['POST']) 107@require_login 108def unpair_locker(): 109 token = session['user'] 110 try: 111 ctx = get_user_context(token) 112 local_id = ctx['local_id'] 113 locker_key = request.form.get('locker_key', '') 114 if locker_key not in LOCKERS: 115 flash("Casier inconnu.") 116 return redirect(url_for('main.settings')) 117 118 # Impossible de dissocier si une session est active 119 locker_id = LOCKERS[locker_key]['id'] 120 lstatus = db.child("lockers").child(locker_id).child("status").get(token).val() 121 if lstatus and lstatus != 'locked': 122 flash("Impossible de dissocier : le casier est actuellement utilise ou reserve. Veuillez terminer la session d'abord.") 123 return redirect(url_for('main.settings')) 124 125 paired = ctx['paired'] 126 paired.pop(locker_key, None) 127 db.child("users").child(local_id).update({"paired": paired}, token) 128 clear_locker_owner(locker_key, token) 129 flash("Casier '{}' dissocie.".format(LOCKERS[locker_key]['name'])) 130 except Exception as e: 131 print("[unpair_locker] {}".format(e)) 132 flash("Erreur lors de la dissociation.") 133 return redirect(url_for('main.settings')) 134 135 136# ══════════════════════════════════════════════════════════════════════════════ 137# PREMIUM TOGGLE (pour la démo TFE) 138# ══════════════════════════════════════════════════════════════════════════════ 139 140@pairing_bp.route('/toggle_premium', methods=['POST']) 141@require_login 142def toggle_premium(): 143 """Bouton de démo pour activer/désactiver Premium (pour le jury).""" 144 token = session['user'] 145 try: 146 ctx = get_user_context(token) 147 new_val = not ctx['is_premium'] 148 db.child("users").child(ctx['local_id']).update({"premium": new_val}, token) 149 flash("Premium {} !".format("active" if new_val else "desactive")) 150 except Exception as e: 151 print("[toggle_premium] {}".format(e)) 152 flash("Erreur lors du changement de statut.") 153 return redirect(url_for('main.settings'))
routes_sport.py
128 lignes
1# ══════════════════════════════════════════════════════════════════════════════ 2# ROUTES — CASIER SPORT 3# ────────────────────────────────────────────────────────────────────────────── 4# Casier réservé aux membres Premium. L'utilisateur démarre une session, ouvre 5# la porte à la demande (pulse), puis termine — ce qui dissocie automatiquement 6# le casier pour libérer la place au prochain utilisateur. 7# ══════════════════════════════════════════════════════════════════════════════ 8 9import time 10 11import config 12 13from flask import Blueprint, render_template, request, redirect, session, url_for, flash 14 15from extensions import db, LOCKERS 16from helpers import ( 17 get_user_context, get_locker_state, _log_session, 18 require_login, _unpair_after_session, lockdown_message, 19) 20 21sport_bp = Blueprint('sport', __name__) 22 23 24@sport_bp.route('/locker/sport') 25@require_login 26def locker_sport(): 27 try: 28 token = session['user'] 29 ctx = get_user_context(token) 30 if 'sport' not in ctx['paired']: 31 flash("Appairez d'abord ce casier depuis le dashboard.") 32 return redirect(url_for('main.home')) 33 state = get_locker_state("sport", token, ctx['local_id']) 34 return render_template('locker_sport.html', 35 pseudo=session.get('pseudo'), 36 photo=ctx['photo_url'], 37 is_premium=ctx['is_premium'], 38 local_id=ctx['local_id'], 39 state=state, 40 admin_email=(config.ADMIN_EMAILS[0] if config.ADMIN_EMAILS else ''), 41 locker=LOCKERS["sport"]) 42 except Exception as e: 43 print("[locker_sport] {}".format(e)) 44 session.clear() 45 return redirect(url_for('main.home')) 46 47 48@sport_bp.route('/locker/sport/toggle', methods=['POST']) 49@require_login 50def toggle_sport(): 51 token = session['user'] 52 try: 53 ctx = get_user_context(token) 54 action = request.form.get('action', '') 55 if 'sport' not in ctx['paired']: 56 flash("Vous devez d'abord appairer ce casier.") 57 return redirect(url_for('main.home')) 58 if not ctx['is_premium']: 59 flash("Acces reserve aux membres Premium.") 60 return redirect(url_for('sport.locker_sport')) 61 62 lid = LOCKERS["sport"]["id"] 63 state = get_locker_state("sport", token, ctx['local_id']) 64 65 # Bloquer toute action si le casier est hors-ligne (Raspberry Pi eteint) 66 if not state.get("online"): 67 flash("Casier hors-ligne : impossible de l'utiliser tant qu'il n'est pas allume.") 68 return redirect(url_for('sport.locker_sport')) 69 70 # Bloquer toute action pendant un lockdown (intrusion : un admin intervient) 71 if state.get("lockdown"): 72 flash(lockdown_message()) 73 return redirect(url_for('sport.locker_sport')) 74 75 if action == 'start' or (not action and state["status"] == "locked"): 76 # Démarrer une nouvelle session 77 sess = { 78 "user_id": ctx['local_id'], 79 "user_email": session.get('email'), 80 "started_at": time.time(), 81 "expires_at": 0, 82 "amount_eur": 0, 83 } 84 db.child("lockers").child(lid).update( 85 {"status": "unlocked", "active_session": sess, "cmd_open_door": time.time()}, token) 86 elif action == 'open': 87 # Ouvrir ponctuellement (pulse) pendant une session active 88 db.child("lockers").child(lid).update({"cmd_open_door": time.time()}, token) 89 elif action == 'end' or (not action and state["status"] == "unlocked"): 90 # ── Verifier que la porte est bien fermee avant de cloturer ── 91 # Si la porte est physiquement ouverte, on refuse la fin de session 92 # pour eviter qu'un utilisateur termine en laissant ses affaires 93 # accessibles a tous. 94 phys = (state.get("physical_state") or "").strip().lower() 95 if phys in ("ouverte", "open"): 96 flash("Veuillez d'abord fermer physiquement la porte du casier avant de terminer la session.") 97 return redirect(url_for('sport.locker_sport')) 98 # Terminer la session 99 _log_session("sport", lid, state["session"], token, "manual") 100 db.child("lockers").child(lid).update( 101 {"status": "locked", "active_session": None}, token) 102 # ── Dissocier automatiquement le casier sport apres la session ── 103 # Le client a fini d'utiliser le casier : on libere la place pour 104 # le prochain utilisateur. Le client devra rescanner le QR pour 105 # une prochaine session. 106 _unpair_after_session("sport", token, ctx['local_id']) 107 # Redirection vers la page "merci" 108 return redirect(url_for('sport.sport_thanks')) 109 except Exception as e: 110 print("[toggle_sport] {}".format(e)) 111 flash("Erreur lors de la commande du casier.") 112 return redirect(url_for('sport.locker_sport')) 113 114 115@sport_bp.route('/locker/sport/thanks') 116@require_login 117def sport_thanks(): 118 """Page de remerciement apres avoir termine la session sport. 119 Le casier a ete dissocie automatiquement, l'utilisateur n'a plus de casier paire.""" 120 try: 121 ctx = get_user_context(session['user']) 122 return render_template('sport_thanks.html', 123 pseudo=session.get('pseudo'), 124 photo=ctx['photo_url'], 125 is_premium=ctx['is_premium']) 126 except Exception as e: 127 print("[sport_thanks] {}".format(e)) 128 return redirect(url_for('main.home'))
routes_school.py
598 lignes
1# ══════════════════════════════════════════════════════════════════════════════ 2# ROUTES — CASIER ECOLE (réservation payante + Stripe) 3# ────────────────────────────────────────────────────────────────────────────── 4# Casier louable à l'heure via paiement Stripe. Gère : 5# • La réservation d'un créneau (checkout Stripe) 6# • La confirmation de paiement (/success) — idempotente 7# • La prolongation d'une session en cours (+1h) 8# • L'ouverture / annulation / fermeture de session 9# • Le webhook Stripe (source de vérité asynchrone) 10# 11# La fonction _apply_school_booking est ici car elle est spécifique au casier 12# école et partagée entre /success (token utilisateur) et le webhook (firebase-admin). 13# ══════════════════════════════════════════════════════════════════════════════ 14 15import time 16from datetime import datetime, timedelta 17 18from flask import Blueprint, render_template, request, redirect, session, url_for, flash 19 20import config 21from extensions import ( 22 db, admin_db, stripe, 23 LOCKERS, SCHOOL_HOURS, PRICE_PER_HOUR, STRIPE_PUB_KEY, 24) 25from helpers import ( 26 get_user_context, get_locker_state, _log_session, 27 _get_booked_slots, require_login, _unpair_after_session, lockdown_message, 28) 29 30school_bp = Blueprint('school', __name__) 31 32 33def _create_checkout(amount, name, description, success_endpoint, metadata): 34 """Crée une session Stripe Checkout (carte, paiement unique) et retourne son URL. 35 `success_endpoint` reçoit ?session_id={CHECKOUT_SESSION_ID} ; annulation → page école.""" 36 return stripe.checkout.Session.create( 37 payment_method_types=['card'], 38 line_items=[{ 39 'price_data': { 40 'currency': 'eur', 41 'unit_amount': amount, 42 'product_data': {'name': name, 'description': description}, 43 }, 44 'quantity': 1, 45 }], 46 mode='payment', 47 success_url=url_for(success_endpoint, _external=True) + '?session_id={CHECKOUT_SESSION_ID}', 48 cancel_url=url_for('school.locker_school', _external=True), 49 metadata=metadata, 50 ).url 51 52 53@school_bp.route('/locker/school') 54@require_login 55def locker_school(): 56 try: 57 token = session['user'] 58 ctx = get_user_context(token) 59 if 'school' not in ctx['paired']: 60 flash("Appairez d'abord ce casier depuis le dashboard.") 61 return redirect(url_for('main.home')) 62 state = get_locker_state("school", token, ctx['local_id']) 63 booked_slots = _get_booked_slots(token) 64 # Si session active, verifie si elle appartient a l'utilisateur courant 65 is_my_session = (state.get("session", {}).get("user_id") == ctx['local_id']) 66 return render_template('locker_school.html', 67 pseudo=session.get('pseudo'), 68 photo=ctx['photo_url'], 69 is_premium=ctx['is_premium'], 70 state=state, 71 is_my_session=is_my_session, 72 locker=LOCKERS["school"], 73 stripe_pub=STRIPE_PUB_KEY, 74 school_hours=SCHOOL_HOURS, 75 booked_slots=booked_slots, 76 admin_email=(config.ADMIN_EMAILS[0] if config.ADMIN_EMAILS else ''), 77 now_ts=time.time()) 78 except Exception as e: 79 print("[locker_school] {}".format(e)) 80 session.clear() 81 return redirect(url_for('main.home')) 82 83 84@school_bp.route('/locker/school/checkout', methods=['POST']) 85@require_login 86def school_checkout(): 87 token = session['user'] 88 try: 89 ctx = get_user_context(token) 90 # Empecher toute reservation si le casier est hors-ligne 91 state_check = get_locker_state("school", token, ctx['local_id']) 92 if not state_check.get("online"): 93 flash("Casier hors-ligne : impossible de reserver tant qu'il n'est pas allume.") 94 return redirect(url_for('school.locker_school')) 95 date_str = request.form.get('date', '').strip() 96 start_str = request.form.get('start_hour', '').strip() 97 hours_str = request.form.get('hours', '1') 98 99 # "now" = reservation immediate : le debut est l'instant present (a la 100 # minute pres) et non un creneau a l'heure pleine. 101 is_now = (start_str.lower() == "now") 102 103 # ── Validation ── 104 errors = [] 105 if not date_str and not is_now: 106 errors.append("Veuillez choisir une date.") 107 if not start_str: 108 errors.append("Veuillez choisir une heure de debut.") 109 110 try: 111 hours = int(hours_str) 112 if hours < 1 or hours > 6: 113 errors.append("Duree invalide (1h a 6h).") 114 except ValueError: 115 hours = 1 116 errors.append("Duree invalide.") 117 118 start_h = 0 119 start_dt = None 120 if is_now: 121 # Reservation immediate : debut = maintenant, fin = +hours (a la minute pres) 122 start_dt = datetime.now() 123 start_h = start_dt.hour 124 end_dt = start_dt + timedelta(hours=hours) 125 # La fin doit rester avant la fermeture (22h00) le meme jour 126 fermeture = start_dt.replace(hour=22, minute=0, second=0, microsecond=0) 127 if end_dt > fermeture: 128 errors.append("La session depasserait l'heure de fermeture (22h00). Reduisez la duree.") 129 # Verification disponibilite sur toutes les heures chevauchees par la session 130 today_str = start_dt.strftime("%Y-%m-%d") 131 booked_slots = _get_booked_slots(token) 132 # On verifie chaque heure pleine touchee par l'intervalle [start_dt, end_dt[ 133 for h in range(start_h, end_dt.hour + (1 if end_dt.minute > 0 else 0)): 134 slot_key = "{}-{:02d}:00".format(today_str, h) 135 if slot_key in booked_slots: 136 errors.append("Le creneau {:02d}h-{:02d}h est deja occupe.".format(h, h + 1)) 137 break 138 elif date_str and start_str: 139 try: 140 booking_date = datetime.strptime(date_str, "%Y-%m-%d") 141 today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) 142 max_date = today + timedelta(days=14) 143 if booking_date < today: 144 errors.append("La date est dans le passe.") 145 elif booking_date > max_date: 146 errors.append("Reservation possible jusqu'a 14 jours a l'avance.") 147 start_h = int(start_str.split(":")[0]) 148 if start_h < 8 or start_h > 21: 149 errors.append("Heure de debut invalide (08h-21h).") 150 end_h = start_h + hours 151 if end_h > 22: 152 errors.append("La session depasse l'heure de fermeture (22h00).") 153 start_dt = datetime.strptime("{} {}".format(date_str, start_str), "%Y-%m-%d %H:%M") 154 if start_dt < datetime.now(): 155 errors.append("Ce creneau est deja passe.") 156 # Verification disponibilite (pre-check) 157 booked_slots = _get_booked_slots(token) 158 for h in range(hours): 159 slot_key = "{}-{:02d}:00".format(date_str, start_h + h) 160 if slot_key in booked_slots: 161 errors.append("Le creneau {:02d}h-{:02d}h est deja occupe.".format(start_h + h, start_h + h + 1)) 162 break 163 except ValueError: 164 errors.append("Format de date ou d'heure invalide.") 165 166 if errors: 167 for e in errors: 168 flash(e) 169 return redirect(url_for('school.locker_school')) 170 171 # ── Calcul montants ── 172 amount = hours * PRICE_PER_HOUR 173 if start_dt is None: # garde-fou (ne devrait pas arriver : errors aurait stoppe) 174 start_dt = datetime.strptime("{} {}".format(date_str, start_str), "%Y-%m-%d %H:%M") 175 start_ts = start_dt.timestamp() 176 expires_ts = start_ts + hours * 3600 177 178 # Pour "maintenant", on derive des libelles lisibles depuis l'instant reel 179 # (sinon date_str serait vide et start_str vaudrait "now"). 180 if is_now: 181 date_str = start_dt.strftime("%Y-%m-%d") 182 start_str = start_dt.strftime("%H:%M") 183 184 checkout_url = _create_checkout( 185 amount, 186 'SmartLocker Ecole - {}h le {}'.format(hours, start_dt.strftime("%d/%m a %Hh")), 187 'Casier Ecole de {}h a {}h'.format(start_h, start_h + hours), 188 'school.school_success', 189 { 190 'user_id': ctx['local_id'], 191 'user_email': session.get('email') or '', 192 'hours': str(hours), 193 'amount_eur': str(amount / 100), 194 'start_ts': str(start_ts), 195 'expires_ts': str(expires_ts), 196 'date_str': date_str, 197 'start_str': start_str, 198 'kind': 'school_booking', 199 }, 200 ) 201 return redirect(checkout_url, code=303) 202 except Exception as e: 203 print("[school_checkout] {}".format(e)) 204 flash("Erreur lors de la creation du paiement. Reessayez.") 205 return redirect(url_for('school.locker_school')) 206 207 208def _record_payment(stripe_session, status_str, token): 209 """ 210 Enregistre le paiement + la reservation dans /payments/{stripe_session_id}. 211 Idempotent (cle = session Stripe). Sert de trace durable et de seconde source 212 de verite pour eviter les erreurs / doubles reservations, meme si /success 213 n'est jamais atteint (le webhook l'ecrit aussi). 214 """ 215 meta = stripe_session.get('metadata', {}) or {} 216 sid = stripe_session.get('id', '') 217 if not sid: 218 return 219 lid = LOCKERS["school"]["id"] 220 record = { 221 "stripe_session_id": sid, 222 "locker_id": lid, 223 "locker_key": "school", 224 "user_id": meta.get('user_id', ''), 225 "user_email": meta.get('user_email', ''), 226 "hours": int(meta.get('hours', 1)), 227 "amount_eur": float(meta.get('amount_eur', 0) or 0), 228 "start_ts": float(meta.get('start_ts', 0) or 0), 229 "expires_ts": float(meta.get('expires_ts', 0) or 0), 230 "date_str": meta.get('date_str', ''), 231 "start_str": meta.get('start_str', ''), 232 "payment_status": stripe_session.get('payment_status', 'paid'), 233 "reservation_status": status_str, # 'unlocked' (en cours) ou 'reserved' (a venir) 234 "recorded_at": time.time(), 235 } 236 try: 237 use_admin = (token is None and admin_db is not None) 238 if use_admin: 239 ref = admin_db.reference("payments/{}".format(sid)) 240 if ref.get(): 241 return # deja enregistre -> idempotent 242 ref.set(record) 243 else: 244 existing = db.child("payments").child(sid).get(token).val() 245 if existing: 246 return # deja enregistre -> idempotent 247 db.child("payments").child(sid).set(record, token) 248 print("[_record_payment] paiement/reservation enregistre : {}".format(sid)) 249 except Exception as e: 250 print("[_record_payment] {}".format(e)) 251 252 253def _apply_school_booking(stripe_session, token): 254 """ 255 Applique la reservation apres paiement. Idempotent. 256 Utilise par /success (avec token utilisateur) ET par le webhook (token=None 257 → utilise firebase-admin si disponible). 258 """ 259 lid = LOCKERS["school"]["id"] 260 meta = stripe_session.get('metadata', {}) or {} 261 262 hours = int(meta.get('hours', 1)) 263 amount_eur = float(meta.get('amount_eur', 5)) 264 start_ts = float(meta.get('start_ts', time.time())) 265 expires_ts = float(meta.get('expires_ts', start_ts + hours * 3600)) 266 date_str = meta.get('date_str', '') 267 start_str = meta.get('start_str', '') 268 user_id = meta.get('user_id', '') 269 user_email = meta.get('user_email', '') 270 sid = stripe_session.get('id', '') 271 272 use_admin = (token is None and admin_db is not None) 273 274 # Lecture session existante 275 if use_admin: 276 existing = admin_db.reference("lockers/{}/active_session".format(lid)).get() or {} 277 else: 278 existing = db.child("lockers").child(lid).child("active_session").get(token).val() or {} 279 if isinstance(existing, str): 280 existing = {} 281 282 # Idempotence — si deja traite, on ne fait rien 283 if existing.get("stripe_session_id") == sid: 284 return { 285 "already_applied": True, 286 "hours": hours, "amount_eur": amount_eur, 287 "start_ts": start_ts, "expires_ts": expires_ts, 288 "date_str": date_str, "start_str": start_str, 289 } 290 291 # Casier deja occupe par une autre reservation non terminee 292 if existing and existing.get("expires_at", 0) > time.time(): 293 return {"conflict": True} 294 295 now = time.time() 296 status = "unlocked" if start_ts <= now < expires_ts else "reserved" 297 298 sess = { 299 "user_id": user_id, 300 "user_email": user_email, 301 "started_at": start_ts, 302 "expires_at": expires_ts, 303 "hours": hours, 304 "amount_eur": amount_eur, 305 "stripe_session_id": sid, 306 "date_str": date_str, 307 "start_str": start_str, 308 } 309 if use_admin: 310 admin_db.reference("lockers/{}".format(lid)).set( 311 {"status": status, "active_session": sess}) 312 else: 313 db.child("lockers").child(lid).set( 314 {"status": status, "active_session": sess}, token) 315 316 # Trace durable du paiement + reservation (anti double-reservation / audit) 317 _record_payment(stripe_session, status, token) 318 319 return { 320 "already_applied": False, 321 "hours": hours, "amount_eur": amount_eur, 322 "start_ts": start_ts, "expires_ts": expires_ts, 323 "date_str": date_str, "start_str": start_str, 324 } 325 326 327@school_bp.route('/locker/school/success') 328@require_login 329def school_success(): 330 stripe_session_id = request.args.get('session_id') 331 if not stripe_session_id: 332 flash("Session de paiement introuvable.") 333 return redirect(url_for('school.locker_school')) 334 token = session['user'] 335 try: 336 stripe_session = stripe.checkout.Session.retrieve(stripe_session_id) 337 if stripe_session.payment_status != 'paid': 338 flash("Paiement non confirme. Reessayez.") 339 return redirect(url_for('school.locker_school')) 340 341 result = _apply_school_booking(stripe_session, token) 342 343 if result.get("conflict"): 344 flash("Le casier vient d'etre occupe par une autre reservation. Contactez le support pour un remboursement.") 345 return redirect(url_for('school.locker_school')) 346 347 return render_template('payment_success.html', 348 hours=result['hours'], 349 amount_eur=result['amount_eur'], 350 expires_at=result['expires_ts'], 351 start_ts=result['start_ts'], 352 date_str=result['date_str'], 353 start_str=result['start_str']) 354 except Exception as e: 355 print("[school_success] {}".format(e)) 356 flash("Erreur de confirmation du paiement.") 357 return redirect(url_for('school.locker_school')) 358 359 360@school_bp.route('/locker/school/extend', methods=['POST']) 361@require_login 362def school_extend(): 363 token = session['user'] 364 try: 365 ctx = get_user_context(token) 366 state = get_locker_state("school", token, ctx['local_id']) 367 if state["status"] != "unlocked" or not state["session"]: 368 flash("Aucune session active a prolonger.") 369 return redirect(url_for('school.locker_school')) 370 expires_at = state["session"].get("expires_at", 0) 371 remaining = expires_at - time.time() 372 if remaining > 1800: 373 flash("Vous ne pouvez prolonger que lorsqu'il reste moins de 30 minutes.") 374 return redirect(url_for('school.locker_school')) 375 new_expires = expires_at + 3600 376 amount = PRICE_PER_HOUR 377 378 checkout_url = _create_checkout( 379 amount, 380 'SmartLocker Ecole - Prolongation +1h', 381 'Extension de votre session en cours', 382 'school.school_extend_success', 383 { 384 'user_id': ctx['local_id'], 385 'amount_eur': '5.0', 386 'new_expires_ts': str(new_expires), 387 'kind': 'school_extend', 388 }, 389 ) 390 return redirect(checkout_url, code=303) 391 except Exception as e: 392 print("[school_extend] {}".format(e)) 393 flash("Erreur lors de la creation du paiement de prolongation.") 394 return redirect(url_for('school.locker_school')) 395 396 397@school_bp.route('/locker/school/extend/success') 398@require_login 399def school_extend_success(): 400 stripe_session_id = request.args.get('session_id') 401 if not stripe_session_id: 402 flash("Session de paiement introuvable.") 403 return redirect(url_for('school.locker_school')) 404 token = session['user'] 405 lid = LOCKERS["school"]["id"] 406 try: 407 stripe_session = stripe.checkout.Session.retrieve(stripe_session_id) 408 if stripe_session.payment_status != 'paid': 409 flash("Paiement de prolongation non confirme.") 410 return redirect(url_for('school.locker_school')) 411 new_expires = float(stripe_session.metadata.get('new_expires_ts', 0)) 412 if not new_expires: 413 flash("Erreur de prolongation.") 414 return redirect(url_for('school.locker_school')) 415 data = db.child("lockers").child(lid).get(token).val() or {} 416 sess = data.get("active_session") or {} 417 if isinstance(sess, str): 418 sess = {} 419 # Idempotence : si déjà appliqué, on ne refait pas l'extension 420 applied_extends = sess.get("applied_extends") or [] 421 if not isinstance(applied_extends, list): 422 applied_extends = [] 423 if stripe_session_id in applied_extends: 424 flash("Prolongation déjà appliquée.") 425 return redirect(url_for('school.locker_school')) 426 sess["expires_at"] = new_expires 427 sess["hours"] = sess.get("hours", 1) + 1 428 sess["amount_eur"] = float(sess.get("amount_eur", 0)) + 5.0 429 applied_extends.append(stripe_session_id) 430 sess["applied_extends"] = applied_extends 431 db.child("lockers").child(lid).update({"active_session": sess}, token) 432 flash("Session prolongee d'1 heure !") 433 except Exception as e: 434 print("[school_extend_success] {}".format(e)) 435 flash("Erreur de confirmation de prolongation.") 436 return redirect(url_for('school.locker_school')) 437 438 439@school_bp.route('/locker/school/open', methods=['POST']) 440@require_login 441def school_open(): 442 """Envoie une commande d'ouverture pulse au Pi du casier ecole. 443 Disponible uniquement pendant une session active (status == unlocked). 444 Le client peut ouvrir/refermer autant de fois qu'il veut pendant ses heures payees.""" 445 token = session['user'] 446 try: 447 ctx = get_user_context(token) 448 if 'school' not in ctx['paired']: 449 flash("Casier non appaire.") 450 return redirect(url_for('main.home')) 451 lid = LOCKERS["school"]["id"] 452 state = get_locker_state("school", token, ctx['local_id']) 453 454 # Verifier que le casier est en ligne 455 if not state.get("online"): 456 flash("Casier hors-ligne : impossible d'ouvrir la porte.") 457 return redirect(url_for('school.locker_school')) 458 459 # Bloquer pendant un lockdown (intrusion detectee : un admin intervient) 460 if state.get("lockdown"): 461 flash(lockdown_message()) 462 return redirect(url_for('school.locker_school')) 463 464 # Verifier qu'une session est active ET appartient a l'utilisateur 465 if state["status"] != "unlocked": 466 flash("Aucune session active : reservez d'abord un creneau.") 467 return redirect(url_for('school.locker_school')) 468 sess = state.get("session") or {} 469 if sess.get("user_id") != ctx['local_id']: 470 flash("Cette session ne vous appartient pas.") 471 return redirect(url_for('school.locker_school')) 472 473 # Envoyer une commande pulse au Pi (ouvre la porte 4s puis se ferme) 474 db.child("lockers").child(lid).update({"cmd_open_door": time.time()}, token) 475 flash("Porte ouverte.") 476 except Exception as e: 477 print("[school_open] {}".format(e)) 478 flash("Erreur lors de l'ouverture.") 479 return redirect(url_for('school.locker_school')) 480 481 482@school_bp.route('/locker/school/cancel', methods=['POST']) 483@require_login 484def school_cancel(): 485 """Annule une reservation FUTURE (statut 'reserved') si l'heure d'ouverture 486 est dans plus d'1 heure. Au-dela on ne peut plus annuler (le client a deja 487 bloque le creneau).""" 488 token = session['user'] 489 try: 490 ctx = get_user_context(token) 491 if 'school' not in ctx['paired']: 492 flash("Casier non appaire.") 493 return redirect(url_for('main.home')) 494 lid = LOCKERS["school"]["id"] 495 state = get_locker_state("school", token, ctx['local_id']) 496 497 # Doit etre en mode 'reserved' (pas encore ouvert) 498 if state["status"] != "reserved": 499 flash("Aucune reservation en attente a annuler.") 500 return redirect(url_for('school.locker_school')) 501 502 sess = state.get("session") or {} 503 if sess.get("user_id") != ctx['local_id']: 504 flash("Cette reservation ne vous appartient pas.") 505 return redirect(url_for('school.locker_school')) 506 507 started_at = sess.get("started_at", 0) 508 time_before = started_at - time.time() 509 # Doit rester au moins 1 heure avant l'ouverture 510 if time_before < 3600: 511 mins = max(0, int(time_before / 60)) 512 flash("Annulation impossible : il reste {} minutes avant l'ouverture. L'annulation n'est possible que jusqu'a 1h avant le creneau.".format(mins)) 513 return redirect(url_for('school.locker_school')) 514 515 # Logger l'annulation dans l'historique (montant=0, raison=cancelled) 516 cancel_sess = dict(sess) 517 cancel_sess["amount_eur"] = 0 # pas de remboursement enregistre cote app 518 _log_session("school", lid, cancel_sess, token, "cancelled") 519 520 # Supprimer la reservation 521 db.child("lockers").child(lid).set( 522 {"status": "locked", "active_session": None}, token) 523 # Reservation annulee -> retirer le casier du compte (libere la place) 524 _unpair_after_session("school", token, ctx['local_id']) 525 flash("Reservation annulee et casier retire de votre compte. Pour le remboursement, contactez l'administration.") 526 except Exception as e: 527 print("[school_cancel] {}".format(e)) 528 flash("Erreur lors de l'annulation.") 529 return redirect(url_for('school.locker_school')) 530 531 532@school_bp.route('/locker/school/close', methods=['POST']) 533@require_login 534def school_close(): 535 token = session['user'] 536 try: 537 ctx = get_user_context(token) 538 if 'school' not in ctx['paired']: 539 flash("Casier non appairé.") 540 return redirect(url_for('main.home')) 541 lid = LOCKERS["school"]["id"] 542 state = get_locker_state("school", token, ctx['local_id']) 543 if state["status"] == "unlocked": 544 sess = state.get("session") or {} 545 # On ne ferme que si c'est la session de l'utilisateur 546 if sess.get("user_id") == ctx['local_id']: 547 _log_session("school", lid, sess, token, "manual") 548 db.child("lockers").child(lid).set( 549 {"status": "locked", "active_session": None}, token) 550 # Fin de session -> retirer le casier du compte (libere la place) 551 _unpair_after_session("school", token, ctx['local_id']) 552 flash("Session terminée. Le casier a été retiré de votre compte.") 553 except Exception as e: 554 print("[school_close] {}".format(e)) 555 flash("Erreur lors de la fermeture.") 556 return redirect(url_for('school.locker_school')) 557 558 559# ============================================================================== 560# STRIPE WEBHOOK (source de verite asynchrone) 561# ============================================================================== 562 563@school_bp.route('/stripe/webhook', methods=['POST']) 564def stripe_webhook(): 565 """ 566 Recoit les evenements Stripe. Est la source de verite : si l'utilisateur 567 ferme son navigateur avant /success, le webhook applique quand meme 568 la reservation. 569 Le webhook n'a pas de session utilisateur, il utilise donc un token admin 570 (ou ecrit directement sans auth). Ici on utilise db.child sans token -- 571 attention aux regles de securite Firebase. 572 """ 573 payload = request.data 574 sig = request.headers.get('Stripe-Signature', '') 575 try: 576 event = stripe.Webhook.construct_event(payload, sig, config.STRIPE_WEBHOOK_SECRET) 577 except Exception as e: 578 print("[stripe_webhook] Signature invalide: {}".format(e)) 579 return '', 400 580 581 if event['type'] == 'checkout.session.completed': 582 data = event['data']['object'] 583 meta = data.get('metadata', {}) or {} 584 kind = meta.get('kind', '') 585 print("[Stripe webhook] {} recu pour {}".format(data.get('id'), kind)) 586 587 # On ecrit directement dans Firebase (token=None). 588 # NOTE: Firebase Realtime Database doit etre configure en mode test 589 # OU avoir des regles qui autorisent l'ecriture sur /lockers sans auth. 590 # A terme, utiliser une service-account cle pour auth admin. 591 if kind == 'school_booking': 592 try: 593 _apply_school_booking(data, None) 594 print("[Stripe webhook] school_booking applique") 595 except Exception as e: 596 print("[Stripe webhook] Erreur school_booking: {}".format(e)) 597 598 return '', 200
routes_api.py
229 lignes
1# ══════════════════════════════════════════════════════════════════════════════ 2# ROUTES — API JSON, HISTORIQUE & NOTIFICATIONS 3# ────────────────────────────────────────────────────────────────────────────── 4# Endpoints appelés en AJAX par le frontend (état des casiers, créneaux, 5# polling de notifications), l'enregistrement du token FCM, ainsi que la page 6# d'historique de l'utilisateur et son export CSV. 7# ══════════════════════════════════════════════════════════════════════════════ 8 9import csv 10import io 11import time 12from datetime import datetime 13 14from flask import ( 15 Blueprint, render_template, request, redirect, session, url_for, 16 flash, jsonify, Response 17) 18 19from extensions import db, LOCKERS 20from helpers import ( 21 get_user_context, get_locker_state, _get_booked_slots, require_login, 22) 23 24api_bp = Blueprint('api', __name__) 25 26 27# ══════════════════════════════════════════════════════════════════════════════ 28# API — ÉTAT PHYSIQUE (lu depuis Firebase, mis à jour par la passerelle) 29# ══════════════════════════════════════════════════════════════════════════════ 30 31@api_bp.route('/api/locker/<locker_key>/physical') 32@require_login 33def api_locker_physical(locker_key): 34 """ 35 Retourne l'état physique réel de la porte (mis à jour par le Pi Zero via passerelle). 36 Utile pour afficher 'porte ouverte / fermée' en temps réel sur le web. 37 """ 38 if locker_key not in LOCKERS: 39 return jsonify({"error": "unknown"}), 404 40 try: 41 lid = LOCKERS[locker_key]["id"] 42 data = db.child("lockers").child(lid).get(session['user']).val() or {} 43 return jsonify({ 44 "door_physical": data.get("door_physical", data.get("etat_porte_physique", "Inconnu")), 45 "door_physical_updated": data.get("door_physical_updated", data.get("derniere_maj_physique", "")), 46 "alert_active": data.get("alert_active", data.get("alerte_active", "")), 47 "gateway_status": "ok" 48 }) 49 except Exception as e: 50 print("[api_locker_physical] {}".format(e)) 51 return jsonify({"error": "fail"}), 500 52 53 54@api_bp.route('/api/passerelle/status') 55@api_bp.route('/api/gateway/status') 56@require_login 57def api_gateway_status(): 58 """Vérifie si la passerelle Pi4B est en ligne (heartbeat). 59 Migration douce : lit /gateways/pi4b, fallback sur /passerelles/pi4b.""" 60 try: 61 token = session['user'] 62 data = (db.child("gateways").child("pi4b").get(token).val() 63 or db.child("passerelles").child("pi4b").get(token).val() 64 or {}) 65 # Nouvelle convention : status / last_seen 66 status = data.get("status", data.get("statut", "unknown")) 67 last_seen = data.get("last_seen", data.get("derniere_activite", "")) 68 # On renvoie l'ancien format ET le nouveau pour ne pas casser les clients existants 69 return jsonify({ 70 "status": status, 71 "last_seen": last_seen, 72 "statut": status, 73 "derniere_activite": last_seen, 74 }) 75 except Exception as e: 76 return jsonify({"status": "error", "detail": str(e)}), 500 77 78 79@api_bp.route('/api/locker/<locker_key>/state') 80@require_login 81def api_locker_state(locker_key): 82 if locker_key not in LOCKERS: 83 return jsonify({"error": "unknown"}), 404 84 try: 85 ctx = get_user_context(session['user']) 86 state = get_locker_state(locker_key, session['user'], ctx['local_id']) 87 return jsonify(state) 88 except Exception as e: 89 print("[api_locker_state] {}".format(e)) 90 return jsonify({"error": "fail"}), 500 91 92 93@api_bp.route('/api/school/booked_slots') 94@require_login 95def api_booked_slots(): 96 booked = _get_booked_slots(session['user']) 97 return jsonify({"booked": booked}) 98 99 100@api_bp.route('/api/notifications/poll') 101@require_login 102def api_notifications_poll(): 103 """API unique de polling cote navigateur. 104 Retourne tous les evenements a notifier pour les casiers appaires de 105 l'utilisateur. Sert de fallback robuste si FCM ne passe pas (Safari, mode 106 prive, blocage par l'utilisateur, push expire, etc.). Le frontend appelle 107 cette API toutes les 5s et detecte les nouveaux evenements via les 108 timestamps (alert_time, reminder_time, object_alert_time). 109 """ 110 token = session['user'] 111 try: 112 ctx = get_user_context(token) 113 events = [] 114 for locker_key in ctx['paired'].keys(): 115 if locker_key not in LOCKERS: 116 continue 117 state = get_locker_state(locker_key, token, ctx['local_id']) 118 lid = LOCKERS[locker_key]['id'] 119 lname = LOCKERS[locker_key]['name'] 120 121 def evt(kind, message, time_, severity): 122 return {'kind': kind, 'locker_key': locker_key, 'locker_id': lid, 123 'locker_name': lname, 'message': message, 124 'time': time_, 'severity': severity} 125 126 if state.get('alert') and state.get('alert_time'): 127 events.append(evt('intrusion', state.get('alert'), state.get('alert_time'), 'critical')) 128 if state.get('reminder') and state.get('reminder_time'): 129 events.append(evt('reminder', state.get('reminder'), state.get('reminder_time'), 'warning')) 130 if state.get('object_alert') and state.get('object_alert_time'): 131 events.append(evt('object_removed', state.get('object_alert'), state.get('object_alert_time'), 'info')) 132 if not state.get('online', True): 133 events.append(evt('offline', 'Casier hors-ligne', state.get('last_seen_raw', ''), 'warning')) 134 135 return jsonify({ 136 'events': events, 137 'server_time': time.time(), 138 }) 139 except Exception as e: 140 print("[api_notifications_poll] {}".format(e)) 141 return jsonify({'events': [], 'error': 'fail'}), 500 142 143 144# ══════════════════════════════════════════════════════════════════════════════ 145# FIREBASE CLOUD MESSAGING — enregistrement du token 146# ══════════════════════════════════════════════════════════════════════════════ 147 148@api_bp.route('/api/fcm_token', methods=['POST']) 149@require_login 150def save_fcm_token(): 151 token = session['user'] 152 fcm_token = request.json.get('fcm_token') 153 if not fcm_token: 154 return jsonify({"success": False}), 400 155 try: 156 ctx = get_user_context(token) 157 local_id = ctx['local_id'] 158 db.child("users").child(local_id).update({"fcm_token": fcm_token}, token) 159 return jsonify({"success": True}) 160 except Exception as e: 161 print("[save_fcm_token] {}".format(e)) 162 return jsonify({"success": False}), 500 163 164 165# ══════════════════════════════════════════════════════════════════════════════ 166# HISTORIQUE 167# ══════════════════════════════════════════════════════════════════════════════ 168 169@api_bp.route('/history') 170@require_login 171def history(): 172 token = session['user'] 173 entries = [] 174 ctx = {'photo_url': '', 'is_premium': False} 175 try: 176 ctx = get_user_context(token) 177 raw = db.child("users").child(ctx['local_id']).child("history").get(token).val() or {} 178 if isinstance(raw, dict): 179 for k, v in raw.items(): 180 if isinstance(v, dict): 181 v['key'] = k 182 entries.append(v) 183 entries.sort(key=lambda x: x.get('started_at', 0), reverse=True) 184 entries = entries[:50] 185 except Exception as e: 186 print("[history] {}".format(e)) 187 return render_template('history.html', 188 pseudo=session.get('pseudo'), 189 photo=ctx.get('photo_url', ''), 190 is_premium=ctx.get('is_premium', False), 191 entries=entries) 192 193 194@api_bp.route('/history/export') 195@require_login 196def history_export(): 197 """Export CSV de l'historique utilisateur.""" 198 token = session['user'] 199 try: 200 ctx = get_user_context(token) 201 raw = db.child("users").child(ctx['local_id']).child("history").get(token).val() or {} 202 entries = sorted(raw.values(), key=lambda x: x.get('started_at', 0), reverse=True) 203 204 output = io.StringIO() 205 writer = csv.writer(output, delimiter=';') 206 writer.writerow(['Date', 'Casier', 'Duree (min)', 'Montant (EUR)', 'Motif']) 207 for v in entries: 208 started = v.get('started_at', 0) 209 dt_str = datetime.fromtimestamp(started).strftime('%Y-%m-%d %H:%M') if started else '-' 210 writer.writerow([ 211 dt_str, 212 v.get('locker_key', ''), 213 v.get('duration_min', 0), 214 "{:.2f}".format(float(v.get('amount_eur', 0) or 0)), 215 v.get('reason', ''), 216 ]) 217 218 csv_data = output.getvalue() 219 return Response( 220 csv_data, 221 mimetype='text/csv; charset=utf-8', 222 headers={ 223 'Content-Disposition': 'attachment; filename=smartlocker_historique.csv' 224 } 225 ) 226 except Exception as e: 227 print("[history_export] {}".format(e)) 228 flash("Erreur lors de l'export.") 229 return redirect(url_for('api.history'))
routes_admin.py
222 lignes
1# ══════════════════════════════════════════════════════════════════════════════ 2# ROUTES — PANNEAU ADMINISTRATEUR 3# ────────────────────────────────────────────────────────────────────────────── 4# Console réservée aux emails listés dans config.ADMIN_EMAILS (décorateur 5# require_admin). Permet de visualiser tous les utilisateurs, casiers et 6# revenus, et d'agir : (dés)activer Premium, dissocier de force un casier, 7# supprimer un utilisateur, débloquer le casier sport en cas de désync. 8# ══════════════════════════════════════════════════════════════════════════════ 9 10from flask import Blueprint, render_template, request, redirect, session, url_for, flash 11 12from extensions import db, LOCKERS 13from helpers import require_admin, clear_locker_owner, is_admin 14 15admin_bp = Blueprint('admin', __name__) 16 17 18@admin_bp.route('/admin') 19@require_admin 20def admin_panel(): 21 """Vue admin : tous les users + casiers + revenus. 22 23 locker_owners est construit en consolidant 3 sources : 24 1) /lockers_index/{key} = uid (source de verite pour la verif d'appairage) 25 2) /users/{uid}/paired (clé nouvelle) 26 3) /users/{uid}/paired_lockers (clé legacy) 27 Ceci garantit que tout casier deja pris par n'importe quel compte (admin ou 28 non) apparaisse comme occupe dans l'interface admin. 29 """ 30 token = session['user'] 31 try: 32 users_raw = db.child("users").get(token).val() or {} 33 lockers_raw = db.child("lockers").get(token).val() or {} 34 # Source 1 : index global des proprietaires 35 try: 36 lockers_index = db.child("lockers_index").get(token).val() or {} 37 except Exception: 38 lockers_index = {} 39 if not isinstance(lockers_index, dict): 40 lockers_index = {} 41 42 users = [] 43 total_sessions = 0 44 total_revenue = 0.0 45 locker_owners = {} 46 47 def _owner_record(uid, udata): 48 return { 49 'uid': uid, 50 'email': udata.get('email', '-'), 51 'pseudo': udata.get('pseudo', udata.get('username', '-')), 52 'photo': udata.get('photoURL', '') or '', 53 } 54 55 # Pass 1 — agrege locker_owners depuis TOUS les comptes (admin inclus) 56 # et calcule en parallele les stats users (en excluant l'admin de la liste). 57 for uid, udata in users_raw.items(): 58 if not isinstance(udata, dict): 59 continue 60 is_admin_account = is_admin(udata.get('email', '')) 61 62 # locker_owners : on inclut TOUS les comptes (meme admin) pour 63 # que la console admin reflete fidelement quel casier est pris 64 paired = udata.get('paired', udata.get('paired_lockers', {})) 65 if isinstance(paired, dict): 66 for lkey in paired.keys(): 67 locker_owners[lkey] = _owner_record(uid, udata) 68 69 # On exclut l'admin de la liste des users affiches uniquement 70 if is_admin_account: 71 continue 72 73 history = udata.get('history', {}) or {} 74 session_count = len(history) if isinstance(history, dict) else 0 75 user_revenue = 0.0 76 for h in (history.values() if isinstance(history, dict) else []): 77 user_revenue += float(h.get('amount_eur', 0) or 0) 78 users.append({ 79 'uid': uid, 80 'pseudo': udata.get('pseudo', udata.get('username', '-')), 81 'email': udata.get('email', '-'), 82 'premium': udata.get('premium', False), 83 'sessions': session_count, 84 'revenue': round(user_revenue, 2), 85 'photo': udata.get('photoURL', '') or '', 86 }) 87 total_sessions += session_count 88 total_revenue += user_revenue 89 90 # Pass 2 — completer avec /lockers_index pour rattraper les cas ou 91 # l'index est present mais users/*/paired ne l'est pas (desync) 92 for lkey, owner_uid in lockers_index.items(): 93 if not owner_uid or lkey in locker_owners: 94 continue 95 udata = users_raw.get(owner_uid) if isinstance(users_raw, dict) else None 96 if isinstance(udata, dict): 97 locker_owners[lkey] = _owner_record(owner_uid, udata) 98 else: 99 # User introuvable dans /users mais index pointe vers lui : 100 # on l'affiche quand meme avec un pseudo generique 101 locker_owners[lkey] = { 102 'uid': owner_uid, 103 'email': '(utilisateur introuvable)', 104 'pseudo': owner_uid[:8] + '...', 105 'photo': '', 106 } 107 108 users.sort(key=lambda u: u['revenue'], reverse=True) 109 return render_template('admin.html', 110 users=users, 111 lockers_data=lockers_raw, 112 total_sessions=total_sessions, 113 total_revenue=round(total_revenue, 2), 114 locker_defs=LOCKERS, 115 locker_owners=locker_owners) 116 except Exception as e: 117 print("[admin_panel] {}".format(e)) 118 flash("Erreur lors du chargement admin.") 119 return redirect(url_for('main.home')) 120 121 122@admin_bp.route('/admin/toggle_premium', methods=['POST']) 123@require_admin 124def admin_toggle_premium(): 125 """L'admin active ou désactive le Premium d'un utilisateur.""" 126 token = session['user'] 127 uid = request.form.get('uid', '').strip() 128 if not uid: 129 flash("Utilisateur introuvable.") 130 return redirect(url_for('admin.admin_panel')) 131 try: 132 user_data = db.child("users").child(uid).get(token).val() or {} 133 new_val = not user_data.get('premium', False) 134 db.child("users").child(uid).update({"premium": new_val}, token) 135 pseudo = user_data.get('pseudo', user_data.get('username', uid)) 136 flash("Premium {} pour {} !".format("activé" if new_val else "désactivé", pseudo)) 137 except Exception as e: 138 print("[admin_toggle_premium] {}".format(e)) 139 flash("Erreur lors de la modification.") 140 return redirect(url_for('admin.admin_panel')) 141 142 143@admin_bp.route('/admin/force_unpair', methods=['POST']) 144@require_admin 145def admin_force_unpair(): 146 """Dissocie de force un casier d'un utilisateur depuis le pannel admin.""" 147 token = session['user'] 148 uid = request.form.get('uid') 149 lkey = request.form.get('locker_key') 150 if not uid or not lkey: 151 return redirect(url_for('admin.admin_panel')) 152 try: 153 # Migration douce : nouveau chemin 'paired', ancien 'paired_lockers' en fallback 154 paired = (db.child("users").child(uid).child("paired").get(token).val() 155 or db.child("users").child(uid).child("paired_lockers").get(token).val() 156 or {}) 157 if lkey in paired: 158 del paired[lkey] 159 # On ecrit dans tous les cas pour normaliser sur la nouvelle cle 'paired' 160 db.child("users").child(uid).update({"paired": paired}, token) 161 # IMPORTANT : nettoyer l'index global pour qu'un nouvel utilisateur 162 # puisse appairer ce casier 163 clear_locker_owner(lkey, token) 164 # Nettoyer aussi l'ancienne cle 'paired_lockers' si elle est encore presente 165 try: 166 db.child("users").child(uid).child("paired_lockers").remove(token) 167 except Exception: 168 pass 169 flash("Casier {} dissocie de force avec succes.".format(lkey.upper())) 170 except Exception as e: 171 print("[force_unpair] {}".format(e)) 172 flash("Erreur lors de la dissociation.") 173 return redirect(url_for('admin.admin_panel')) 174 175 176@admin_bp.route('/admin/delete_user', methods=['POST']) 177@require_admin 178def admin_delete_user(): 179 """Supprime les donnees d'un utilisateur de la base.""" 180 token = session['user'] 181 uid = request.form.get('uid', '').strip() 182 if not uid: 183 flash("Utilisateur introuvable.") 184 return redirect(url_for('admin.admin_panel')) 185 try: 186 # On recupere d'abord les casiers appaires pour nettoyer l'index global 187 user_data = db.child("users").child(uid).get(token).val() or {} 188 paired = user_data.get('paired', user_data.get('paired_lockers', {})) or {} 189 if isinstance(paired, dict): 190 for lkey in list(paired.keys()): 191 clear_locker_owner(lkey, token) 192 # Verif supplementaire : nettoyer toute entree de l'index qui pointe 193 # encore vers ce uid (au cas ou /users/{uid}/paired etait desync) 194 try: 195 idx = db.child("lockers_index").get(token).val() or {} 196 if isinstance(idx, dict): 197 for lkey, owner_uid in idx.items(): 198 if owner_uid == uid: 199 clear_locker_owner(lkey, token) 200 except Exception: 201 pass 202 # On supprime ses donnees de la base 203 db.child("users").child(uid).remove(token) 204 flash("Utilisateur supprime avec succes (donnees effacees).") 205 except Exception as e: 206 print("[admin_delete_user] {}".format(e)) 207 flash("Erreur lors de la suppression.") 208 return redirect(url_for('admin.admin_panel')) 209 210 211@admin_bp.route('/admin/force_free_sport', methods=['GET']) 212@require_admin 213def admin_force_free_sport(): 214 """Route temporaire pour reparer le bug de desync.""" 215 token = session['user'] 216 try: 217 db.child('lockers').child('SPORT_01').update({'status': 'locked'}, token) 218 db.child('lockers').child('SPORT_01').child('active_session').remove(token) 219 flash("Bug repare ! Casier SPORT_01 re-verrouille de force.") 220 except Exception as e: 221 flash("Erreur: {}".format(e)) 222 return redirect(url_for('admin.admin_panel'))
config.example.py
34 lignes
1# ═══════════════════════════════════════════════════════════════════════════ 2# SMARTLOCKER — CONFIG (EXEMPLE) 3# ⚠️ Renommez ce fichier en "config.py" et remplissez vos propres clés. 4# ⚠️ Ne publiez JAMAIS vos vraies clés secrètes (ajoutez config.py au .gitignore). 5# ═══════════════════════════════════════════════════════════════════════════ 6 7# ── Flask ───────────────────────────────────────────────────────────────── 8SECRET_KEY = "VOTRE_CLE_SECRETE_FLASK" 9 10# ── Stripe ──────────────────────────────────────────────────────────────── 11STRIPE_SECRET_KEY = "sk_test_VOTRE_CLE_SECRETE_STRIPE" 12STRIPE_PUBLISHABLE_KEY = "pk_test_VOTRE_CLE_PUBLIQUE_STRIPE" 13STRIPE_WEBHOOK_SECRET = "whsec_VOTRE_SECRET_WEBHOOK" 14PRICE_PER_HOUR = 500 # centimes = 5,00 EUR 15 16# ── Firebase ────────────────────────────────────────────────────────────── 17FIREBASE_CONFIG = { 18 "apiKey": "VOTRE_API_KEY_FIREBASE", 19 "authDomain": "votre-projet.firebaseapp.com", 20 "databaseURL": "https://votre-projet-default-rtdb.europe-west1.firebasedatabase.app", 21 "projectId": "votre-projet", 22 "storageBucket": "votre-projet.firebasestorage.app" 23} 24 25# ── Admin ───────────────────────────────────────────────────────────────── 26ADMIN_EMAILS = ["admin@exemple.be"] 27 28LOCKERS = { 29 "sport": {"id": "SPORT_01", "name": "Salle de sport", "location": "Zone fitness", "type": "sport"}, 30 "school": {"id": "ECOLE_01", "name": "Ecole", "location": "Couloir B", "type": "school"}, 31} 32LOCKER_CODES = {"SL-SPORT-01": "sport", "SL-ECOLE-01": "school"} 33SCHOOL_HOURS = ["08:00","09:00","10:00","11:00","12:00","13:00","14:00", 34 "15:00","16:00","17:00","18:00","19:00","20:00","21:00"]
Code_casiers/Casier1.py
236 lignes
1# ============================================================ 2# CASIER ECOLE (ECOLE_01) 3# Tourne sur le Raspberry Pi Zero du casier Ecole (fichier autonome). 4# Role : ouvrir la porte sur commande, surveiller la porte, lire les badges 5# RFID admin, prevenir en cas d'intrusion. 6# Communique en MQTT avec la passerelle (Pi 4B). 7# ============================================================ 8 9from gpiozero import OutputDevice, Button, DistanceSensor 10import paho.mqtt.client as mqtt 11from time import sleep, time 12from datetime import datetime, timezone 13import threading 14import warnings 15 16# Le module RFID n'est pas toujours installe : on gere le cas proprement. 17try: 18 from mfrc522 import SimpleMFRC522 19except ImportError: 20 SimpleMFRC522 = None 21 22# On masque les avertissements non essentiels affiches au demarrage 23warnings.filterwarnings("ignore") 24try: 25 import RPi.GPIO as GPIO 26 GPIO.setwarnings(False) # evite "This channel is already in use" (pin RST du RFID) 27except Exception: 28 pass 29 30# ============================================================ 31# REGLAGES 32# ============================================================ 33LOCKER_ID = "ECOLE_01" # identite du casier (doit matcher le bridge + Flask) 34 35# Broches GPIO (numerotation BCM) 36PIN_RELAIS = 17 # relais qui ouvre la porte 37PIN_PORTE = 27 # capteur de porte (ouverte/fermee) 38PIN_TRIG = 23 # ultrason - trig 39PIN_ECHO = 24 # ultrason - echo 40 41# Sens du capteur : True si is_pressed == porte FERMEE. 42# >>> Si l'app affiche "ouvert" alors que la porte est fermee, INVERSE cette valeur. <<< 43PORTE_FERMEE_QUAND_PRESSED = True 44 45DUREE_OUVERTURE = 4 # secondes pendant lesquelles la porte reste ouverte 46PORTE_OUVERTE_MAX = 60 # secondes : au-dela on previent (porte restee ouverte) 47LOCKDOWN_DUREE = 300 # 5 min : verrouillage securite apres intrusion 48ADMIN_RFID_UIDS = [] # badges admin autorises ([] = tous acceptes) 49 50MQTT_BROKER = "192.168.129.34" # le broker tourne sur le Pi 4B 51MQTT_PORT = 1883 52 53 54def iso_now(): 55 return datetime.now(timezone.utc).astimezone().isoformat() 56 57 58class Locker: 59 """Pilotage du casier : porte, capteurs, RFID, MQTT. Tout l'etat est en instance.""" 60 61 def __init__(self): 62 self.t_commande = f"locker/{LOCKER_ID}/commande" # on recoit les commandes ici 63 self.t_etat = f"locker/{LOCKER_ID}/etat" # on envoie l'etat de la porte 64 self.t_alerte = f"locker/{LOCKER_ID}/alerte" # on envoie les alertes 65 self.t_presence = f"locker/{LOCKER_ID}/presence" # on envoie la distance ultrason 66 self.t_status = f"locker/{LOCKER_ID}/status" # signal de vie (heartbeat) 67 68 # Materiel (GPIO) 69 self.relais = OutputDevice(PIN_RELAIS, active_high=True, initial_value=False) 70 self.porte = Button(PIN_PORTE, pull_up=True) 71 self.ultrason = DistanceSensor(echo=PIN_ECHO, trigger=PIN_TRIG, max_distance=2.0) 72 self.rfid = SimpleMFRC522() if SimpleMFRC522 else None 73 74 # Client MQTT (nouvelle API si disponible pour eviter un warning) 75 try: 76 self.mqtt = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) 77 except AttributeError: 78 self.mqtt = mqtt.Client() 79 80 # Etat 81 self.porte_ouverte_depuis = None # heure d'ouverture, ou None si fermee 82 self.lockdown_fin = 0 # timestamp de fin de lockdown (0 = aucun) 83 self.ouverture_volontaire = False # True quand c'est NOUS qui ouvrons 84 self.arret = threading.Event() 85 86 # ── Utilitaires ────────────────────────────────────────── 87 def log(self, msg): 88 print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}") 89 90 def envoyer(self, topic, message): 91 """Publie un message MQTT (silencieux si pas connecte).""" 92 try: 93 self.mqtt.publish(topic, message, qos=1) 94 except Exception as e: 95 self.log(f"Erreur envoi MQTT : {e}") 96 97 # ── Capteurs ───────────────────────────────────────────── 98 def porte_fermee(self): 99 return self.porte.is_pressed if PORTE_FERMEE_QUAND_PRESSED else not self.porte.is_pressed 100 101 def etat_porte_texte(self): 102 return "Fermee" if self.porte_fermee() else "Ouverte" 103 104 def distance_cm(self): 105 try: 106 return self.ultrason.distance * 100 107 except Exception: 108 return None 109 110 # ── Ouverture de la porte ──────────────────────────────── 111 def ouvrir_porte(self): 112 """Active le relais quelques secondes pour ouvrir la porte.""" 113 if time() < self.lockdown_fin: # refuse pendant un lockdown 114 self.log("Ouverture refusee : lockdown actif.") 115 self.envoyer(self.t_etat, "lockdown_actif") 116 return 117 self.log("Ouverture de la porte.") 118 self.ouverture_volontaire = True 119 self.envoyer(self.t_etat, "commande_recue") 120 self.relais.on() 121 sleep(DUREE_OUVERTURE) 122 self.relais.off() 123 sleep(1) # apres quoi l'ouverture n'est plus "volontaire" 124 self.ouverture_volontaire = False 125 126 def declencher_intrusion(self): 127 """Porte ouverte sans autorisation -> alerte + lockdown.""" 128 self.log("INTRUSION detectee !") 129 self.lockdown_fin = time() + LOCKDOWN_DUREE 130 self.envoyer(self.t_alerte, "intrusion") # le bridge previent l'utilisateur + l'admin 131 132 def reset_lockdown(self): 133 """Annule le lockdown (ex: badge admin).""" 134 self.lockdown_fin = 0 135 self.log("Lockdown annule.") 136 self.envoyer(self.t_alerte, "lockdown_reset") 137 138 # ── Reception des commandes (depuis la passerelle) ─────── 139 def sur_connexion(self, client, userdata, flags, rc): 140 self.log("Connecte au broker MQTT.") 141 client.subscribe(self.t_commande) 142 self.envoyer(self.t_status, f"en_ligne|{iso_now()}") 143 self.envoyer(self.t_etat, self.etat_porte_texte()) 144 145 def sur_message(self, client, userdata, msg): 146 commande = msg.payload.decode("utf-8", "ignore").strip().lower() 147 self.log(f"Commande recue : {commande}") 148 if commande == "ouvrir": 149 threading.Thread(target=self.ouvrir_porte, daemon=True).start() # ne pas bloquer MQTT 150 elif commande == "reset": 151 self.reset_lockdown() 152 elif commande == "etat": 153 self.envoyer(self.t_etat, self.etat_porte_texte()) 154 155 # ── Surveillance de la porte (boucle de fond) ──────────── 156 def surveiller_porte(self): 157 dernier = None 158 while not self.arret.is_set(): 159 etat = self.etat_porte_texte() 160 if etat != dernier: # changement d'etat 161 self.log(f"Porte : {etat}") 162 self.envoyer(self.t_etat, etat) 163 if etat == "Ouverte": 164 self.porte_ouverte_depuis = time() 165 if not self.ouverture_volontaire: # ouverture non commandee -> intrusion 166 self.declencher_intrusion() 167 else: 168 self.porte_ouverte_depuis = None 169 dernier = etat 170 if self.porte_ouverte_depuis and time() - self.porte_ouverte_depuis > PORTE_OUVERTE_MAX: 171 self.log("Porte restee ouverte trop longtemps.") 172 self.envoyer(self.t_alerte, "porte_ouverte_rappel") 173 self.porte_ouverte_depuis = time() # re-arme le compteur (anti-spam) 174 self.arret.wait(0.5) 175 176 # ── Lecture des badges RFID (boucle de fond) ───────────── 177 def surveiller_rfid(self): 178 if self.rfid is None: 179 self.log("Pas de lecteur RFID.") 180 return 181 while not self.arret.is_set(): 182 try: 183 uid = self.rfid.read_id_no_block() # None si aucun badge 184 if uid is not None: 185 self.log(f"Badge RFID : {uid}") 186 if ADMIN_RFID_UIDS and uid not in ADMIN_RFID_UIDS: 187 self.log("Badge refuse (pas dans la liste).") 188 else: 189 self.reset_lockdown() # le badge admin debloque 190 threading.Thread(target=self.ouvrir_porte, daemon=True).start() 191 sleep(3) # anti-rebond 192 except Exception as e: 193 self.log(f"Erreur RFID : {e}") 194 sleep(1) 195 self.arret.wait(0.2) 196 197 # ── Signal de vie + distance (boucle de fond) ──────────── 198 def envoyer_heartbeat(self): 199 while not self.arret.is_set(): 200 self.envoyer(self.t_status, f"en_ligne|{iso_now()}") 201 if self.porte_fermee(): # distance utile quand la porte est fermee 202 d = self.distance_cm() 203 if d is not None: 204 self.envoyer(self.t_presence, f"{d:.1f}") 205 self.arret.wait(30) 206 207 # ── Programme principal ────────────────────────────────── 208 def run(self): 209 self.log(f"Demarrage du casier {LOCKER_ID}") 210 self.mqtt.on_connect = self.sur_connexion 211 self.mqtt.on_message = self.sur_message 212 try: 213 while not self.arret.is_set(): # connexion MQTT avec retry 214 try: 215 self.mqtt.connect(MQTT_BROKER, MQTT_PORT, keepalive=60) 216 break 217 except Exception as e: 218 self.log(f"MQTT injoignable ({e}), nouvel essai dans 5s...") 219 sleep(5) 220 self.mqtt.loop_start() 221 for cible in (self.surveiller_porte, self.surveiller_rfid, self.envoyer_heartbeat): 222 threading.Thread(target=cible, daemon=True).start() 223 while True: # on laisse tourner jusqu'a Ctrl+C 224 sleep(1) 225 except KeyboardInterrupt: 226 self.log("Arret demande.") 227 finally: 228 self.arret.set() 229 self.relais.off() 230 self.envoyer(self.t_status, "hors_ligne") 231 self.mqtt.loop_stop() 232 self.log("Casier arrete.") 233 234 235if __name__ == "__main__": 236 Locker().run()
Code_casiers/Casier2.py
236 lignes
1# ============================================================ 2# CASIER SPORT (SPORT_01) 3# Tourne sur le Raspberry Pi Zero du casier Sport (fichier autonome). 4# Role : ouvrir la porte sur commande, surveiller la porte, lire les badges 5# RFID admin, prevenir en cas d'intrusion. 6# Communique en MQTT avec la passerelle (Pi 4B). 7# ============================================================ 8 9from gpiozero import OutputDevice, Button, DistanceSensor 10import paho.mqtt.client as mqtt 11from time import sleep, time 12from datetime import datetime, timezone 13import threading 14import warnings 15 16# Le module RFID n'est pas toujours installe : on gere le cas proprement. 17try: 18 from mfrc522 import SimpleMFRC522 19except ImportError: 20 SimpleMFRC522 = None 21 22# On masque les avertissements non essentiels affiches au demarrage 23warnings.filterwarnings("ignore") 24try: 25 import RPi.GPIO as GPIO 26 GPIO.setwarnings(False) # evite "This channel is already in use" (pin RST du RFID) 27except Exception: 28 pass 29 30# ============================================================ 31# REGLAGES 32# ============================================================ 33LOCKER_ID = "SPORT_01" # identite du casier (doit matcher le bridge + Flask) 34 35# Broches GPIO (numerotation BCM) 36PIN_RELAIS = 17 # relais qui ouvre la porte 37PIN_PORTE = 27 # capteur de porte (ouverte/fermee) 38PIN_TRIG = 23 # ultrason - trig 39PIN_ECHO = 24 # ultrason - echo 40 41# Sens du capteur : True si is_pressed == porte FERMEE. 42# >>> Si l'app affiche "ouvert" alors que la porte est fermee, INVERSE cette valeur. <<< 43PORTE_FERMEE_QUAND_PRESSED = True 44 45DUREE_OUVERTURE = 4 # secondes pendant lesquelles la porte reste ouverte 46PORTE_OUVERTE_MAX = 60 # secondes : au-dela on previent (porte restee ouverte) 47LOCKDOWN_DUREE = 300 # 5 min : verrouillage securite apres intrusion 48ADMIN_RFID_UIDS = [] # badges admin autorises ([] = tous acceptes) 49 50MQTT_BROKER = "192.168.129.34" # le broker tourne sur le Pi 4B 51MQTT_PORT = 1883 52 53 54def iso_now(): 55 return datetime.now(timezone.utc).astimezone().isoformat() 56 57 58class Locker: 59 """Pilotage du casier : porte, capteurs, RFID, MQTT. Tout l'etat est en instance.""" 60 61 def __init__(self): 62 self.t_commande = f"locker/{LOCKER_ID}/commande" # on recoit les commandes ici 63 self.t_etat = f"locker/{LOCKER_ID}/etat" # on envoie l'etat de la porte 64 self.t_alerte = f"locker/{LOCKER_ID}/alerte" # on envoie les alertes 65 self.t_presence = f"locker/{LOCKER_ID}/presence" # on envoie la distance ultrason 66 self.t_status = f"locker/{LOCKER_ID}/status" # signal de vie (heartbeat) 67 68 # Materiel (GPIO) 69 self.relais = OutputDevice(PIN_RELAIS, active_high=True, initial_value=False) 70 self.porte = Button(PIN_PORTE, pull_up=True) 71 self.ultrason = DistanceSensor(echo=PIN_ECHO, trigger=PIN_TRIG, max_distance=2.0) 72 self.rfid = SimpleMFRC522() if SimpleMFRC522 else None 73 74 # Client MQTT (nouvelle API si disponible pour eviter un warning) 75 try: 76 self.mqtt = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) 77 except AttributeError: 78 self.mqtt = mqtt.Client() 79 80 # Etat 81 self.porte_ouverte_depuis = None # heure d'ouverture, ou None si fermee 82 self.lockdown_fin = 0 # timestamp de fin de lockdown (0 = aucun) 83 self.ouverture_volontaire = False # True quand c'est NOUS qui ouvrons 84 self.arret = threading.Event() 85 86 # ── Utilitaires ────────────────────────────────────────── 87 def log(self, msg): 88 print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}") 89 90 def envoyer(self, topic, message): 91 """Publie un message MQTT (silencieux si pas connecte).""" 92 try: 93 self.mqtt.publish(topic, message, qos=1) 94 except Exception as e: 95 self.log(f"Erreur envoi MQTT : {e}") 96 97 # ── Capteurs ───────────────────────────────────────────── 98 def porte_fermee(self): 99 return self.porte.is_pressed if PORTE_FERMEE_QUAND_PRESSED else not self.porte.is_pressed 100 101 def etat_porte_texte(self): 102 return "Fermee" if self.porte_fermee() else "Ouverte" 103 104 def distance_cm(self): 105 try: 106 return self.ultrason.distance * 100 107 except Exception: 108 return None 109 110 # ── Ouverture de la porte ──────────────────────────────── 111 def ouvrir_porte(self): 112 """Active le relais quelques secondes pour ouvrir la porte.""" 113 if time() < self.lockdown_fin: # refuse pendant un lockdown 114 self.log("Ouverture refusee : lockdown actif.") 115 self.envoyer(self.t_etat, "lockdown_actif") 116 return 117 self.log("Ouverture de la porte.") 118 self.ouverture_volontaire = True 119 self.envoyer(self.t_etat, "commande_recue") 120 self.relais.on() 121 sleep(DUREE_OUVERTURE) 122 self.relais.off() 123 sleep(1) # apres quoi l'ouverture n'est plus "volontaire" 124 self.ouverture_volontaire = False 125 126 def declencher_intrusion(self): 127 """Porte ouverte sans autorisation -> alerte + lockdown.""" 128 self.log("INTRUSION detectee !") 129 self.lockdown_fin = time() + LOCKDOWN_DUREE 130 self.envoyer(self.t_alerte, "intrusion") # le bridge previent l'utilisateur + l'admin 131 132 def reset_lockdown(self): 133 """Annule le lockdown (ex: badge admin).""" 134 self.lockdown_fin = 0 135 self.log("Lockdown annule.") 136 self.envoyer(self.t_alerte, "lockdown_reset") 137 138 # ── Reception des commandes (depuis la passerelle) ─────── 139 def sur_connexion(self, client, userdata, flags, rc): 140 self.log("Connecte au broker MQTT.") 141 client.subscribe(self.t_commande) 142 self.envoyer(self.t_status, f"en_ligne|{iso_now()}") 143 self.envoyer(self.t_etat, self.etat_porte_texte()) 144 145 def sur_message(self, client, userdata, msg): 146 commande = msg.payload.decode("utf-8", "ignore").strip().lower() 147 self.log(f"Commande recue : {commande}") 148 if commande == "ouvrir": 149 threading.Thread(target=self.ouvrir_porte, daemon=True).start() # ne pas bloquer MQTT 150 elif commande == "reset": 151 self.reset_lockdown() 152 elif commande == "etat": 153 self.envoyer(self.t_etat, self.etat_porte_texte()) 154 155 # ── Surveillance de la porte (boucle de fond) ──────────── 156 def surveiller_porte(self): 157 dernier = None 158 while not self.arret.is_set(): 159 etat = self.etat_porte_texte() 160 if etat != dernier: # changement d'etat 161 self.log(f"Porte : {etat}") 162 self.envoyer(self.t_etat, etat) 163 if etat == "Ouverte": 164 self.porte_ouverte_depuis = time() 165 if not self.ouverture_volontaire: # ouverture non commandee -> intrusion 166 self.declencher_intrusion() 167 else: 168 self.porte_ouverte_depuis = None 169 dernier = etat 170 if self.porte_ouverte_depuis and time() - self.porte_ouverte_depuis > PORTE_OUVERTE_MAX: 171 self.log("Porte restee ouverte trop longtemps.") 172 self.envoyer(self.t_alerte, "porte_ouverte_rappel") 173 self.porte_ouverte_depuis = time() # re-arme le compteur (anti-spam) 174 self.arret.wait(0.5) 175 176 # ── Lecture des badges RFID (boucle de fond) ───────────── 177 def surveiller_rfid(self): 178 if self.rfid is None: 179 self.log("Pas de lecteur RFID.") 180 return 181 while not self.arret.is_set(): 182 try: 183 uid = self.rfid.read_id_no_block() # None si aucun badge 184 if uid is not None: 185 self.log(f"Badge RFID : {uid}") 186 if ADMIN_RFID_UIDS and uid not in ADMIN_RFID_UIDS: 187 self.log("Badge refuse (pas dans la liste).") 188 else: 189 self.reset_lockdown() # le badge admin debloque 190 threading.Thread(target=self.ouvrir_porte, daemon=True).start() 191 sleep(3) # anti-rebond 192 except Exception as e: 193 self.log(f"Erreur RFID : {e}") 194 sleep(1) 195 self.arret.wait(0.2) 196 197 # ── Signal de vie + distance (boucle de fond) ──────────── 198 def envoyer_heartbeat(self): 199 while not self.arret.is_set(): 200 self.envoyer(self.t_status, f"en_ligne|{iso_now()}") 201 if self.porte_fermee(): # distance utile quand la porte est fermee 202 d = self.distance_cm() 203 if d is not None: 204 self.envoyer(self.t_presence, f"{d:.1f}") 205 self.arret.wait(30) 206 207 # ── Programme principal ────────────────────────────────── 208 def run(self): 209 self.log(f"Demarrage du casier {LOCKER_ID}") 210 self.mqtt.on_connect = self.sur_connexion 211 self.mqtt.on_message = self.sur_message 212 try: 213 while not self.arret.is_set(): # connexion MQTT avec retry 214 try: 215 self.mqtt.connect(MQTT_BROKER, MQTT_PORT, keepalive=60) 216 break 217 except Exception as e: 218 self.log(f"MQTT injoignable ({e}), nouvel essai dans 5s...") 219 sleep(5) 220 self.mqtt.loop_start() 221 for cible in (self.surveiller_porte, self.surveiller_rfid, self.envoyer_heartbeat): 222 threading.Thread(target=cible, daemon=True).start() 223 while True: # on laisse tourner jusqu'a Ctrl+C 224 sleep(1) 225 except KeyboardInterrupt: 226 self.log("Arret demande.") 227 finally: 228 self.arret.set() 229 self.relais.off() 230 self.envoyer(self.t_status, "hors_ligne") 231 self.mqtt.loop_stop() 232 self.log("Casier arrete.") 233 234 235if __name__ == "__main__": 236 Locker().run()
Code_casiers/bridge_firebase.py
581 lignes
1import firebase_admin 2from firebase_admin import credentials, db as fdb, messaging, firestore 3import paho.mqtt.client as mqtt 4from datetime import datetime, timezone 5import logging 6import sys 7import time 8import threading 9 10# ========================================== 11# CONFIGURATION 12# ========================================== 13URL_DATABASE = 'https://smartlocker-94e11-default-rtdb.europe-west1.firebasedatabase.app/' 14CHEMIN_CRED = "cle-firebase.json" 15 16BROKER_HOST = "localhost" 17BROKER_PORT = 1883 18 19# Mapping LOCKER_ID → locker_key Flask 20# Doit correspondre exactement à config.py Flask 21LOCKERS = { 22 "SPORT_01": "sport", 23 "ECOLE_01": "school", 24} 25 26# Noms lisibles + adresses physiques des casiers (pour les alertes email admin) 27# Doit correspondre a config.py Flask (LOCKERS[...]['name'] / ['location']). 28LOCKER_INFO = { 29 "SPORT_01": {"name": "Salle de sport", "location": "Zone fitness - Rez-de-chaussee"}, 30 "ECOLE_01": {"name": "Ecole", "location": "Couloir B - 1er etage"}, 31} 32 33# ── Alerte email intrusion (via extension Firebase "Trigger Email") ────────── 34# La passerelle ecrit un document dans la collection Firestore MAIL_COLLECTION ; 35# l'extension Firebase envoie l'email automatiquement. 36# - Laisse ALERT_EMAIL_TO vide pour DESACTIVER l'envoi. 37ALERT_EMAIL_TO = "ismail.lamrine@inraci.be" 38MAIL_COLLECTION = "mail" 39 40HEARTBEAT_SEC = 30 41POLL_SEC = 1.0 # fréquence de lecture Firebase 42LOG_FILE = "passerelle.log" 43 44# ========================================== 45# LOGGING 46# ========================================== 47logging.basicConfig( 48 level=logging.INFO, 49 format="%(asctime)s [%(levelname)s] %(message)s", 50 handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler(sys.stdout)] 51) 52log = logging.getLogger("Passerelle") 53 54# ========================================== 55# ÉTAT 56# ========================================== 57arret_demande = threading.Event() 58mqtt_client = None 59derniers_statuts = {lid: None for lid in LOCKERS} # statuts Firebase connus 60derniers_cmds = {lid: None for lid in LOCKERS} # commandes d'ouverture ponctuelles 61 62 63def iso_now(): 64 return datetime.now(timezone.utc).astimezone().isoformat() 65 66 67# ========================================== 68# FIREBASE 69# ========================================== 70def init_firebase(): 71 try: 72 cred = credentials.Certificate(CHEMIN_CRED) 73 firebase_admin.initialize_app(cred, {'databaseURL': URL_DATABASE}) 74 log.info("✅ Firebase Admin SDK initialisé.") 75 return True 76 except FileNotFoundError: 77 log.critical(f"Clé Firebase introuvable : {CHEMIN_CRED}") 78 return False 79 except Exception as e: 80 log.critical(f"Erreur Firebase : {e}") 81 return False 82 83 84def lire_statut_locker(locker_id): 85 try: 86 data = fdb.reference(f'lockers/{locker_id}').get() 87 if not data: 88 return 'locked' 89 return data.get('status', 'locked') 90 except Exception as e: 91 log.error(f"Lecture Firebase {locker_id} : {e}") 92 return None 93 94 95def ecrire_firebase(locker_id, champs: dict): 96 try: 97 fdb.reference(f'lockers/{locker_id}').update(champs) 98 except Exception as e: 99 log.error(f"Écriture Firebase {locker_id} : {e}") 100 101 102def set_gateway_status(status): 103 """Met à jour l'état (online/offline) + horodatage de la passerelle pi4b.""" 104 try: 105 fdb.reference('gateways/pi4b').update({'status': status, 'last_seen': iso_now()}) 106 except Exception as e: 107 log.warning(f"Gateway status : {e}") 108 109 110def push_history(locker_id, evenement): 111 try: 112 fdb.reference('history').push({ 113 'casier': locker_id, 114 'evenement': evenement, 115 'date': iso_now() 116 }) 117 except Exception as e: 118 log.error(f"Push history : {e}") 119 120 121_firestore_client = None 122 123def _get_firestore(): 124 """Retourne le client Firestore (lazy). None si indisponible.""" 125 global _firestore_client 126 if _firestore_client is None: 127 try: 128 _firestore_client = firestore.client() 129 except Exception as e: 130 log.error(f"Firestore indisponible : {e}") 131 return None 132 return _firestore_client 133 134 135def _send_intrusion_email(locker_id, alerte): 136 """ 137 Envoie un email d'alerte a l'administrateur via l'extension Firebase 138 "Trigger Email" : on ecrit un document dans la collection Firestore. 139 Inclut le nom + l'adresse physique du casier concerne. 140 Degrade proprement si non configure (ALERT_EMAIL_TO vide / Firestore KO). 141 """ 142 if not ALERT_EMAIL_TO: 143 log.warning("Email admin desactive (ALERT_EMAIL_TO vide) — alerte non envoyee.") 144 return 145 fs = _get_firestore() 146 if fs is None: 147 return 148 149 info = LOCKER_INFO.get(locker_id, {}) 150 nom = info.get("name", locker_id) 151 adresse = info.get("location", "Adresse inconnue") 152 horodate = iso_now() 153 154 sujet = f"[SmartLocker] ALERTE intrusion - {nom} ({locker_id})" 155 html = ( 156 f"<h2 style='color:#b91c1c;'>⚠ Intrusion detectee</h2>" 157 f"<p>Une intrusion vient d'etre detectee sur un casier SmartLocker.</p>" 158 f"<table style='border-collapse:collapse;'>" 159 f"<tr><td style='padding:4px 12px;'><b>Casier</b></td><td style='padding:4px 12px;'>{nom} ({locker_id})</td></tr>" 160 f"<tr><td style='padding:4px 12px;'><b>Adresse</b></td><td style='padding:4px 12px;'>{adresse}</td></tr>" 161 f"<tr><td style='padding:4px 12px;'><b>Type d'alerte</b></td><td style='padding:4px 12px;'>{alerte}</td></tr>" 162 f"<tr><td style='padding:4px 12px;'><b>Date/heure</b></td><td style='padding:4px 12px;'>{horodate}</td></tr>" 163 f"</table>" 164 f"<p style='margin-top:16px;'>Le casier est en mode securite (5 min). " 165 f"Une intervention administrateur est requise.</p>" 166 ) 167 texte = (f"Intrusion detectee.\n" 168 f"Casier : {nom} ({locker_id})\n" 169 f"Adresse : {adresse}\n" 170 f"Alerte : {alerte}\n" 171 f"Date : {horodate}\n" 172 f"Le casier est en mode securite. Intervention admin requise.") 173 174 try: 175 fs.collection(MAIL_COLLECTION).add({ 176 "to": [ALERT_EMAIL_TO], 177 "message": { 178 "subject": sujet, 179 "text": texte, 180 "html": html, 181 }, 182 }) 183 log.info(f"📧 Email d'alerte intrusion ecrit dans Firestore ({MAIL_COLLECTION}) pour {ALERT_EMAIL_TO}") 184 except Exception as e: 185 log.error(f"Ecriture email Firestore echouee : {e}") 186 187 188def _send_fcm_to_locker_owners(locker_id, title, body, msg_type, 189 urgency="high", require_interaction=True): 190 """ 191 Envoie une notification FCM au(x) proprietaire(s) d'un casier donne. 192 Utilise pour : intrusion, rappel porte ouverte, objet retire, lockdown reset. 193 Avec urgence haute + require_interaction, le navigateur reveille l'utilisateur 194 meme avec l'app fermee (PWA / web push). 195 """ 196 try: 197 users = fdb.reference('users').get() or {} 198 locker_key = LOCKERS.get(locker_id) 199 if not locker_key: 200 return 201 for uid, udata in users.items(): 202 if not isinstance(udata, dict): 203 continue 204 # Migration douce : 'paired' (nouveau) ou 'paired_lockers' (legacy) 205 paired = udata.get('paired', udata.get('paired_lockers', {})) or {} 206 if locker_key not in paired: 207 continue 208 fcm_token = udata.get('fcm_token') 209 if not fcm_token: 210 continue 211 try: 212 message = messaging.Message( 213 notification=messaging.Notification(title=title, body=body), 214 data={ 215 "type": msg_type, 216 "locker_id": locker_id, 217 "title": title, 218 "body": body, 219 }, 220 webpush=messaging.WebpushConfig( 221 headers={"Urgency": urgency, "TTL": "86400"}, 222 notification=messaging.WebpushNotification( 223 require_interaction=require_interaction, 224 icon="/static/icons/icon-192.png", 225 badge="/static/icons/icon-192.png", 226 vibrate=[500, 200, 500, 200, 1000], 227 tag=f"smartlocker-{msg_type}", 228 renotify=True 229 ) 230 ), 231 token=fcm_token 232 ) 233 messaging.send(message) 234 log.info(f"📲 Push FCM [{msg_type}] envoyé à {uid}") 235 except messaging.UnregisteredError: 236 log.warning(f"Token FCM expiré pour {uid} — suppression") 237 try: 238 fdb.reference(f'users/{uid}/fcm_token').delete() 239 except Exception: 240 pass 241 except Exception as send_err: 242 log.error(f"Envoi FCM à {uid} : {send_err}") 243 except Exception as e: 244 log.error(f"_send_fcm_to_locker_owners : {e}") 245 246 247# ========================================== 248# HEARTBEAT PASSERELLE 249# ========================================== 250def heartbeat(): 251 while not arret_demande.is_set(): 252 set_gateway_status('online') 253 arret_demande.wait(timeout=HEARTBEAT_SEC) 254 255 256# ========================================== 257# POLLING FIREBASE → MQTT 258# Détecte quand le web change le statut → envoie la commande au casier 259# ========================================== 260def polling_firebase(): 261 log.info("🔄 Polling Firebase démarré.") 262 263 # Throttling des logs d'erreur pour ne pas spammer si Firebase down 264 last_error_log = {} # {locker_id: timestamp} 265 ERROR_LOG_INTERVAL = 60 # log au max 1x/min par locker 266 267 while not arret_demande.is_set(): 268 for locker_id, locker_key in LOCKERS.items(): 269 try: 270 data = fdb.reference(f'lockers/{locker_id}').get() or {} 271 if not isinstance(data, dict): 272 data = {} 273 statut = data.get('status', 'locked') 274 cmd = data.get('cmd_open_door') 275 276 # Vérifier si le casier est en lockdown 277 lockdown_until = data.get('lockdown_until', 0) or 0 278 try: 279 is_locked_down = float(lockdown_until) > time.time() 280 except (TypeError, ValueError): 281 is_locked_down = False 282 283 precedent = derniers_statuts[locker_id] 284 prec_cmd = derniers_cmds[locker_id] 285 286 # Détection changement de statut 287 # IMPORTANT : on N'OUVRE PLUS automatiquement la porte sur un 288 # changement de statut. Le statut 'unlocked' indique seulement 289 # qu'une session logique est active. La porte ne s'ouvre QUE 290 # sur une commande explicite cmd_open_door (cf. bloc ci-dessous). 291 # Cela empeche la porte de s'ouvrir toute seule a l'appairage 292 # (Flask passe le statut a 'unlocked' sans envoyer cmd_open_door). 293 if statut != precedent: 294 log.info(f"Firebase [{locker_id}] : {precedent} → {statut}") 295 if statut == 'unlocked' and precedent in ('locked', 'reserved'): 296 if is_locked_down: 297 log.warning(f"⛔ Statut unlocked refuse [{locker_id}] : LOCKDOWN actif") 298 ecrire_firebase(locker_id, {'status': 'locked', 'active_session': None}) 299 else: 300 log.info(f"ℹ️ [{locker_id}] session logique active (porte fermee, attend cmd_open_door)") 301 derniers_statuts[locker_id] = statut 302 303 # Détection commande d'ouverture manuelle (pulse) 304 # IMPORTANT : on n'ouvre que sur une NOUVELLE commande recente. 305 # - Premier passage (prec_cmd == None) : on enregistre la valeur SANS ouvrir 306 # pour eviter une ouverture parasite au boot du bridge si Firebase 307 # contient encore un ancien cmd_open_door. 308 # - Verif fraicheur : cmd_open_door est un timestamp (time.time()). 309 # On ignore les commandes vieilles de plus de 10 secondes. 310 if cmd and cmd != prec_cmd: 311 if prec_cmd is None: 312 # Premier passage : on memorise sans ouvrir 313 log.info(f"ℹ️ [{locker_id}] cmd_open_door initiale ignoree au boot (cmd={cmd})") 314 else: 315 # Verifier que la commande est fraiche (moins de 10s) 316 try: 317 cmd_age = time.time() - float(cmd) 318 except (TypeError, ValueError): 319 cmd_age = 0 # si on n'arrive pas a parser, on considere fraiche 320 if cmd_age > 10: 321 log.info(f"⏰ [{locker_id}] cmd_open_door ignoree (vieille de {cmd_age:.0f}s)") 322 elif is_locked_down: 323 log.warning(f"⛔ Commande bloquée [{locker_id}] : LOCKDOWN actif") 324 else: 325 topic_commande = f"locker/{locker_id}/commande" 326 log.info(f"📤 MQTT → {topic_commande} : ouvrir (via cmd_open_door)") 327 mqtt_client.publish(topic_commande, "ouvrir", qos=1) 328 derniers_cmds[locker_id] = cmd 329 330 except Exception as e: 331 # Throttle les logs d'erreur pour éviter de noyer la sortie 332 now = time.time() 333 last = last_error_log.get(locker_id, 0) 334 if now - last >= ERROR_LOG_INTERVAL: 335 log.error(f"Polling [{locker_id}] : {e}") 336 last_error_log[locker_id] = now 337 338 arret_demande.wait(timeout=POLL_SEC) 339 340 341# ========================================== 342# CALLBACKS MQTT (Pi Zero → Passerelle → Firebase) 343# ========================================== 344def sur_connexion(client, userdata, flags, rc, properties=None): 345 if rc == 0: 346 log.info("✅ Connecté au broker MQTT.") 347 # Abonnement générique : tous les casiers 348 client.subscribe("locker/#", qos=1) 349 else: 350 log.error(f"Échec MQTT (code {rc})") 351 352 353def sur_deconnexion(client, userdata, *args, **kwargs): 354 rc = args[0] if args else 0 355 if rc != 0: 356 log.warning(f"⚠️ Déconnexion MQTT inattendue (code {rc}) — reconnexion auto.") 357 358 359def sur_message(client, userdata, msg): 360 try: 361 payload = msg.payload.decode('utf-8', errors='replace').strip() 362 except Exception as e: 363 log.error(f"Décodage MQTT : {e}") 364 return 365 366 # Décoder le topic : locker/{LOCKER_ID}/{type} 367 parts = msg.topic.split('/') 368 if len(parts) != 3 or parts[0] != 'locker': 369 return 370 371 locker_id = parts[1] 372 event_type = parts[2] 373 374 # Ignorer les topics de commande (on les envoie, on ne les reçoit pas) 375 if event_type == 'commande': 376 return 377 378 if locker_id not in LOCKERS: 379 log.warning(f"MQTT : casier inconnu '{locker_id}' — ignoré.") 380 return 381 382 log.info(f"📥 MQTT [{locker_id}/{event_type}] → {payload}") 383 now = iso_now() 384 385 if event_type == 'etat': 386 # Met à jour l'état physique réel de la porte dans Firebase sans forcer le statut de session 387 ecrire_firebase(locker_id, { 388 'door_physical': payload, 389 'door_physical_updated': now 390 }) 391 392 elif event_type == 'alerte': 393 alerte = payload[:100] 394 395 # ── Commande de reset du lockdown ─────────────────────────────── 396 if alerte == 'lockdown_reset': 397 ecrire_firebase(locker_id, { 398 'lockdown_until': 0, 399 'lockdown_reason': '', 400 'alert_active': '', 401 'alert_time': now 402 }) 403 push_history(locker_id, 'lockdown_reset_admin') 404 log.info(f"✅ Lockdown [{locker_id}] réinitialisé — casier débloqué") 405 _send_fcm_to_locker_owners( 406 locker_id, 407 title="✅ CASIER DÉBLOQUÉ", 408 body=f"Un administrateur a débloqué votre casier {locker_id}. La situation est rétablie.", 409 msg_type="lockdown_reset", 410 urgency="high", 411 require_interaction=True 412 ) 413 return 414 415 # ── Signal informatif uniquement ──────────────────────────────── 416 if alerte == 'lockdown_actif': 417 return 418 419 # ── RAPPEL : porte ouverte trop longtemps (pas de LOCKDOWN) ───── 420 # Le Pi envoie 'porte_ouverte_rappel' jusqu'a 3 fois avant d'escalader. 421 if alerte == 'porte_ouverte_rappel': 422 # On stocke l'info dans Firebase pour declencher la popup web sans toucher au lockdown 423 ecrire_firebase(locker_id, { 424 'reminder_active': 'porte_ouverte_rappel', 425 'reminder_time': now 426 }) 427 push_history(locker_id, 'porte_ouverte_rappel') 428 log.warning(f"⏰ Rappel [{locker_id}] : porte restée ouverte — notification utilisateur") 429 _send_fcm_to_locker_owners( 430 locker_id, 431 title="⏰ FERMEZ VOTRE CASIER", 432 body=f"Votre casier {locker_id} est resté ouvert ! Pensez à bien le refermer.", 433 msg_type="door_open_reminder", 434 urgency="high", 435 require_interaction=True 436 ) 437 return 438 439 # ── OBJET RETIRE : info uniquement, pas de LOCKDOWN ───────────── 440 if alerte == 'objet_retire': 441 ecrire_firebase(locker_id, { 442 'object_alert_active': 'objet_retire', 443 'object_alert_time': now 444 }) 445 push_history(locker_id, 'objet_retire') 446 log.warning(f"📦 Objet retire [{locker_id}] — notification utilisateur") 447 _send_fcm_to_locker_owners( 448 locker_id, 449 title="📦 OBJET RETIRÉ DU CASIER", 450 body=f"Un objet vient d'être retiré de votre casier {locker_id}.", 451 msg_type="object_removed", 452 urgency="high", 453 require_interaction=False 454 ) 455 return 456 457 # ── VRAIE alerte d'intrusion → LOCKDOWN ───────────────────────── 458 # (intrusion, porte_ouverte_trop_longtemps escalade, etc.) 459 lockdown_end = time.time() + 300 460 ecrire_firebase(locker_id, { 461 'alert_active': alerte, 462 'alert_time': now, 463 'lockdown_until': lockdown_end, 464 'lockdown_reason': alerte, 465 'status': 'locked', 466 'active_session': None, 467 'reminder_active': '' # On nettoie les rappels en cours 468 }) 469 push_history(locker_id, alerte) 470 log.warning(f"🚨 Alerte [{locker_id}] : {alerte} — LOCKDOWN 5min activé") 471 472 # Messages adaptes selon le type d'alerte 473 if alerte == 'porte_ouverte_trop_longtemps': 474 title = "🚨 CASIER VERROUILLÉ - INTERVENTION ADMIN" 475 body = (f"Le casier {locker_id} est resté ouvert trop longtemps malgré les rappels. " 476 f"Un administrateur va intervenir.") 477 elif alerte == 'intrusion': 478 title = "🚨 INTRUSION DÉTECTÉE" 479 body = f"Tentative d'ouverture forcée détectée sur votre casier {locker_id} ! Un administrateur va intervenir." 480 else: 481 title = "🚨 ALERTE SMARTLOCKER" 482 body = f"Alerte sur votre casier {locker_id} ({alerte}). Un administrateur va intervenir." 483 484 _send_fcm_to_locker_owners( 485 locker_id, 486 title=title, 487 body=body, 488 msg_type="intrusion_alert", 489 urgency="high", 490 require_interaction=True 491 ) 492 493 # Email a l'administrateur (avec adresse du casier) via Firebase Trigger Email 494 _send_intrusion_email(locker_id, alerte) 495 496 elif event_type == 'presence': 497 # Distance ultrason — on stocke pour affichage web 498 try: 499 distance = float(payload) 500 ecrire_firebase(locker_id, { 501 'distance_cm': distance, 502 'content_state': 'occupied' if distance < 15 else 'empty', 503 'door_physical_updated': now 504 }) 505 except ValueError: 506 pass 507 508 elif event_type == 'status': 509 # Heartbeat du casier (online / offline) 510 raw_status = payload.split('|')[0] 511 # Normalisation : en_ligne -> online, hors_ligne -> offline 512 normalized = 'online' if raw_status in ('en_ligne', 'online') else 'offline' 513 ecrire_firebase(locker_id, { 514 'online_status': normalized, 515 'last_heartbeat': now 516 }) 517 518 519# ========================================== 520# MAIN 521# ========================================== 522def main(): 523 global mqtt_client 524 525 log.info("=" * 50) 526 log.info(" 🔗 PASSERELLE PI4B DÉMARRÉE") 527 log.info("=" * 50) 528 529 if not init_firebase(): 530 sys.exit(1) 531 532 try: 533 mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) 534 except AttributeError: 535 mqtt_client = mqtt.Client() 536 537 mqtt_client.on_connect = sur_connexion 538 mqtt_client.on_disconnect = sur_deconnexion 539 mqtt_client.on_message = sur_message 540 mqtt_client.reconnect_delay_set(min_delay=1, max_delay=30) 541 # Heartbeat : si le bridge crash, le broker MQTT le saura 542 mqtt_client.will_set("passerelle/pi4b/status", "hors_ligne|crash", qos=1, retain=False) 543 544 # Connexion MQTT avec boucle infinie de retry 545 connected = False 546 attempt = 0 547 while not connected and not arret_demande.is_set(): 548 attempt += 1 549 try: 550 log.info(f"🔄 Tentative de connexion MQTT #{attempt} ({BROKER_HOST}:{BROKER_PORT})...") 551 mqtt_client.connect(BROKER_HOST, BROKER_PORT, keepalive=60) 552 mqtt_client.loop_start() 553 connected = True 554 log.info("✅ Connexion MQTT établie.") 555 except Exception as e: 556 log.warning(f"❌ Connexion MQTT échouée : {e} — nouvelle tentative dans 5s...") 557 time.sleep(5) 558 559 # Marquer en ligne 560 set_gateway_status('online') 561 562 threading.Thread(target=heartbeat, daemon=True).start() 563 threading.Thread(target=polling_firebase, daemon=True).start() 564 565 log.info("✅ Passerelle active. CTRL+C pour arrêter.") 566 567 try: 568 while not arret_demande.is_set(): 569 time.sleep(1) 570 except KeyboardInterrupt: 571 log.info("CTRL+C — arrêt.") 572 finally: 573 arret_demande.set() 574 set_gateway_status('offline') 575 mqtt_client.loop_stop() 576 mqtt_client.disconnect() 577 log.info("Passerelle arrêtée proprement 👋") 578 579 580if __name__ == "__main__": 581 main()
templates/index.html
968 lignes
1<!DOCTYPE html> 2<html lang="fr"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>SmartLocker</title> 7 <link rel="manifest" href="/manifest.json"> 8 <meta name="theme-color" content="#02040a"> 9 <meta name="mobile-web-app-capable" content="yes"> 10 <meta name="apple-mobile-web-app-capable" content="yes"> 11 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> 12 <meta name="apple-mobile-web-app-title" content="SmartLocker"> 13 <link rel="apple-touch-icon" href="/static/icons/icon-192.png"> 14 <link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}"> 15 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> 16 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css"> 17 <script src="https://www.gstatic.com/firebasejs/10.7.0/firebase-app-compat.js"></script> 18 <script src="https://www.gstatic.com/firebasejs/10.7.0/firebase-auth-compat.js"></script> 19 <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script> 20 21 {% if logged_in %}<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>{% endif %} 22</head> 23<body> 24 25<div id="offline-overlay"> 26 <div class="offline-icon-wrap"> 27 <div class="offline-ring"></div><div class="offline-ring-2"></div> 28 <div class="offline-core"><i class="fa-solid fa-wifi"></i></div> 29 </div> 30 <div class="offline-title">SIGNAL <span>PERDU</span></div> 31 <p class="offline-sub">Vérifiez votre connexion réseau. L'app se reconnectera automatiquement.</p> 32</div> 33 34<!-- ══ ONBOARDING SAMSUNG (auto-géré) ══════════════════════════════ --> 35<style> 36 .sams-overlay { 37 position: fixed; inset: 0; z-index: 99999; 38 background: rgba(2, 4, 10, 0.97); 39 display: none; flex-direction: column; 40 justify-content: center; align-items: center; 41 padding: 30px 20px; text-align: center; 42 backdrop-filter: blur(15px); 43 animation: samsFadeIn 0.4s ease; 44 } 45 .sams-overlay.show { display: flex; } 46 @keyframes samsFadeIn { from{opacity:0;transform:scale(0.95)} to{opacity:1;transform:scale(1)} } 47 .sams-logo { 48 font-size: 4rem; color: #00f0ff; 49 margin-bottom: 20px; 50 text-shadow: 0 0 30px rgba(0,240,255,0.5); 51 } 52 .sams-title { 53 font-family: 'Orbitron', sans-serif; 54 font-size: 1.1rem; font-weight: 900; 55 letter-spacing: 3px; color: #fff; 56 margin-bottom: 10px; 57 } 58 .sams-sub { 59 font-family: 'Rajdhani', sans-serif; 60 color: #94a3b8; font-size: 0.95rem; 61 margin-bottom: 28px; max-width: 340px; line-height: 1.5; 62 } 63 .sams-step { 64 display: flex; align-items: center; gap: 14px; 65 background: rgba(0, 240, 255, 0.05); 66 border: 1px solid rgba(0, 240, 255, 0.2); 67 border-radius: 8px; 68 padding: 14px 18px; margin-bottom: 12px; 69 max-width: 340px; width: 100%; 70 text-align: left; 71 } 72 .sams-step-num { 73 flex-shrink: 0; 74 width: 32px; height: 32px; 75 background: rgba(0, 240, 255, 0.15); 76 border: 1px solid #00f0ff; 77 border-radius: 50%; 78 display: flex; align-items: center; justify-content: center; 79 font-family: 'Orbitron', sans-serif; 80 font-size: 0.85rem; font-weight: 700; color: #00f0ff; 81 } 82 .sams-step-num.done { 83 background: rgba(0, 255, 157, 0.2); 84 border-color: #00ff9d; color: #00ff9d; 85 } 86 .sams-step-text { 87 font-family: 'Rajdhani', sans-serif; 88 font-size: 0.92rem; color: #e2e8f0; line-height: 1.3; 89 } 90 .sams-step-text small { 91 display: block; color: #94a3b8; 92 font-size: 0.78rem; margin-top: 2px; 93 } 94 .sams-btn { 95 margin-top: 24px; 96 background: linear-gradient(135deg, #00f0ff, #00ff9d); 97 color: #02040a; 98 border: none; padding: 14px 32px; 99 font-family: 'Orbitron', sans-serif; 100 font-size: 0.85rem; font-weight: 900; letter-spacing: 2px; 101 border-radius: 50px; cursor: pointer; 102 box-shadow: 0 0 25px rgba(0, 240, 255, 0.4); 103 transition: 0.3s; text-transform: uppercase; 104 } 105 .sams-btn:active { transform: scale(0.97); } 106 .sams-btn:disabled { opacity: 0.5; cursor: not-allowed; } 107 .sams-skip { 108 margin-top: 12px; background: transparent; 109 border: none; color: #64748b; 110 font-family: 'Rajdhani', sans-serif; font-size: 0.85rem; 111 cursor: pointer; text-decoration: underline; 112 } 113 .sams-error { 114 margin-top: 14px; 115 color: #ff2a6d; font-size: 0.85rem; max-width: 320px; 116 } 117</style> 118 119<div id="sams-overlay" class="sams-overlay"> 120 <div class="sams-logo"><i class="fa-solid fa-mobile-screen-button"></i></div> 121 <div class="sams-title">CONFIGURATION AUTOMATIQUE</div> 122 <div class="sams-sub">Pour une expérience optimale sur votre Samsung, autorisons la caméra avant l'installation.</div> 123 124 <div class="sams-step" id="sams-step-1"> 125 <div class="sams-step-num" id="sams-num-1">1</div> 126 <div class="sams-step-text"> 127 Autoriser la caméra 128 <small>Pour scanner les QR codes des casiers</small> 129 </div> 130 </div> 131 <div class="sams-step" id="sams-step-2"> 132 <div class="sams-step-num" id="sams-num-2">2</div> 133 <div class="sams-step-text"> 134 Installer l'application 135 <small>Ajout sur l'écran d'accueil</small> 136 </div> 137 </div> 138 139 <button class="sams-btn" id="sams-btn-action" onclick="samsHandleAction()"> 140 <i class="fa-solid fa-camera"></i> AUTORISER LA CAMÉRA 141 </button> 142 <button class="sams-skip" onclick="samsSkip()">Plus tard</button> 143 <div class="sams-error" id="sams-error" style="display:none;"></div> 144</div> 145 146<!-- Crop modal --> 147<div id="crop-modal-signup" class="crop-modal-signup hidden"> 148 <div class="crop-modal-box"> 149 <div class="crop-head"> 150 <h3>PHOTO DE PROFIL</h3> 151 <button onclick="closeCropSignup()" class="crop-close-btn">×</button> 152 </div> 153 <div class="crop-area-inner"><img id="signup-crop-img" src=""></div> 154 <div class="crop-footer-btns"> 155 <button onclick="closeCropSignup()" class="crop-btn crop-btn-cancel"><i class="fa-solid fa-xmark"></i> ANNULER</button> 156 <button onclick="confirmCropSignup()" class="crop-btn crop-btn-confirm"><i class="fa-solid fa-check"></i> VALIDER</button> 157 </div> 158 </div> 159</div> 160 161 162 163<!-- Formulaire caché pour appairage par QR --> 164<form action="/pair_locker" method="POST" id="form-qr-pair" style="display:none;"> 165 <input type="hidden" name="locker_code" id="qr-pair-code"> 166</form> 167 168<div class="cyber-grid"></div> 169 170<nav class="navbar" id="main-navbar" {% if not logged_in %}style="display:none;"{% endif %}> 171 <div class="nav-container"> 172 <a href="/" class="nav-logo"><i class="fa-solid fa-fingerprint"></i> SMARTLOCKER</a> 173 {% if logged_in %}<a href="/logout" class="btn-logout"><i class="fa-solid fa-power-off"></i> Quitter</a>{% endif %} 174 </div> 175</nav> 176 177<div class="app-container"> 178 179 {% with messages = get_flashed_messages() %}{% if messages %} 180 {% for m in messages %} 181 <div class="toast {{ 'toast-error' if 'erreur' in m|lower or 'invalide' in m|lower or 'deja' in m|lower or 'depasse' in m|lower or 'passe' in m|lower or 'hors-ligne' in m|lower or 'impossible' in m|lower else 'toast-success' }}"> 182 <i class="fa-solid {{ 'fa-circle-xmark' if 'erreur' in m|lower or 'invalide' in m|lower or 'deja' in m|lower or 'depasse' in m|lower or 'passe' in m|lower or 'hors-ligne' in m|lower or 'impossible' in m|lower else 'fa-circle-check' }}"></i> {{ m }} 183 </div> 184 {% endfor %} 185 {% endif %}{% endwith %} 186 187 {% if not logged_in %} 188 189 <!-- ══ WELCOME ══ --> 190 <div class="welcome-screen" id="screen-welcome"> 191 <div class="welcome-logo-big"> 192 <i class="fa-solid fa-fingerprint"></i> 193 <div class="logo-text"><span>SMARTLOCKER</span></div> 194 </div> 195 <p class="welcome-sub">Contrôlez votre casier connecté en toute sécurité depuis votre téléphone.</p> 196 <div class="welcome-btns"> 197 <button class="btn-welcome-login" onclick="showScreen('login')"><i class="fa-solid fa-terminal"></i> SE CONNECTER</button> 198 <button class="btn-welcome-signup" onclick="showScreen('signup')"><i class="fa-solid fa-user-plus"></i> CRÉER UN COMPTE</button> 199 </div> 200 </div> 201 202 <!-- ══ LOGIN ══ --> 203 <div class="auth-screen" id="screen-login"> 204 <button class="auth-back" onclick="showScreen('welcome')"><i class="fa-solid fa-arrow-left"></i> RETOUR</button> 205 <div class="auth-screen-title">CONNEXION</div> 206 <div class="auth-screen-sub">Accédez à votre espace SmartLocker</div> 207 <div class="app-card" style="margin:0;"> 208 <form action="/login" method="POST"> 209 <div class="form-group"><input type="email" name="email" placeholder="Email" class="form-input" required></div> 210 <div class="form-group"><input type="password" name="password" placeholder="Mot de passe" class="form-input" required></div> 211 <button type="submit" class="btn btn-primary"><i class="fa-solid fa-terminal"></i> ACCÉDER AU SYSTÈME</button> 212 </form> 213 <div class="divider">OU</div> 214 <button onclick="loginGoogle()" class="btn-google-auth"> 215 <img src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg" width="18" alt="G"> CONTINUER AVEC GOOGLE 216 </button> 217 </div> 218 <p style="text-align:center;margin-top:1.25rem;color:var(--text-muted);font-size:0.82rem;"> 219 Pas encore de compte ? <span onclick="showScreen('signup')" style="color:var(--primary);cursor:pointer;font-family:'Orbitron',sans-serif;font-size:0.7rem;letter-spacing:1px;">S'INSCRIRE</span> 220 </p> 221 </div> 222 223 <!-- ══ SIGNUP ══ --> 224 <div class="auth-screen" id="screen-signup"> 225 <button class="auth-back" onclick="showScreen('welcome')"><i class="fa-solid fa-arrow-left"></i> RETOUR</button> 226 <div class="auth-screen-title">INSCRIPTION</div> 227 <div class="auth-screen-sub">Créez votre profil SmartLocker</div> 228 <div class="app-card" style="margin:0;"> 229 <form action="/signup" method="POST" enctype="multipart/form-data"> 230 <div class="avatar-signup-wrap"> 231 <div class="avatar-signup-preview" onclick="document.getElementById('signup-photo-input').click()"> 232 <div class="avatar-default" id="avatar-default-placeholder"><i class="fa-solid fa-camera"></i><span>PHOTO</span></div> 233 <img id="signup-avatar-preview" src="" alt="avatar" style="display:none;"> 234 <div class="avatar-signup-overlay" id="avatar-edit-overlay" style="display:none;"><i class="fa-solid fa-camera"></i><span>Changer</span></div> 235 </div> 236 <input type="file" id="signup-photo-input" accept="image/*" style="display:none;"> 237 <input type="hidden" name="photo_b64" id="signup-photo-b64"> 238 </div> 239 <div class="form-group"><input type="text" name="pseudo" placeholder="Pseudo" class="form-input" required></div> 240 <div class="form-group"><input type="email" name="email" placeholder="Email" class="form-input" required></div> 241 <div class="form-group"><input type="password" name="password" placeholder="Mot de passe" class="form-input" required></div> 242 <button type="submit" class="btn btn-success"><i class="fa-solid fa-user-plus"></i> CRÉER LE PROFIL</button> 243 </form> 244 <div class="divider">OU</div> 245 <button onclick="loginGoogle()" class="btn-google-auth"> 246 <img src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg" width="18" alt="G"> CONTINUER AVEC GOOGLE 247 </button> 248 </div> 249 <p style="text-align:center;margin-top:1.25rem;color:var(--text-muted);font-size:0.82rem;"> 250 Déjà un compte ? <span onclick="showScreen('login')" style="color:var(--primary);cursor:pointer;font-family:'Orbitron',sans-serif;font-size:0.7rem;letter-spacing:1px;">SE CONNECTER</span> 251 </p> 252 </div> 253 254 <form action="/google_login" method="POST" id="form-google" style="display:none;"> 255 <input type="hidden" name="email" id="g-email"> 256 <input type="hidden" name="idToken" id="g-uid"> 257 <input type="hidden" name="name" id="g-name"> 258 <input type="hidden" name="photo" id="g-photo"> 259 </form> 260 261 {% else %} 262 263 <!-- ══ DASHBOARD ══ --> 264 <div class="app-card"> 265 266 <!-- Profil --> 267 <div class="profile-header"> 268 <img src="{{ photo }}" class="user-avatar" alt="avatar"> 269 <div class="user-info"> 270 <h2>{{ pseudo }}</h2> 271 <p>{{ email }}</p> 272 {% if not is_verified %} 273 <span class="verify-tag"><i class="fa-solid fa-triangle-exclamation"></i> Email non vérifié</span> 274 {% endif %} 275 </div> 276 <a href="/settings" class="settings-btn"><i class="fa-solid fa-sliders"></i></a> 277 </div> 278 279 <!-- Stats --> 280 {% if stats %} 281 <div class="stats-grid"> 282 <div class="stat-block"> 283 <div class="stat-val">{{ stats.sessions_month }}</div> 284 <div class="stat-label">Sessions / 30j</div> 285 </div> 286 <div class="stat-block"> 287 <div class="stat-val" style="color:var(--secondary);text-shadow:0 0 10px rgba(0,240,255,0.3);"> 288 {% if stats.total_minutes >= 60 %}{{ stats.total_minutes // 60 }}h{% if stats.total_minutes % 60 > 0 %}{{ '%02d' % (stats.total_minutes % 60) }}{% endif %}{% else %}{{ stats.total_minutes }}m{% endif %} 289 </div> 290 <div class="stat-label">Temps total</div> 291 </div> 292 <div class="stat-block"> 293 <div class="stat-val" style="color:var(--warning);text-shadow:0 0 10px rgba(255,200,0,0.4);"> 294 {{ "%.0f"|format(stats.total_spent) }}€ 295 </div> 296 <div class="stat-label">Dépensé / 30j</div> 297 </div> 298 </div> 299 <div class="chart-block"> 300 <div class="chart-title"> 301 <span><i class="fa-solid fa-chart-simple"></i> USAGE — 7 DERNIERS JOURS</span> 302 <span class="chart-total" id="chart-total-label">—</span> 303 </div> 304 <div class="chart-canvas-wrap"><canvas id="usage-chart"></canvas></div> 305 </div> 306 {% endif %} 307 308 <!-- Casiers --> 309 {% if paired_lockers %} 310 <div class="section-label"><i class="fa-solid fa-lock"></i> Mes casiers</div> 311 <div class="locker-cards"> 312 313 <!-- ════ SPORT ════ --> 314 {% if 'sport' in paired_lockers %} 315 <a href="/locker/sport" class="locker-card sport-card {% if not sport_state.online %}offline{% endif %}"> 316 <div class="lc-header"> 317 <div class="lc-icon"><i class="fa-solid fa-dumbbell"></i></div> 318 <div class="lc-info"> 319 <div class="lc-name">SALLE DE SPORT</div> 320 <div class="lc-loc"><i class="fa-solid fa-location-dot"></i> Zone fitness — Rez-de-chaussée</div> 321 </div> 322 <div class="lc-badge-wrap"> 323 {% if not sport_state.online %} 324 <span class="lc-badge offline" id="sport-badge"><i class="fa-solid fa-plug-circle-xmark" style="margin-right:4px;"></i>HORS-LIGNE</span> 325 {% elif is_premium %} 326 <span class="lc-badge {{ sport_state.status }}" id="sport-badge"> 327 {{ 'OUVERT' if sport_state.status == 'unlocked' else 'FERMÉ' }} 328 </span> 329 {% else %} 330 <span class="lc-badge premium"><i class="fa-solid fa-crown" style="margin-right:3px;font-size:0.5rem;"></i>PREMIUM</span> 331 {% endif %} 332 <span class="lc-arrow"><i class="fa-solid fa-chevron-right"></i></span> 333 </div> 334 </div> 335 <div class="lc-status-footer"> 336 {% if not sport_state.online %} 337 <div class="lc-status-line offline" id="sport-status-line"> 338 <span class="live-dot offline"></span> 339 <span id="sport-status-text">CASIER ETEINT — INDISPONIBLE</span> 340 </div> 341 {% else %} 342 <div class="lc-status-line loading" id="sport-status-line"> 343 <span class="live-dot"></span> 344 <span id="sport-status-text">VÉRIFICATION...</span> 345 </div> 346 {% if is_premium %} 347 <div class="lc-action-hint"><i class="fa-solid fa-fingerprint"></i> CONTRÔLER</div> 348 {% endif %} 349 {% endif %} 350 </div> 351 </a> 352 {% endif %} 353 354 <!-- ════ ÉCOLE ════ --> 355 {% if 'school' in paired_lockers %} 356 <a href="/locker/school" class="locker-card school-card {% if not school_state.online %}offline{% endif %}"> 357 <div class="lc-header"> 358 <div class="lc-icon"><i class="fa-solid fa-school"></i></div> 359 <div class="lc-info"> 360 <div class="lc-name">ÉCOLE</div> 361 <div class="lc-loc"><i class="fa-solid fa-location-dot"></i> Couloir B — 1er étage · 5,00 €/h</div> 362 </div> 363 <div class="lc-badge-wrap"> 364 {% if not school_state.online %} 365 <span class="lc-badge offline"><i class="fa-solid fa-plug-circle-xmark" style="margin-right:4px;"></i>HORS-LIGNE</span> 366 {% else %} 367 <span class="lc-badge {{ school_state.status }}"> 368 {% if school_state.status == 'unlocked' %}EN COURS 369 {% elif school_state.status == 'reserved' %}RÉSERVÉ 370 {% else %}LIBRE{% endif %} 371 </span> 372 {% endif %} 373 <span class="lc-arrow"><i class="fa-solid fa-chevron-right"></i></span> 374 </div> 375 </div> 376 <div class="lc-status-footer"> 377 {% if not school_state.online %} 378 <div class="lc-status-line offline"> 379 <span class="live-dot offline"></span> CASIER ETEINT — INDISPONIBLE 380 </div> 381 {% else %} 382 {% if school_state.status == 'unlocked' and school_state.session %} 383 <div class="lc-status-line unlocked" id="school-status-line"> 384 <span class="live-dot"></span> 385 <span id="school-timer">--:--:--</span> 386 </div> 387 {% elif school_state.status == 'reserved' and school_state.session %} 388 <div class="lc-status-line" style="color:var(--warning);"> 389 <span class="live-dot" style="background:var(--warning);"></span> 390 RÉSERVÉ — {{ school_state.session.get('start_str','--') }} 391 </div> 392 {% else %} 393 <div class="lc-status-line available"> 394 <span class="live-dot"></span> DISPONIBLE 395 </div> 396 {% endif %} 397 <div class="lc-action-hint"><i class="fa-solid fa-calendar"></i> RÉSERVER</div> 398 {% endif %} 399 </div> 400 </a> 401 {% endif %} 402 403 </div> 404 {% endif %} 405 406 {% if not paired_lockers %} 407 <div class="no-locker-scan-wrap"> 408 <div class="no-locker-icon"><i class="fa-solid fa-qrcode"></i></div> 409 <div class="no-locker-title">AUCUN CASIER APPAIRÉ</div> 410 <div class="no-locker-sub">Scannez le QR code de votre casier pour l'associer à votre compte.</div> 411 <a href="/scan" class="no-locker-btn"> 412 <i class="fa-solid fa-qrcode"></i> SCANNER LE CASIER 413 </a> 414 </div> 415 {% endif %} 416 417 <a href="/history" class="btn-history"><i class="fa-solid fa-clock-rotate-left"></i> Voir l'historique</a> 418 {% if is_admin %}<a href="/admin" class="btn-admin"><i class="fa-solid fa-shield-halved"></i> Panneau admin</a>{% endif %} 419 420 </div> 421 {% endif %} 422</div> 423 424<script> 425 firebase.initializeApp({apiKey:"AIzaSyCQHsEjxGFa-MtsG8ogCCWEmarppA4BHC0",authDomain:"smartlocker-94e11.firebaseapp.com"}); 426 427 function loginGoogle() { 428 firebase.auth().signInWithPopup(new firebase.auth.GoogleAuthProvider()).then(r => { 429 r.user.getIdToken().then(t => { 430 document.getElementById('g-uid').value = t; 431 document.getElementById('g-email').value = r.user.email; 432 document.getElementById('g-photo').value = r.user.photoURL||''; 433 document.getElementById('g-name').value = r.user.displayName||''; 434 document.getElementById('form-google').submit(); 435 }); 436 }).catch(e => console.error('[Google]',e)); 437 } 438 function showScreen(name) { 439 const w = document.getElementById('screen-welcome'); 440 const n = document.getElementById('main-navbar'); 441 if (!w) return; 442 w.style.display = 'none'; 443 ['login','signup'].forEach(s => document.getElementById('screen-'+s).classList.remove('active')); 444 if (name==='welcome') { w.style.display='flex'; n.style.display='none'; } 445 else { document.getElementById('screen-'+name).classList.add('active'); n.style.display='flex'; } 446 } 447 448 449 // ── CASIER SPORT — Polling toutes les 3s ────────────────── 450 {% if logged_in and 'sport' in paired_lockers %} 451 (function(){ 452 const line = document.getElementById('sport-status-line'); 453 const text = document.getElementById('sport-status-text'); 454 const badge = document.getElementById('sport-badge'); 455 if (!line || !text) return; 456 457 const IS_PREMIUM = {{ 'true' if is_premium else 'false' }}; 458 const LOCK_DELAY = 4; 459 const MY_PSEUDO = {{ pseudo | default('') | tojson | safe }}; 460 const MY_EMAIL = {{ email | default('') | tojson | safe }}; 461 462 let prevStatus = "{{ sport_state.status }}"; 463 let lockTimer = null; 464 465 function setLine(html, cls) { line.className = 'lc-status-line ' + (cls||''); text.innerHTML = html; } 466 function setBadge(t, cls) { if (badge) { badge.className = 'lc-badge '+cls; badge.textContent = t; } } 467 function stopTimer() { if (lockTimer) { clearInterval(lockTimer); lockTimer=null; } } 468 469 function startLockCountdown() { 470 stopTimer(); 471 let n = LOCK_DELAY; 472 setLine(`VERROUILLAGE DANS ${n}s`, 'locking'); 473 lockTimer = setInterval(()=>{ 474 n--; 475 if (n <= 0) { 476 stopTimer(); 477 setLine('DISPONIBLE', 'available'); 478 setBadge('FERMÉ', 'locked'); 479 } else { 480 setLine(`VERROUILLAGE DANS ${n}s`, 'locking'); 481 } 482 }, 1000); 483 } 484 485 function applyState(data) { 486 const status = data.status || 'locked'; 487 const session = data.session || {}; 488 const sessionUser = session.user_pseudo || session.user_email || ''; 489 const isMine = !sessionUser || sessionUser === MY_PSEUDO || sessionUser === MY_EMAIL; 490 491 // Casier hors-ligne (Raspberry Pi eteint) — on l'affiche en priorite 492 if (data.online === false) { 493 stopTimer(); 494 setLine('CASIER ETEINT — INDISPONIBLE', 'offline'); 495 setBadge('HORS-LIGNE', 'offline'); 496 prevStatus = 'offline'; 497 return; 498 } 499 500 if (!IS_PREMIUM) { 501 if (status === 'unlocked') { 502 setLine(sessionUser ? `OCCUPÉ — ${sessionUser}` : 'OCCUPÉ EN CE MOMENT', 'occupied'); 503 } else { 504 setLine('DISPONIBLE', 'available'); 505 } 506 return; 507 } 508 509 if (status === 'unlocked' && !isMine) { 510 stopTimer(); 511 setLine(`OCCUPÉ — ${sessionUser}`, 'occupied'); 512 setBadge('OCCUPÉ', 'occupied'); 513 prevStatus = status; 514 return; 515 } 516 517 if (status === prevStatus) return; 518 519 if (status === 'unlocked') { 520 stopTimer(); 521 setLine('OUVERT — Porte déverrouillée', 'unlocked'); 522 setBadge('OUVERT', 'unlocked'); 523 } else if (status === 'locked' && prevStatus === 'unlocked') { 524 startLockCountdown(); 525 } else if (status === 'locked') { 526 stopTimer(); 527 setLine('DISPONIBLE', 'available'); 528 setBadge('FERMÉ', 'locked'); 529 } 530 prevStatus = status; 531 } 532 533 async function poll() { 534 try { 535 const r = await fetch('/api/locker/sport/state', { cache: 'no-store' }); 536 if (!r.ok) return; 537 const d = await r.json(); 538 applyState(d); 539 } catch(e) {} 540 } 541 poll(); 542 // Polling moins fréquent sur le dashboard (5s) — la page locker_sport 543 // est plus précise pour le contrôle en temps réel 544 const pollInterval = setInterval(poll, 5000); 545 // Pause le polling quand l'onglet est en arrière-plan 546 document.addEventListener('visibilitychange', () => { 547 if (document.hidden) { 548 clearInterval(pollInterval); 549 } else { 550 poll(); 551 } 552 }); 553 })(); 554 {% endif %} 555 556 // ── ÉCOLE — Countdown ───────────────────────────────────── 557 {% if logged_in and school_state.status == 'unlocked' and school_state.session %} 558 (function(){ 559 const exp = {{ school_state.session.get('expires_at', 0) }}; 560 const el = document.getElementById('school-timer'); 561 if (!el||!exp) return; 562 function tick(){ 563 const rem = Math.max(0, Math.floor(exp - Date.now()/1000)); 564 const h=Math.floor(rem/3600), m=Math.floor((rem%3600)/60), s=rem%60; 565 el.textContent = String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0'); 566 const ln = document.getElementById('school-status-line'); 567 if (rem < 600 && ln) ln.classList.add('urgent'); 568 if (rem === 0) { el.textContent='EXPIRÉ'; clearInterval(iv); } 569 } 570 const iv = setInterval(tick, 1000); 571 tick(); 572 })(); 573 {% endif %} 574 575 // ── Chart.js ────────────────────────────────────────────── 576 {% if logged_in and stats %} 577 (function(){ 578 const canvas = document.getElementById('usage-chart'); 579 if (!canvas||typeof Chart==='undefined') return; 580 const data = {{ stats.usage_7days|tojson }}; 581 const now=new Date(), days=['Dim','Lun','Mar','Mer','Jeu','Ven','Sam'], labels=[]; 582 for(let i=6;i>=0;i--){const d=new Date(now);d.setDate(now.getDate()-i);labels.push(days[d.getDay()]);} 583 const tot = data.reduce((a,b)=>a+b,0); 584 document.getElementById('chart-total-label').textContent = 585 tot>=60 ? (Math.floor(tot/60)+'h'+String(tot%60).padStart(2,'0')) : tot+' min'; 586 new Chart(canvas,{type:'bar',data:{labels,datasets:[{ 587 data, 588 backgroundColor:data.map(v=>v>0?'rgba(0,240,255,0.6)':'rgba(255,255,255,0.04)'), 589 borderColor:'rgba(0,240,255,1)', 590 borderWidth:data.map(v=>v>0?1:0), 591 borderRadius:2 592 }]},options:{ 593 responsive:true,maintainAspectRatio:false, 594 plugins:{legend:{display:false},tooltip:{ 595 backgroundColor:'rgba(2,4,10,0.95)',borderColor:'rgba(0,240,255,0.4)',borderWidth:1, 596 titleFont:{family:'Orbitron',size:9},bodyFont:{family:'Rajdhani',size:12}, 597 callbacks:{label:ctx=>ctx.raw+' min'} 598 }}, 599 scales:{ 600 x:{grid:{display:false},ticks:{color:'#94a3b8',font:{family:'Orbitron',size:8}}}, 601 y:{display:false,beginAtZero:true} 602 } 603 }}); 604 })(); 605 {% endif %} 606 607 // ── Crop signup ─────────────────────────────────────────── 608 let signupCropper=null; 609 const signupPhotoInput=document.getElementById('signup-photo-input'); 610 if(signupPhotoInput){ 611 signupPhotoInput.onchange=e=>{ 612 const file = e.target.files[0]; 613 if (!file) return; 614 if (file.size > 5 * 1024 * 1024) { 615 alert('Image trop lourde (max 5 Mo).'); 616 signupPhotoInput.value = ''; 617 return; 618 } 619 const reader=new FileReader(); 620 reader.onload=evt=>{ 621 document.getElementById('signup-crop-img').src=evt.target.result; 622 document.getElementById('crop-modal-signup').classList.remove('hidden'); 623 if(signupCropper)signupCropper.destroy(); 624 signupCropper=new Cropper(document.getElementById('signup-crop-img'),{aspectRatio:1,viewMode:1}); 625 }; 626 reader.readAsDataURL(file); 627 }; 628 } 629 function closeCropSignup(){ 630 document.getElementById('crop-modal-signup').classList.add('hidden'); 631 if(signupCropper){signupCropper.destroy();signupCropper=null;} 632 if(signupPhotoInput)signupPhotoInput.value=''; 633 } 634 function confirmCropSignup(){ 635 if(!signupCropper)return; 636 const url=signupCropper.getCroppedCanvas({width:200,height:200}).toDataURL('image/jpeg', 0.85); 637 document.getElementById('signup-photo-b64').value=url; 638 const img=document.getElementById('signup-avatar-preview'); 639 img.src=url;img.style.display='block'; 640 document.getElementById('avatar-default-placeholder').style.display='none'; 641 document.getElementById('avatar-edit-overlay').style.display='flex'; 642 document.getElementById('crop-modal-signup').classList.add('hidden'); 643 if(signupCropper){signupCropper.destroy();signupCropper=null;} 644 } 645 // Fermer modal avec ESC 646 document.addEventListener('keydown', (e) => { 647 if (e.key === 'Escape' && signupCropper) closeCropSignup(); 648 }); 649 650 651 652 // ── SW & Offline ────────────────────────────────────────── 653 if ('serviceWorker' in navigator) { 654 navigator.serviceWorker.register('/service-worker.js').then((reg) => { 655 // Si une nouvelle version du SW est dispo, on l'active immédiatement 656 reg.addEventListener('updatefound', () => { 657 const newWorker = reg.installing; 658 if (!newWorker) return; 659 newWorker.addEventListener('statechange', () => { 660 if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { 661 // Nouvelle version installée → on demande au SW de prendre le contrôle 662 newWorker.postMessage('SKIP_WAITING'); 663 } 664 }); 665 }); 666 }).catch((err) => console.warn('[SW] Registration failed:', err)); 667 668 // NOTE : on N'utilise PAS window.location.reload() sur 'controllerchange'. 669 // Avec firebase-messaging-sw.js + service-worker.js + skipWaiting + clients.claim, 670 // ce reload provoquait une boucle de rechargement infinie de la home. 671 // La nouvelle version du SW prend le controle silencieusement, l'utilisateur 672 // verra les changements au prochain refresh manuel. 673 } 674 const ov = document.getElementById('offline-overlay'); 675 window.addEventListener('offline', () => { if (ov) ov.style.display = 'flex'; }); 676 window.addEventListener('online', () => { if (ov) ov.style.display = 'none'; }); 677 678 // ════════════════════════════════════════════════════════════════ 679 // ONBOARDING UNIVERSEL (Samsung / Android / iOS) 680 // Détecte le device, demande caméra, guide l'install PWA 681 // ════════════════════════════════════════════════════════════════ 682 (function() { 683 const STORAGE_KEY = 'sl_onboarding_done_v2'; 684 const SKIP_KEY = 'sl_onboarding_skipped_v2'; 685 686 // ── Détection device ──────────────────────────────────── 687 const ua = navigator.userAgent || ''; 688 const isAndroid = /android/i.test(ua); 689 const isIOS = /iPad|iPhone|iPod/.test(ua) && !window.MSStream; 690 const isSamsung = /SamsungBrowser/i.test(ua); 691 const isChrome = /Chrome/i.test(ua) && !/Edg|OPR|SamsungBrowser/i.test(ua); 692 const isSafari = /Safari/i.test(ua) && !/Chrome|CriOS|FxiOS/i.test(ua); 693 const isFirefox = /Firefox|FxiOS/i.test(ua); 694 const isMobile = isAndroid || isIOS; 695 696 // Détection mode standalone (PWA déjà installée et lancée) 697 const isStandalone = window.matchMedia('(display-mode: standalone)').matches 698 || window.matchMedia('(display-mode: fullscreen)').matches 699 || window.navigator.standalone === true; 700 701 // Si on est déjà en mode app installée, ou pas sur mobile, ou déjà fait : on n'affiche rien 702 if (isStandalone || !isMobile) return; 703 if (localStorage.getItem(STORAGE_KEY) === '1') return; 704 if (localStorage.getItem(SKIP_KEY) === '1') return; 705 706 // L'onboarding ne s'affiche que pour les utilisateurs connectés 707 const loggedIn = {{ 'true' if logged_in else 'false' }}; 708 if (!loggedIn) return; 709 710 const overlay = document.getElementById('sams-overlay'); 711 const btn = document.getElementById('sams-btn-action'); 712 const errEl = document.getElementById('sams-error'); 713 const num1 = document.getElementById('sams-num-1'); 714 const num2 = document.getElementById('sams-num-2'); 715 716 let cameraGranted = false; 717 let beforeInstallEvt = null; 718 719 // Capture l'event d'installation (Chrome/Edge/Samsung) 720 window.addEventListener('beforeinstallprompt', (e) => { 721 e.preventDefault(); 722 beforeInstallEvt = e; 723 }); 724 725 // Affichage retardé (laisse 1.5s pour que l'utilisateur voit l'app) 726 setTimeout(() => { 727 overlay.classList.add('show'); 728 }, 1500); 729 730 // ── Étape 1 : demande caméra ──────────────────────────── 731 async function requestCamera() { 732 errEl.style.display = 'none'; 733 btn.disabled = true; 734 btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> CHARGEMENT...'; 735 736 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { 737 errEl.textContent = "Votre navigateur ne supporte pas la caméra. Essayez Chrome ou Safari."; 738 errEl.style.display = 'block'; 739 btn.disabled = false; 740 btn.innerHTML = '<i class="fa-solid fa-rotate-right"></i> RÉESSAYER'; 741 return false; 742 } 743 744 try { 745 const stream = await navigator.mediaDevices.getUserMedia({ 746 video: { facingMode: { ideal: 'environment' } } 747 }); 748 stream.getTracks().forEach(t => t.stop()); 749 cameraGranted = true; 750 num1.classList.add('done'); 751 num1.innerHTML = '<i class="fa-solid fa-check"></i>'; 752 return true; 753 } catch (err) { 754 console.error('[Onboarding cam]', err); 755 if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { 756 if (isIOS) { 757 errEl.innerHTML = "Caméra refusée. Dans Réglages → Safari → Caméra → Demander, puis rechargez la page."; 758 } else if (isSamsung) { 759 errEl.innerHTML = "Caméra refusée. Touchez l'icône de cadenas en haut → Autorisations → Caméra → Autoriser."; 760 } else { 761 errEl.innerHTML = "Caméra refusée. Touchez l'icône à gauche de l'URL → Autorisations → Caméra → Autoriser."; 762 } 763 } else if (err.name === 'NotFoundError') { 764 errEl.textContent = "Aucune caméra détectée sur cet appareil."; 765 } else { 766 errEl.textContent = "Erreur caméra : " + err.name; 767 } 768 errEl.style.display = 'block'; 769 btn.disabled = false; 770 btn.innerHTML = '<i class="fa-solid fa-rotate-right"></i> RÉESSAYER'; 771 return false; 772 } 773 } 774 775 // ── Étape 2 : installation PWA ────────────────────────── 776 async function triggerInstall() { 777 // Cas 1 : Chrome/Edge/Samsung supportent beforeinstallprompt 778 if (beforeInstallEvt) { 779 btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> INSTALLATION...'; 780 try { 781 beforeInstallEvt.prompt(); 782 const { outcome } = await beforeInstallEvt.userChoice; 783 if (outcome === 'accepted') { 784 num2.classList.add('done'); 785 num2.innerHTML = '<i class="fa-solid fa-check"></i>'; 786 localStorage.setItem(STORAGE_KEY, '1'); 787 showSuccess(); 788 } else { 789 btn.disabled = false; 790 btn.innerHTML = '<i class="fa-solid fa-download"></i> INSTALLER L\'APP'; 791 } 792 } catch (e) { 793 showManualInstructions(); 794 } 795 return; 796 } 797 798 // Cas 2 : iOS Safari OU Samsung sans event → instructions manuelles 799 showManualInstructions(); 800 } 801 802 function showManualInstructions() { 803 const step1 = isIOS 804 ? 'Touchez le bouton <i class="fa-solid fa-arrow-up-from-bracket" style="color:#00f0ff;"></i> Partager (en bas)' 805 : 'Touchez le menu <i class="fa-solid fa-ellipsis-vertical" style="color:#00f0ff;"></i> en haut à droite'; 806 const step2 = isIOS 807 ? 'Sélectionnez « Sur l’écran d’accueil »' 808 : 'Sélectionnez « Ajouter à l’écran d’accueil » ou « Installer l’application »'; 809 810 overlay.innerHTML = 811 '<div class="sams-logo"><i class="fa-solid fa-circle-check"></i></div>' + 812 '<div class="sams-title">CAMÉRA AUTORISÉE</div>' + 813 '<div class="sams-sub">Pour terminer, ajoutez l’app à votre écran d’accueil :</div>' + 814 '<div class="sams-step">' + 815 '<div class="sams-step-num">1</div>' + 816 '<div class="sams-step-text">' + step1 + '</div>' + 817 '</div>' + 818 '<div class="sams-step">' + 819 '<div class="sams-step-num">2</div>' + 820 '<div class="sams-step-text">' + step2 + '</div>' + 821 '</div>' + 822 '<div class="sams-step">' + 823 '<div class="sams-step-num">3</div>' + 824 '<div class="sams-step-text">Lancez SmartLocker depuis l’icône (et non depuis le navigateur)</div>' + 825 '</div>' + 826 '<button class="sams-btn" onclick="samsClose()">' + 827 '<i class="fa-solid fa-check"></i> J’AI COMPRIS' + 828 '</button>'; 829 localStorage.setItem(STORAGE_KEY, '1'); 830 } 831 832 function showSuccess() { 833 overlay.innerHTML = 834 '<div class="sams-logo" style="color:#00ff9d;"><i class="fa-solid fa-circle-check"></i></div>' + 835 '<div class="sams-title" style="color:#00ff9d;">INSTALLATION RÉUSSIE</div>' + 836 '<div class="sams-sub">SmartLocker est prêt ! Lancez l’app depuis votre écran d’accueil pour profiter du mode plein écran.</div>' + 837 '<button class="sams-btn" onclick="samsClose()">' + 838 '<i class="fa-solid fa-check"></i> CONTINUER' + 839 '</button>'; 840 } 841 842 // ── Handler bouton ────────────────────────────────────── 843 window.samsHandleAction = async function() { 844 if (!cameraGranted) { 845 const ok = await requestCamera(); 846 if (!ok) return; 847 btn.innerHTML = '<i class="fa-solid fa-download"></i> INSTALLER L\'APP'; 848 btn.disabled = false; 849 return; 850 } 851 await triggerInstall(); 852 }; 853 854 window.samsSkip = function() { 855 localStorage.setItem(SKIP_KEY, '1'); 856 overlay.classList.remove('show'); 857 }; 858 859 window.samsClose = function() { 860 overlay.classList.remove('show'); 861 }; 862 863 // Si l'app est ouverte et qu'on n'est PAS en standalone alors qu'elle a été installée, 864 // c'est que Samsung a mal géré l'install → on relance l'onboarding plus tard 865 window.addEventListener('appinstalled', () => { 866 localStorage.setItem(STORAGE_KEY, '1'); 867 }); 868 })(); 869 870 setTimeout(()=>{ 871 document.querySelectorAll('.toast').forEach(t=>{ 872 t.style.transition='opacity 0.5s';t.style.opacity='0'; 873 setTimeout(()=>t.remove(),500); 874 }); 875 },3000); 876</script> 877 878{% if logged_in %} 879<!-- ═══ SMARTLOCKER NOTIFICATIONS — fallback robuste (polling + popup + audio) ═══ --> 880<script src="{{ url_for('static', filename='notifications.js') }}?v=1"></script> 881 882<!-- ═══ FORCE FCM PUSH NOTIFICATIONS ═══════════════════════════ --> 883<script type="module"> 884 import { initializeApp } from "https://www.gstatic.com/firebasejs/10.9.0/firebase-app.js"; 885 import { getMessaging, getToken, onMessage } from "https://www.gstatic.com/firebasejs/10.9.0/firebase-messaging.js"; 886 887 const firebaseConfig = { 888 apiKey: "AIzaSyCQHsEjxGFa-MtsG8ogCCWEmarppA4BHC0", 889 projectId: "smartlocker-94e11", 890 messagingSenderId: "224254595209", 891 appId: "1:224254595209:web:82804d2f2c7fd83046f658" 892 }; 893 894 const app = initializeApp(firebaseConfig); 895 const messaging = getMessaging(app); 896 897 async function ensurePush() { 898 try { 899 if (!("Notification" in window)) return; 900 let permission = Notification.permission; 901 if (permission === "default") { 902 permission = await Notification.requestPermission(); 903 } 904 if (permission !== "granted") return; 905 const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js'); 906 const token = await getToken(messaging, { 907 vapidKey: 'BLV0pKgxQGyWhNeyd6Olg1uMCh14mEf1cmr9x0BQXUQDKoaQs2rN5dq_Gs7MaRMvA768mlzOuBxIfZ_H4R3XFLk', 908 serviceWorkerRegistration: registration 909 }); 910 if (token) { 911 await fetch('/api/fcm_token', { 912 method: 'POST', 913 headers: { 'Content-Type': 'application/json' }, 914 body: JSON.stringify({ fcm_token: token }) 915 }); 916 console.log("[FCM] token enregistré"); 917 } 918 } catch (e) { 919 console.error("[FCM] erreur permission/token:", e); 920 } 921 } 922 // On lance immediatement 923 ensurePush(); 924 // ET on relance a chaque clic utilisateur (pour les navigateurs qui exigent une activation user) 925 document.addEventListener('click', () => { ensurePush(); }, { once: true }); 926 927 // Messages recus en foreground -> on transmet au pipeline unifie 928 // pour beneficier du popup in-app, du son, de la vibration et de la deduplication 929 onMessage(messaging, (payload) => { 930 console.log("[FCM] foreground:", payload); 931 try { 932 const d = payload.data || {}; 933 const title = (payload.notification && payload.notification.title) || d.title || "SmartLocker"; 934 const body = (payload.notification && payload.notification.body) || d.body || ""; 935 const kindMap = { 936 'intrusion_alert': 'intrusion', 937 'door_open_reminder': 'reminder', 938 'object_removed': 'object_removed', 939 'lockdown_reset': 'info' 940 }; 941 const kind = kindMap[d.type] || 'intrusion'; 942 const severity = (kind === 'intrusion') ? 'critical' 943 : (kind === 'reminder') ? 'warning' 944 : 'info'; 945 const event = { 946 kind: kind, 947 locker_id: d.locker_id || '', 948 locker_key: '', 949 locker_name: title, 950 message: body, 951 time: String(Date.now()), 952 severity: severity 953 }; 954 if (window.smartLockerNotify) { 955 window.smartLockerNotify.handleEvent(event); 956 } else { 957 // Fallback si notifications.js pas charge 958 if ('vibrate' in navigator) navigator.vibrate([400, 150, 400]); 959 if (Notification.permission === 'granted') { 960 new Notification(title, { body: body, icon: "/static/icons/icon-192.png" }); 961 } 962 } 963 } catch(e) { console.error(e); } 964 }); 965</script> 966{% endif %} 967</body> 968</html>
templates/scan.html
203 lignes
1<!DOCTYPE html> 2<html lang="fr"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Scanner — SmartLocker</title> 7 <meta name="theme-color" content="#000000"> 8 <meta name="mobile-web-app-capable" content="yes"> 9 <meta name="apple-mobile-web-app-capable" content="yes"> 10 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> 11 <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;400;500;600;700&display=swap" rel="stylesheet"> 12 <script src="https://unpkg.com/html5-qrcode@2.3.8" type="text/javascript"></script> 13 <style> 14 * { margin:0; padding:0; box-sizing:border-box; } 15 body { 16 background:#000; color:#fff; 17 font-family:'Rajdhani',sans-serif; 18 min-height:100vh; display:flex; flex-direction:column; 19 overflow:hidden; 20 } 21 22 .scan-topbar { 23 display:flex; align-items:center; justify-content:space-between; 24 padding:14px 20px; 25 background:rgba(2,4,10,0.95); 26 border-bottom:1px solid rgba(0,240,255,0.2); 27 flex-shrink:0; z-index:10; 28 } 29 .scan-topbar-title { 30 font-family:'Orbitron',sans-serif; font-size:0.85rem; font-weight:900; 31 letter-spacing:3px; color:#fff; 32 display:flex; align-items:center; gap:10px; 33 } 34 .scan-topbar-title i { color:#00f0ff; } 35 .scan-back { 36 text-decoration:none; background:transparent; 37 border:1px solid rgba(255,255,255,0.15); 38 color:#fff; padding:8px 16px; border-radius:50px; 39 font-family:'Orbitron',sans-serif; font-size:0.65rem; 40 letter-spacing:1px; display:flex; align-items:center; gap:8px; 41 transition:0.2s; 42 } 43 .scan-back:hover { background:rgba(255,255,255,0.1); } 44 45 .scan-camera { 46 flex:1; position:relative; overflow:hidden; 47 } 48 #reader { 49 width:100%; height:100%; 50 } 51 #reader video { 52 width:100% !important; height:100% !important; 53 object-fit:cover !important; 54 } 55 /* Cacher les éléments UI internes de html5-qrcode */ 56 #reader__dashboard_section { display:none !important; } 57 #reader__header_message { display:none !important; } 58 #reader__scan_region { 59 min-height:100% !important; 60 } 61 #reader__scan_region video { 62 width:100% !important; height:100% !important; 63 object-fit:cover !important; 64 } 65 66 .scan-bottombar { 67 display:flex; align-items:center; justify-content:center; gap:12px; 68 padding:20px; 69 background:rgba(2,4,10,0.95); 70 border-top:1px solid rgba(0,240,255,0.15); 71 flex-shrink:0; 72 } 73 .scan-status-icon { 74 color:#00f0ff; font-size:1.2rem; 75 animation:pulse 2s ease-in-out infinite; 76 } 77 .scan-status-text { 78 font-family:'Orbitron',sans-serif; font-size:0.72rem; 79 letter-spacing:1.5px; color:#00f0ff; 80 } 81 .scan-status-text.error { color:#ff2a6d; } 82 .scan-status-text.success { color:#00ff9d; } 83 84 @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} } 85 </style> 86</head> 87<body> 88 89<div class="scan-topbar"> 90 <div class="scan-topbar-title"><i class="fa-solid fa-qrcode"></i> SCANNER</div> 91 <a href="/" class="scan-back"><i class="fa-solid fa-arrow-left"></i> RETOUR</a> 92</div> 93 94<div class="scan-camera"> 95 <div id="reader"></div> 96</div> 97 98<div class="scan-bottombar"> 99 <div class="scan-status-icon"><i class="fa-solid fa-crosshairs"></i></div> 100 <div class="scan-status-text" id="scan-status">Placez le QR code dans le cadre</div> 101</div> 102 103<!-- Formulaire caché pour appairage --> 104<form action="/pair_locker" method="POST" id="form-qr-pair" style="display:none;"> 105 <input type="hidden" name="locker_code" id="qr-pair-code"> 106</form> 107 108<script> 109 const statusEl = document.getElementById('scan-status'); 110 111 function extractCode(raw) { 112 try { 113 const url = new URL(raw); 114 const param = url.searchParams.get('code'); 115 if (param) return param.trim().toUpperCase(); 116 } catch(_) {} 117 return raw.trim().toUpperCase(); 118 } 119 120 // Détection device pour messages d'erreur adaptés 121 const ua = navigator.userAgent || ''; 122 const isIOS = /iPad|iPhone|iPod/.test(ua) && !window.MSStream; 123 const isSamsung = /SamsungBrowser/i.test(ua); 124 const isAndroid = /android/i.test(ua); 125 126 function getCameraDeniedMessage() { 127 if (isIOS) { 128 return "Caméra refusée — Réglages iOS → Safari → Caméra → Demander, puis rechargez"; 129 } else if (isSamsung) { 130 return "Caméra refusée — Touchez le cadenas dans la barre d'URL → Caméra → Autoriser"; 131 } else if (isAndroid) { 132 return "Caméra refusée — Touchez l'icône à gauche de l'URL → Autorisations → Caméra"; 133 } 134 return "Caméra refusée — Autorisez la caméra dans votre navigateur"; 135 } 136 137 async function startScanner() { 138 // 1) Vérifier le support du navigateur 139 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { 140 statusEl.textContent = 'Caméra non supportée — utilisez Chrome ou Safari'; 141 statusEl.className = 'scan-status-text error'; 142 return; 143 } 144 145 // 2) DEMANDE EXPLICITE de permission caméra (indispensable Samsung Internet + iOS) 146 try { 147 const stream = await navigator.mediaDevices.getUserMedia({ 148 video: { facingMode: { ideal: 'environment' } } 149 }); 150 // On libère immédiatement le flux, html5-qrcode va rouvrir lui-même 151 stream.getTracks().forEach(t => t.stop()); 152 } catch (err) { 153 console.error('[Permission caméra]', err); 154 if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { 155 statusEl.innerHTML = getCameraDeniedMessage(); 156 } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') { 157 statusEl.textContent = 'Aucune caméra détectée'; 158 } else if (err.name === 'NotReadableError') { 159 statusEl.textContent = 'Caméra utilisée par une autre app — fermez-la'; 160 } else { 161 statusEl.textContent = 'Erreur caméra : ' + err.name; 162 } 163 statusEl.className = 'scan-status-text error'; 164 return; 165 } 166 167 // 3) Lancer html5-qrcode 168 const html5QrCode = new Html5Qrcode("reader"); 169 html5QrCode.start( 170 { facingMode: "environment" }, 171 { 172 fps: 10, 173 qrbox: function(viewfinderWidth, viewfinderHeight) { 174 const size = Math.min(viewfinderWidth, viewfinderHeight) * 0.7; 175 return { width: size, height: size }; 176 }, 177 aspectRatio: 1.0, 178 }, 179 (decodedText) => { 180 statusEl.textContent = 'QR détecté !'; 181 statusEl.className = 'scan-status-text success'; 182 183 html5QrCode.stop().then(() => { 184 const code = extractCode(decodedText); 185 document.getElementById('qr-pair-code').value = code; 186 document.getElementById('form-qr-pair').submit(); 187 }); 188 }, 189 (errorMessage) => { 190 // Silencieux pendant le scan 191 } 192 ).catch((err) => { 193 console.error('[Scanner]', err); 194 statusEl.textContent = 'Erreur démarrage caméra : ' + (err.message || err); 195 statusEl.className = 'scan-status-text error'; 196 }); 197 } 198 199 startScanner(); 200</script> 201 202</body> 203</html>
templates/locker_school.html
865 lignes
1<!DOCTYPE html> 2<html lang="fr"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>SmartLocker — École</title> 7 <link rel="manifest" href="/manifest.json"> 8 <meta name="theme-color" content="#02040a"> 9 <meta name="mobile-web-app-capable" content="yes"> 10 <meta name="apple-mobile-web-app-capable" content="yes"> 11 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> 12 <meta name="apple-mobile-web-app-title" content="SmartLocker"> 13 <link rel="apple-touch-icon" href="/static/icons/icon-192.png"> 14 <link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}"> 15 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> 16 <style> 17 .nav-container { display:flex; align-items:center; justify-content:space-between; width:100%; } 18 .nav-back { background:transparent; border:1px solid rgba(0,240,255,0.2); color:var(--text-muted); padding:6px 14px; font-family:'Orbitron',sans-serif; font-size:0.7rem; letter-spacing:1px; cursor:pointer; transition:0.3s; text-decoration:none; display:flex; align-items:center; gap:8px; } 19 .nav-back:hover { border-color:var(--secondary); color:var(--secondary); } 20 21 .locker-hero { text-align:center; padding:1.5rem 0 1rem; } 22 .locker-icon-wrap { position:relative; width:110px; height:110px; margin:0 auto 1.25rem; display:flex; align-items:center; justify-content:center; } 23 .locker-ring { position:absolute; inset:0; border-radius:50%; border:1.5px solid rgba(0,240,255,0.15); } 24 .locker-ring-spin { position:absolute; inset:8px; border-radius:50%; border:1.5px dashed rgba(0,240,255,0.25); animation:spin 6s linear infinite; } 25 .locker-ring-spin2 { position:absolute; inset:20px; border-radius:50%; border:1.5px dashed rgba(0,255,157,0.18); animation:spin 10s linear infinite reverse; } 26 .locker-core { width:56px; height:56px; border-radius:50%; background:rgba(0,240,255,0.06); border:1.5px solid rgba(0,240,255,0.3); display:grid; place-items:center; position:relative; z-index:1; } 27 .locker-core i { font-size:1.5rem; color:var(--secondary); } 28 .locker-core.open { border-color:rgba(0,255,157,0.5); background:rgba(0,255,157,0.1); box-shadow:0 0 20px rgba(0,255,157,0.2); } 29 .locker-core.open i { color:var(--primary); } 30 .locker-core.closed { border-color:rgba(255,42,109,0.4); background:rgba(255,42,109,0.06); } 31 .locker-core.closed i { color:var(--danger); } 32 .locker-name { font-family:'Orbitron',sans-serif; font-size:1.1rem; font-weight:900; letter-spacing:3px; color:#fff; margin-bottom:4px; } 33 .locker-loc { color:var(--text-muted); font-size:0.8rem; display:flex; align-items:center; justify-content:center; gap:6px; } 34 35 .status-bar { display:flex; align-items:center; justify-content:center; gap:12px; padding:0.875rem 1rem; margin:1.25rem 0; font-family:'Orbitron',sans-serif; font-size:0.9rem; letter-spacing:2px; border:1px solid; } 36 .status-bar.locked { border-color:var(--danger); color:var(--danger); background:rgba(255,42,109,0.05); text-shadow:0 0 8px var(--danger); } 37 .status-bar.unlocked { border-color:var(--primary); color:var(--primary); background:rgba(0,255,157,0.05); text-shadow:0 0 8px var(--primary); } 38 .status-bar.reserved { border-color:#ffc800; color:#ffc800; background:rgba(255,200,0,0.05); text-shadow:0 0 8px #ffc800; } 39 40 /* Reserved block */ 41 .reserved-block { background:rgba(255,200,0,0.04); border:1px solid rgba(255,200,0,0.25); padding:1.5rem 1.25rem; text-align:center; margin-bottom:1.25rem; } 42 .reserved-icon-wrap { width:60px; height:60px; margin:0 auto 1rem; border-radius:50%; background:rgba(255,200,0,0.08); border:1px solid rgba(255,200,0,0.3); display:grid; place-items:center; color:#ffc800; font-size:1.3rem; } 43 .reserved-title { font-family:'Orbitron',sans-serif; font-size:0.85rem; font-weight:900; letter-spacing:2px; color:#ffc800; margin-bottom:0.4rem; } 44 .reserved-sub { color:var(--text-muted); font-size:0.8rem; line-height:1.5; margin-bottom:1rem; } 45 .reserved-countdown { font-family:'Orbitron',sans-serif; font-size:1.6rem; font-weight:900; color:#ffc800; letter-spacing:3px; text-shadow:0 0 12px rgba(255,200,0,0.4); margin-bottom:0.4rem; } 46 .reserved-countdown-label { font-family:'Orbitron',sans-serif; font-size:0.55rem; letter-spacing:2px; color:var(--text-muted); text-transform:uppercase; } 47 48 /* Timer */ 49 .timer-block { background:rgba(0,0,0,0.3); border:1px solid rgba(0,240,255,0.15); padding:1.25rem; text-align:center; margin-bottom:1.25rem; } 50 .timer-label { font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:3px; color:var(--text-muted); text-transform:uppercase; margin-bottom:0.625rem; } 51 .timer-display { font-family:'Orbitron',sans-serif; font-size:2.2rem; font-weight:900; color:var(--secondary); letter-spacing:4px; text-shadow:0 0 15px rgba(0,240,255,0.4); } 52 .timer-display.urgent { color:var(--danger); text-shadow:0 0 15px rgba(255,42,109,0.4); animation:blink-t 1s step-end infinite; } 53 .timer-sub { font-size:0.75rem; color:var(--text-muted); margin-top:5px; } 54 55 /* Info grid */ 56 .info-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:1.25rem; } 57 .info-block { background:rgba(0,0,0,0.25); border:1px solid rgba(255,255,255,0.05); padding:0.875rem 1rem; } 58 .info-block .ilabel { font-family:'Orbitron',sans-serif; font-size:0.55rem; letter-spacing:2px; color:var(--text-muted); text-transform:uppercase; margin-bottom:5px; } 59 .info-block .ival { font-family:'Orbitron',sans-serif; font-size:0.85rem; color:#fff; } 60 61 /* ─── FORMULAIRE DE RÉSERVATION ─── */ 62 .booking-title { font-family:'Orbitron',sans-serif; font-size:0.7rem; letter-spacing:2px; color:var(--text-muted); margin-bottom:1rem; display:flex; align-items:center; gap:8px; } 63 .booking-title::after { content:''; flex:1; height:1px; background:rgba(255,255,255,0.06); } 64 65 /* Sélecteur de date */ 66 .date-selector { margin-bottom:1.25rem; } 67 .date-tabs { display:flex; gap:6px; overflow-x:auto; padding-bottom:4px; scrollbar-width:none; } 68 .date-tabs::-webkit-scrollbar { display:none; } 69 .date-tab { flex-shrink:0; background:rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.08); padding:8px 14px; text-align:center; cursor:pointer; transition:0.2s; min-width:64px; } 70 .date-tab .dt-day { font-family:'Orbitron',sans-serif; font-size:0.55rem; letter-spacing:1px; color:var(--text-muted); text-transform:uppercase; } 71 .date-tab .dt-num { font-family:'Orbitron',sans-serif; font-size:1.1rem; font-weight:900; color:#fff; margin:2px 0; } 72 .date-tab .dt-month { font-family:'Orbitron',sans-serif; font-size:0.5rem; letter-spacing:1px; color:var(--text-muted); } 73 .date-tab:hover { border-color:rgba(0,240,255,0.4); } 74 .date-tab.selected { border-color:var(--secondary); background:rgba(0,240,255,0.07); box-shadow:var(--glow-secondary); } 75 .date-tab.selected .dt-num, .date-tab.selected .dt-day, .date-tab.selected .dt-month { color:var(--secondary); } 76 .date-tab.today .dt-day::after { content:' ·'; color:var(--primary); } 77 78 /* Sélecteur d'heure */ 79 .hour-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:6px; margin-bottom:1.25rem; } 80 .hour-btn { background:rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.08); padding:10px 4px; text-align:center; cursor:pointer; transition:0.2s; font-family:'Orbitron',sans-serif; font-size:0.7rem; color:var(--text-muted); position:relative; } 81 .hour-btn:hover:not(.booked):not(.past) { border-color:rgba(0,240,255,0.4); color:var(--secondary); } 82 .hour-btn.selected { border-color:var(--secondary); background:rgba(0,240,255,0.09); color:var(--secondary); text-shadow:var(--glow-secondary); } 83 .hour-btn.booked { background:rgba(255,42,109,0.07); border-color:rgba(255,42,109,0.25); color:rgba(255,42,109,0.5); cursor:not-allowed; } 84 .hour-btn.booked::after { content:'●'; position:absolute; top:2px; right:4px; font-size:0.4rem; color:var(--danger); } 85 .hour-btn.past { opacity:0.3; cursor:not-allowed; } 86 .hour-btn.end-overflow { border-color:rgba(255,150,0,0.3); color:rgba(255,150,0,0.5); cursor:not-allowed; } 87 /* Bouton "Maintenant" : reservation immediate */ 88 .hour-btn.now { grid-column:span 2; border-color:rgba(0,255,140,0.4); color:#00ff8c; background:rgba(0,255,140,0.06); letter-spacing:1px; } 89 .hour-btn.now:hover { border-color:rgba(0,255,140,0.7); color:#00ff8c; background:rgba(0,255,140,0.12); } 90 .hour-btn.now.selected { border-color:#00ff8c; background:rgba(0,255,140,0.15); color:#00ff8c; text-shadow:0 0 10px rgba(0,255,140,0.5); } 91 92 /* Durée */ 93 .duration-row { display:flex; align-items:center; gap:10px; margin-bottom:1.25rem; } 94 .dur-label { font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:2px; color:var(--text-muted); white-space:nowrap; } 95 .dur-stepper { display:flex; align-items:center; gap:0; border:1px solid rgba(255,255,255,0.1); flex:1; } 96 .dur-btn { background:rgba(0,0,0,0.4); border:none; color:var(--text-muted); width:40px; height:40px; display:grid; place-items:center; cursor:pointer; font-size:1rem; transition:0.2s; flex-shrink:0; } 97 .dur-btn:hover { color:var(--secondary); background:rgba(0,240,255,0.07); } 98 .dur-val { flex:1; text-align:center; font-family:'Orbitron',sans-serif; font-size:1rem; color:#fff; background:transparent; border:none; outline:none; } 99 100 /* Récapitulatif */ 101 .booking-summary { background:rgba(0,240,255,0.04); border:1px solid rgba(0,240,255,0.15); padding:1rem; margin-bottom:1rem; } 102 .bs-row { display:flex; justify-content:space-between; align-items:center; padding:4px 0; } 103 .bs-row:last-child { border-top:1px solid rgba(255,255,255,0.06); padding-top:8px; margin-top:4px; } 104 .bs-key { font-family:'Orbitron',sans-serif; font-size:0.58rem; letter-spacing:1.5px; color:var(--text-muted); text-transform:uppercase; } 105 .bs-val { font-family:'Orbitron',sans-serif; font-size:0.82rem; color:#fff; } 106 .bs-val.price { color:var(--secondary); font-size:1rem; text-shadow:var(--glow-secondary); } 107 108 /* Erreurs de validation */ 109 .booking-errors { background:rgba(255,42,109,0.07); border:1px solid rgba(255,42,109,0.3); padding:0.875rem 1rem; margin-bottom:1rem; } 110 .booking-errors .err-title { font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:2px; color:var(--danger); margin-bottom:6px; display:flex; align-items:center; gap:6px; } 111 .booking-errors ul { list-style:none; padding:0; margin:0; } 112 .booking-errors li { color:var(--danger); font-size:0.82rem; padding:2px 0; display:flex; align-items:flex-start; gap:6px; } 113 .booking-errors li::before { content:'›'; flex-shrink:0; } 114 115 /* Inline error */ 116 .field-error { font-family:'Rajdhani',sans-serif; font-size:0.8rem; color:var(--danger); margin-top:5px; display:none; } 117 .field-error.show { display:flex; align-items:center; gap:5px; } 118 119 /* Buttons */ 120 .btn-pay { width:100%; padding:1rem; border:none; font-family:'Orbitron',sans-serif; font-weight:700; text-transform:uppercase; letter-spacing:2px; cursor:pointer; transition:0.3s; font-size:0.9rem; display:flex; align-items:center; justify-content:center; gap:10px; clip-path:polygon(10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%,0 10px); background:var(--secondary); color:#000; box-shadow:var(--glow-secondary); } 121 .btn-pay:hover:not(:disabled) { background:#fff; } 122 .btn-pay:disabled { opacity:0.4; cursor:not-allowed; box-shadow:none; } 123 .btn-close { width:100%; padding:0.875rem; border:1px solid var(--danger); background:rgba(255,42,109,0.08); color:var(--danger); font-family:'Orbitron',sans-serif; font-weight:700; font-size:0.85rem; letter-spacing:2px; cursor:pointer; transition:0.3s; display:flex; align-items:center; justify-content:center; gap:10px; clip-path:polygon(10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%,0 10px); margin-top:10px; } 124 .btn-close:hover { background:var(--danger); color:#fff; } 125 .btn-extend { width:100%; padding:0.875rem; border:1px solid var(--primary); background:rgba(0,255,157,0.07); color:var(--primary); font-family:'Orbitron',sans-serif; font-weight:700; font-size:0.85rem; letter-spacing:2px; cursor:pointer; transition:0.3s; display:flex; align-items:center; justify-content:center; gap:10px; clip-path:polygon(10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%,0 10px); margin-top:10px; } 126 .btn-extend:hover { background:var(--primary); color:#000; } 127 .btn-cancel-reservation { 128 width:100%; padding:0.875rem; 129 border:1px solid var(--danger); 130 background:rgba(255,42,109,0.08); 131 color:var(--danger); 132 font-family:'Orbitron',sans-serif; font-weight:700; font-size:0.85rem; letter-spacing:2px; 133 cursor:pointer; transition:0.3s; 134 display:flex; align-items:center; justify-content:center; gap:10px; 135 clip-path:polygon(10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%,0 10px); 136 } 137 .btn-cancel-reservation:hover { background:var(--danger); color:#fff; } 138 .btn-cancel-reservation:active { transform:scale(0.98); } 139 .cancel-hint { 140 display:flex; align-items:center; gap:8px; 141 margin-top:8px; padding:8px 12px; 142 font-family:'Rajdhani',sans-serif; font-size:0.82rem; 143 color:var(--text-muted); line-height:1.3; 144 } 145 .cancel-hint i { color:var(--secondary); flex-shrink:0; } 146 .cancel-disabled-hint { 147 display:flex; align-items:center; gap:8px; 148 margin-top:14px; padding:12px 14px; 149 font-family:'Rajdhani',sans-serif; font-size:0.85rem; 150 color:var(--text-muted); line-height:1.35; 151 background:rgba(255,255,255,0.025); 152 border:1px dashed rgba(255,255,255,0.1); 153 border-radius:4px; 154 } 155 .cancel-disabled-hint i { color:var(--warning); flex-shrink:0; } 156 157 .btn-open-school { 158 width:100%; padding:1rem; 159 border:1px solid var(--secondary); 160 background:linear-gradient(135deg, rgba(0,240,255,0.12), rgba(0,240,255,0.05)); 161 color:var(--secondary); 162 font-family:'Orbitron',sans-serif; font-weight:700; font-size:0.9rem; letter-spacing:2px; 163 cursor:pointer; transition:0.3s; 164 display:flex; align-items:center; justify-content:center; gap:10px; 165 clip-path:polygon(10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%,0 10px); 166 box-shadow:0 0 18px rgba(0,240,255,0.18); 167 } 168 .btn-open-school:hover { background:var(--secondary); color:#000; box-shadow:0 0 25px rgba(0,240,255,0.5); } 169 .btn-open-school:active { transform:scale(0.98); } 170 171 .price-summary { background:rgba(0,240,255,0.04); border:1px solid rgba(0,240,255,0.15); padding:1rem; margin-bottom:1rem; display:flex; justify-content:space-between; align-items:center; } 172 .price-summary .ps-label { font-family:'Orbitron',sans-serif; font-size:0.7rem; letter-spacing:1px; color:var(--text-muted); } 173 .price-summary .ps-val { font-family:'Orbitron',sans-serif; font-size:1.1rem; color:var(--secondary); font-weight:700; } 174 175 .toast { background:rgba(0,255,157,0.08); border:1px solid var(--primary); color:var(--primary); padding:10px 16px; font-family:'Rajdhani',sans-serif; font-size:0.95rem; display:flex; align-items:center; gap:10px; margin-bottom:12px; } 176 .toast.err { background:rgba(255,42,109,0.08); border-color:var(--danger); color:var(--danger); } 177 178 /* Extend notice */ 179 .extend-notice { background:rgba(0,255,157,0.04); border:1px solid rgba(0,255,157,0.2); padding:0.75rem 1rem; margin-bottom:10px; font-size:0.8rem; color:var(--text-muted); display:flex; align-items:center; gap:8px; } 180 .extend-notice i { color:var(--primary); } 181 182 @keyframes spin { from{transform:rotate(0deg)}to{transform:rotate(360deg)} } 183 @keyframes blink-t { 0%,100%{opacity:1}50%{opacity:0.4} } 184 </style> 185</head> 186<body> 187<div class="cyber-grid"></div> 188 189<nav class="navbar"> 190 <div class="nav-container"> 191 <a href="/" class="nav-logo"><i class="fa-solid fa-fingerprint"></i> SMARTLOCKER</a> 192 <a href="/" class="nav-back"><i class="fa-solid fa-arrow-left"></i> Retour</a> 193 </div> 194</nav> 195 196<div class="app-container"> 197 198 {% with messages = get_flashed_messages() %}{% if messages %} 199 {% for m in messages %}<div class="toast err"><i class="fa-solid fa-triangle-exclamation"></i> {{ m }}</div>{% endfor %} 200 {% endif %}{% endwith %} 201 202 <div class="app-card"> 203 <div class="locker-hero"> 204 <div class="locker-icon-wrap"> 205 <div class="locker-ring"></div> 206 <div class="locker-ring-spin"></div> 207 <div class="locker-ring-spin2"></div> 208 <div class="locker-core {{ 'open' if state.status == 'unlocked' else 'closed' }}"> 209 <i class="fa-solid fa-{{ 'lock-open' if state.status == 'unlocked' else 'school' }}"></i> 210 </div> 211 </div> 212 <div class="locker-name">{{ locker.name | upper }}</div> 213 <div class="locker-loc"><i class="fa-solid fa-location-dot"></i> {{ locker.location }}</div> 214 </div> 215 216 <div class="status-bar {{ state.status }}"> 217 <i class="fa-solid fa-{% if state.status == 'locked' %}shield-halved{% elif state.status == 'reserved' %}calendar-check{% else %}shield-virus{% endif %}"></i> 218 {% if state.status == 'locked' %}CASIER LIBRE 219 {% elif state.status == 'reserved' %}RÉSERVATION EN ATTENTE 220 {% else %}CASIER EN COURS D'UTILISATION{% endif %} 221 </div> 222 223 <!-- Bandeau HORS-LIGNE — affiche quand le Raspberry Pi est eteint --> 224 {% if not state.online %} 225 <div class="offline-banner"> 226 <div class="offline-banner-icon"><i class="fa-solid fa-plug-circle-xmark"></i></div> 227 <div class="offline-banner-text"> 228 <div class="offline-banner-title">CASIER HORS-LIGNE</div> 229 <div class="offline-banner-sub">Le casier n'est pas allumé ou la connexion est perdue. Aucune réservation n'est possible tant qu'il n'est pas remis en marche.</div> 230 </div> 231 </div> 232 {% endif %} 233 234 {% if state.status == 'reserved' and state.session and is_my_session %} 235 <!-- Bloc réservation future --> 236 <div class="reserved-block"> 237 <div class="reserved-icon-wrap"><i class="fa-solid fa-calendar-clock"></i></div> 238 <div class="reserved-title">RÉSERVATION CONFIRMÉE</div> 239 <div class="reserved-sub"> 240 Votre casier s'ouvrira automatiquement à l'heure prévue.<br> 241 Pas besoin de revenir sur l'app. 242 </div> 243 <div class="reserved-countdown" id="reserved-countdown">--:--:--</div> 244 <div class="reserved-countdown-label">Avant ouverture</div> 245 </div> 246 <div class="info-grid"> 247 <div class="info-block"> 248 <div class="ilabel">Créneau</div> 249 <div class="ival" style="color:#ffc800;">{{ state.session.start_str }} — +{{ state.session.hours }}h</div> 250 </div> 251 <div class="info-block"> 252 <div class="ilabel">Montant</div> 253 <div class="ival" style="color:#ffc800;">{{ "%.2f"|format(state.session.amount_eur) }} €</div> 254 </div> 255 </div> 256 257 {# Bouton Annulation — visible uniquement si l'ouverture est dans + d'1h #} 258 {% set seconds_before = state.session.started_at - now_ts %} 259 {% if seconds_before > 3600 %} 260 <form action="/locker/school/cancel" method="POST" 261 onsubmit="return confirm('Annuler votre réservation ? Pour le remboursement, vous devrez contacter l\'administration.');" 262 style="margin-top:14px;"> 263 <button type="submit" class="btn-cancel-reservation"> 264 <i class="fa-solid fa-xmark"></i> ANNULER LA RÉSERVATION 265 </button> 266 </form> 267 <div class="cancel-hint"> 268 <i class="fa-solid fa-circle-info"></i> 269 L'annulation n'est possible que jusqu'à 1h avant l'ouverture. 270 </div> 271 {% else %} 272 <div class="cancel-disabled-hint"> 273 <i class="fa-solid fa-lock"></i> 274 Annulation indisponible : il reste moins d'1h avant l'ouverture. 275 </div> 276 {% endif %} 277 {% endif %} 278 279 <!-- Bouton Info Casier (cliquable pour révéler les détails) --> 280 <button type="button" class="btn-locker-info" onclick="toggleLockerInfo()"> 281 <i class="fa-solid fa-circle-info"></i> INFO CASIER 282 <i class="fa-solid fa-chevron-down" id="info-chevron"></i> 283 </button> 284 285 <!-- Info grid (masqué par défaut) --> 286 <div class="info-grid" id="locker-info-grid" style="display:none;"> 287 <div class="info-block"> 288 <div class="ilabel">Identifiant</div> 289 <div class="ival">{{ locker.id }}</div> 290 </div> 291 <div class="info-block"> 292 <div class="ilabel">Tarif</div> 293 <div class="ival" style="color:var(--secondary);">5,00 € / h</div> 294 </div> 295 </div> 296 297 {% if state.status == 'unlocked' and state.session %} 298 <div class="timer-block"> 299 <div class="timer-label">Temps restant</div> 300 <div class="timer-display" id="timer-display">--:--:--</div> 301 <div class="timer-sub" id="timer-sub">Session en cours — {{ state.session.hours }}h payées</div> 302 </div> 303 304 <div class="info-grid"> 305 <div class="info-block"> 306 <div class="ilabel">Montant payé</div> 307 <div class="ival" style="color:var(--secondary);">{{ "%.2f"|format(state.session.amount_eur) }} €</div> 308 </div> 309 <div class="info-block"> 310 <div class="ilabel">Expire à</div> 311 <div class="ival" id="expire-display">--:--</div> 312 </div> 313 </div> 314 315 <div id="extend-wrap" style="display:none;"> 316 <div class="extend-notice"> 317 <i class="fa-solid fa-clock"></i> 318 Moins de 30 minutes restantes — vous pouvez prolonger d'1h pour 5,00 €. 319 </div> 320 <form action="/locker/school/extend" method="POST"> 321 <button type="submit" class="btn-extend"> 322 <i class="fa-solid fa-plus"></i> PROLONGER +1H (5,00 €) 323 </button> 324 </form> 325 </div> 326 327 <!-- Formulaire cache : l'action reelle (open/close) est soumise apres validation biometrique --> 328 <form id="school-action-form" method="POST" style="margin:0;"> 329 <input type="hidden" name="action_url" id="school-action-url" value=""> 330 </form> 331 332 <!-- Bouton OUVRIR (pulse — la porte s'ouvre 4 secondes puis se referme) --> 333 <!-- L'empreinte (WebAuthn) est exigee avant l'ouverture, comme le casier sport. --> 334 <button type="button" class="btn-open-school" style="margin-top:10px;" 335 onclick="schoolBioOrSubmit('/locker/school/open', this, true)"> 336 <i class="fa-solid fa-lock-open"></i> OUVRIR LA PORTE 337 </button> 338 339 <!-- Bouton FERMER (termine la session, la duree restante n'est pas remboursee) --> 340 <!-- L'empreinte est aussi exigee pour terminer la session. --> 341 <button type="button" class="btn-close" 342 onclick="schoolBioOrSubmit('/locker/school/close', this, false)"> 343 <i class="fa-solid fa-stop"></i> TERMINER LA SESSION 344 </button> 345 346 <script> 347 const expiresAt = {{ state.session.expires_at | default('0') }}; 348 let timerDone = false; 349 let notified5min = false; 350 let notified1min = false; 351 const extendWrap = document.getElementById('extend-wrap'); 352 const expireEl = document.getElementById('expire-display'); 353 354 // Demander la permission de notifs (client-side seulement) 355 if ('Notification' in window && Notification.permission === 'default') { 356 Notification.requestPermission(); 357 } 358 359 function notify(title, body) { 360 if ('Notification' in window && Notification.permission === 'granted') { 361 new Notification(title, { body: body, icon: '/static/icons/icon-192.png', badge: '/static/icons/icon-192.png' }); 362 } 363 if ('vibrate' in navigator) navigator.vibrate([200, 100, 200]); 364 } 365 366 // Afficher heure d'expiration 367 const ed = new Date(expiresAt * 1000); 368 expireEl.textContent = String(ed.getHours()).padStart(2,'0') + 'h' + String(ed.getMinutes()).padStart(2,'0'); 369 370 function updateTimer() { 371 const remaining = Math.max(0, Math.floor(expiresAt - Date.now() / 1000)); 372 const h = Math.floor(remaining / 3600); 373 const m = Math.floor((remaining % 3600) / 60); 374 const s = remaining % 60; 375 const el = document.getElementById('timer-display'); 376 el.textContent = String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0'); 377 if (remaining < 300) el.classList.add('urgent'); 378 // Notifications aux moments clés 379 if (remaining <= 300 && remaining > 295 && !notified5min) { 380 notified5min = true; 381 notify('⏰ SmartLocker — 5 minutes restantes', 'Votre session École expire bientôt. Pensez à prolonger ou à vider votre casier.'); 382 } 383 if (remaining <= 60 && remaining > 55 && !notified1min) { 384 notified1min = true; 385 notify('🔒 SmartLocker — 1 minute !', 'Votre casier va se verrouiller automatiquement.'); 386 } 387 // Bouton d'extension si < 30 min 388 if (remaining > 0 && remaining <= 1800) extendWrap.style.display = 'block'; 389 else extendWrap.style.display = 'none'; 390 if (remaining === 0 && !timerDone) { 391 timerDone = true; 392 el.textContent = "00:00:00"; 393 setTimeout(() => location.reload(), 1500); 394 } 395 } 396 updateTimer(); 397 setInterval(updateTimer, 1000); 398 </script> 399 400 {% elif state.status == 'locked' %} 401 {% if not state.online %} 402 <!-- Casier hors-ligne : pas de formulaire de reservation --> 403 <div class="booking-title"><i class="fa-solid fa-plug-circle-xmark"></i> Réservation indisponible</div> 404 <div style="background:rgba(255,42,109,0.04);border:1px dashed rgba(255,42,109,0.3);padding:18px 20px;text-align:center;color:var(--text-muted);font-size:0.9rem;"> 405 Le casier doit être allumé et connecté pour pouvoir être réservé. 406 </div> 407 {% else %} 408 <div class="booking-title"><i class="fa-solid fa-calendar-check"></i> Choisir un créneau</div> 409 410 {% with messages = get_flashed_messages() %}{% if messages %} 411 <div class="booking-errors"> 412 <div class="err-title"><i class="fa-solid fa-circle-exclamation"></i> ERREUR DE RÉSERVATION</div> 413 <ul>{% for m in messages %}<li>{{ m }}</li>{% endfor %}</ul> 414 </div> 415 {% endif %}{% endwith %} 416 417 <form action="/locker/school/checkout" method="POST" id="checkout-form" onsubmit="return validateBooking()"> 418 419 <div class="date-selector"> 420 <div class="booking-title" style="margin-bottom:0.6rem;"><i class="fa-solid fa-calendar"></i> Date</div> 421 <div class="date-tabs" id="date-tabs"></div> 422 <div class="field-error" id="err-date"><i class="fa-solid fa-circle-exclamation"></i> Choisissez une date.</div> 423 <input type="hidden" name="date" id="selected-date"> 424 </div> 425 426 <div> 427 <div class="booking-title" style="margin-bottom:0.6rem;"><i class="fa-solid fa-clock"></i> Heure de début</div> 428 <div class="hour-grid" id="hour-grid"></div> 429 <div class="field-error" id="err-hour"><i class="fa-solid fa-circle-exclamation"></i> Choisissez une heure de début.</div> 430 <input type="hidden" name="start_hour" id="selected-hour"> 431 </div> 432 433 <div style="margin-bottom:1.25rem;"> 434 <div class="booking-title" style="margin-bottom:0.6rem;"><i class="fa-solid fa-hourglass-half"></i> Durée</div> 435 <div class="duration-row"> 436 <span class="dur-label">HEURES</span> 437 <div class="dur-stepper"> 438 <button type="button" class="dur-btn" onclick="changeDur(-1)"><i class="fa-solid fa-minus"></i></button> 439 <div class="dur-val" id="dur-display">1</div> 440 <button type="button" class="dur-btn" onclick="changeDur(1)"><i class="fa-solid fa-plus"></i></button> 441 </div> 442 </div> 443 <div class="field-error" id="err-dur"><i class="fa-solid fa-circle-exclamation"></i> <span id="err-dur-msg">Durée invalide.</span></div> 444 <input type="hidden" name="hours" id="selected-hours" value="1"> 445 </div> 446 447 <div class="booking-summary" id="booking-summary" style="display:none;"> 448 <div class="bs-row"> 449 <span class="bs-key">Date</span> 450 <span class="bs-val" id="sum-date">—</span> 451 </div> 452 <div class="bs-row"> 453 <span class="bs-key">Créneau</span> 454 <span class="bs-val" id="sum-slot">—</span> 455 </div> 456 <div class="bs-row"> 457 <span class="bs-key">Durée</span> 458 <span class="bs-val" id="sum-dur">—</span> 459 </div> 460 <div class="bs-row"> 461 <span class="bs-key">Total</span> 462 <span class="bs-val price" id="sum-price">—</span> 463 </div> 464 </div> 465 466 <button type="submit" class="btn-pay" id="btn-pay" disabled> 467 <i class="fa-solid fa-credit-card"></i> <span id="btn-pay-label">SÉLECTIONNEZ UN CRÉNEAU</span> 468 </button> 469 </form> 470 {% endif %} 471 {% endif %} 472 </div> 473</div> 474 475<script> 476 // ════════════════════════════════════════════════════════════════ 477 // EMPREINTE (WebAuthn) — meme mecanisme que le casier sport. 478 // Exigee pour OUVRIR et TERMINER la session (pas pour prolonger). 479 // ════════════════════════════════════════════════════════════════ 480 const BIO_KEY = 'sl_cred_id'; // meme cle que le sport : empreinte partagee 481 const LOCK_DELAY = 4; // doit correspondre a DUREE_OUVERTURE du Pi Zero 482 483 function b64url(buf) { 484 return btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,''); 485 } 486 function b64urlDecode(s) { 487 s = s.replace(/-/g,'+').replace(/_/g,'/'); 488 while (s.length % 4) s += '='; 489 return Uint8Array.from(atob(s), c => c.charCodeAt(0)).buffer; 490 } 491 492 function showToast(msg, type='success') { 493 const t = document.createElement('div'); 494 let cls = '', icon = 'circle-check'; 495 if (type === 'err') { cls = 'err'; icon = 'triangle-exclamation'; } 496 if (type === 'warning') { cls = 'err'; icon = 'circle-info'; } 497 t.className = 'toast ' + cls; 498 t.innerHTML = `<i class="fa-solid fa-${icon}"></i> ${msg}`; 499 const c = document.querySelector('.app-container'); 500 if (c) c.prepend(t); 501 setTimeout(() => { t.style.transition='opacity 0.5s'; t.style.opacity='0'; setTimeout(()=>t.remove(),500); }, 4000); 502 } 503 504 async function registerBio() { 505 const reg = await navigator.credentials.create({ publicKey: { 506 challenge: crypto.getRandomValues(new Uint8Array(32)), 507 rp: { name: 'SmartLocker', id: location.hostname }, 508 user: { id: crypto.getRandomValues(new Uint8Array(16)), name: 'smartlocker-user', displayName: 'SmartLocker User' }, 509 pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], 510 authenticatorSelection: { authenticatorAttachment: 'platform', userVerification: 'required' }, 511 timeout: 60000, attestation: 'none' 512 }}); 513 const id = b64url(reg.rawId); 514 localStorage.setItem(BIO_KEY, id); 515 return id; 516 } 517 518 async function verifyBio(credId) { 519 await navigator.credentials.get({ publicKey: { 520 challenge: crypto.getRandomValues(new Uint8Array(32)), 521 rpId: location.hostname, 522 allowCredentials: [{ type: 'public-key', id: b64urlDecode(credId) }], 523 userVerification: 'required', timeout: 60000 524 }}); 525 } 526 527 // Gate biometrique : demande l'empreinte puis soumet le formulaire vers actionUrl. 528 async function schoolBioOrSubmit(actionUrl, btn, isOpening) { 529 const orig = btn.innerHTML; 530 const form = document.getElementById('school-action-form'); 531 document.getElementById('school-action-url').value = actionUrl; 532 form.action = actionUrl; 533 534 // Confirmation pour la fermeture (duree restante non remboursee) 535 if (!isOpening) { 536 if (!confirm('Terminer votre session maintenant ? La durée restante ne sera pas remboursée.')) return; 537 } else { 538 showToast(`La porte va s'ouvrir pour ${LOCK_DELAY} secondes`, 'warning'); 539 } 540 541 // Si la biometrie n'est pas supportee : on soumet directement (fallback) 542 if (!window.PublicKeyCredential) { 543 showToast('Biométrie non supportée, exécution directe.', 'warning'); 544 setTimeout(() => form.submit(), 600); 545 return; 546 } 547 548 btn.innerHTML = '<i class="fa-solid fa-fingerprint fa-beat"></i> AUTH...'; 549 btn.disabled = true; 550 try { 551 let credId = localStorage.getItem(BIO_KEY); 552 if (!credId) { 553 credId = await registerBio(); 554 await verifyBio(credId); 555 } else { 556 try { await verifyBio(credId); } 557 catch(e) { 558 if (e.name === 'NotAllowedError') throw e; 559 localStorage.removeItem(BIO_KEY); 560 credId = await registerBio(); 561 await verifyBio(credId); 562 } 563 } 564 if ('vibrate' in navigator) navigator.vibrate([100, 50, 100]); 565 btn.innerHTML = '<i class="fa-solid fa-check"></i> VALIDÉ...'; 566 form.submit(); 567 } catch(err) { 568 console.error('[WebAuthn]', err.name, err.message); 569 btn.innerHTML = orig; btn.disabled = false; 570 const errMap = { 571 'NotAllowedError': 'Authentification annulée ou refusée.', 572 'InvalidStateError': 'Empreinte réinitialisée — réessayez.', 573 'SecurityError': 'Erreur sécurité : vérifiez HTTPS.', 574 'NotSupportedError': 'Biométrie non supportée par ce navigateur.', 575 'AbortError': 'Opération annulée.', 576 'NotReadableError': 'Capteur biométrique inaccessible.' 577 }; 578 if (err.name === 'InvalidStateError') localStorage.removeItem(BIO_KEY); 579 showToast(errMap[err.name] || 'Erreur : ' + (err.name || 'inconnue'), 'err'); 580 } 581 } 582 583 // Toggle d'affichage des infos du casier (ID, etc.) 584 function toggleLockerInfo() { 585 const grid = document.getElementById('locker-info-grid'); 586 const chev = document.getElementById('info-chevron'); 587 if (!grid) return; 588 const isHidden = grid.style.display === 'none' || grid.style.display === ''; 589 grid.style.display = isHidden ? 'grid' : 'none'; 590 if (chev) chev.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'; 591 } 592 593 // ── Données serveur ────────────────────────────────────────────── 594 // L'ajout du filtre |safe empêche Jinja d'encoder les guillemets en " ce qui fait crasher le JSON 595 const BOOKED_SLOTS = {{ booked_slots | tojson | safe }}; 596 const ALL_HOURS = {{ school_hours | tojson | safe }}; 597 598 let selectedDate = null; 599 let selectedHour = null; 600 let currentDur = 1; 601 602 // ── Génération des onglets de dates (aujourd'hui + 13 jours) ───── 603 function buildDateTabs() { 604 const container = document.getElementById('date-tabs'); 605 if (!container) return; 606 const dayNames = ['DIM','LUN','MAR','MER','JEU','VEN','SAM']; 607 const monthNames = ['JAN','FÉV','MAR','AVR','MAI','JUN','JUL','AOÛ','SEP','OCT','NOV','DÉC']; 608 const now = new Date(); 609 for (let i = 0; i < 14; i++) { 610 const d = new Date(now); 611 d.setDate(now.getDate() + i); 612 const dateStr = d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); 613 const tab = document.createElement('div'); 614 tab.className = 'date-tab' + (i === 0 ? ' today' : ''); 615 tab.dataset.date = dateStr; 616 tab.innerHTML = ` 617 <div class="dt-day">${dayNames[d.getDay()]}</div> 618 <div class="dt-num">${d.getDate()}</div> 619 <div class="dt-month">${monthNames[d.getMonth()]}</div> 620 `; 621 tab.addEventListener('click', () => selectDate(dateStr, tab)); 622 container.appendChild(tab); 623 } 624 // Sélectionner aujourd'hui par défaut 625 const firstTab = container.querySelector('.date-tab'); 626 if (firstTab) selectDate(firstTab.dataset.date, firstTab); 627 } 628 629 function selectDate(dateStr, tabEl) { 630 selectedDate = dateStr; 631 document.getElementById('selected-date').value = dateStr; 632 // MAJ visuel onglets 633 document.querySelectorAll('.date-tab').forEach(t => t.classList.remove('selected')); 634 tabEl.classList.add('selected'); 635 // Masquer erreur 636 hideErr('err-date'); 637 // Régénérer la grille d'heures 638 buildHourGrid(dateStr); 639 updateSummary(); 640 } 641 642 function buildHourGrid(dateStr) { 643 const grid = document.getElementById('hour-grid'); 644 if (!grid) return; 645 grid.innerHTML = ''; 646 const now = new Date(); 647 const todayStr = now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0')+'-'+String(now.getDate()).padStart(2,'0'); 648 const isToday = (dateStr === todayStr); 649 650 // ── Option "Maintenant" : reservation immediate (aujourd'hui seulement, 651 // et tant qu'il reste assez de temps avant la fermeture 22h) ── 652 if (isToday && now.getHours() < 22) { 653 const nowBtn = document.createElement('div'); 654 nowBtn.className = 'hour-btn now'; 655 nowBtn.innerHTML = '<i class="fa-solid fa-bolt"></i> MAINTENANT'; 656 nowBtn.dataset.hour = 'now'; 657 nowBtn.title = 'Demarrer la session immediatement'; 658 nowBtn.addEventListener('click', () => selectHour('now', nowBtn)); 659 grid.appendChild(nowBtn); 660 } 661 662 ALL_HOURS.forEach(hStr => { 663 const h = parseInt(hStr.split(':')[0]); 664 const btn = document.createElement('div'); 665 btn.className = 'hour-btn'; 666 btn.textContent = hStr; 667 btn.dataset.hour = hStr; 668 669 const slotKey = dateStr+'-'+hStr; 670 const isPast = isToday && h <= now.getHours(); 671 const isBooked = BOOKED_SLOTS.includes(slotKey); 672 673 if (isBooked) { 674 btn.classList.add('booked'); 675 btn.title = 'Créneau occupé'; 676 } else if (isPast) { 677 btn.classList.add('past'); 678 btn.title = 'Créneau passé'; 679 } else { 680 btn.addEventListener('click', () => selectHour(hStr, btn)); 681 } 682 grid.appendChild(btn); 683 }); 684 685 // Réinitialiser la sélection d'heure 686 selectedHour = null; 687 document.getElementById('selected-hour').value = ''; 688 } 689 690 function selectHour(hStr, btnEl) { 691 selectedHour = hStr; 692 document.getElementById('selected-hour').value = hStr; 693 document.querySelectorAll('.hour-btn').forEach(b => b.classList.remove('selected')); 694 btnEl.classList.add('selected'); 695 hideErr('err-hour'); 696 updateSummary(); 697 } 698 699 // Heure de debut effective : si "now", on prend l'heure courante (ex. 14) 700 function effectiveStartHour() { 701 if (selectedHour === 'now') return new Date().getHours(); 702 if (selectedHour) return parseInt(selectedHour.split(':')[0]); 703 return null; 704 } 705 706 // ── Stepper durée ────────────────────────────────────────────── 707 function changeDur(delta) { 708 const newVal = currentDur + delta; 709 if (newVal < 1 || newVal > 6) return; 710 711 // Vérifier que la fin reste avant 22h 712 if (selectedHour) { 713 const startH = effectiveStartHour(); 714 if (startH + newVal > 22) { 715 document.getElementById('err-dur-msg').textContent = 'La session dépasserait 22h00. Réduisez la durée.'; 716 showErr('err-dur'); 717 return; 718 } 719 } 720 hideErr('err-dur'); 721 currentDur = newVal; 722 document.getElementById('dur-display').textContent = currentDur; 723 document.getElementById('selected-hours').value = currentDur; 724 updateSummary(); 725 } 726 727 // ── Récapitulatif ────────────────────────────────────────────── 728 function updateSummary() { 729 const summary = document.getElementById('booking-summary'); 730 const payBtn = document.getElementById('btn-pay'); 731 const payLbl = document.getElementById('btn-pay-label'); 732 733 if (selectedDate && selectedHour) { 734 const isNow = (selectedHour === 'now'); 735 // Calculer heure de fin (pour "now", basee sur l'heure courante) 736 const startH = effectiveStartHour(); 737 const endH = startH + currentDur; 738 739 // Vérifier débordement 740 if (endH > 22) { 741 document.getElementById('err-dur-msg').textContent = 'La session dépasserait 22h00.'; 742 showErr('err-dur'); 743 payBtn.disabled = true; 744 payLbl.textContent = 'CRÉNEAU INVALIDE'; 745 return; 746 } 747 hideErr('err-dur'); 748 749 // Vérifier créneaux bloqués pour toute la durée (heures chevauchées) 750 let blocked = false; 751 for (let h = startH; h < endH; h++) { 752 const slotKey = selectedDate+'-'+String(h).padStart(2,'0')+':00'; 753 if (BOOKED_SLOTS.includes(slotKey)) { blocked = true; break; } 754 } 755 756 // Formater date lisible 757 const [yr, mo, dy] = selectedDate.split('-'); 758 const monthsFr = ['janv.','févr.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.']; 759 const dateLabel = parseInt(dy) + ' ' + monthsFr[parseInt(mo)-1]; 760 761 document.getElementById('sum-date').textContent = isNow ? "Aujourd'hui (maintenant)" : dateLabel; 762 if (isNow) { 763 const n = new Date(); 764 const startLbl = String(n.getHours()).padStart(2,'0')+'h'+String(n.getMinutes()).padStart(2,'0'); 765 const e = new Date(n.getTime() + currentDur * 3600 * 1000); 766 const endLbl = String(e.getHours()).padStart(2,'0')+'h'+String(e.getMinutes()).padStart(2,'0'); 767 document.getElementById('sum-slot').textContent = 'Maintenant ('+startLbl+') → '+endLbl; 768 } else { 769 document.getElementById('sum-slot').textContent = String(startH).padStart(2,'0')+'h00 → '+String(endH).padStart(2,'0')+'h00'; 770 } 771 document.getElementById('sum-dur').textContent = currentDur+'h'; 772 document.getElementById('sum-price').textContent = (currentDur * 5).toFixed(2).replace('.',',')+' €'; 773 summary.style.display = 'block'; 774 775 if (blocked) { 776 payBtn.disabled = true; 777 payLbl.textContent = 'CRÉNEAU OCCUPÉ'; 778 } else { 779 payBtn.disabled = false; 780 payLbl.textContent = 'PAYER '+(currentDur * 5).toFixed(2).replace('.',',')+' € ET RÉSERVER'; 781 } 782 } else { 783 summary.style.display = 'none'; 784 payBtn.disabled = true; 785 payLbl.textContent = 'SÉLECTIONNEZ UN CRÉNEAU'; 786 } 787 } 788 789 // ── Validation finale avant soumission ───────────────────────── 790 function validateBooking() { 791 let ok = true; 792 if (!selectedDate) { showErr('err-date'); ok = false; } 793 if (!selectedHour) { showErr('err-hour'); ok = false; } 794 if (!ok) return false; 795 796 const startH = effectiveStartHour(); 797 const endH = startH + currentDur; 798 if (endH > 22) { 799 document.getElementById('err-dur-msg').textContent = 'La session dépasserait 22h00. Réduisez la durée.'; 800 showErr('err-dur'); return false; 801 } 802 803 // Vérifier qu'aucun créneau n'est bloqué 804 for (let h = startH; h < endH; h++) { 805 const slotKey = selectedDate+'-'+String(h).padStart(2,'0')+':00'; 806 if (BOOKED_SLOTS.includes(slotKey)) { 807 showErr('err-hour'); 808 document.getElementById('err-hour').querySelector('span') || 809 (document.getElementById('err-hour').innerHTML = '<i class="fa-solid fa-circle-exclamation"></i> Ce créneau est occupé.'); 810 return false; 811 } 812 } 813 return true; 814 } 815 816 function showErr(id) { const el = document.getElementById(id); if(el) el.classList.add('show'); } 817 function hideErr(id) { const el = document.getElementById(id); if(el) el.classList.remove('show'); } 818 819 // ── Countdown reservation future ── 820 {% if state.status == 'reserved' and state.session and is_my_session %} 821 (function() { 822 const startTs = {{ state.session.started_at | default('0') }}; 823 const el = document.getElementById('reserved-countdown'); 824 if (!el) return; 825 function tick() { 826 const remaining = Math.max(0, Math.floor(startTs - Date.now() / 1000)); 827 const h = Math.floor(remaining / 3600); 828 const m = Math.floor((remaining % 3600) / 60); 829 const s = remaining % 60; 830 el.textContent = String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0'); 831 if (remaining === 0) { 832 el.textContent = 'OUVERTURE EN COURS...'; 833 setTimeout(() => location.reload(), 2000); 834 } 835 } 836 tick(); 837 setInterval(tick, 1000); 838 })(); 839 {% endif %} 840 841 // ── Loading state sur bouton paiement ── 842 document.getElementById('checkout-form')?.addEventListener('submit', function() { 843 const btn = document.getElementById('btn-pay'); 844 if (btn && !btn.disabled) { 845 btn.disabled = true; 846 btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> REDIRECTION VERS STRIPE...'; 847 } 848 }); 849 850 // ── Init ── 851 buildDateTabs(); 852 853 // Auto-hide toasts 854 setTimeout(() => { 855 document.querySelectorAll('.toast').forEach(t => { 856 t.style.transition = 'opacity 0.5s'; t.style.opacity = '0'; 857 setTimeout(() => t.remove(), 500); 858 }); 859 }, 4000); 860</script> 861 862<!-- SMARTLOCKER NOTIFICATIONS - fallback robuste --> 863<script src="{{ url_for('static', filename='notifications.js') }}?v=1"></script> 864</body> 865</html>
templates/locker_sport.html
765 lignes
1<!DOCTYPE html> 2<html lang="fr"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>SmartLocker — Salle de sport</title> 7 <link rel="manifest" href="/manifest.json"> 8 <meta name="theme-color" content="#02040a"> 9 <meta name="mobile-web-app-capable" content="yes"> 10 <meta name="apple-mobile-web-app-capable" content="yes"> 11 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> 12 <meta name="apple-mobile-web-app-title" content="SmartLocker"> 13 <link rel="apple-touch-icon" href="/static/icons/icon-192.png"> 14 <link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}"> 15 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> 16 <style> 17 .alert-modal { 18 position: fixed; top: 0; left: 0; width: 100%; height: 100%; 19 background: rgba(0, 0, 0, 0.92); z-index: 10000; 20 display: flex; justify-content: center; align-items: center; 21 text-align: center; backdrop-filter: blur(8px); 22 } 23 .alert-content { 24 background: linear-gradient(135deg, #1a0000, #110000); padding: 35px 25px; 25 border-radius: 18px; border: 2px solid #ff3333; 26 box-shadow: 0 0 40px rgba(255, 51, 51, 0.5), 0 0 80px rgba(255, 0, 0, 0.15); 27 max-width: 90%; width: 340px; animation: alertPulse 2s ease-in-out infinite; 28 } 29 @keyframes alertPulse { 0%,100%{box-shadow:0 0 40px rgba(255,51,51,0.5)} 50%{box-shadow:0 0 60px rgba(255,51,51,0.8)} } 30 .alert-content h2 { color: #ff3333; margin: 0 0 10px 0; font-size: 1.4rem; letter-spacing: 3px; } 31 .alert-content p { color: #ccc; margin: 0; font-size: 1rem; line-height: 1.5; } 32 33 .lockdown-overlay { 34 position: fixed; top: 0; left: 0; width: 100%; height: 100%; 35 background: rgba(10, 0, 0, 0.95); z-index: 9999; 36 display: flex; flex-direction: column; justify-content: center; align-items: center; 37 text-align: center; backdrop-filter: blur(10px); 38 } 39 .lockdown-icon { font-size: 5rem; color: #ff3333; margin-bottom: 20px; } 40 .lockdown-title { color: #ff3333; font-size: 1.6rem; font-weight: 800; letter-spacing: 4px; margin-bottom: 8px; } 41 .lockdown-sub { color: #aaa; font-size: 0.95rem; margin-bottom: 25px; max-width: 320px; line-height: 1.5; } 42 .lockdown-timer { 43 font-size: 3.5rem; font-weight: 900; color: #fff; 44 font-family: 'Courier New', monospace; letter-spacing: 6px; 45 background: rgba(255, 51, 51, 0.1); border: 2px solid rgba(255, 51, 51, 0.3); 46 border-radius: 12px; padding: 15px 30px; margin-bottom: 20px; 47 } 48 .lockdown-reason { color: #ff6666; font-size: 0.85rem; opacity: 0.8; } 49 .lockdown-bar { width: 260px; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; margin-top: 20px; overflow: hidden; } 50 .lockdown-bar-fill { height: 100%; background: linear-gradient(90deg, #ff3333, #ff6600); border-radius: 3px; transition: width 1s linear; } 51 </style> 52</head> 53<body> 54<!-- LOCKDOWN OVERLAY (bloque tout) --> 55<div id="lockdown-overlay" class="lockdown-overlay" style="display: none;"> 56 <i class="fa-solid fa-shield-halved fa-beat-fade lockdown-icon"></i> 57 <div class="lockdown-title">INTERVENTION EN COURS</div> 58 <div class="lockdown-sub">Une intrusion a été détectée sur ce casier. Un administrateur a été alerté et intervient. Veuillez patienter.{% if admin_email %}<br><br>En cas d'urgence, contactez l'administrateur :<br><a href="mailto:{{ admin_email }}" style="color:#00f0ff;">{{ admin_email }}</a>{% endif %}</div> 59 <div class="lockdown-timer" id="lockdown-timer">05:00</div> 60 <div class="lockdown-reason" id="lockdown-reason"></div> 61 <div class="lockdown-bar"><div class="lockdown-bar-fill" id="lockdown-bar-fill" style="width:100%"></div></div> 62</div> 63 64<!-- ALERT POPUP (par-dessus le lockdown) --> 65<div id="alert-modal" class="alert-modal" style="display: none;"> 66 <div class="alert-content"> 67 <i class="fa-solid fa-triangle-exclamation fa-beat" style="font-size: 4rem; color: #ff3333; margin-bottom: 20px;"></i> 68 <h2>⚠ ALERTE SÉCURITÉ</h2> 69 <p id="alert-text">Une intrusion a été détectée sur votre casier ! Un administrateur a été alerté et intervient.</p> 70 <button type="button" class="btn-main btn-lock" style="background: #ff3333; border-color: #ff3333; color: white; margin-top: 20px;" onclick="document.getElementById('alert-modal').style.display='none'"> 71 J'AI COMPRIS 72 </button> 73 </div> 74</div> 75 76<!-- RESOLVED POPUP (pour intervention admin) --> 77<div id="resolved-modal" class="alert-modal" style="display: none; z-index: 10001;"> 78 <div class="alert-content" style="border-color: #00ffaa; box-shadow: 0 0 40px rgba(0, 255, 170, 0.4); animation: none;"> 79 <i class="fa-solid fa-circle-check fa-beat" style="font-size: 4rem; color: #00ffaa; margin-bottom: 20px;"></i> 80 <h2 style="color: #00ffaa;">SITUATION RÉTABLIE</h2> 81 <p>Un administrateur a débloqué votre casier. Vous pouvez à nouveau l'utiliser en toute sécurité.</p> 82 <button type="button" class="btn-main btn-lock" style="background: #00ffaa; border-color: #00ffaa; color: #000; font-weight: bold; margin-top: 20px;" onclick="document.getElementById('resolved-modal').style.display='none'"> 83 CONTINUER 84 </button> 85 </div> 86</div> 87 88<!-- RAPPEL "FERMEZ VOTRE CASIER" --> 89<div id="reminder-modal" class="alert-modal" style="display: none; z-index: 10002;"> 90 <div class="alert-content" style="border-color: #ffaa00; box-shadow: 0 0 40px rgba(255, 170, 0, 0.5); animation: none;"> 91 <i class="fa-solid fa-clock fa-beat" style="font-size: 4rem; color: #ffaa00; margin-bottom: 20px;"></i> 92 <h2 style="color: #ffaa00;">⏰ FERMEZ VOTRE CASIER</h2> 93 <p id="reminder-text">Votre casier est resté ouvert ! Pensez à bien le refermer pour votre sécurité.</p> 94 <button type="button" class="btn-main btn-lock" style="background: #ffaa00; border-color: #ffaa00; color: #000; font-weight: bold; margin-top: 20px;" onclick="document.getElementById('reminder-modal').style.display='none'"> 95 JE LE FERME TOUT DE SUITE 96 </button> 97 </div> 98</div> 99 100<!-- ALERTE OBJET RETIRÉ --> 101<div id="object-removed-modal" class="alert-modal" style="display: none; z-index: 10003;"> 102 <div class="alert-content" style="border-color: #00b8ff; box-shadow: 0 0 40px rgba(0, 184, 255, 0.4); animation: none;"> 103 <i class="fa-solid fa-box-open fa-beat" style="font-size: 4rem; color: #00b8ff; margin-bottom: 20px;"></i> 104 <h2 style="color: #00b8ff;">📦 OBJET RETIRÉ</h2> 105 <p>Un objet vient d'être retiré de votre casier. Si ce n'est pas vous, contactez un administrateur immédiatement.</p> 106 <button type="button" class="btn-main btn-lock" style="background: #00b8ff; border-color: #00b8ff; color: #000; font-weight: bold; margin-top: 20px;" onclick="document.getElementById('object-removed-modal').style.display='none'"> 107 COMPRIS 108 </button> 109 </div> 110</div> 111 112<div class="cyber-grid"></div> 113 114<nav class="navbar"> 115 <div class="nav-container"> 116 <a href="/" class="nav-logo"><i class="fa-solid fa-fingerprint"></i> SMARTLOCKER</a> 117 <a href="/" class="nav-back"><i class="fa-solid fa-arrow-left"></i> Retour</a> 118 </div> 119</nav> 120 121<div class="app-container"> 122 123 {% with messages = get_flashed_messages() %}{% if messages %} 124 {% for m in messages %} 125 {% if 'fermer' in m|lower or 'porte' in m|lower or 'hors-ligne' in m|lower or 'impossible' in m|lower %} 126 <div class="toast" style="background:linear-gradient(135deg,#ff9500,#ff6b00);border-color:#ff9500;color:#fff;"> 127 <i class="fa-solid fa-triangle-exclamation"></i> {{ m }} 128 </div> 129 {% else %} 130 <div class="toast"><i class="fa-solid fa-circle-check"></i> {{ m }}</div> 131 {% endif %} 132 {% endfor %} 133 <script> 134 // Vibration douce si flash d'avertissement 135 (function() { 136 const last = "{{ messages[-1]|e }}".toLowerCase(); 137 if (last.includes('fermer') || last.includes('porte') || last.includes('hors-ligne') || last.includes('impossible')) { 138 if ('vibrate' in navigator) navigator.vibrate([200, 100, 200]); 139 } 140 })(); 141 </script> 142 {% endif %}{% endwith %} 143 144 <div class="app-card"> 145 146 <!-- Hero --> 147 <div class="locker-hero"> 148 <div class="locker-icon-wrap"> 149 <div class="locker-ring"></div> 150 <div class="locker-ring-spin"></div> 151 <div class="locker-ring-spin2"></div> 152 <div class="locker-core {{ 'open' if state.status == 'unlocked' else 'closed' }}" id="locker-core"> 153 <i class="fa-solid fa-{{ 'lock-open' if state.status == 'unlocked' else 'lock' }}" id="locker-core-icon"></i> 154 </div> 155 </div> 156 <div class="locker-name">{{ locker.name | upper }}</div> 157 <div class="locker-loc"><i class="fa-solid fa-location-dot"></i> {{ locker.location }}</div> 158 </div> 159 160 <!-- Status bar --> 161 <div class="status-bar {{ state.status }}" id="status-bar"> 162 <i class="fa-solid fa-{{ 'shield-halved' if state.status == 'locked' else 'shield-virus' }}" id="status-icon"></i> 163 <span id="status-text">CASIER {{ 'SÉCURISÉ' if state.status == 'locked' else 'DÉVERROUILLÉ' }}</span> 164 </div> 165 166 <!-- Bandeau HORS-LIGNE — affiche quand le Raspberry Pi est eteint --> 167 <div class="offline-banner" id="offline-banner" style="{% if state.online %}display:none;{% endif %}"> 168 <div class="offline-banner-icon"><i class="fa-solid fa-plug-circle-xmark"></i></div> 169 <div class="offline-banner-text"> 170 <div class="offline-banner-title">CASIER HORS-LIGNE</div> 171 <div class="offline-banner-sub">Le casier n'est pas allumé ou la connexion est perdue. Aucune action n'est possible tant qu'il n'est pas remis en marche.</div> 172 </div> 173 </div> 174 175 <!-- Bouton Info Casier (cliquable pour révéler les détails) --> 176 <button type="button" class="btn-locker-info" onclick="toggleLockerInfo()"> 177 <i class="fa-solid fa-circle-info"></i> INFO CASIER 178 <i class="fa-solid fa-chevron-down" id="info-chevron"></i> 179 </button> 180 181 <!-- Info grid (masqué par défaut) --> 182 <div class="info-grid" id="locker-info-grid" style="display:none;"> 183 <div class="info-block"> 184 <div class="ilabel">Identifiant</div> 185 <div class="ival">{{ locker.id }}</div> 186 </div> 187 <div class="info-block"> 188 <div class="ilabel">Accès</div> 189 <div class="ival" style="color:{% if is_premium %}var(--primary){% else %}var(--warning){% endif %};"> 190 {% if is_premium %}<i class="fa-solid fa-crown" style="color:var(--warning);margin-right:5px;font-size:0.75rem;"></i>PREMIUM{% else %}GRATUIT{% endif %} 191 </div> 192 </div> 193 </div> 194 195 {% if is_premium %} 196 197 <!-- Live indicator --> 198 <div class="live-indicator" id="live-indicator"> 199 <span class="live-dot"></span> 200 <span class="live-text" id="live-text">Vérification de l'état...</span> 201 <span class="live-time" id="live-time"></span> 202 </div> 203 204 <!-- Notice --> 205 <div class="info-notice" id="info-notice"> 206 <i class="fa-solid fa-circle-info"></i> 207 <div>La porte restera déverrouillée pendant <span>4 secondes</span> après l'ouverture.</div> 208 </div> 209 210 <!-- Actions --> 211 <form action="/locker/sport/toggle" method="POST" id="toggle-form"> 212 <input type="hidden" name="action" id="form-action" value=""> 213 214 {% if not state.online %} 215 <!-- Casier hors-ligne : tous les boutons sont desactives --> 216 <div id="btn-group-offline" style="display:flex; flex-direction:column; gap:10px;"> 217 <button type="button" class="btn-main btn-disabled" disabled> 218 <i class="fa-solid fa-plug-circle-xmark"></i> CASIER HORS-LIGNE 219 </button> 220 </div> 221 {% else %} 222 <!-- Le bouton DEMARRER LA SESSION a ete supprime : 223 la session demarre automatiquement a l'appairage du casier. 224 On garde un fallback masque par defaut pour les cas exotiques 225 (status=locked sans session) --> 226 <div id="btn-group-locked" style="display: none; flex-direction:column; gap:10px;"> 227 <button type="button" class="btn-main btn-unlock" onclick="triggerBioOrSubmit('start', this)"> 228 <i class="fa-solid fa-play"></i> RELANCER LA SESSION 229 </button> 230 </div> 231 232 <div id="btn-group-unlocked" style="display: {{ 'flex' if state.status == 'unlocked' else 'none' }}; flex-direction:column; gap:10px;"> 233 <button type="button" class="btn-main btn-unlock" onclick="triggerBioOrSubmit('open', this)"> 234 <i class="fa-solid fa-lock-open"></i> OUVRIR LA PORTE 235 </button> 236 <button type="button" class="btn-main btn-lock" style="background:transparent; border:1px solid var(--danger); color:var(--danger);" onclick="triggerBioOrSubmit('end', this)"> 237 <i class="fa-solid fa-stop"></i> TERMINER LA SESSION 238 </button> 239 </div> 240 241 <div id="btn-group-occupied" style="display: none;"> 242 <button type="button" class="btn-main btn-disabled" disabled> 243 <i class="fa-solid fa-ban"></i> CASIER OCCUPÉ 244 </button> 245 </div> 246 {% endif %} 247 </form> 248 249 {% else %} 250 <div class="premium-wall"> 251 <i class="fa-solid fa-crown"></i> 252 <h3>ACCÈS PREMIUM REQUIS</h3> 253 <p>La salle de sport est réservée aux membres Premium. Contactez l'administration pour activer votre accès.</p> 254 </div> 255 {% endif %} 256 </div> 257</div> 258 259<script> 260 const BIO_KEY = 'sl_cred_id'; 261 const LOCK_DELAY = 4; // Doit correspondre à DUREE_OUVERTURE du Pi Zero 262 263 // Toggle d'affichage des infos du casier (ID, etc.) 264 function toggleLockerInfo() { 265 const grid = document.getElementById('locker-info-grid'); 266 const chev = document.getElementById('info-chevron'); 267 if (!grid) return; 268 const isHidden = grid.style.display === 'none' || grid.style.display === ''; 269 grid.style.display = isHidden ? 'grid' : 'none'; 270 if (chev) chev.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'; 271 } 272 273 function b64url(buf) { 274 return btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,''); 275 } 276 function b64urlDecode(s) { 277 s = s.replace(/-/g,'+').replace(/_/g,'/'); 278 while (s.length % 4) s += '='; 279 return Uint8Array.from(atob(s), c => c.charCodeAt(0)).buffer; 280 } 281 282 function showToast(msg, type='success') { 283 const t = document.createElement('div'); 284 let cls = '', icon = 'circle-check'; 285 if (type === 'err') { cls = 'err'; icon = 'triangle-exclamation'; } 286 if (type === 'warning') { cls = 'warning'; icon = 'circle-info'; } 287 t.className = 'toast ' + cls; 288 t.innerHTML = `<i class="fa-solid fa-${icon}"></i> ${msg}`; 289 document.querySelector('.app-container').prepend(t); 290 setTimeout(() => { 291 t.style.transition='opacity 0.5s'; t.style.opacity='0'; 292 setTimeout(()=>t.remove(),500); 293 }, 4000); 294 } 295 296 // ── POLLING TEMPS RÉEL toutes les 3s ────────────────────── 297 {% if is_premium %} 298 (function(){ 299 const statusBar = document.getElementById('status-bar'); 300 const statusIcon = document.getElementById('status-icon'); 301 const statusText = document.getElementById('status-text'); 302 const core = document.getElementById('locker-core'); 303 const coreIcon = document.getElementById('locker-core-icon'); 304 const liveInd = document.getElementById('live-indicator'); 305 const liveText = document.getElementById('live-text'); 306 const liveTime = document.getElementById('live-time'); 307 const infoNotice = document.getElementById('info-notice'); 308 309 const grpLocked = document.getElementById('btn-group-locked'); 310 const grpUnlocked = document.getElementById('btn-group-unlocked'); 311 const grpOccupied = document.getElementById('btn-group-occupied'); 312 313 const MY_ID = "{{ local_id if local_id else '' }}"; 314 let prevStatus = ""; // Force update on first poll 315 let prevPhys = ""; 316 let prevAlertTime = "{{ state.alert_time if state.alert_time else '' }}"; 317 let prevReminderTime = "{{ state.reminder_time if state.reminder_time else '' }}"; 318 let prevObjectAlertTime = "{{ state.object_alert_time if state.object_alert_time else '' }}"; 319 let lockTimer = null; 320 let wasInLockdown = false; 321 let lockdownInterval = null; 322 323 function setStatusBar(text, cls, icon) { 324 statusBar.className = 'status-bar ' + cls; 325 statusText.textContent = text; 326 statusIcon.className = 'fa-solid fa-' + icon; 327 } 328 function setCore(cls, icon) { 329 core.className = 'locker-core ' + cls; 330 coreIcon.className = 'fa-solid fa-' + icon; 331 } 332 function setLive(text, cls, time='') { 333 liveInd.className = 'live-indicator ' + cls; 334 liveText.textContent = text; 335 liveTime.textContent = time; 336 } 337 function stopTimer() { if (lockTimer) { clearInterval(lockTimer); lockTimer=null; } } 338 339 function startLockCountdown() { 340 stopTimer(); 341 let n = LOCK_DELAY; 342 setStatusBar(`OUVERTURE ${n}s`, 'opening', 'hourglass-half'); 343 setCore('opening', 'lock-open'); 344 setLive('Porte ouverte — verrouillage automatique', 'opening', `${n}s`); 345 grpLocked.style.display = 'none'; 346 grpUnlocked.style.display = 'none'; 347 grpOccupied.style.display = 'none'; // Cacher les boutons pendant l'ouverture 348 349 lockTimer = setInterval(() => { 350 n--; 351 if (n <= 0) { 352 stopTimer(); 353 // L'état final sera déterminé par la prochaine vérification poll() 354 // mais on remet l'affichage unlocked par défaut au cas où 355 setStatusBar('CASIER DÉVERROUILLÉ', 'unlocked', 'shield-virus'); 356 setCore('open', 'lock-open'); 357 setLive('Vous utilisez ce casier', 'unlocked', 'OUVERT'); 358 grpUnlocked.style.display = 'flex'; 359 } else { 360 setStatusBar(`OUVERTURE ${n}s`, 'opening', 'hourglass-half'); 361 setLive('Porte ouverte — verrouillage automatique', 'opening', `${n}s`); 362 } 363 }, 1000); 364 } 365 366 function updateLockdownUI(remaining, reason) { 367 const overlay = document.getElementById('lockdown-overlay'); 368 const timer = document.getElementById('lockdown-timer'); 369 const reasonEl = document.getElementById('lockdown-reason'); 370 const barFill = document.getElementById('lockdown-bar-fill'); 371 372 if (remaining > 0) { 373 overlay.style.display = 'flex'; 374 const min = Math.floor(remaining / 60); 375 const sec = remaining % 60; 376 timer.textContent = `${String(min).padStart(2,'0')}:${String(sec).padStart(2,'0')}`; 377 if (reason) reasonEl.textContent = `Raison : ${reason}`; 378 barFill.style.width = `${(remaining / 300) * 100}%`; 379 380 // Cacher tous les boutons d'action 381 grpLocked.style.display = 'none'; 382 grpUnlocked.style.display = 'none'; 383 grpOccupied.style.display = 'none'; 384 setStatusBar('🔒 MODE LOCKDOWN', 'occupied', 'shield-halved'); 385 setCore('closed', 'lock'); 386 setLive('Casier verrouillé — intrusion détectée', 'occupied', 'BLOQUÉ'); 387 } else { 388 overlay.style.display = 'none'; 389 if (lockdownInterval) { clearInterval(lockdownInterval); lockdownInterval = null; } 390 } 391 } 392 393 function applyState(data) { 394 const status = data.status || 'locked'; 395 const phys = data.physical_state || 'Inconnue'; 396 const alertMsg = data.alert; 397 const alertTime = data.alert_time; 398 const session = data.session || {}; 399 const sessionUser = session.user_email || session.user_pseudo || 'un autre utilisateur'; 400 const isMine = !session.user_id || session.user_id === MY_ID; 401 const lockdown = data.lockdown || false; 402 const lockdownRemaining = data.lockdown_remaining || 0; 403 const lockdownReason = data.lockdown_reason || ''; 404 405 // Si le casier passe online/offline pendant que la page est ouverte, 406 // on recharge pour rerendre les boutons cote serveur (etat coherent). 407 const wasOffline = document.getElementById('offline-banner') 408 && document.getElementById('offline-banner').style.display !== 'none'; 409 const isOffline = (data.online === false); 410 if (wasOffline !== isOffline) { 411 // L'etat online/offline a change : on recharge proprement 412 location.reload(); 413 return; 414 } 415 if (isOffline) { 416 // Toujours offline, rien a faire 417 return; 418 } 419 420 // ── Détection d'une nouvelle alerte (intrusion / forcing) ── 421 if (alertMsg && alertTime && alertTime !== prevAlertTime) { 422 if ('vibrate' in navigator) navigator.vibrate([500, 200, 500, 200, 1000]); 423 const modal = document.getElementById('alert-modal'); 424 let msgUser = `Le casier a été forcé (${alertMsg}) !`; 425 if (alertMsg === 'porte_ouverte_trop_longtemps') { 426 msgUser = "Le casier est resté ouvert trop longtemps malgré les rappels. Un administrateur va intervenir."; 427 } else if (alertMsg === 'intrusion') { 428 msgUser = "Tentative d'ouverture forcée détectée ! Un administrateur va intervenir."; 429 } 430 document.getElementById('alert-text').textContent = msgUser; 431 if (modal) modal.style.display = 'flex'; 432 showToast(`🚨 ALERTE : ${alertMsg.toUpperCase()}`, 'err'); 433 try { 434 if ('Notification' in window && Notification.permission === 'granted') { 435 new Notification("🚨 ALERTE SMARTLOCKER", { 436 body: msgUser, 437 icon: "/static/icons/icon-192.png", 438 vibrate: [500, 200, 500, 200, 1000], 439 requireInteraction: true 440 }); 441 } 442 } catch(e) {} 443 prevAlertTime = alertTime; 444 } 445 446 // ── Détection d'un rappel "fermez votre casier" (pas de lockdown) ── 447 const reminderMsg = data.reminder || ''; 448 const reminderTime = data.reminder_time || ''; 449 if (reminderMsg && reminderTime && reminderTime !== prevReminderTime) { 450 if ('vibrate' in navigator) navigator.vibrate([300, 100, 300, 100, 300]); 451 const rmodal = document.getElementById('reminder-modal'); 452 if (rmodal) rmodal.style.display = 'flex'; 453 showToast("⏰ Fermez votre casier !", 'warning'); 454 try { 455 if ('Notification' in window && Notification.permission === 'granted') { 456 new Notification("⏰ FERMEZ VOTRE CASIER", { 457 body: "Votre casier est resté ouvert ! Pensez à bien le refermer.", 458 icon: "/static/icons/icon-192.png", 459 vibrate: [300, 100, 300, 100, 300], 460 requireInteraction: true, 461 tag: 'smartlocker-reminder', 462 renotify: true 463 }); 464 } 465 } catch(e) {} 466 prevReminderTime = reminderTime; 467 } 468 469 // ── Détection d'un objet retiré du casier (notif info) ── 470 const objMsg = data.object_alert || ''; 471 const objTime = data.object_alert_time || ''; 472 if (objMsg && objTime && objTime !== prevObjectAlertTime) { 473 if ('vibrate' in navigator) navigator.vibrate([200, 100, 200]); 474 const omodal = document.getElementById('object-removed-modal'); 475 if (omodal) omodal.style.display = 'flex'; 476 showToast("📦 Un objet a été retiré de votre casier", 'warning'); 477 try { 478 if ('Notification' in window && Notification.permission === 'granted') { 479 new Notification("📦 OBJET RETIRÉ", { 480 body: "Un objet vient d'être retiré de votre casier.", 481 icon: "/static/icons/icon-192.png", 482 vibrate: [200, 100, 200] 483 }); 484 } 485 } catch(e) {} 486 prevObjectAlertTime = objTime; 487 } 488 489 // LOCKDOWN MODE 490 if (lockdown && lockdownRemaining > 0) { 491 stopTimer(); 492 wasInLockdown = true; 493 updateLockdownUI(lockdownRemaining, lockdownReason); 494 495 // Démarrer le décompte local si pas déjà en cours 496 if (!lockdownInterval) { 497 let remaining = lockdownRemaining; 498 lockdownInterval = setInterval(() => { 499 remaining--; 500 if (remaining <= 0) { 501 clearInterval(lockdownInterval); 502 lockdownInterval = null; 503 updateLockdownUI(0, ''); 504 } else { 505 updateLockdownUI(remaining, lockdownReason); 506 } 507 }, 1000); 508 } 509 prevStatus = status; 510 prevPhys = phys; 511 return; 512 } else if (wasInLockdown) { 513 // Lockdown vient d'être levé (par admin ou auto-reset) 514 wasInLockdown = false; 515 if (lockdownInterval) { clearInterval(lockdownInterval); lockdownInterval = null; } 516 updateLockdownUI(0, ''); 517 if ('vibrate' in navigator) navigator.vibrate([100, 50, 100, 50, 100]); 518 519 // Afficher la popup de résolution 520 const resolvedModal = document.getElementById('resolved-modal'); 521 if (resolvedModal) resolvedModal.style.display = 'flex'; 522 523 // Fermer le modal d'alerte s'il est encore affiché 524 const alertModal = document.getElementById('alert-modal'); 525 if (alertModal) alertModal.style.display = 'none'; 526 527 // FORCER le rafraîchissement complet de l'UI des boutons 528 prevStatus = ""; 529 } else { 530 updateLockdownUI(0, ''); 531 } 532 533 // Occupé par un autre 534 if (status === 'unlocked' && !isMine) { 535 stopTimer(); 536 setStatusBar(`OCCUPÉ — ${sessionUser}`, 'occupied', 'user-lock'); 537 setCore('closed', 'user-lock'); 538 setLive(`Casier utilisé par ${sessionUser} (Porte: ${phys})`, 'occupied', 'OCCUPÉ'); 539 grpLocked.style.display = 'none'; 540 grpUnlocked.style.display = 'none'; 541 grpOccupied.style.display = 'flex'; 542 if (infoNotice) infoNotice.style.display = 'none'; 543 prevStatus = status; 544 prevPhys = phys; 545 return; 546 } 547 548 if (status === prevStatus && phys === prevPhys) { 549 return; 550 } 551 552 if (status === 'locked' && prevStatus === 'unlocked') { 553 startLockCountdown(); 554 prevStatus = status; 555 prevPhys = phys; 556 return; 557 } 558 559 if (status === 'unlocked') { 560 stopTimer(); 561 if (phys === 'Ouverte') { 562 setStatusBar('CASIER DÉVERROUILLÉ', 'unlocked', 'door-open'); 563 setCore('open', 'lock-open'); 564 setLive('Session active — PORTE PHYSIQUEMENT OUVERTE', 'unlocked', 'OUVERT'); 565 } else { 566 setStatusBar('SESSION ACTIVE', 'locked', 'door-closed'); 567 setCore('closed', 'lock'); 568 setLive('Session active — PORTE PHYSIQUEMENT FERMÉE', 'unlocked', 'FERMÉ'); 569 } 570 grpLocked.style.display = 'none'; 571 grpOccupied.style.display = 'none'; 572 grpUnlocked.style.display = 'flex'; 573 if (infoNotice) infoNotice.style.display = 'none'; 574 } else if (status === 'locked') { 575 stopTimer(); 576 setStatusBar('CASIER SÉCURISÉ', 'locked', 'shield-halved'); 577 setCore('closed', 'lock'); 578 setLive(`Casier disponible — prêt à ouvrir (Porte: ${phys})`, 'unlocked', 'LIBRE'); 579 grpUnlocked.style.display = 'none'; 580 grpOccupied.style.display = 'none'; 581 // Le bouton "RELANCER LA SESSION" reste masque : la session 582 // demarre automatiquement a l'appairage. Si le casier est 583 // locked, c'est qu'on vient de terminer (et on est redirige 584 // vers la page thanks) ou que le casier n'est pas a nous. 585 grpLocked.style.display = 'none'; 586 if (infoNotice) infoNotice.style.display = 'flex'; 587 } 588 589 prevStatus = status; 590 prevPhys = phys; 591 } 592 593 async function poll() { 594 try { 595 const r = await fetch('/api/locker/sport/state?t=' + Date.now(), { cache: "no-store" }); 596 if (!r.ok) return; 597 const d = await r.json(); 598 applyState(d); 599 } catch(e) {} 600 } 601 602 // Init is handled entirely by the first poll() since prevStatus is empty string. 603 poll(); 604 let pollHandle = setInterval(poll, 1500); 605 // Pause le polling quand la page est en arrière-plan (économie batterie) 606 document.addEventListener('visibilitychange', () => { 607 if (document.hidden) { 608 clearInterval(pollHandle); 609 pollHandle = null; 610 } else if (!pollHandle) { 611 poll(); 612 pollHandle = setInterval(poll, 1500); 613 } 614 }); 615 })(); 616 {% endif %} 617 618 // ── WebAuthn ────────────────────────────────────────────── 619 async function registerBio() { 620 const reg = await navigator.credentials.create({ publicKey: { 621 challenge: crypto.getRandomValues(new Uint8Array(32)), 622 rp: { name: 'SmartLocker', id: location.hostname }, 623 user: { id: crypto.getRandomValues(new Uint8Array(16)), name: 'smartlocker-user', displayName: 'SmartLocker User' }, 624 pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], 625 authenticatorSelection: { authenticatorAttachment: 'platform', userVerification: 'required' }, 626 timeout: 60000, attestation: 'none' 627 }}); 628 const id = b64url(reg.rawId); 629 localStorage.setItem(BIO_KEY, id); 630 return id; 631 } 632 633 async function verifyBio(credId) { 634 await navigator.credentials.get({ publicKey: { 635 challenge: crypto.getRandomValues(new Uint8Array(32)), 636 rpId: location.hostname, 637 allowCredentials: [{ type: 'public-key', id: b64urlDecode(credId) }], 638 userVerification: 'required', timeout: 60000 639 }}); 640 } 641 642 async function triggerBioOrSubmit(actionName, btn) { 643 const orig = btn.innerHTML; 644 document.getElementById('form-action').value = actionName; 645 646 // Demande la permission de notification et initialise le Push FCM 647 try { 648 if (window.requestPushPermission) window.requestPushPermission(); 649 } catch(e) { console.log(e); } 650 651 const isOpening = (actionName === 'start' || actionName === 'open'); 652 if (isOpening) showToast(`La porte va s'ouvrir pour ${LOCK_DELAY} secondes`, 'warning'); 653 654 if (!window.PublicKeyCredential) { 655 showToast('Biométrie non supportée, exécution directe.', 'warning'); 656 setTimeout(() => document.getElementById('toggle-form').submit(), 800); 657 return; 658 } 659 660 btn.innerHTML = '<i class="fa-solid fa-fingerprint fa-beat"></i> AUTH...'; 661 btn.disabled = true; 662 663 try { 664 let credId = localStorage.getItem(BIO_KEY); 665 if (!credId) { 666 credId = await registerBio(); 667 await verifyBio(credId); 668 } else { 669 try { await verifyBio(credId); } 670 catch(e) { 671 if (e.name === 'NotAllowedError') throw e; 672 localStorage.removeItem(BIO_KEY); 673 credId = await registerBio(); 674 await verifyBio(credId); 675 } 676 } 677 if ('vibrate' in navigator) navigator.vibrate([100, 50, 100]); 678 btn.innerHTML = '<i class="fa-solid fa-check"></i> VALIDÉ — OUVERTURE...'; 679 document.getElementById('toggle-form').submit(); 680 } catch(err) { 681 console.error('[WebAuthn]', err.name, err.message); 682 btn.innerHTML = orig; btn.disabled = false; 683 const errMap = { 684 'NotAllowedError': 'Authentification annulée ou refusée.', 685 'InvalidStateError': 'Empreinte réinitialisée — réessayez.', 686 'SecurityError': 'Erreur sécurité : vérifiez HTTPS.', 687 'NotSupportedError': 'Biométrie non supportée par ce navigateur.', 688 'AbortError': 'Opération annulée.', 689 'NotReadableError': 'Capteur biométrique inaccessible.' 690 }; 691 if (err.name === 'InvalidStateError') localStorage.removeItem(BIO_KEY); 692 showToast(errMap[err.name] || 'Erreur : ' + (err.name || 'inconnue'), 'err'); 693 } 694 } 695 696 setTimeout(() => { 697 document.querySelectorAll('.toast').forEach(t => { 698 t.style.transition='opacity 0.5s'; t.style.opacity='0'; 699 setTimeout(()=>t.remove(),500); 700 }); 701 }, 4000); 702</script> 703 704<!-- Firebase Cloud Messaging Setup --> 705<script type="module"> 706 import { initializeApp } from "https://www.gstatic.com/firebasejs/10.9.0/firebase-app.js"; 707 import { getMessaging, getToken, onMessage } from "https://www.gstatic.com/firebasejs/10.9.0/firebase-messaging.js"; 708 709 const firebaseConfig = { 710 apiKey: "AIzaSyCQHsEjxGFa-MtsG8ogCCWEmarppA4BHC0", 711 projectId: "smartlocker-94e11", 712 messagingSenderId: "224254595209", 713 appId: "1:224254595209:web:82804d2f2c7fd83046f658" 714 }; 715 716 const app = initializeApp(firebaseConfig); 717 const messaging = getMessaging(app); 718 719 window.requestPushPermission = async function() { 720 try { 721 const permission = await Notification.requestPermission(); 722 if (permission === 'granted') { 723 const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js'); 724 const token = await getToken(messaging, { 725 vapidKey: 'BLV0pKgxQGyWhNeyd6Olg1uMCh14mEf1cmr9x0BQXUQDKoaQs2rN5dq_Gs7MaRMvA768mlzOuBxIfZ_H4R3XFLk', 726 serviceWorkerRegistration: registration 727 }); 728 if (token) { 729 await fetch('/api/fcm_token', { 730 method: 'POST', 731 headers: { 'Content-Type': 'application/json' }, 732 body: JSON.stringify({ fcm_token: token }) 733 }); 734 } 735 } 736 } catch (e) { 737 console.error('Erreur FCM Push Permission:', e); 738 } 739 }; 740 741 onMessage(messaging, (payload) => { 742 if (window.smartLockerNotify) { 743 // Pipeline unifie (popup + son + dedup) 744 const d = payload.data || {}; 745 const kindMap = { 746 'intrusion_alert': 'intrusion', 747 'door_open_reminder': 'reminder', 748 'object_removed': 'object_removed' 749 }; 750 window.smartLockerNotify.handleEvent({ 751 kind: kindMap[d.type] || 'intrusion', 752 locker_id: d.locker_id || 'SPORT_01', 753 locker_name: 'Casier sport', 754 message: (payload.notification && payload.notification.body) || d.body || '', 755 time: String(Date.now()), 756 severity: (d.type === 'intrusion_alert') ? 'critical' : 'warning' 757 }); 758 } 759 }); 760</script> 761 762<!-- ═══ SMARTLOCKER NOTIFICATIONS — fallback robuste ═══ --> 763<script src="{{ url_for('static', filename='notifications.js') }}?v=1"></script> 764</body> 765</html>
templates/history.html
115 lignes
1<!DOCTYPE html> 2<html lang="fr"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>SmartLocker — Historique</title> 7 <link rel="manifest" href="/manifest.json"> 8 <meta name="theme-color" content="#02040a"> 9 <meta name="mobile-web-app-capable" content="yes"> 10 <meta name="apple-mobile-web-app-capable" content="yes"> 11 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> 12 <meta name="apple-mobile-web-app-title" content="SmartLocker"> 13 <link rel="apple-touch-icon" href="/static/icons/icon-192.png"> 14 <link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}"> 15 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> 16 <style> 17 .nav-container { display:flex; align-items:center; justify-content:space-between; width:100%; } 18 .nav-back { background:transparent; border:1px solid rgba(0,240,255,0.2); color:var(--text-muted); padding:6px 14px; font-family:'Orbitron',sans-serif; font-size:0.7rem; letter-spacing:1px; cursor:pointer; transition:0.3s; text-decoration:none; display:flex; align-items:center; gap:8px; } 19 .nav-back:hover { border-color:var(--secondary); color:var(--secondary); } 20 21 .page-title { font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:3px; color:var(--text-muted); text-transform:uppercase; margin-bottom:1.25rem; display:flex; align-items:center; gap:10px; } 22 .page-title::after { content:''; flex:1; height:1px; background:rgba(255,255,255,0.06); } 23 .page-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem; } 24 .page-header .page-title { margin-bottom:0; flex:1; } 25 .btn-export { background:transparent; border:1px solid rgba(0,240,255,0.25); color:var(--secondary); padding:6px 12px; font-family:'Orbitron',sans-serif; font-size:0.58rem; letter-spacing:1px; cursor:pointer; transition:0.3s; text-decoration:none; display:flex; align-items:center; gap:6px; flex-shrink:0; } 26 .btn-export:hover { background:rgba(0,240,255,0.08); border-color:var(--secondary); box-shadow:var(--glow-secondary); } 27 28 .entry { background:var(--bg-card); border:1px solid rgba(255,255,255,0.05); padding:1rem 1.1rem; margin-bottom:10px; position:relative; transition:0.3s; } 29 .entry::before { content:''; position:absolute; top:0; left:0; width:3px; height:100%; } 30 .entry.sport::before { background:var(--primary); } 31 .entry.school::before { background:var(--secondary); } 32 .entry:hover { border-color:rgba(255,255,255,0.1); } 33 34 .entry-top { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:0.625rem; } 35 .entry-badge { display:inline-flex; align-items:center; gap:6px; font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:1px; padding:2px 8px; border:1px solid; } 36 .entry-badge.sport { border-color:rgba(0,255,157,0.3); color:var(--primary); background:rgba(0,255,157,0.06); } 37 .entry-badge.school { border-color:rgba(0,240,255,0.3); color:var(--secondary); background:rgba(0,240,255,0.06); } 38 .entry-reason { font-family:'Orbitron',sans-serif; font-size:0.55rem; letter-spacing:1px; color:var(--text-muted); padding:2px 6px; border:1px solid rgba(255,255,255,0.07); } 39 40 .entry-date { font-family:'Orbitron',sans-serif; font-size:0.75rem; color:#fff; margin-bottom:3px; } 41 .entry-meta { display:flex; gap:16px; flex-wrap:wrap; } 42 .entry-meta span { font-size:0.78rem; color:var(--text-muted); display:flex; align-items:center; gap:5px; } 43 .entry-meta span i { font-size:0.7rem; } 44 .entry-meta .amount { color:var(--secondary); } 45 46 .empty-state { text-align:center; padding:3rem 1rem; } 47 .empty-state i { font-size:2.5rem; color:rgba(255,255,255,0.08); margin-bottom:1rem; display:block; } 48 .empty-state p { color:var(--text-muted); font-size:0.85rem; } 49 </style> 50</head> 51<body> 52<div class="cyber-grid"></div> 53 54<nav class="navbar"> 55 <div class="nav-container"> 56 <a href="/" class="nav-logo"><i class="fa-solid fa-fingerprint"></i> SMARTLOCKER</a> 57 <a href="/" class="nav-back"><i class="fa-solid fa-arrow-left"></i> Retour</a> 58 </div> 59</nav> 60 61<div class="app-container"> 62 <div class="app-card"> 63 <div class="page-header"> 64 <div class="page-title"><i class="fa-solid fa-clock-rotate-left"></i> Historique des sessions</div> 65 {% if entries %} 66 <a href="/history/export" class="btn-export"> 67 <i class="fa-solid fa-file-csv"></i> CSV 68 </a> 69 {% endif %} 70 </div> 71 72 {% if entries %} 73 {% for e in entries %} 74 <div class="entry {{ e.locker_key }}"> 75 <div class="entry-top"> 76 <span class="entry-badge {{ e.locker_key }}"> 77 <i class="fa-solid fa-{{ 'dumbbell' if e.locker_key == 'sport' else 'school' }}"></i> 78 {{ 'SPORT' if e.locker_key == 'sport' else 'ÉCOLE' }} 79 </span> 80 <span class="entry-reason">{{ e.reason | upper }}</span> 81 </div> 82 <div class="entry-date" id="date-{{ loop.index }}" data-ts="{{ e.started_at }}">--</div> 83 <div class="entry-meta"> 84 <span><i class="fa-regular fa-clock"></i> {{ e.duration_min }} min</span> 85 {% if e.amount_eur and e.amount_eur > 0 %} 86 <span class="amount"><i class="fa-solid fa-euro-sign"></i> {{ "%.2f"|format(e.amount_eur) }} €</span> 87 {% endif %} 88 </div> 89 </div> 90 {% endfor %} 91 {% else %} 92 <div class="empty-state"> 93 <i class="fa-solid fa-box-archive"></i> 94 <p>Aucune session enregistrée pour le moment.</p> 95 </div> 96 {% endif %} 97 </div> 98</div> 99 100<script> 101 // Convertir timestamps en dates lisibles 102 document.querySelectorAll('[data-ts]').forEach(el => { 103 const ts = parseFloat(el.dataset.ts); 104 if (!ts) return; 105 const d = new Date(ts * 1000); 106 el.textContent = d.toLocaleDateString('fr-BE', { day:'2-digit', month:'short', year:'numeric' }) 107 + ' — ' + d.getHours().toString().padStart(2,'0') 108 + ':' + d.getMinutes().toString().padStart(2,'0'); 109 }); 110</script> 111 112<!-- ═══ SMARTLOCKER NOTIFICATIONS — fallback robuste ═══ --> 113<script src="{{ url_for('static', filename='notifications.js') }}?v=1"></script> 114</body> 115</html>
templates/settings.html
346 lignes
1<!DOCTYPE html> 2<html lang="fr"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>SmartLocker — Paramètres</title> 7 <link rel="manifest" href="/manifest.json"> 8 <meta name="theme-color" content="#02040a"> 9 <meta name="mobile-web-app-capable" content="yes"> 10 <meta name="apple-mobile-web-app-capable" content="yes"> 11 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> 12 <meta name="apple-mobile-web-app-title" content="SmartLocker"> 13 <link rel="apple-touch-icon" href="/static/icons/icon-192.png"> 14 <link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}"> 15 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> 16 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css"> 17 <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script> 18 <style> 19 .nav-container { display:flex; align-items:center; justify-content:space-between; width:100%; } 20 .nav-back { background:transparent; border:1px solid rgba(0,255,157,0.2); color:var(--text-muted); padding:6px 14px; font-family:'Orbitron',sans-serif; font-size:0.7rem; letter-spacing:1px; cursor:pointer; transition:0.3s; text-decoration:none; display:flex; align-items:center; gap:8px; } 21 .nav-back:hover { border-color:var(--primary); color:var(--primary); } 22 23 .page-title { font-family:'Orbitron',sans-serif; font-size:0.75rem; letter-spacing:3px; color:var(--text-muted); text-transform:uppercase; margin-bottom:1.5rem; display:flex; align-items:center; gap:10px; } 24 .page-title::after { content:''; flex:1; height:1px; background:rgba(0,255,157,0.1); } 25 26 .settings-section { margin-bottom:1.25rem; } 27 .section-label { font-family:'Orbitron',sans-serif; font-size:0.62rem; letter-spacing:3px; color:var(--primary); text-transform:uppercase; margin-bottom:0.625rem; padding-left:2px; opacity:0.8; } 28 29 .settings-card { background:var(--bg-card); border:1px solid rgba(255,255,255,0.05); padding:1.5rem; position:relative; clip-path:polygon(0 0,100% 0,100% 95%,97% 100%,0 100%); } 30 .settings-card::before { content:''; position:absolute; top:0; left:0; right:0; height:1px; background:linear-gradient(90deg,var(--primary),transparent 60%); } 31 32 /* Photo */ 33 .photo-section { display:flex; align-items:center; gap:1.5rem; } 34 .avatar-wrap { position:relative; flex-shrink:0; } 35 .avatar-wrap img { width:90px; height:90px; border-radius:50%; border:2px solid var(--primary); object-fit:cover; display:block; box-shadow:var(--glow-primary); } 36 .avatar-overlay { position:absolute; inset:0; border-radius:50%; background:rgba(0,0,0,0.65); display:grid; place-items:center; opacity:0; transition:0.2s; cursor:pointer; color:var(--primary); font-size:1rem; } 37 .avatar-wrap:hover .avatar-overlay { opacity:1; } 38 .photo-info h3 { font-family:'Orbitron',sans-serif; font-size:0.85rem; color:#fff; margin-bottom:5px; letter-spacing:1px; } 39 .photo-info p { color:var(--text-muted); font-size:0.8rem; line-height:1.5; } 40 .photo-actions { margin-top:10px; display:flex; gap:8px; flex-wrap:wrap; } 41 .btn-sm { background:transparent; border:1px solid rgba(0,255,157,0.3); color:var(--primary); padding:6px 14px; font-family:'Orbitron',sans-serif; font-size:0.65rem; letter-spacing:1px; cursor:pointer; transition:0.3s; display:flex; align-items:center; gap:6px; } 42 .btn-sm:hover { background:rgba(0,255,157,0.1); box-shadow:var(--glow-primary); } 43 44 /* Form */ 45 .field-row { margin-bottom:1.1rem; } 46 .field-row:last-child { margin-bottom:0; } 47 .field-label { font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:2px; color:var(--text-muted); text-transform:uppercase; margin-bottom:6px; display:flex; align-items:center; gap:8px; } 48 .field-label .badge { background:rgba(0,240,255,0.1); border:1px solid rgba(0,240,255,0.3); color:var(--secondary); padding:1px 7px; font-size:0.55rem; letter-spacing:1px; } 49 .field-label .badge-locked { background:rgba(255,42,109,0.1); border:1px solid rgba(255,42,109,0.3); color:var(--danger); padding:1px 7px; font-size:0.55rem; letter-spacing:1px; } 50 .form-input { width:100%; background:rgba(0,0,0,0.3); border:1px solid #2d3748; color:var(--secondary); padding:11px 14px; font-family:'Rajdhani',sans-serif; font-size:1rem; transition:0.3s; } 51 .form-input:focus { outline:none; border-color:var(--primary); box-shadow:0 0 10px rgba(0,255,157,0.15); } 52 .form-input:disabled { opacity:0.35; cursor:not-allowed; color:var(--text-muted); } 53 .field-hint { font-size:0.75rem; color:var(--text-muted); margin-top:5px; display:flex; align-items:center; gap:6px; } 54 .field-hint i { color:var(--danger); font-size:0.7rem; } 55 .field-divider { height:1px; background:rgba(255,255,255,0.05); margin:1.1rem 0; } 56 57 .btn-save { width:100%; padding:0.95rem; border:none; font-family:'Orbitron',sans-serif; font-weight:700; text-transform:uppercase; letter-spacing:2px; cursor:pointer; transition:0.3s; font-size:0.9rem; display:flex; align-items:center; justify-content:center; gap:10px; clip-path:polygon(10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%,0 10px); background:var(--primary); color:#000; box-shadow:var(--glow-primary); } 58 .btn-save:hover { background:#fff; box-shadow:0 0 20px #fff; } 59 60 /* Badges */ 61 .google-badge { display:inline-flex; align-items:center; gap:6px; background:rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.12); padding:3px 10px; font-size:0.72rem; color:var(--text-muted); margin-top:6px; } 62 .google-badge img { width:14px; } 63 .premium-badge { display:inline-flex; align-items:center; gap:6px; background:rgba(255,200,0,0.1); border:1px solid rgba(255,200,0,0.4); color:#ffc800; padding:3px 10px; font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:1px; margin-top:6px; } 64 .free-badge { display:inline-flex; align-items:center; gap:6px; background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.1); color:var(--text-muted); padding:3px 10px; font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:1px; margin-top:6px; } 65 66 /* Casiers appairés */ 67 .locker-paired-item { display:flex; align-items:center; justify-content:space-between; padding:0.875rem 1rem; border:1px solid rgba(255,255,255,0.07); margin-bottom:10px; background:rgba(0,0,0,0.2); transition:0.3s; } 68 .locker-paired-item:hover { border-color:rgba(255,255,255,0.12); } 69 .locker-paired-left { display:flex; align-items:center; gap:12px; } 70 .locker-paired-icon { width:36px; height:36px; border-radius:50%; display:grid; place-items:center; font-size:0.9rem; flex-shrink:0; } 71 .locker-paired-icon.sport { background:rgba(0,255,157,0.07); border:1px solid rgba(0,255,157,0.2); color:var(--primary); } 72 .locker-paired-icon.school { background:rgba(0,240,255,0.07); border:1px solid rgba(0,240,255,0.2); color:var(--secondary); } 73 .locker-paired-name { font-family:'Orbitron',sans-serif; font-size:0.75rem; color:#fff; letter-spacing:1px; margin-bottom:2px; } 74 .locker-paired-id { font-size:0.7rem; color:var(--text-muted); } 75 .btn-unpair { background:transparent; border:1px solid rgba(255,42,109,0.3); color:var(--danger); padding:5px 12px; font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:1px; cursor:pointer; transition:0.3s; } 76 .btn-unpair:hover { background:var(--danger); color:#fff; } 77 78 .empty-locker-msg { text-align:center; padding:1.5rem; color:var(--text-muted); font-size:0.82rem; border:1px dashed rgba(255,255,255,0.07); } 79 .empty-locker-msg i { display:block; font-size:1.5rem; margin-bottom:0.5rem; opacity:0.3; } 80 81 /* Premium info */ 82 .premium-info-card { background:rgba(255,200,0,0.04); border:1px solid rgba(255,200,0,0.2); padding:1.25rem; } 83 .premium-info-card::before { content:''; position:absolute; top:0; left:0; right:0; height:1px; background:linear-gradient(90deg,#ffc800,transparent 60%); } 84 .premium-active { display:flex; align-items:center; gap:12px; } 85 .premium-active i { font-size:1.8rem; color:#ffc800; } 86 .premium-active h3 { font-family:'Orbitron',sans-serif; font-size:0.85rem; color:#ffc800; letter-spacing:2px; margin-bottom:4px; } 87 .premium-active p { color:var(--text-muted); font-size:0.78rem; line-height:1.5; } 88 .free-plan { display:flex; align-items:center; gap:12px; } 89 .free-plan i { font-size:1.8rem; color:var(--text-muted); } 90 .free-plan h3 { font-family:'Orbitron',sans-serif; font-size:0.85rem; color:var(--text-muted); letter-spacing:2px; margin-bottom:4px; } 91 .free-plan p { color:var(--text-muted); font-size:0.78rem; line-height:1.5; } 92 93 /* Danger */ 94 .danger-card { background:rgba(255,42,109,0.04); border:1px solid rgba(255,42,109,0.2); padding:1.5rem; position:relative; clip-path:polygon(0 0,100% 0,100% 95%,97% 100%,0 100%); } 95 .danger-card::before { content:''; position:absolute; top:0; left:0; right:0; height:1px; background:linear-gradient(90deg,var(--danger),transparent 60%); } 96 .danger-card h3 { font-family:'Orbitron',sans-serif; font-size:0.78rem; color:var(--danger); letter-spacing:2px; margin-bottom:0.5rem; } 97 .danger-card p { color:var(--text-muted); font-size:0.82rem; margin-bottom:1rem; } 98 .btn-danger { width:100%; padding:0.875rem; border:1px solid var(--danger); background:rgba(255,42,109,0.1); color:var(--danger); font-family:'Orbitron',sans-serif; font-weight:700; font-size:0.8rem; letter-spacing:2px; cursor:pointer; transition:0.3s; display:flex; align-items:center; justify-content:center; gap:10px; clip-path:polygon(10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%,0 10px); text-decoration:none; } 99 .btn-danger:hover { background:var(--danger); color:#fff; box-shadow:0 0 15px var(--danger); } 100 101 /* Crop modal */ 102 .crop-modal { position:fixed; inset:0; z-index:300; background:rgba(2,4,10,0.95); backdrop-filter:blur(8px); display:flex; flex-direction:column; align-items:center; justify-content:center; padding:1.5rem; } 103 .crop-modal-box2 { background:var(--bg-panel); border:1px solid rgba(0,255,157,0.2); width:100%; max-width:440px; position:relative; clip-path:polygon(0 0,100% 0,100% 95%,96% 100%,0 100%); } 104 .crop-modal-box2::before { content:''; position:absolute; top:0; left:0; right:0; height:2px; background:linear-gradient(90deg,var(--primary),var(--secondary),transparent); } 105 .crop-head2 { display:flex; align-items:center; justify-content:space-between; padding:1.1rem 1.25rem 0.75rem; } 106 .crop-head2 h3 { font-family:'Orbitron',sans-serif; font-size:0.78rem; letter-spacing:2px; color:var(--primary); } 107 .crop-close2 { background:transparent; border:1px solid rgba(255,42,109,0.3); color:var(--danger); width:28px; height:28px; display:grid; place-items:center; cursor:pointer; font-size:1rem; transition:0.2s; } 108 .crop-close2:hover { background:var(--danger); color:#fff; } 109 .crop-area2 { overflow:hidden; margin:0 1.25rem; max-height:300px; } 110 .crop-area2 img { max-width:100%; display:block; } 111 .crop-footer2 { display:flex; gap:10px; padding:1rem 1.25rem 1.25rem; } 112 .crop-footer2 .btn-sm { flex:1; justify-content:center; padding:10px; } 113 .btn-sm-success { background:rgba(0,240,255,0.1); border:1px solid var(--secondary); color:var(--secondary); flex:1; justify-content:center; padding:10px; font-family:'Orbitron',sans-serif; font-size:0.65rem; letter-spacing:1px; cursor:pointer; transition:0.3s; display:flex; align-items:center; gap:6px; } 114 .btn-sm-success:hover { background:var(--secondary); color:#000; } 115 116 .toast { background:rgba(0,255,157,0.08); border:1px solid var(--primary); color:var(--primary); padding:10px 16px; font-family:'Rajdhani',sans-serif; font-size:0.95rem; display:flex; align-items:center; gap:10px; margin-bottom:12px; } 117 </style> 118</head> 119<body> 120<div class="cyber-grid"></div> 121 122<!-- Crop modal --> 123<div id="crop-modal" class="crop-modal hidden"> 124 <div class="crop-modal-box2"> 125 <div class="crop-head2"> 126 <h3>RECADRER LA PHOTO</h3> 127 <button onclick="closeCrop()" class="crop-close2">×</button> 128 </div> 129 <div class="crop-area2"><img id="image-to-crop" src=""></div> 130 <div class="crop-footer2"> 131 <button onclick="closeCrop()" class="btn-sm"><i class="fa-solid fa-xmark"></i> ANNULER</button> 132 <button onclick="savePhoto()" class="btn-sm-success"><i class="fa-solid fa-check"></i> VALIDER</button> 133 </div> 134 </div> 135</div> 136 137<nav class="navbar"> 138 <div class="nav-container"> 139 <a href="/" class="nav-logo"><i class="fa-solid fa-fingerprint"></i> SMARTLOCKER</a> 140 <a href="/" class="nav-back"><i class="fa-solid fa-arrow-left"></i> Retour</a> 141 </div> 142</nav> 143 144<div class="app-container" style="max-width:520px;"> 145 146 {% with messages = get_flashed_messages() %}{% if messages %} 147 {% for m in messages %}<div class="toast"><i class="fa-solid fa-circle-check"></i> {{ m }}</div>{% endfor %} 148 {% endif %}{% endwith %} 149 150 <div class="page-title"><i class="fa-solid fa-sliders"></i> Paramètres du profil</div> 151 152 <!-- ── Photo ── --> 153 <div class="settings-section"> 154 <div class="section-label">Photo de profil</div> 155 <div class="settings-card"> 156 <div class="photo-section"> 157 <div class="avatar-wrap" onclick="triggerPhoto()"> 158 <img src="{{ photo }}" id="avatar-preview" alt="avatar"> 159 <div class="avatar-overlay"><i class="fa-solid fa-camera"></i></div> 160 </div> 161 <div class="photo-info"> 162 <h3>{{ pseudo }}</h3> 163 <p>{{ email }}</p> 164 {% if is_premium %} 165 <div class="premium-badge"><i class="fa-solid fa-crown"></i> PREMIUM</div> 166 {% else %} 167 <div class="free-badge"><i class="fa-solid fa-user"></i> GRATUIT</div> 168 {% endif %} 169 {% if is_google %} 170 <div class="google-badge"> 171 <img src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg" alt="Google"> 172 Compte Google 173 </div> 174 {% endif %} 175 <div class="photo-actions"> 176 <button class="btn-sm" onclick="triggerPhoto()"><i class="fa-solid fa-upload"></i> CHANGER LA PHOTO</button> 177 </div> 178 </div> 179 </div> 180 </div> 181 </div> 182 183 <!-- ── Infos générales ── --> 184 <div class="settings-section"> 185 <div class="section-label">Informations générales</div> 186 <div class="settings-card"> 187 <form action="/update_settings" method="POST"> 188 <div class="field-row"> 189 <div class="field-label">Pseudo <span class="badge">MODIFIABLE</span></div> 190 <input type="text" name="new_pseudo" value="{{ pseudo }}" class="form-input" placeholder="Pseudo"> 191 </div> 192 <div class="field-divider"></div> 193 <div class="field-row"> 194 <div class="field-label"> 195 Adresse email 196 {% if is_google %}<span class="badge-locked">GOOGLE — VERROUILLÉ</span>{% else %}<span class="badge">MODIFIABLE</span>{% endif %} 197 </div> 198 <input type="email" name="new_email" value="{{ email }}" class="form-input" {% if is_google %}disabled{% endif %}> 199 {% if is_google %} 200 <div class="field-hint"><i class="fa-solid fa-circle-info"></i> L'email est géré par votre compte Google.</div> 201 {% endif %} 202 </div> 203 <div class="field-divider"></div> 204 <div class="field-row"> 205 <div class="field-label">Mot de passe <span class="badge">MODIFIABLE</span></div> 206 <input type="password" name="new_password" class="form-input" placeholder="Nouveau mot de passe (6 caractères min)"> 207 <div class="field-hint">Laisser vide pour ne pas modifier.</div> 208 </div> 209 <div style="margin-top:1.5rem;"> 210 <button type="submit" class="btn-save"><i class="fa-solid fa-floppy-disk"></i> SAUVEGARDER</button> 211 </div> 212 </form> 213 </div> 214 </div> 215 216 <!-- ── Abonnement ── --> 217 <div class="settings-section"> 218 <div class="section-label">Abonnement</div> 219 <div class="settings-card" style="position:relative;"> 220 {% if is_premium %} 221 <div class="premium-active"> 222 <i class="fa-solid fa-crown"></i> 223 <div> 224 <h3>MEMBRE PREMIUM</h3> 225 <p>Vous avez accès à tous les casiers Premium, dont la salle de sport.</p> 226 </div> 227 </div> 228 {% else %} 229 <div class="free-plan"> 230 <i class="fa-solid fa-user"></i> 231 <div> 232 <h3>PLAN GRATUIT</h3> 233 <p>Vous n'avez pas accès aux casiers Premium (salle de sport). Contactez l'administration pour activer le Premium.</p> 234 </div> 235 </div> 236 {% endif %} 237 </div> 238 </div> 239 240 <!-- ── Casiers appairés ── --> 241 <div class="settings-section"> 242 <div class="section-label">Casiers appairés</div> 243 <div class="settings-card"> 244 {% if paired_lockers %} 245 {% for key, val in paired_lockers.items() %} 246 {% if key in lockers %} 247 <div class="locker-paired-item"> 248 <div class="locker-paired-left"> 249 <div class="locker-paired-icon {{ key }}"> 250 <i class="fa-solid fa-{{ 'dumbbell' if key == 'sport' else 'school' }}"></i> 251 </div> 252 <div> 253 <div class="locker-paired-name">{{ lockers[key].name | upper }}</div> 254 <div class="locker-paired-id">{{ lockers[key].id }}</div> 255 </div> 256 </div> 257 <form action="/unpair_locker" method="POST" onsubmit="return confirm('Dissocier ce casier ?')"> 258 <input type="hidden" name="locker_key" value="{{ key }}"> 259 <button type="submit" class="btn-unpair"><i class="fa-solid fa-link-slash"></i> DISSOCIER</button> 260 </form> 261 </div> 262 {% endif %} 263 {% endfor %} 264 {% else %} 265 <div class="empty-locker-msg"> 266 <i class="fa-solid fa-box-archive"></i> 267 Aucun casier appairé. Retournez au dashboard pour en appairer un. 268 </div> 269 {% endif %} 270 </div> 271 </div> 272 273 <!-- ── Danger ── --> 274 <div class="settings-section"> 275 <div class="section-label" style="color:var(--danger);opacity:1;">Zone dangereuse</div> 276 <div class="danger-card"> 277 <h3><i class="fa-solid fa-triangle-exclamation"></i> DÉCONNEXION</h3> 278 <p>Vous serez redirigé vers la page de connexion. Votre session sera effacée.</p> 279 <a href="/logout" class="btn-danger"><i class="fa-solid fa-power-off"></i> SE DÉCONNECTER</a> 280 </div> 281 </div> 282 283</div> 284 285<input type="file" id="input-file" accept="image/*" style="display:none;"> 286<form id="form-photo" action="/update_photo" method="POST" style="display:none;"> 287 <input type="hidden" name="photo_b64" id="photo-b64"> 288</form> 289 290<script> 291 let cropper = null; 292 const inputFile = document.getElementById('input-file'); 293 294 function triggerPhoto() { inputFile.click(); } 295 296 function closeCrop() { 297 document.getElementById('crop-modal').classList.add('hidden'); 298 if (cropper) { cropper.destroy(); cropper = null; } 299 inputFile.value = ''; // Permet de re-sélectionner le même fichier après annulation 300 } 301 302 inputFile.onchange = (e) => { 303 const file = e.target.files[0]; 304 if (!file) return; 305 // Validation taille (max 5 MB) 306 if (file.size > 5 * 1024 * 1024) { 307 alert('Image trop lourde (max 5 Mo).'); 308 inputFile.value = ''; 309 return; 310 } 311 const reader = new FileReader(); 312 reader.onload = (evt) => { 313 document.getElementById('image-to-crop').src = evt.target.result; 314 document.getElementById('crop-modal').classList.remove('hidden'); 315 if (cropper) cropper.destroy(); 316 cropper = new Cropper(document.getElementById('image-to-crop'), { aspectRatio:1, viewMode:1 }); 317 }; 318 reader.readAsDataURL(file); 319 }; 320 321 function savePhoto() { 322 if (!cropper) return; 323 const data = cropper.getCroppedCanvas({ width:200, height:200 }).toDataURL('image/jpeg', 0.85); 324 document.getElementById('photo-b64').value = data; 325 document.getElementById('avatar-preview').src = data; 326 closeCrop(); 327 document.getElementById('form-photo').submit(); 328 } 329 330 // Fermer modal avec touche ESC 331 document.addEventListener('keydown', (e) => { 332 if (e.key === 'Escape' && cropper) closeCrop(); 333 }); 334 335 setTimeout(() => { 336 document.querySelectorAll('.toast').forEach(t => { 337 t.style.transition = 'opacity 0.5s'; t.style.opacity = '0'; 338 setTimeout(() => t.remove(), 500); 339 }); 340 }, 3000); 341</script> 342 343<!-- ═══ SMARTLOCKER NOTIFICATIONS — fallback robuste ═══ --> 344<script src="{{ url_for('static', filename='notifications.js') }}?v=1"></script> 345</body> 346</html>
templates/admin.html
895 lignes
1<!DOCTYPE html> 2<html lang="fr"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>SmartLocker — Admin Console</title> 7 <link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}"> 8 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> 9 <style> 10 /* ===================================================== 11 ADMIN CONSOLE — Layout desktop large écran 12 ===================================================== */ 13 body { 14 background: #02040a; 15 color: #e2e8f0; 16 font-family: 'Rajdhani', sans-serif; 17 min-height: 100vh; 18 margin: 0; 19 } 20 21 /* Topbar fixe */ 22 .admin-topbar { 23 position: sticky; top: 0; z-index: 100; 24 display: flex; align-items: center; justify-content: space-between; 25 padding: 14px 32px; 26 background: rgba(2, 4, 10, 0.96); 27 border-bottom: 1px solid rgba(255, 200, 0, 0.2); 28 backdrop-filter: blur(10px); 29 } 30 .admin-topbar-left { 31 display: flex; align-items: center; gap: 18px; 32 } 33 .admin-brand { 34 display: flex; align-items: center; gap: 10px; 35 color: #fff; text-decoration: none; 36 font-family: 'Orbitron', sans-serif; 37 font-size: 0.95rem; font-weight: 900; 38 letter-spacing: 3px; 39 } 40 .admin-brand i { color: #00f0ff; font-size: 1.1rem; } 41 .admin-badge-pill { 42 background: rgba(255, 200, 0, 0.1); 43 border: 1px solid rgba(255, 200, 0, 0.45); 44 color: #ffc800; 45 padding: 5px 12px; 46 font-family: 'Orbitron', sans-serif; 47 font-size: 0.62rem; letter-spacing: 2px; 48 border-radius: 50px; 49 display: flex; align-items: center; gap: 6px; 50 } 51 .admin-topbar-right { display: flex; align-items: center; gap: 10px; } 52 .admin-topbar-btn { 53 display: flex; align-items: center; gap: 8px; 54 background: transparent; 55 border: 1px solid rgba(255, 255, 255, 0.15); 56 color: #e2e8f0; 57 padding: 8px 16px; 58 font-family: 'Orbitron', sans-serif; 59 font-size: 0.65rem; letter-spacing: 1.5px; 60 border-radius: 6px; text-decoration: none; 61 cursor: pointer; transition: 0.2s; 62 } 63 .admin-topbar-btn:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.3); } 64 .admin-topbar-btn.refresh { border-color: rgba(0, 240, 255, 0.4); color: #00f0ff; } 65 .admin-topbar-btn.refresh:hover { background: rgba(0, 240, 255, 0.08); } 66 .admin-topbar-btn.logout { border-color: rgba(255, 42, 109, 0.4); color: #ff2a6d; } 67 .admin-topbar-btn.logout:hover { background: rgba(255, 42, 109, 0.08); } 68 69 /* Wrapper principal */ 70 .admin-wrap { 71 max-width: 1600px; 72 margin: 0 auto; 73 padding: 28px 32px 60px; 74 } 75 76 /* Header titre */ 77 .admin-page-header { 78 margin-bottom: 26px; 79 display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; 80 } 81 .admin-page-title { 82 font-family: 'Orbitron', sans-serif; 83 font-size: 1.6rem; font-weight: 900; 84 letter-spacing: 3px; color: #fff; 85 margin: 0 0 6px; 86 } 87 .admin-page-title span { color: #ffc800; text-shadow: 0 0 18px rgba(255,200,0,0.5); } 88 .admin-page-sub { 89 color: #94a3b8; font-size: 0.95rem; 90 font-family: 'Rajdhani', sans-serif; 91 } 92 .admin-clock { 93 font-family: 'Orbitron', sans-serif; 94 font-size: 1.05rem; color: #00f0ff; 95 background: rgba(0, 240, 255, 0.06); 96 border: 1px solid rgba(0, 240, 255, 0.3); 97 padding: 10px 18px; border-radius: 8px; 98 letter-spacing: 2px; 99 } 100 101 /* Toasts */ 102 .toast { 103 background: rgba(0, 255, 157, 0.08); 104 border: 1px solid #00ff9d; 105 color: #00ff9d; 106 padding: 12px 18px; 107 font-family: 'Rajdhani', sans-serif; 108 font-size: 0.95rem; 109 display: flex; align-items: center; gap: 10px; 110 margin-bottom: 16px; 111 border-radius: 6px; 112 } 113 114 /* ====== KPIs (4 colonnes, larges) ====== */ 115 .kpi-row { 116 display: grid; 117 grid-template-columns: repeat(4, 1fr); 118 gap: 16px; 119 margin-bottom: 28px; 120 } 121 .kpi-card { 122 background: linear-gradient(135deg, rgba(13, 19, 30, 0.7) 0%, rgba(2, 4, 10, 0.8) 100%); 123 border: 1px solid rgba(255, 255, 255, 0.06); 124 border-radius: 10px; 125 padding: 22px 24px; 126 position: relative; overflow: hidden; 127 transition: 0.25s; 128 } 129 .kpi-card:hover { transform: translateY(-2px); border-color: rgba(255, 200, 0, 0.25); } 130 .kpi-card::before { 131 content: ''; position: absolute; 132 top: 0; left: 0; right: 0; height: 3px; 133 background: linear-gradient(90deg, var(--kpi-color, #ffc800), transparent); 134 } 135 .kpi-card .kpi-icon { 136 position: absolute; top: 20px; right: 22px; 137 width: 38px; height: 38px; 138 background: rgba(255, 200, 0, 0.08); 139 border: 1px solid rgba(255, 200, 0, 0.3); 140 border-radius: 50%; 141 display: flex; align-items: center; justify-content: center; 142 color: var(--kpi-color, #ffc800); 143 font-size: 1.05rem; 144 } 145 .kpi-card .kpi-label { 146 font-family: 'Orbitron', sans-serif; 147 font-size: 0.6rem; letter-spacing: 2.5px; 148 color: #94a3b8; text-transform: uppercase; 149 margin-bottom: 12px; 150 } 151 .kpi-card .kpi-val { 152 font-family: 'Orbitron', sans-serif; 153 font-size: 2.4rem; font-weight: 900; 154 color: var(--kpi-color, #ffc800); 155 text-shadow: 0 0 15px rgba(255, 200, 0, 0.35); 156 line-height: 1; 157 } 158 .kpi-card .kpi-sub { 159 font-size: 0.78rem; color: #64748b; 160 margin-top: 8px; 161 } 162 163 .kpi-card.users { --kpi-color: #00f0ff; } 164 .kpi-card.users .kpi-icon { background: rgba(0, 240, 255, 0.08); border-color: rgba(0, 240, 255, 0.3); } 165 .kpi-card.users .kpi-val { text-shadow: 0 0 15px rgba(0, 240, 255, 0.35); } 166 167 .kpi-card.sessions { --kpi-color: #00ff9d; } 168 .kpi-card.sessions .kpi-icon { background: rgba(0, 255, 157, 0.08); border-color: rgba(0, 255, 157, 0.3); } 169 .kpi-card.sessions .kpi-val { text-shadow: 0 0 15px rgba(0, 255, 157, 0.35); } 170 171 .kpi-card.revenue { --kpi-color: #ffc800; } 172 .kpi-card.revenue .kpi-icon { background: rgba(255, 200, 0, 0.08); border-color: rgba(255, 200, 0, 0.3); } 173 174 .kpi-card.premium { --kpi-color: #ff2a6d; } 175 .kpi-card.premium .kpi-icon { background: rgba(255, 42, 109, 0.08); border-color: rgba(255, 42, 109, 0.3); } 176 .kpi-card.premium .kpi-val { text-shadow: 0 0 15px rgba(255, 42, 109, 0.35); } 177 178 /* ====== Layout en grille principale ====== */ 179 .admin-main { 180 display: grid; 181 grid-template-columns: 1fr 1fr; 182 gap: 22px; 183 margin-bottom: 26px; 184 } 185 186 /* ====== Panels ====== */ 187 .admin-panel { 188 background: linear-gradient(135deg, rgba(13, 19, 30, 0.6) 0%, rgba(2, 4, 10, 0.7) 100%); 189 border: 1px solid rgba(255, 255, 255, 0.05); 190 border-radius: 10px; 191 padding: 24px 26px; 192 } 193 .admin-panel-header { 194 display: flex; align-items: center; justify-content: space-between; 195 margin-bottom: 18px; 196 padding-bottom: 14px; 197 border-bottom: 1px solid rgba(255, 255, 255, 0.06); 198 } 199 .admin-panel-title { 200 font-family: 'Orbitron', sans-serif; 201 font-size: 0.85rem; font-weight: 700; 202 letter-spacing: 2.5px; color: #fff; 203 text-transform: uppercase; 204 display: flex; align-items: center; gap: 10px; 205 } 206 .admin-panel-title i { color: #ffc800; font-size: 1rem; } 207 .admin-panel-count { 208 background: rgba(255, 200, 0, 0.08); 209 border: 1px solid rgba(255, 200, 0, 0.3); 210 color: #ffc800; 211 padding: 3px 10px; 212 font-family: 'Orbitron', sans-serif; 213 font-size: 0.6rem; letter-spacing: 1.5px; 214 border-radius: 50px; 215 } 216 217 /* ====== Casiers en direct (live blocks) ====== */ 218 .lockers-live { 219 display: grid; 220 grid-template-columns: 1fr 1fr; 221 gap: 16px; 222 } 223 .live-block { 224 background: rgba(0, 0, 0, 0.35); 225 border: 1px solid rgba(255, 255, 255, 0.06); 226 border-radius: 8px; 227 padding: 18px 18px 16px; 228 position: relative; 229 display: flex; flex-direction: column; 230 } 231 .live-block.sport.active { border-color: rgba(0, 255, 157, 0.45); background: rgba(0, 255, 157, 0.04); } 232 .live-block.school.active { border-color: rgba(0, 240, 255, 0.45); background: rgba(0, 240, 255, 0.04); } 233 .live-block.reserved { border-color: rgba(255, 200, 0, 0.45); background: rgba(255, 200, 0, 0.04); } 234 .live-block.empty { opacity: 0.55; } 235 236 .live-block-head { 237 display: flex; align-items: center; justify-content: space-between; 238 margin-bottom: 14px; 239 } 240 .live-block-name { 241 font-family: 'Orbitron', sans-serif; 242 font-size: 0.8rem; letter-spacing: 2px; 243 display: flex; align-items: center; gap: 9px; 244 } 245 .live-block-name i { font-size: 1.1rem; } 246 .live-block.sport .live-block-name { color: #00ff9d; } 247 .live-block.school .live-block-name { color: #00f0ff; } 248 .live-block.empty .live-block-name { color: #64748b; } 249 250 .live-status-tag { 251 font-family: 'Orbitron', sans-serif; 252 font-size: 0.58rem; letter-spacing: 1.5px; 253 padding: 4px 10px; border-radius: 50px; 254 display: flex; align-items: center; gap: 5px; 255 } 256 .live-status-tag.unlocked { background: rgba(0, 255, 157, 0.12); border: 1px solid #00ff9d; color: #00ff9d; } 257 .live-status-tag.reserved { background: rgba(255, 200, 0, 0.12); border: 1px solid #ffc800; color: #ffc800; } 258 .live-status-tag.locked { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.15); color: #94a3b8; } 259 .live-status-tag.unpaired { background: rgba(255, 42, 109, 0.08); border: 1px solid rgba(255, 42, 109, 0.4); color: #ff2a6d; } 260 261 .live-owner-row { 262 display: flex; align-items: center; gap: 10px; 263 background: rgba(0, 0, 0, 0.45); 264 padding: 10px 12px; 265 border: 1px solid rgba(255, 255, 255, 0.05); 266 border-radius: 6px; 267 margin-bottom: 12px; 268 } 269 .live-owner-avatar { 270 width: 40px; height: 40px; 271 border-radius: 50%; 272 background: rgba(0, 240, 255, 0.1); 273 border: 1.5px solid rgba(0, 240, 255, 0.4); 274 display: flex; align-items: center; justify-content: center; 275 color: #00f0ff; font-size: 1rem; 276 flex-shrink: 0; 277 overflow: hidden; 278 box-shadow: 0 0 12px rgba(0, 240, 255, 0.2); 279 } 280 .live-owner-avatar img { 281 width: 100%; height: 100%; 282 object-fit: cover; 283 display: block; 284 } 285 .live-owner-info { flex: 1; min-width: 0; } 286 .live-owner-pseudo { 287 font-family: 'Orbitron', sans-serif; 288 font-size: 0.68rem; letter-spacing: 1px; 289 color: #fff; 290 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 291 } 292 .live-owner-email { 293 font-size: 0.78rem; color: #94a3b8; 294 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 295 margin-top: 2px; 296 } 297 298 .live-timer-row { 299 display: flex; align-items: center; justify-content: space-between; 300 background: rgba(0, 0, 0, 0.45); 301 padding: 10px 14px; 302 border-radius: 6px; 303 margin-bottom: 12px; 304 border: 1px solid rgba(0, 255, 157, 0.2); 305 } 306 .live-timer-row.warning { border-color: rgba(255, 200, 0, 0.3); } 307 .live-timer-label { 308 font-family: 'Orbitron', sans-serif; 309 font-size: 0.55rem; letter-spacing: 1.5px; 310 color: #94a3b8; text-transform: uppercase; 311 } 312 .live-timer-value { 313 font-family: 'Orbitron', sans-serif; 314 font-size: 1rem; font-weight: 700; 315 color: #00ff9d; 316 letter-spacing: 1px; 317 } 318 .live-timer-row.warning .live-timer-value { color: #ffc800; } 319 320 .live-actions { display: flex; gap: 8px; margin-top: auto; } 321 .live-btn { 322 flex: 1; 323 background: transparent; 324 border: 1px solid rgba(255, 42, 109, 0.4); 325 color: #ff2a6d; 326 padding: 9px 12px; 327 font-family: 'Orbitron', sans-serif; 328 font-size: 0.6rem; letter-spacing: 1.5px; 329 border-radius: 5px; cursor: pointer; 330 display: flex; align-items: center; justify-content: center; gap: 7px; 331 transition: 0.2s; 332 } 333 .live-btn:hover { background: rgba(255, 42, 109, 0.1); } 334 335 .live-empty-body { 336 text-align: center; padding: 24px 8px; 337 color: #64748b; 338 } 339 .live-empty-body i { font-size: 1.8rem; opacity: 0.4; display: block; margin-bottom: 8px; } 340 .live-empty-body .live-empty-text { 341 font-family: 'Orbitron', sans-serif; 342 font-size: 0.65rem; letter-spacing: 2px; 343 } 344 345 /* ====== Quick stats panel (résumé revenus / plans) ====== */ 346 .quick-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } 347 .qs-block { 348 background: rgba(0, 0, 0, 0.3); 349 border: 1px solid rgba(255, 255, 255, 0.05); 350 border-radius: 8px; 351 padding: 16px 18px; 352 } 353 .qs-block .qs-label { 354 font-family: 'Orbitron', sans-serif; 355 font-size: 0.55rem; letter-spacing: 2px; 356 color: #94a3b8; text-transform: uppercase; 357 margin-bottom: 8px; 358 display: flex; align-items: center; gap: 6px; 359 } 360 .qs-block .qs-val { 361 font-family: 'Orbitron', sans-serif; 362 font-size: 1.4rem; font-weight: 900; 363 } 364 .qs-block .qs-bar-wrap { 365 margin-top: 10px; 366 height: 6px; 367 background: rgba(255, 255, 255, 0.05); 368 border-radius: 50px; 369 overflow: hidden; 370 } 371 .qs-block .qs-bar { 372 height: 100%; 373 border-radius: 50px; 374 transition: width 0.5s ease; 375 } 376 377 /* ====== Table utilisateurs ====== */ 378 .users-panel { margin-bottom: 0; } 379 .users-table-wrap { 380 overflow-x: auto; 381 border-radius: 6px; 382 } 383 .users-table { 384 width: 100%; border-collapse: collapse; 385 font-family: 'Rajdhani', sans-serif; 386 } 387 .users-table thead { 388 background: rgba(255, 200, 0, 0.04); 389 } 390 .users-table th { 391 font-family: 'Orbitron', sans-serif; 392 font-size: 0.6rem; letter-spacing: 2px; 393 color: rgba(255, 200, 0, 0.85); 394 text-transform: uppercase; 395 padding: 12px 14px; 396 text-align: left; 397 border-bottom: 1px solid rgba(255, 200, 0, 0.2); 398 white-space: nowrap; 399 } 400 .users-table td { 401 padding: 14px; 402 border-bottom: 1px solid rgba(255, 255, 255, 0.04); 403 font-size: 0.92rem; 404 color: #e2e8f0; 405 vertical-align: middle; 406 } 407 .users-table tbody tr { transition: 0.15s; } 408 .users-table tbody tr:hover { background: rgba(0, 240, 255, 0.025); } 409 .users-table tr:last-child td { border-bottom: none; } 410 411 .ut-pseudo-cell { display: flex; align-items: center; gap: 11px; } 412 .ut-avatar { 413 width: 38px; height: 38px; 414 border-radius: 50%; 415 background: linear-gradient(135deg, #00f0ff, #00ff9d); 416 color: #02040a; 417 display: flex; align-items: center; justify-content: center; 418 font-family: 'Orbitron', sans-serif; 419 font-weight: 900; font-size: 0.85rem; 420 flex-shrink: 0; 421 overflow: hidden; 422 border: 2px solid rgba(0, 240, 255, 0.3); 423 box-shadow: 0 0 10px rgba(0, 240, 255, 0.15); 424 } 425 .ut-avatar img { 426 width: 100%; height: 100%; 427 object-fit: cover; 428 display: block; 429 } 430 .ut-pseudo-text { 431 font-family: 'Orbitron', sans-serif; 432 font-size: 0.78rem; color: #fff; 433 letter-spacing: 1px; 434 } 435 .ut-email { color: #94a3b8; font-size: 0.82rem; } 436 .ut-num { font-family: 'Orbitron', sans-serif; font-size: 0.85rem; } 437 .ut-rev { font-family: 'Orbitron', sans-serif; font-size: 0.9rem; color: #ffc800; } 438 .ut-rev.zero { color: rgba(255, 255, 255, 0.18); } 439 440 .badge-premium { 441 background: rgba(255, 200, 0, 0.12); 442 border: 1px solid rgba(255, 200, 0, 0.5); 443 color: #ffc800; 444 padding: 4px 11px; 445 font-family: 'Orbitron', sans-serif; 446 font-size: 0.58rem; letter-spacing: 1.5px; 447 border-radius: 50px; 448 display: inline-flex; align-items: center; gap: 5px; 449 } 450 .badge-free { 451 background: rgba(255, 255, 255, 0.04); 452 border: 1px solid rgba(255, 255, 255, 0.1); 453 color: #94a3b8; 454 padding: 4px 11px; 455 font-family: 'Orbitron', sans-serif; 456 font-size: 0.58rem; letter-spacing: 1.5px; 457 border-radius: 50px; 458 } 459 460 .ut-actions { display: flex; gap: 8px; flex-wrap: wrap; } 461 .ut-action-btn { 462 background: rgba(255, 255, 255, 0.04); 463 border: 1px solid rgba(255, 255, 255, 0.12); 464 color: #94a3b8; 465 border-radius: 5px; 466 cursor: pointer; 467 display: inline-flex; align-items: center; justify-content: center; gap: 7px; 468 padding: 7px 12px; 469 font-family: 'Orbitron', sans-serif; 470 font-size: 0.62rem; letter-spacing: 1.5px; 471 text-transform: uppercase; 472 transition: 0.2s; 473 white-space: nowrap; 474 } 475 .ut-action-btn i { font-size: 0.78rem; } 476 .ut-action-btn:hover { background: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.25); color: #fff; } 477 .ut-action-btn.premium-on { background: rgba(255, 200, 0, 0.1); border-color: rgba(255, 200, 0, 0.5); color: #ffc800; } 478 .ut-action-btn.premium-on:hover { background: rgba(255, 42, 109, 0.1); border-color: rgba(255, 42, 109, 0.5); color: #ff2a6d; } 479 .ut-action-btn.danger { color: #ff2a6d; border-color: rgba(255, 42, 109, 0.3); } 480 .ut-action-btn.danger:hover { background: rgba(255, 42, 109, 0.1); border-color: rgba(255, 42, 109, 0.6); color: #ff2a6d; } 481 482 /* Recherche */ 483 .users-toolbar { 484 display: flex; align-items: center; gap: 12px; 485 margin-bottom: 16px; 486 } 487 .users-search { 488 flex: 1; max-width: 360px; 489 background: rgba(0, 0, 0, 0.4); 490 border: 1px solid rgba(255, 255, 255, 0.08); 491 color: #e2e8f0; 492 padding: 10px 14px 10px 38px; 493 font-family: 'Rajdhani', sans-serif; 494 font-size: 0.95rem; 495 border-radius: 6px; 496 outline: none; 497 transition: 0.2s; 498 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2394a3b8'%3E%3Cpath d='M10 18a8 8 0 1 1 5.293-14.293A8 8 0 0 1 10 18zm0-2a6 6 0 1 0 0-12 6 6 0 0 0 0 12zm8.485 1.929 2.829 2.828-1.415 1.415-2.828-2.829 1.414-1.414z'/%3E%3C/svg%3E"); 499 background-repeat: no-repeat; 500 background-position: 12px center; 501 background-size: 16px; 502 } 503 .users-search:focus { border-color: rgba(0, 240, 255, 0.4); box-shadow: 0 0 0 3px rgba(0, 240, 255, 0.08); } 504 .users-filter-tag { 505 font-family: 'Orbitron', sans-serif; 506 font-size: 0.6rem; letter-spacing: 1.5px; 507 color: #94a3b8; padding: 8px 14px; 508 border: 1px solid rgba(255, 255, 255, 0.08); 509 border-radius: 6px; 510 background: transparent; 511 cursor: pointer; transition: 0.2s; 512 } 513 .users-filter-tag.active { 514 border-color: rgba(255, 200, 0, 0.5); 515 color: #ffc800; 516 background: rgba(255, 200, 0, 0.06); 517 } 518 .users-filter-tag:hover { border-color: rgba(255, 255, 255, 0.2); color: #fff; } 519 520 .users-empty { 521 text-align: center; 522 padding: 60px 20px; 523 color: #64748b; 524 } 525 .users-empty i { 526 font-size: 2.5rem; opacity: 0.25; 527 display: block; margin-bottom: 14px; 528 } 529 530 /* Responsive : compresser un peu sous 1280px */ 531 @media (max-width: 1280px) { 532 .kpi-row { grid-template-columns: repeat(2, 1fr); } 533 .admin-main { grid-template-columns: 1fr; } 534 } 535 @media (max-width: 720px) { 536 .admin-wrap { padding: 18px; } 537 .admin-topbar { padding: 12px 16px; } 538 .lockers-live { grid-template-columns: 1fr; } 539 .quick-stats { grid-template-columns: 1fr; } 540 .admin-page-header { flex-direction: column; align-items: flex-start; } 541 } 542 </style> 543</head> 544<body> 545<div class="cyber-grid"></div> 546 547<!-- ═══ TOPBAR ═══════════════════════════════════════════════ --> 548<nav class="admin-topbar"> 549 <div class="admin-topbar-left"> 550 <a href="/" class="admin-brand"><i class="fa-solid fa-fingerprint"></i> SMARTLOCKER</a> 551 <span class="admin-badge-pill"><i class="fa-solid fa-shield-halved"></i> ADMIN</span> 552 </div> 553 <div class="admin-topbar-right"> 554 <a href="/admin" class="admin-topbar-btn refresh"><i class="fa-solid fa-rotate-right"></i> ACTUALISER</a> 555 <a href="/logout" class="admin-topbar-btn logout"><i class="fa-solid fa-power-off"></i> QUITTER</a> 556 </div> 557</nav> 558 559<div class="admin-wrap"> 560 561 {% with messages = get_flashed_messages() %}{% if messages %} 562 {% for m in messages %} 563 <div class="toast"><i class="fa-solid fa-circle-check"></i> {{ m }}</div> 564 {% endfor %} 565 {% endif %}{% endwith %} 566 567 <!-- ═══ HEADER ═══ --> 568 <div class="admin-page-header"> 569 <div> 570 <h1 class="admin-page-title">CONSOLE <span>ADMIN</span></h1> 571 <div class="admin-page-sub">Supervision en temps réel des casiers et des utilisateurs</div> 572 </div> 573 <div class="admin-clock" id="admin-clock">--:--:--</div> 574 </div> 575 576 <!-- ═══ KPIs ═══════════════════════════════════════════════ --> 577 {% set premium_count = users | selectattr('premium') | list | length %} 578 {% set active_lockers = locker_owners | length %} 579 <div class="kpi-row"> 580 <div class="kpi-card users"> 581 <div class="kpi-icon"><i class="fa-solid fa-users"></i></div> 582 <div class="kpi-label">Utilisateurs</div> 583 <div class="kpi-val">{{ users | length }}</div> 584 <div class="kpi-sub">{{ premium_count }} premium · {{ users | length - premium_count }} gratuit</div> 585 </div> 586 <div class="kpi-card sessions"> 587 <div class="kpi-icon"><i class="fa-solid fa-bolt"></i></div> 588 <div class="kpi-label">Sessions totales</div> 589 <div class="kpi-val">{{ total_sessions }}</div> 590 <div class="kpi-sub">Toutes périodes confondues</div> 591 </div> 592 <div class="kpi-card revenue"> 593 <div class="kpi-icon"><i class="fa-solid fa-euro-sign"></i></div> 594 <div class="kpi-label">Revenus totaux</div> 595 <div class="kpi-val">{{ "%.0f"|format(total_revenue) }}€</div> 596 <div class="kpi-sub">Montant cumulé</div> 597 </div> 598 <div class="kpi-card premium"> 599 <div class="kpi-icon"><i class="fa-solid fa-lock"></i></div> 600 <div class="kpi-label">Casiers actifs</div> 601 <div class="kpi-val">{{ active_lockers }} / {{ locker_defs | length }}</div> 602 <div class="kpi-sub">Appairés à un utilisateur</div> 603 </div> 604 </div> 605 606 <!-- ═══ MAIN — 2 colonnes ═══════════════════════════════════ --> 607 <div class="admin-main"> 608 609 <!-- LIVE LOCKERS --> 610 <div class="admin-panel"> 611 <div class="admin-panel-header"> 612 <div class="admin-panel-title"><i class="fa-solid fa-satellite-dish"></i> Casiers en direct</div> 613 <span class="admin-panel-count">{{ locker_defs | length }} casiers</span> 614 </div> 615 616 <div class="lockers-live"> 617 {% for key, locker_def in locker_defs.items() %} 618 {% set lid = locker_def.id %} 619 {% set ldata = lockers_data.get(lid, {}) or {} %} 620 {% set lstatus = ldata.get('status', 'locked') %} 621 {% set lsess = ldata.get('active_session') or {} %} 622 {% set owner = locker_owners.get(key) %} 623 {% set is_active = (lstatus == 'unlocked' or lstatus == 'reserved') %} 624 <div class="live-block {{ key }} {% if owner and is_active %}active{% endif %} {% if lstatus == 'reserved' %}reserved{% endif %} {% if not owner %}empty{% endif %}"> 625 <div class="live-block-head"> 626 <div class="live-block-name"> 627 <i class="fa-solid fa-{{ 'dumbbell' if key == 'sport' else 'school' }}"></i> 628 {{ locker_def.name | upper }} 629 </div> 630 {% if not owner %} 631 <span class="live-status-tag unpaired"><i class="fa-solid fa-link-slash"></i> NON APPAIRÉ</span> 632 {% elif lstatus == 'unlocked' %} 633 <span class="live-status-tag unlocked"><i class="fa-solid fa-lock-open"></i> EN COURS</span> 634 {% elif lstatus == 'reserved' %} 635 <span class="live-status-tag reserved"><i class="fa-solid fa-calendar-check"></i> RÉSERVÉ</span> 636 {% else %} 637 <span class="live-status-tag locked"><i class="fa-solid fa-lock"></i> FERMÉ</span> 638 {% endif %} 639 </div> 640 641 {% if owner %} 642 <div class="live-owner-row"> 643 <div class="live-owner-avatar"> 644 {% if owner.photo %} 645 <img src="{{ owner.photo }}" alt="{{ owner.pseudo }}" onerror="this.parentNode.innerHTML='<i class=\'fa-solid fa-user\'></i>';"> 646 {% else %} 647 <i class="fa-solid fa-user"></i> 648 {% endif %} 649 </div> 650 <div class="live-owner-info"> 651 <div class="live-owner-pseudo">{{ owner.pseudo }}</div> 652 <div class="live-owner-email">{{ owner.email }}</div> 653 </div> 654 </div> 655 656 {% if lstatus == 'unlocked' and lsess.get('expires_at') %} 657 <div class="live-timer-row"> 658 <span class="live-timer-label">Temps restant</span> 659 <span class="live-timer-value" data-expires="{{ lsess.get('expires_at', 0) }}">--:--:--</span> 660 </div> 661 {% elif lstatus == 'reserved' and lsess.get('started_at') %} 662 <div class="live-timer-row warning"> 663 <span class="live-timer-label">Avant ouverture</span> 664 <span class="live-timer-value" data-starts="{{ lsess.get('started_at', 0) }}">--:--:--</span> 665 </div> 666 {% endif %} 667 668 <div class="live-actions"> 669 <form action="/admin/force_unpair" method="POST" style="flex:1;margin:0;" 670 onsubmit="return confirm('Dissocier de force ce casier de {{ owner.pseudo }} ?');"> 671 <input type="hidden" name="uid" value="{{ owner.uid }}"> 672 <input type="hidden" name="locker_key" value="{{ key }}"> 673 <button type="submit" class="live-btn"> 674 <i class="fa-solid fa-unlink"></i> DISSOCIER DE FORCE 675 </button> 676 </form> 677 </div> 678 {% else %} 679 <div class="live-empty-body"> 680 <i class="fa-solid fa-lock"></i> 681 <div class="live-empty-text">AUCUN UTILISATEUR<br>ASSOCIÉ</div> 682 </div> 683 {% endif %} 684 </div> 685 {% endfor %} 686 </div> 687 </div> 688 689 <!-- QUICK STATS / DISTRIBUTION --> 690 <div class="admin-panel"> 691 <div class="admin-panel-header"> 692 <div class="admin-panel-title"><i class="fa-solid fa-chart-pie"></i> Aperçu rapide</div> 693 </div> 694 {% set total_u = users | length if users | length > 0 else 1 %} 695 <div class="quick-stats"> 696 <div class="qs-block"> 697 <div class="qs-label" style="color:#ffc800;"><i class="fa-solid fa-crown"></i> Plan premium</div> 698 <div class="qs-val" style="color:#ffc800;">{{ premium_count }}<span style="font-size:0.85rem;color:#64748b;"> / {{ users | length }}</span></div> 699 <div class="qs-bar-wrap"><div class="qs-bar" style="width:{{ (premium_count / total_u * 100) | round(0) }}%;background:linear-gradient(90deg,#ffc800,#ff9500);"></div></div> 700 </div> 701 <div class="qs-block"> 702 <div class="qs-label" style="color:#94a3b8;"><i class="fa-solid fa-user"></i> Plan gratuit</div> 703 <div class="qs-val" style="color:#fff;">{{ users | length - premium_count }}<span style="font-size:0.85rem;color:#64748b;"> / {{ users | length }}</span></div> 704 <div class="qs-bar-wrap"><div class="qs-bar" style="width:{{ ((users | length - premium_count) / total_u * 100) | round(0) }}%;background:linear-gradient(90deg,#64748b,#94a3b8);"></div></div> 705 </div> 706 <div class="qs-block"> 707 <div class="qs-label" style="color:#00f0ff;"><i class="fa-solid fa-link"></i> Casiers appairés</div> 708 <div class="qs-val" style="color:#00f0ff;">{{ active_lockers }}<span style="font-size:0.85rem;color:#64748b;"> / {{ locker_defs | length }}</span></div> 709 <div class="qs-bar-wrap"><div class="qs-bar" style="width:{{ (active_lockers / (locker_defs | length) * 100) | round(0) }}%;background:linear-gradient(90deg,#00f0ff,#0096ff);"></div></div> 710 </div> 711 <div class="qs-block"> 712 <div class="qs-label" style="color:#00ff9d;"><i class="fa-solid fa-coins"></i> Revenu moyen / user</div> 713 <div class="qs-val" style="color:#00ff9d;">{{ "%.1f"|format(total_revenue / total_u) }}€</div> 714 <div class="qs-bar-wrap"><div class="qs-bar" style="width:100%;background:linear-gradient(90deg,#00ff9d,#00f0ff);"></div></div> 715 </div> 716 </div> 717 718 <div style="margin-top:20px;padding:14px 16px;background:rgba(0,240,255,0.04);border:1px dashed rgba(0,240,255,0.25);border-radius:6px;display:flex;gap:12px;align-items:flex-start;"> 719 <i class="fa-solid fa-circle-info" style="color:#00f0ff;font-size:1rem;margin-top:2px;"></i> 720 <div style="font-size:0.85rem;color:#94a3b8;line-height:1.5;"> 721 Cliquez sur le bouton <span style="color:#00f0ff;font-weight:700;">ACTUALISER</span> en haut à droite pour mettre à jour les données affichées. 722 </div> 723 </div> 724 </div> 725 726 </div> 727 728 <!-- ═══ USERS TABLE ═════════════════════════════════════════ --> 729 <div class="admin-panel users-panel"> 730 <div class="admin-panel-header"> 731 <div class="admin-panel-title"><i class="fa-solid fa-users"></i> Utilisateurs</div> 732 <span class="admin-panel-count">{{ users | length }} comptes</span> 733 </div> 734 735 {% if users %} 736 <div class="users-toolbar"> 737 <input type="text" class="users-search" id="users-search" placeholder="Rechercher un pseudo ou un email..."> 738 <button class="users-filter-tag active" data-filter="all">TOUS</button> 739 <button class="users-filter-tag" data-filter="premium">PREMIUM</button> 740 <button class="users-filter-tag" data-filter="free">GRATUIT</button> 741 </div> 742 743 <div class="users-table-wrap"> 744 <table class="users-table" id="users-table"> 745 <thead> 746 <tr> 747 <th style="width:18%;">Utilisateur</th> 748 <th style="width:20%;">Email</th> 749 <th style="width:9%;">Plan</th> 750 <th style="width:9%;text-align:center;">Sessions</th> 751 <th style="width:10%;">Revenus</th> 752 <th style="width:34%;text-align:right;">Actions</th> 753 </tr> 754 </thead> 755 <tbody> 756 {% for u in users %} 757 <tr data-premium="{{ '1' if u.premium else '0' }}" data-search="{{ (u.pseudo + ' ' + u.email) | lower }}"> 758 <td> 759 <div class="ut-pseudo-cell"> 760 <div class="ut-avatar"> 761 {% if u.photo %} 762 <img src="{{ u.photo }}" alt="{{ u.pseudo }}" onerror="this.parentNode.innerHTML='{{ (u.pseudo[0] if u.pseudo and u.pseudo != '-' else '?') | upper }}';"> 763 {% else %} 764 {{ (u.pseudo[0] if u.pseudo and u.pseudo != '-' else '?') | upper }} 765 {% endif %} 766 </div> 767 <div class="ut-pseudo-text">{{ u.pseudo }}</div> 768 </div> 769 </td> 770 <td><div class="ut-email" title="{{ u.email }}">{{ u.email }}</div></td> 771 <td> 772 {% if u.premium %} 773 <span class="badge-premium"><i class="fa-solid fa-crown"></i> PREMIUM</span> 774 {% else %} 775 <span class="badge-free">GRATUIT</span> 776 {% endif %} 777 </td> 778 <td style="text-align:center;"><span class="ut-num">{{ u.sessions }}</span></td> 779 <td><span class="ut-rev {% if u.revenue == 0 %}zero{% endif %}">{{ "%.2f"|format(u.revenue) }} €</span></td> 780 <td> 781 <div class="ut-actions" style="justify-content:flex-end;"> 782 <form action="/admin/toggle_premium" method="POST" style="margin:0;"> 783 <input type="hidden" name="uid" value="{{ u.uid }}"> 784 {% if u.premium %} 785 <button type="submit" class="ut-action-btn premium-on" title="Retirer le statut Premium"> 786 <i class="fa-solid fa-crown"></i> RETIRER PREMIUM 787 </button> 788 {% else %} 789 <button type="submit" class="ut-action-btn" title="Activer le statut Premium"> 790 <i class="fa-solid fa-crown"></i> ACTIVER PREMIUM 791 </button> 792 {% endif %} 793 </form> 794 <form action="/admin/delete_user" method="POST" style="margin:0;" 795 onsubmit="return confirm('Supprimer l\'utilisateur {{ u.pseudo }} et toutes ses données ?');"> 796 <input type="hidden" name="uid" value="{{ u.uid }}"> 797 <button type="submit" class="ut-action-btn danger" title="Supprimer définitivement cet utilisateur"> 798 <i class="fa-solid fa-trash"></i> SUPPRIMER 799 </button> 800 </form> 801 </div> 802 </td> 803 </tr> 804 {% endfor %} 805 </tbody> 806 </table> 807 </div> 808 {% else %} 809 <div class="users-empty"> 810 <i class="fa-solid fa-users"></i> 811 <div style="font-family:'Orbitron',sans-serif;font-size:0.8rem;letter-spacing:2px;">AUCUN UTILISATEUR ENREGISTRÉ</div> 812 </div> 813 {% endif %} 814 </div> 815 816</div> 817 818<script> 819 // ── Horloge live (HH:MM:SS) ───────────────────────────────── 820 (function(){ 821 const el = document.getElementById('admin-clock'); 822 if (!el) return; 823 function tick() { 824 const d = new Date(); 825 const pad = n => String(n).padStart(2,'0'); 826 el.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); 827 } 828 tick(); 829 setInterval(tick, 1000); 830 })(); 831 832 // ── Timers casiers (compte à rebours) ─────────────────────── 833 document.querySelectorAll('[data-expires]').forEach(el => { 834 const exp = parseFloat(el.dataset.expires); 835 function tick() { 836 const rem = Math.max(0, Math.floor(exp - Date.now() / 1000)); 837 const h = Math.floor(rem / 3600), m = Math.floor((rem % 3600) / 60), s = rem % 60; 838 el.textContent = String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0'); 839 if (rem === 0) { el.textContent = 'EXPIRÉ'; el.style.color = '#ff2a6d'; } 840 } 841 tick(); setInterval(tick, 1000); 842 }); 843 844 document.querySelectorAll('[data-starts]').forEach(el => { 845 const starts = parseFloat(el.dataset.starts); 846 function tick() { 847 const rem = Math.max(0, Math.floor(starts - Date.now() / 1000)); 848 if (rem === 0) { el.textContent = 'OUVERTURE…'; return; } 849 const h = Math.floor(rem / 3600), m = Math.floor((rem % 3600) / 60), s = rem % 60; 850 el.textContent = String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0'); 851 } 852 tick(); setInterval(tick, 1000); 853 }); 854 855 // ── Recherche + filtres utilisateurs ──────────────────────── 856 (function(){ 857 const input = document.getElementById('users-search'); 858 const tags = document.querySelectorAll('.users-filter-tag'); 859 const rows = document.querySelectorAll('#users-table tbody tr'); 860 let activeFilter = 'all'; 861 862 function applyFilters() { 863 const q = (input ? input.value : '').toLowerCase().trim(); 864 rows.forEach(r => { 865 const search = r.dataset.search || ''; 866 const isPrem = r.dataset.premium === '1'; 867 const matchSearch = !q || search.indexOf(q) !== -1; 868 const matchFilter = activeFilter === 'all' 869 || (activeFilter === 'premium' && isPrem) 870 || (activeFilter === 'free' && !isPrem); 871 r.style.display = (matchSearch && matchFilter) ? '' : 'none'; 872 }); 873 } 874 875 if (input) input.addEventListener('input', applyFilters); 876 tags.forEach(t => { 877 t.addEventListener('click', () => { 878 tags.forEach(x => x.classList.remove('active')); 879 t.classList.add('active'); 880 activeFilter = t.dataset.filter; 881 applyFilters(); 882 }); 883 }); 884 })(); 885 886 // ── Auto-dismiss des toasts ───────────────────────────────── 887 setTimeout(() => { 888 document.querySelectorAll('.toast').forEach(t => { 889 t.style.transition = 'opacity 0.5s'; t.style.opacity = '0'; 890 setTimeout(() => t.remove(), 500); 891 }); 892 }, 3500); 893</script> 894</body> 895</html>
templates/payment_success.html
152 lignes
1<!DOCTYPE html> 2<html lang="fr"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>SmartLocker — Paiement confirmé</title> 7 <link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}"> 8 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> 9 <style> 10 .nav-container { display:flex; align-items:center; justify-content:space-between; width:100%; } 11 .success-wrap { text-align:center; padding:2rem 0 1rem; } 12 .success-icon { position:relative; width:100px; height:100px; margin:0 auto 1.5rem; } 13 .success-ring { position:absolute; inset:0; border-radius:50%; border:1px solid rgba(0,255,157,0.2); animation:ring-p 2.5s ease-in-out infinite; } 14 .success-ring2 { position:absolute; inset:-10px; border-radius:50%; border:1px solid rgba(0,255,157,0.1); animation:ring-p 2.5s ease-in-out infinite; animation-delay:0.4s; } 15 .success-core { position:absolute; inset:0; border-radius:50%; background:rgba(0,255,157,0.08); border:2px solid var(--primary); display:grid; place-items:center; font-size:2rem; color:var(--primary); box-shadow:0 0 30px rgba(0,255,157,0.25); } 16 .success-title { font-family:'Orbitron',sans-serif; font-size:1.2rem; font-weight:900; letter-spacing:3px; color:#fff; margin-bottom:0.5rem; } 17 .success-sub { color:var(--text-muted); font-size:0.85rem; line-height:1.6; max-width:280px; margin:0 auto 2rem; } 18 19 .receipt { background:rgba(0,0,0,0.3); border:1px solid rgba(0,255,157,0.15); padding:1.25rem; margin-bottom:1.5rem; } 20 .receipt::before { content:''; display:block; height:1px; background:linear-gradient(90deg,transparent,var(--primary),transparent); margin-bottom:1.25rem; } 21 .receipt-row { display:flex; justify-content:space-between; align-items:center; padding:0.5rem 0; border-bottom:1px solid rgba(255,255,255,0.04); } 22 .receipt-row:last-child { border-bottom:none; } 23 .receipt-row .rk { font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:2px; color:var(--text-muted); text-transform:uppercase; } 24 .receipt-row .rv { font-family:'Orbitron',sans-serif; font-size:0.85rem; color:#fff; } 25 .receipt-row.total .rv { color:var(--primary); font-size:1rem; text-shadow:var(--glow-primary); } 26 27 /* Badge réservé futur */ 28 .future-notice { background:rgba(0,240,255,0.06); border:1px solid rgba(0,240,255,0.2); padding:0.875rem 1rem; display:flex; align-items:center; gap:10px; margin-bottom:1.5rem; } 29 .future-notice i { color:var(--secondary); font-size:1.1rem; flex-shrink:0; } 30 .future-notice p { font-family:'Orbitron',sans-serif; font-size:0.65rem; letter-spacing:1px; color:var(--secondary); line-height:1.6; } 31 .future-notice p span { color:#fff; } 32 33 .countdown-bar { background:rgba(0,240,255,0.04); border:1px solid rgba(0,240,255,0.15); padding:0.875rem 1rem; display:flex; align-items:center; justify-content:space-between; margin-bottom:1.5rem; } 34 .cb-label { font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:2px; color:var(--text-muted); } 35 .cb-time { font-family:'Orbitron',sans-serif; font-size:1rem; color:var(--secondary); text-shadow:var(--glow-secondary); } 36 37 .btn-go { width:100%; padding:1rem; border:none; font-family:'Orbitron',sans-serif; font-weight:700; text-transform:uppercase; letter-spacing:2px; cursor:pointer; transition:0.3s; font-size:0.9rem; display:flex; align-items:center; justify-content:center; gap:10px; clip-path:polygon(10px 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%,0 10px); background:var(--primary); color:#000; box-shadow:var(--glow-primary); text-decoration:none; } 38 .btn-go:hover { background:#fff; } 39 40 @keyframes ring-p { 0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.05);opacity:0.4} } 41 </style> 42</head> 43<body> 44<div class="cyber-grid"></div> 45 46<nav class="navbar"> 47 <div class="nav-container"> 48 <a href="/" class="nav-logo"><i class="fa-solid fa-fingerprint"></i> SMARTLOCKER</a> 49 </div> 50</nav> 51 52<div class="app-container"> 53 <div class="app-card"> 54 <div class="success-wrap"> 55 <div class="success-icon"> 56 <div class="success-ring"></div> 57 <div class="success-ring2"></div> 58 <div class="success-core"><i class="fa-solid fa-check"></i></div> 59 </div> 60 <div class="success-title">PAIEMENT CONFIRMÉ</div> 61 <p class="success-sub">Votre réservation du casier École a été enregistrée avec succès.</p> 62 </div> 63 64 <div class="receipt"> 65 <div class="receipt-row"> 66 <span class="rk">Service</span> 67 <span class="rv">Casier École</span> 68 </div> 69 <div class="receipt-row"> 70 <span class="rk">Date</span> 71 <span class="rv" id="receipt-date">--</span> 72 </div> 73 <div class="receipt-row"> 74 <span class="rk">Créneau</span> 75 <span class="rv" id="receipt-slot">--</span> 76 </div> 77 <div class="receipt-row"> 78 <span class="rk">Durée</span> 79 <span class="rv">{{ hours }}h</span> 80 </div> 81 <div class="receipt-row total"> 82 <span class="rk">Montant</span> 83 <span class="rv">{{ "%.2f"|format(amount_eur) }} €</span> 84 </div> 85 </div> 86 87 <!-- Notice si réservation future --> 88 <div class="future-notice" id="future-notice" style="display:none;"> 89 <i class="fa-solid fa-calendar-clock"></i> 90 <p>Votre casier s'ouvrira automatiquement le <span id="future-date-txt">--</span>.<br> 91 Vous n'avez rien à faire.</p> 92 </div> 93 94 <!-- Countdown si session immédiate --> 95 <div class="countdown-bar" id="countdown-bar"> 96 <span class="cb-label">Temps restant</span> 97 <span class="cb-time" id="cb-timer">--:--:--</span> 98 </div> 99 100 <a href="/locker/school" class="btn-go"> 101 <i class="fa-solid fa-school"></i> VOIR MON CASIER 102 </a> 103 </div> 104</div> 105 106<script> 107 const expiresAt = {{ expires_at }}; 108 const startTs = {{ start_ts }}; 109 const dateStr = "{{ date_str }}"; 110 const startStr = "{{ start_str }}"; 111 const hours = {{ hours }}; 112 113 // Formater date lisible 114 if (dateStr) { 115 const [yr, mo, dy] = dateStr.split('-'); 116 const monthsFr = ['janv.','févr.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.']; 117 document.getElementById('receipt-date').textContent = parseInt(dy) + ' ' + monthsFr[parseInt(mo)-1] + ' ' + yr; 118 } 119 // Créneau 120 if (startStr) { 121 const startH = parseInt(startStr.split(':')[0]); 122 const endH = startH + hours; 123 document.getElementById('receipt-slot').textContent = 124 String(startH).padStart(2,'0') + 'h00 → ' + String(endH).padStart(2,'0') + 'h00'; 125 } 126 127 const isFuture = startTs > Date.now() / 1000 + 60; 128 if (isFuture) { 129 document.getElementById('future-notice').style.display = 'flex'; 130 document.getElementById('countdown-bar').style.display = 'none'; 131 // Texte date future 132 const sd = new Date(startTs * 1000); 133 const monthsFr = ['janv.','févr.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.']; 134 document.getElementById('future-date-txt').textContent = 135 sd.getDate() + ' ' + monthsFr[sd.getMonth()] + ' à ' + 136 String(sd.getHours()).padStart(2,'0') + 'h' + String(sd.getMinutes()).padStart(2,'0'); 137 } else { 138 // Session immédiate — afficher compte à rebours 139 function updateTimer() { 140 const remaining = Math.max(0, Math.floor(expiresAt - Date.now() / 1000)); 141 const h = Math.floor(remaining / 3600); 142 const m = Math.floor((remaining % 3600) / 60); 143 const s = remaining % 60; 144 document.getElementById('cb-timer').textContent = 145 String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0'); 146 } 147 updateTimer(); 148 setInterval(updateTimer, 1000); 149 } 150</script> 151</body> 152</html>
templates/sport_thanks.html
320 lignes
1<!DOCTYPE html> 2<html lang="fr"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Merci — SmartLocker</title> 7 <link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}"> 8 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"> 9 <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> 10 <link rel="manifest" href="/manifest.json"> 11 <style> 12 body { 13 margin: 0; 14 min-height: 100vh; 15 background: radial-gradient(ellipse at top, #0a1929 0%, #050a14 50%, #000 100%); 16 color: #fff; 17 font-family: 'Inter', sans-serif; 18 overflow-x: hidden; 19 position: relative; 20 } 21 22 /* Particules animées en arrière-plan */ 23 .particles { 24 position: fixed; 25 inset: 0; 26 overflow: hidden; 27 z-index: 0; 28 pointer-events: none; 29 } 30 .particle { 31 position: absolute; 32 background: radial-gradient(circle, rgba(0, 255, 157, 0.8), transparent 70%); 33 border-radius: 50%; 34 animation: float 8s infinite ease-in-out; 35 } 36 @keyframes float { 37 0%, 100% { transform: translateY(0) translateX(0); opacity: 0.3; } 38 25% { transform: translateY(-30px) translateX(20px); opacity: 0.8; } 39 50% { transform: translateY(-60px) translateX(-10px); opacity: 1; } 40 75% { transform: translateY(-30px) translateX(-20px); opacity: 0.6; } 41 } 42 43 .thanks-wrap { 44 position: relative; 45 z-index: 1; 46 min-height: 100vh; 47 display: flex; 48 align-items: center; 49 justify-content: center; 50 padding: 24px; 51 } 52 .thanks-card { 53 max-width: 580px; 54 width: 100%; 55 background: linear-gradient(135deg, rgba(15, 25, 41, 0.95), rgba(5, 10, 20, 0.95)); 56 border: 2px solid rgba(0, 255, 157, 0.35); 57 border-radius: 24px; 58 padding: 56px 40px; 59 text-align: center; 60 box-shadow: 61 0 0 60px rgba(0, 255, 157, 0.25), 62 0 20px 80px rgba(0, 0, 0, 0.5), 63 inset 0 1px 0 rgba(255, 255, 255, 0.05); 64 backdrop-filter: blur(20px); 65 animation: cardIn 0.8s cubic-bezier(0.34, 1.56, 0.64, 1); 66 } 67 @keyframes cardIn { 68 from { opacity: 0; transform: translateY(40px) scale(0.92); } 69 to { opacity: 1; transform: translateY(0) scale(1); } 70 } 71 72 .thanks-icon-wrap { 73 position: relative; 74 display: inline-flex; 75 align-items: center; 76 justify-content: center; 77 width: 130px; 78 height: 130px; 79 margin-bottom: 24px; 80 } 81 .thanks-icon-wrap::before { 82 content: ""; 83 position: absolute; 84 inset: -10px; 85 border-radius: 50%; 86 background: conic-gradient(from 0deg, #00ff9d, #00d4ff, #00ff9d); 87 animation: spin 4s linear infinite; 88 filter: blur(8px); 89 opacity: 0.7; 90 } 91 .thanks-icon-wrap::after { 92 content: ""; 93 position: absolute; 94 inset: 4px; 95 border-radius: 50%; 96 background: radial-gradient(circle at 30% 30%, #0a1929, #050a14); 97 border: 2px solid rgba(0, 255, 157, 0.4); 98 } 99 @keyframes spin { 100 to { transform: rotate(360deg); } 101 } 102 .thanks-icon { 103 position: relative; 104 z-index: 1; 105 font-size: 4rem; 106 color: #00ff9d; 107 filter: drop-shadow(0 0 20px rgba(0, 255, 157, 0.8)); 108 animation: pulse 2s ease-in-out infinite; 109 } 110 @keyframes pulse { 111 0%, 100% { transform: scale(1); } 112 50% { transform: scale(1.08); } 113 } 114 115 .thanks-title { 116 font-family: 'Orbitron', sans-serif; 117 font-size: 2.5rem; 118 font-weight: 800; 119 margin: 0 0 8px; 120 background: linear-gradient(90deg, #00ff9d, #00d4ff); 121 -webkit-background-clip: text; 122 background-clip: text; 123 -webkit-text-fill-color: transparent; 124 letter-spacing: 2px; 125 } 126 .thanks-subtitle { 127 font-size: 1.1rem; 128 color: #94a3b8; 129 margin: 0 0 32px; 130 font-weight: 300; 131 } 132 .thanks-message { 133 font-size: 1.05rem; 134 line-height: 1.7; 135 color: #cbd5e1; 136 margin: 0 0 36px; 137 font-weight: 300; 138 } 139 .thanks-message strong { 140 color: #00ff9d; 141 font-weight: 600; 142 } 143 .thanks-stats { 144 display: grid; 145 grid-template-columns: 1fr 1fr; 146 gap: 12px; 147 margin-bottom: 36px; 148 } 149 .stat-pill { 150 background: rgba(0, 255, 157, 0.05); 151 border: 1px solid rgba(0, 255, 157, 0.2); 152 border-radius: 12px; 153 padding: 14px; 154 display: flex; 155 flex-direction: column; 156 align-items: center; 157 gap: 4px; 158 } 159 .stat-pill i { 160 font-size: 1.4rem; 161 color: #00ff9d; 162 margin-bottom: 4px; 163 } 164 .stat-pill-label { 165 font-size: 0.7rem; 166 text-transform: uppercase; 167 letter-spacing: 1.2px; 168 color: #64748b; 169 font-weight: 600; 170 } 171 .stat-pill-val { 172 font-family: 'Orbitron', sans-serif; 173 font-size: 0.9rem; 174 color: #fff; 175 font-weight: 600; 176 } 177 .thanks-actions { 178 display: flex; 179 flex-direction: column; 180 gap: 12px; 181 } 182 .btn-thanks-primary { 183 display: inline-flex; 184 align-items: center; 185 justify-content: center; 186 gap: 10px; 187 padding: 16px 28px; 188 background: linear-gradient(135deg, #00ff9d, #00d4ff); 189 color: #000; 190 text-decoration: none; 191 border-radius: 12px; 192 font-family: 'Orbitron', sans-serif; 193 font-weight: 700; 194 font-size: 0.95rem; 195 letter-spacing: 1.5px; 196 text-transform: uppercase; 197 transition: all 0.3s; 198 box-shadow: 0 8px 24px rgba(0, 255, 157, 0.3); 199 } 200 .btn-thanks-primary:hover { 201 transform: translateY(-2px); 202 box-shadow: 0 12px 32px rgba(0, 255, 157, 0.5); 203 } 204 .btn-thanks-secondary { 205 display: inline-flex; 206 align-items: center; 207 justify-content: center; 208 gap: 10px; 209 padding: 14px 24px; 210 background: transparent; 211 color: #94a3b8; 212 text-decoration: none; 213 border: 1px solid rgba(148, 163, 184, 0.3); 214 border-radius: 12px; 215 font-family: 'Orbitron', sans-serif; 216 font-weight: 500; 217 font-size: 0.85rem; 218 letter-spacing: 1.2px; 219 text-transform: uppercase; 220 transition: all 0.3s; 221 } 222 .btn-thanks-secondary:hover { 223 color: #fff; 224 border-color: rgba(0, 255, 157, 0.4); 225 background: rgba(0, 255, 157, 0.05); 226 } 227 .thanks-signature { 228 margin-top: 32px; 229 padding-top: 20px; 230 border-top: 1px solid rgba(255, 255, 255, 0.08); 231 font-size: 0.75rem; 232 color: #64748b; 233 letter-spacing: 1.5px; 234 text-transform: uppercase; 235 font-family: 'Orbitron', sans-serif; 236 } 237 .thanks-signature i { 238 color: #00ff9d; 239 margin-right: 6px; 240 } 241 242 @media (max-width: 600px) { 243 .thanks-card { padding: 40px 24px; } 244 .thanks-title { font-size: 1.8rem; } 245 .thanks-message { font-size: 0.95rem; } 246 } 247 </style> 248</head> 249<body> 250 <!-- Particules d'arrière-plan --> 251 <div class="particles" id="particles"></div> 252 253 <div class="thanks-wrap"> 254 <div class="thanks-card"> 255 <div class="thanks-icon-wrap"> 256 <i class="fa-solid fa-check thanks-icon"></i> 257 </div> 258 259 <h1 class="thanks-title">MERCI !</h1> 260 <p class="thanks-subtitle">{{ pseudo or 'Cher utilisateur' }}, votre session est terminée</p> 261 262 <p class="thanks-message"> 263 Nous espérons que vous avez apprécié votre expérience <strong>SmartLocker</strong>.<br> 264 Votre casier a bien été libéré et est désormais disponible pour le prochain utilisateur. 265 <br><br> 266 À très bientôt pour votre prochaine séance ! 267 </p> 268 269 <div class="thanks-stats"> 270 <div class="stat-pill"> 271 <i class="fa-solid fa-lock"></i> 272 <span class="stat-pill-label">Casier</span> 273 <span class="stat-pill-val">Libéré</span> 274 </div> 275 <div class="stat-pill"> 276 <i class="fa-solid fa-shield-check"></i> 277 <span class="stat-pill-label">Statut</span> 278 <span class="stat-pill-val">Sécurisé</span> 279 </div> 280 </div> 281 282 <div class="thanks-actions"> 283 <a href="/scan" class="btn-thanks-primary"> 284 <i class="fa-solid fa-qrcode"></i> Réserver un nouveau casier 285 </a> 286 <a href="/" class="btn-thanks-secondary"> 287 <i class="fa-solid fa-house"></i> Retour à l'accueil 288 </a> 289 </div> 290 291 <div class="thanks-signature"> 292 <i class="fa-solid fa-fingerprint"></i> SmartLocker — Sécurité connectée 293 </div> 294 </div> 295 </div> 296 297 <script> 298 // Génération de particules flottantes 299 (function() { 300 const container = document.getElementById('particles'); 301 const N = 18; 302 for (let i = 0; i < N; i++) { 303 const p = document.createElement('div'); 304 p.className = 'particle'; 305 const size = 4 + Math.random() * 8; 306 p.style.width = size + 'px'; 307 p.style.height = size + 'px'; 308 p.style.left = Math.random() * 100 + '%'; 309 p.style.top = Math.random() * 100 + '%'; 310 p.style.animationDelay = -Math.random() * 8 + 's'; 311 p.style.animationDuration = (6 + Math.random() * 6) + 's'; 312 container.appendChild(p); 313 } 314 })(); 315 316 // Petite vibration de confirmation a l'arrivee 317 if ('vibrate' in navigator) navigator.vibrate([60, 40, 60]); 318 </script> 319</body> 320</html>
static/app.css
938 lignes
1/* ════════════════════════════════════════════════════════════════ 2 SMARTLOCKER — APP.CSS COMPLET 3 ════════════════════════════════════════════════════════════════ */ 4 5/* ── 1. Polices ──────────────────────────────────────────────── */ 6@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;400;500;600;700&display=swap'); 7 8/* ── 2. Variables ────────────────────────────────────────────── */ 9:root { 10 --bg-dark: #02040a; 11 --bg-panel: #0b101b; 12 --bg-card: rgba(13, 19, 30, 0.85); 13 14 --primary: #00ff9d; 15 --secondary: #00f0ff; 16 --accent: #7000ff; 17 --danger: #ff2a6d; 18 --warning: #ffc800; 19 20 --text-main: #e2e8f0; 21 --text-muted: #94a3b8; 22 23 --glow-primary: 0 0 10px rgba(0, 255, 157, 0.4); 24 --glow-secondary: 0 0 10px rgba(0, 240, 255, 0.4); 25 --glow-danger: 0 0 10px rgba(255, 42, 109, 0.4); 26 --glow-warning: 0 0 10px rgba(255, 200, 0, 0.4); 27 28 --border-tech: 1px solid rgba(0, 255, 157, 0.2); 29 --radius: 4px; 30 --transition: 0.3s ease; 31} 32 33/* ── 3. Reset & Base ─────────────────────────────────────────── */ 34*, *::before, *::after { box-sizing:border-box; margin:0; padding:0; } 35 36body { 37 background-color: var(--bg-dark); 38 color: var(--text-main); 39 font-family: 'Rajdhani', sans-serif; 40 min-height: 100vh; 41 overflow-x: hidden; 42 line-height: 1.5; 43} 44 45a { color:inherit; text-decoration:none; } 46button { font-family:inherit; } 47 48/* ── 4. Cyber Grid ───────────────────────────────────────────── */ 49.cyber-grid { 50 position:fixed; inset:0; z-index:-1; 51 background: 52 linear-gradient(rgba(0,255,157,0.03) 1px, transparent 1px), 53 linear-gradient(90deg, rgba(0,255,157,0.03) 1px, transparent 1px); 54 background-size: 40px 40px; 55} 56.cyber-grid::after { 57 content:''; position:absolute; inset:0; 58 background: radial-gradient(circle at 50% 50%, transparent 0%, var(--bg-dark) 90%); 59} 60 61/* ── 5. Navbar ───────────────────────────────────────────────── */ 62.navbar { 63 display:flex; justify-content:space-between; align-items:center; 64 padding:15px 20px; 65 background: rgba(2,4,10,0.95); 66 border-bottom: 1px solid rgba(0,255,157,0.2); 67 backdrop-filter: blur(10px); 68 -webkit-backdrop-filter: blur(10px); 69 position: sticky; top: 0; z-index: 100; 70} 71.nav-container { display:flex; align-items:center; justify-content:space-between; width:100%; } 72.nav-logo { 73 font-family:'Orbitron',sans-serif; font-weight:900; font-size:1.2rem; 74 color:white; display:flex; align-items:center; gap:10px; 75 text-shadow: var(--glow-primary); 76} 77.nav-logo i { color: var(--primary); } 78 79.nav-back { 80 background:transparent; border:1px solid rgba(0,255,157,0.2); 81 color:var(--text-muted); padding:6px 14px; 82 font-family:'Orbitron',sans-serif; font-size:0.7rem; letter-spacing:1px; 83 cursor:pointer; transition:0.3s; text-decoration:none; 84 display:flex; align-items:center; gap:8px; 85} 86.nav-back:hover { border-color:var(--primary); color:var(--primary); } 87 88.btn-logout { 89 background:transparent; border:1px solid var(--danger); 90 color:var(--danger); padding:5px 15px; 91 font-family:'Orbitron',sans-serif; font-size:0.8rem; 92 text-transform:uppercase; letter-spacing:1px; 93 cursor:pointer; transition:all var(--transition); 94 border-radius:var(--radius); 95} 96.btn-logout:hover { background:var(--danger); color:white; box-shadow:var(--glow-danger); } 97 98/* ── 6. Container ────────────────────────────────────────────── */ 99.app-container { 100 max-width:480px; 101 margin:40px auto; 102 padding:0 20px 60px; 103} 104 105/* ── 7. Cards ────────────────────────────────────────────────── */ 106.app-card { 107 background: var(--bg-card); 108 border: 1px solid rgba(255,255,255,0.05); 109 padding:2rem 1.75rem; 110 position:relative; 111 box-shadow: 0 0 30px rgba(0,0,0,0.5); 112 margin-bottom:20px; 113 clip-path: polygon(0 0, 100% 0, 100% 95%, 95% 100%, 0 100%); 114 transition: border-color var(--transition), box-shadow var(--transition); 115} 116.app-card:hover { border-color:rgba(0,255,157,0.3); box-shadow:var(--glow-primary); } 117.app-card::before { 118 content:''; position:absolute; top:0; left:0; width:100%; height:2px; 119 background: linear-gradient(90deg, var(--primary), transparent); 120} 121 122.card-title { 123 font-family:'Orbitron',sans-serif; font-size:1.5rem; color:white; 124 text-align:center; letter-spacing:2px; margin-bottom:0.5rem; 125 text-shadow: var(--glow-primary); 126} 127.card-subtitle { text-align:center; color:var(--text-muted); font-size:0.9rem; margin-bottom:2rem; } 128 129/* ── 8. Formulaires ──────────────────────────────────────────── */ 130.form-group { margin-bottom:1.2rem; } 131.form-input { 132 width:100%; background:rgba(0,0,0,0.3); border:1px solid #2d3748; 133 color:var(--secondary); padding:12px 15px; 134 font-family:'Rajdhani',sans-serif; font-size:1.1rem; 135 transition: border-color var(--transition), box-shadow var(--transition); 136 border-radius:var(--radius); 137} 138.form-input:focus { outline:none; border-color:var(--primary); box-shadow:0 0 10px rgba(0,255,157,0.2); } 139.form-input::placeholder { color:#4a5568; } 140 141/* ── 9. Boutons ──────────────────────────────────────────────── */ 142.btn { 143 width:100%; padding:1rem; border:none; 144 font-family:'Orbitron',sans-serif; font-weight:700; font-size:0.9rem; 145 text-transform:uppercase; letter-spacing:1px; 146 cursor:pointer; transition:all var(--transition); 147 margin-top:10px; 148 display:flex; justify-content:center; align-items:center; gap:10px; 149 clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px); 150} 151.btn-primary { background:var(--primary); color:#000; box-shadow:var(--glow-primary); } 152.btn-primary:hover { background:white; box-shadow:0 0 20px rgba(255,255,255,0.4); } 153.btn-success { background:rgba(0,240,255,0.1); border:1px solid var(--secondary); color:var(--secondary); } 154.btn-success:hover { background:var(--secondary); color:black; box-shadow:var(--glow-secondary); } 155.btn-secondary { background:transparent; border:1px solid var(--text-muted); color:var(--text-main); } 156.btn-secondary:hover { border-color:var(--text-main); background:rgba(255,255,255,0.05); } 157 158.btn-google { 159 background:white; color:#333; margin-top:15px; 160 font-family:'Rajdhani',sans-serif; font-weight:600; font-size:1rem; 161 clip-path:none; border-radius:var(--radius); 162} 163.btn-google:hover { background:#f0f0f0; box-shadow:0 2px 10px rgba(255,255,255,0.2); } 164 165.btn-google-auth { 166 width:100%; padding:0.9rem; background:#fff; color:#333; border:none; 167 font-family:'Rajdhani',sans-serif; font-weight:600; font-size:0.95rem; 168 letter-spacing:1px; cursor:pointer; transition:0.3s; 169 display:flex; align-items:center; justify-content:center; gap:10px; 170} 171.btn-google-auth:hover { background:#f0f0f0; } 172 173/* ── 10. Tabs Auth (legacy) ──────────────────────────────────── */ 174.auth-toggle { display:flex; border-bottom:1px solid rgba(255,255,255,0.1); margin-bottom:20px; } 175.auth-tab { 176 flex:1; text-align:center; padding:10px; cursor:pointer; 177 color:var(--text-muted); font-family:'Orbitron',sans-serif; font-size:0.85rem; 178 transition: color var(--transition); user-select:none; 179} 180.auth-tab:hover { color:var(--text-main); } 181.auth-tab.active { color:var(--primary); border-bottom:2px solid var(--primary); text-shadow:var(--glow-primary); } 182 183/* ── 11. Welcome / Auth screens ──────────────────────────────── */ 184.welcome-screen { display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:90vh; text-align:center; padding:2rem 1.5rem; } 185.welcome-logo-big { display:flex; flex-direction:column; align-items:center; gap:14px; margin-bottom:2.5rem; } 186.welcome-logo-big i { font-size:3.5rem; color:var(--primary); filter:drop-shadow(0 0 12px rgba(0,255,157,0.7)); } 187.welcome-logo-big .logo-text { font-family:'Orbitron',sans-serif; font-size:2rem; font-weight:900; letter-spacing:4px; } 188.welcome-logo-big .logo-text span { 189 background:linear-gradient(90deg,#fff 0%,var(--primary) 40%,#fff 60%,var(--primary) 80%,#fff 100%); 190 background-size:200% auto; 191 -webkit-background-clip:text; -webkit-text-fill-color:transparent; 192 background-clip:text; animation:shine 8s linear infinite; 193} 194.welcome-sub { color:var(--text-muted); font-size:0.85rem; margin-bottom:3.5rem; line-height:1.7; max-width:260px; } 195.welcome-btns { width:100%; max-width:320px; display:flex; flex-direction:column; gap:12px; } 196 197.btn-welcome-login { 198 width:100%; padding:1rem; background:var(--primary); color:#000; border:none; 199 font-family:'Orbitron',sans-serif; font-weight:700; font-size:0.9rem; letter-spacing:2px; 200 cursor:pointer; transition:0.3s; 201 display:flex; align-items:center; justify-content:center; gap:10px; 202 clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px); 203} 204.btn-welcome-login:hover { background:#fff; } 205.btn-welcome-signup { 206 width:100%; padding:1rem; background:transparent; color:var(--primary); border:1px solid rgba(0,255,157,0.4); 207 font-family:'Orbitron',sans-serif; font-weight:700; font-size:0.9rem; letter-spacing:2px; 208 cursor:pointer; transition:0.3s; 209 display:flex; align-items:center; justify-content:center; gap:10px; 210 clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px); 211} 212.btn-welcome-signup:hover { background:rgba(0,255,157,0.08); border-color:var(--primary); box-shadow:var(--glow-primary); } 213 214.auth-screen { display:none; flex-direction:column; min-height:80vh; padding:1rem; } 215.auth-screen.active { display:flex; } 216.auth-back { 217 background:transparent; border:none; color:var(--text-muted); 218 font-family:'Orbitron',sans-serif; font-size:0.7rem; letter-spacing:1px; 219 cursor:pointer; display:flex; align-items:center; gap:8px; 220 padding:0.5rem 0; margin-bottom:1.5rem; transition:0.2s; 221} 222.auth-back:hover { color:var(--primary); } 223.auth-screen-title { font-family:'Orbitron',sans-serif; font-size:1.2rem; font-weight:900; letter-spacing:3px; color:#fff; margin-bottom:0.4rem; } 224.auth-screen-sub { color:var(--text-muted); font-size:0.85rem; margin-bottom:2rem; } 225 226.divider { 227 display:flex; align-items:center; gap:12px; margin:1rem 0; 228 color:var(--text-muted); font-size:0.75rem; 229} 230.divider::before, .divider::after { content:''; flex:1; height:1px; background:rgba(255,255,255,0.08); } 231 232/* ── 12. Avatar signup ───────────────────────────────────────── */ 233.avatar-signup-wrap { display:flex; justify-content:center; margin-bottom:1.25rem; } 234.avatar-signup-preview { position:relative; width:80px; height:80px; cursor:pointer; } 235.avatar-signup-preview img { width:80px; height:80px; border-radius:50%; border:2px solid rgba(0,255,157,0.3); object-fit:cover; display:block; transition:0.2s; } 236.avatar-signup-preview:hover img { border-color:var(--primary); box-shadow:var(--glow-primary); } 237.avatar-default { 238 width:80px; height:80px; border-radius:50%; 239 border:2px dashed rgba(0,255,157,0.3); 240 background:rgba(0,255,157,0.04); 241 display:flex; flex-direction:column; align-items:center; justify-content:center; 242 gap:4px; transition:0.2s; cursor:pointer; 243} 244.avatar-default:hover { border-color:var(--primary); background:rgba(0,255,157,0.08); box-shadow:var(--glow-primary); } 245.avatar-default i { font-size:1.4rem; color:rgba(0,255,157,0.4); } 246.avatar-default span { font-family:'Orbitron',sans-serif; font-size:0.5rem; letter-spacing:1px; color:rgba(0,255,157,0.4); } 247.avatar-signup-overlay { 248 position:absolute; inset:0; border-radius:50%; background:rgba(0,0,0,0.6); 249 display:flex; flex-direction:column; align-items:center; justify-content:center; 250 gap:3px; opacity:0; transition:0.2s; color:var(--primary); font-size:0.75rem; 251} 252.avatar-signup-overlay i { font-size:1rem; } 253.avatar-signup-preview:hover .avatar-signup-overlay { opacity:1; } 254 255/* ── 13. Crop modal ──────────────────────────────────────────── */ 256.crop-modal-signup { 257 position:fixed; inset:0; z-index:400; 258 background:rgba(2,4,10,0.95); backdrop-filter:blur(8px); 259 display:flex; align-items:center; justify-content:center; padding:1rem; 260} 261.crop-modal-box { 262 background:var(--bg-panel); border:1px solid rgba(0,255,157,0.2); 263 width:100%; max-width:400px; position:relative; 264 clip-path: polygon(0 0, 100% 0, 100% 95%, 96% 100%, 0 100%); 265} 266.crop-modal-box::before { content:''; position:absolute; top:0; left:0; right:0; height:2px; background:linear-gradient(90deg, var(--primary), var(--secondary), transparent); } 267.crop-head { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.25rem 0.75rem; } 268.crop-head h3 { font-family:'Orbitron',sans-serif; font-size:0.78rem; letter-spacing:2px; color:var(--primary); } 269.crop-close-btn { 270 background:transparent; border:1px solid rgba(255,42,109,0.3); color:var(--danger); 271 width:28px; height:28px; display:grid; place-items:center; 272 cursor:pointer; font-size:1rem; transition:0.2s; 273} 274.crop-close-btn:hover { background:var(--danger); color:#fff; } 275.crop-area-inner { overflow:hidden; margin:0 1.25rem; max-height:260px; } 276.crop-area-inner img { max-width:100%; display:block; } 277.crop-footer-btns { display:flex; gap:10px; padding:0.875rem 1.25rem 1.25rem; } 278.crop-btn { 279 flex:1; padding:9px; 280 font-family:'Orbitron',sans-serif; font-size:0.65rem; letter-spacing:1px; 281 cursor:pointer; transition:0.3s; 282 display:flex; align-items:center; justify-content:center; gap:6px; 283} 284.crop-btn-cancel { background:transparent; border:1px solid rgba(255,255,255,0.1); color:var(--text-muted); } 285.crop-btn-cancel:hover { border-color:var(--danger); color:var(--danger); } 286.crop-btn-confirm { background:rgba(0,255,157,0.1); border:1px solid var(--primary); color:var(--primary); } 287.crop-btn-confirm:hover { background:var(--primary); color:#000; } 288 289/* ════════════════════════════════════════════════════════════ 290 14. INDEX (DASHBOARD) — Profil, stats, casiers 291 ════════════════════════════════════════════════════════════ */ 292 293/* Profil header */ 294.profile-header { 295 display:flex; align-items:center; gap:1rem; 296 margin-bottom:2rem; padding-bottom:1.5rem; 297 border-bottom:1px solid rgba(0,255,157,0.08); 298} 299.user-avatar { 300 width:60px; height:60px; border-radius:50%; 301 border:2px solid var(--primary); object-fit:cover; 302 box-shadow:0 0 12px rgba(0,255,157,0.25); flex-shrink:0; 303} 304.user-info { flex:1; min-width:0; } 305.user-info h2 { 306 font-family:'Orbitron',sans-serif; font-size:0.95rem; 307 color:#fff; letter-spacing:1.5px; margin-bottom:4px; 308 white-space:nowrap; overflow:hidden; text-overflow:ellipsis; 309} 310.user-info p { 311 color:var(--text-muted); font-size:0.78rem; 312 white-space:nowrap; overflow:hidden; text-overflow:ellipsis; 313} 314.verify-tag { 315 display:inline-flex; align-items:center; gap:5px; margin-top:6px; 316 background:rgba(255,42,109,0.08); border:1px solid rgba(255,42,109,0.3); 317 color:var(--danger); padding:2px 8px; font-size:0.6rem; 318 font-family:'Orbitron',sans-serif; letter-spacing:1px; 319} 320.settings-btn { 321 background:transparent; border:1px solid rgba(0,255,157,0.15); 322 color:var(--text-muted); width:38px; height:38px; 323 display:grid; place-items:center; cursor:pointer; 324 transition:0.3s; flex-shrink:0; text-decoration:none; 325} 326.settings-btn:hover { border-color:var(--primary); color:var(--primary); box-shadow:0 0 10px rgba(0,255,157,0.2); } 327 328/* Stats */ 329.stats-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:1.5rem; } 330.stat-block { 331 background:rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.06); 332 padding:1.1rem 0.6rem; text-align:center; transition:0.3s; 333 border-radius:8px; 334} 335.stat-block:hover { border-color:rgba(0,255,157,0.25); } 336.stat-val { 337 font-family:'Orbitron',sans-serif; font-size:2rem; font-weight:900; 338 color:var(--primary); text-shadow:0 0 14px rgba(0,255,157,0.45); 339 line-height:1.1; 340} 341.stat-label { 342 font-family:'Orbitron',sans-serif; font-size:0.72rem; letter-spacing:1.8px; 343 color:#cbd5e1; text-transform:uppercase; margin-top:8px; 344 font-weight:700; 345} 346 347/* Chart */ 348.chart-block { 349 background:rgba(0,0,0,0.25); border:1px solid rgba(255,255,255,0.06); 350 padding:1.1rem 1.2rem; margin-bottom:2rem; position:relative; 351 border-radius:8px; 352} 353.chart-block::before { 354 content:''; position:absolute; top:0; left:0; height:2px; width:35%; 355 background:linear-gradient(90deg, var(--secondary), transparent); 356} 357.chart-title { 358 font-family:'Orbitron',sans-serif; font-size:0.9rem; letter-spacing:2px; 359 color:#fff; text-transform:uppercase; margin-bottom:0.8rem; 360 font-weight:800; 361 display:flex; justify-content:space-between; align-items:center; 362} 363.chart-title .chart-total { color:var(--secondary); font-size:1rem; font-weight:800; } 364.chart-canvas-wrap { position:relative; height:110px; } 365 366/* Section labels */ 367.section-label { 368 font-family:'Orbitron',sans-serif; font-size:0.6rem; letter-spacing:3px; 369 color:var(--text-muted); text-transform:uppercase; margin-bottom:1rem; 370 display:flex; align-items:center; gap:12px; 371} 372.section-label::after { content:''; flex:1; height:1px; background:rgba(255,255,255,0.05); } 373.section-label i { color:var(--primary); font-size:0.7rem; } 374 375/* ── No locker (scanner CTA centré) ───────────────────────── */ 376.no-locker-scan-wrap { 377 display:flex; flex-direction:column; align-items:center; justify-content:center; 378 gap:18px; 379 padding: 40px 24px; 380 margin: 1.5rem 0; 381 background: linear-gradient(135deg, rgba(0,240,255,0.04) 0%, rgba(0,0,0,0.3) 100%); 382 border: 1px dashed rgba(0,240,255,0.3); 383 border-radius: 16px; 384 text-align: center; 385} 386.no-locker-icon { 387 width: 80px; height: 80px; 388 border-radius: 50%; 389 background: rgba(0,240,255,0.08); 390 border: 1px solid rgba(0,240,255,0.4); 391 display: flex; align-items: center; justify-content: center; 392 font-size: 2rem; color: #00f0ff; 393 box-shadow: 0 0 30px rgba(0,240,255,0.2); 394 animation: noLockerPulse 2.5s ease-in-out infinite; 395} 396@keyframes noLockerPulse { 397 0%,100% { box-shadow: 0 0 30px rgba(0,240,255,0.2); transform: scale(1); } 398 50% { box-shadow: 0 0 50px rgba(0,240,255,0.45); transform: scale(1.04); } 399} 400.no-locker-title { 401 font-family:'Orbitron', sans-serif; 402 font-size: 0.85rem; font-weight: 900; 403 letter-spacing: 3px; color: #fff; 404} 405.no-locker-sub { 406 font-family:'Rajdhani', sans-serif; 407 font-size: 0.95rem; color: var(--text-muted); 408 max-width: 320px; line-height: 1.4; 409} 410.no-locker-btn { 411 margin-top: 6px; 412 background: linear-gradient(135deg, #00f0ff, #00ff9d); 413 color: #02040a; 414 border: none; padding: 14px 30px; 415 font-family: 'Orbitron', sans-serif; 416 font-size: 0.78rem; font-weight: 900; letter-spacing: 2px; 417 border-radius: 50px; cursor: pointer; 418 text-transform: uppercase; text-decoration: none; 419 display: inline-flex; align-items: center; gap: 10px; 420 box-shadow: 0 0 25px rgba(0, 240, 255, 0.35); 421 transition: 0.25s; 422} 423.no-locker-btn:hover { transform: translateY(-2px); box-shadow: 0 0 35px rgba(0, 240, 255, 0.6); } 424.no-locker-btn:active { transform: scale(0.97); } 425 426/* ── Locker cards (dashboard) ─────────────────────────────── */ 427.locker-cards { display:flex; flex-direction:column; gap:14px; margin-bottom:1.5rem; } 428 429.locker-card { 430 background:linear-gradient(135deg, rgba(0,0,0,0.4) 0%, rgba(13,19,30,0.6) 100%); 431 border:1px solid rgba(255,255,255,0.05); 432 padding:1.25rem 1.25rem 1.1rem; 433 text-decoration:none; color:var(--text-main); 434 transition:0.3s; position:relative; overflow:hidden; 435 display:block; 436} 437.locker-card::before { 438 content:''; position:absolute; top:0; left:0; 439 width:3px; height:100%; transition:0.3s; 440} 441.locker-card.sport-card::before { background:linear-gradient(180deg, var(--primary), transparent); } 442.locker-card.school-card::before { background:linear-gradient(180deg, var(--secondary), transparent); } 443.locker-card.unpaired { opacity:0.45; pointer-events:none; } 444 445.locker-card:not(.unpaired):hover { transform:translateY(-2px); border-color:rgba(255,255,255,0.12); } 446.locker-card.sport-card:not(.unpaired):hover { border-color:rgba(0,255,157,0.25); box-shadow:0 6px 25px rgba(0,255,157,0.1); } 447.locker-card.school-card:not(.unpaired):hover { border-color:rgba(0,240,255,0.25); box-shadow:0 6px 25px rgba(0,240,255,0.1); } 448 449.lc-header { display:flex; align-items:flex-start; gap:1rem; margin-bottom:0.9rem; } 450.lc-icon { 451 width:48px; height:48px; border-radius:50%; 452 display:grid; place-items:center; flex-shrink:0; 453 font-size:1.2rem; position:relative; 454} 455.sport-card .lc-icon { 456 background:radial-gradient(circle, rgba(0,255,157,0.15) 0%, rgba(0,255,157,0.05) 100%); 457 border:1.5px solid rgba(0,255,157,0.4); color:var(--primary); 458} 459.school-card .lc-icon { 460 background:radial-gradient(circle, rgba(0,240,255,0.15) 0%, rgba(0,240,255,0.05) 100%); 461 border:1.5px solid rgba(0,240,255,0.4); color:var(--secondary); 462} 463 464.lc-info { flex:1; min-width:0; } 465.lc-name { 466 font-family:'Orbitron',sans-serif; font-size:0.9rem; font-weight:900; 467 letter-spacing:2.5px; color:#fff; margin-bottom:6px; 468} 469.lc-id-row { display:flex; align-items:center; gap:8px; margin-bottom:6px; } 470.lc-id { 471 font-family:'Orbitron',sans-serif; font-size:0.6rem; 472 letter-spacing:1.5px; padding:2px 8px; 473 background:rgba(0,0,0,0.4); border:1px solid; 474} 475.sport-card .lc-id { color:var(--primary); border-color:rgba(0,255,157,0.3); } 476.school-card .lc-id { color:var(--secondary); border-color:rgba(0,240,255,0.3); } 477.lc-id i { margin-right:4px; opacity:0.7; } 478 479.lc-loc { font-size:0.72rem; color:var(--text-muted); margin-bottom:0; } 480.lc-loc i { margin-right:4px; opacity:0.7; } 481 482/* Badge à droite */ 483.lc-badge-wrap { display:flex; flex-direction:column; align-items:flex-end; gap:6px; flex-shrink:0; } 484.lc-badge { 485 font-family:'Orbitron',sans-serif; font-size:0.55rem; letter-spacing:1.5px; 486 padding:4px 10px; border:1px solid; white-space:nowrap; 487 transition:0.3s; 488} 489.lc-badge.locked { border-color:rgba(255,42,109,0.4); color:var(--danger); background:rgba(255,42,109,0.07); } 490.lc-badge.unlocked { border-color:rgba(0,255,157,0.4); color:var(--primary); background:rgba(0,255,157,0.07); box-shadow:0 0 10px rgba(0,255,157,0.2); } 491.lc-badge.reserved { border-color:rgba(255,200,0,0.4); color:#ffc800; background:rgba(255,200,0,0.07); } 492.lc-badge.occupied { border-color:rgba(255,42,109,0.4); color:var(--danger); background:rgba(255,42,109,0.07); } 493.lc-badge.premium { border-color:rgba(255,200,0,0.4); color:#ffc800; background:rgba(255,200,0,0.07); } 494.lc-badge.notpaired { border-color:rgba(255,255,255,0.15); color:var(--text-muted); background:transparent; } 495.lc-badge.offline { border-color:rgba(255,42,109,0.5); color:var(--danger); background:rgba(255,42,109,0.08); animation:offlineBadgeBlink 2s ease-in-out infinite; } 496@keyframes offlineBadgeBlink { 0%,100%{opacity:1} 50%{opacity:0.55} } 497 498/* Carte d'un casier hors-ligne : grisee et desaturee */ 499.locker-card.offline { 500 opacity: 0.78; 501 filter: grayscale(0.35); 502 border-color: rgba(255, 42, 109, 0.3) !important; 503 position: relative; 504} 505.locker-card.offline::before { 506 content: ''; 507 position: absolute; inset: 0; 508 background: repeating-linear-gradient( 509 45deg, 510 transparent 0, 511 transparent 14px, 512 rgba(255, 42, 109, 0.025) 14px, 513 rgba(255, 42, 109, 0.025) 16px 514 ); 515 pointer-events: none; 516 border-radius: inherit; 517} 518 519.lc-arrow { color:var(--text-muted); font-size:0.7rem; } 520 521/* Footer status */ 522.lc-status-footer { 523 display:flex; align-items:center; justify-content:space-between; 524 padding-top:0.9rem; border-top:1px dashed rgba(255,255,255,0.06); 525} 526.lc-status-line { 527 font-family:'Orbitron',sans-serif; font-size:0.65rem; letter-spacing:1.2px; 528 display:flex; align-items:center; gap:8px; transition:0.3s; 529} 530.lc-status-line.available { color:var(--primary); } 531.lc-status-line.unlocked { color:var(--primary); } 532.lc-status-line.locking { color:var(--warning); } 533.lc-status-line.occupied { color:var(--danger); } 534.lc-status-line.urgent { color:var(--danger); animation:blink 1s step-end infinite; } 535.lc-status-line.loading { color:var(--text-muted); opacity:0.6; } 536.lc-status-line.premium-required { color:var(--warning); } 537.lc-status-line.offline { color:var(--danger); font-weight:700; } 538 539.live-dot { 540 width:7px; height:7px; border-radius:50%; background:currentColor; 541 display:inline-block; flex-shrink:0; 542 animation:pulse-dot 1.5s ease-in-out infinite; 543 box-shadow:0 0 6px currentColor; 544} 545.lc-status-line.loading .live-dot { animation:none; opacity:0.4; box-shadow:none; } 546.live-dot.offline { background:var(--danger); box-shadow:0 0 6px var(--danger); animation:pulse-dot 1.5s ease-in-out infinite; } 547 548.lc-action-hint { 549 font-family:'Orbitron',sans-serif; font-size:0.55rem; letter-spacing:1px; 550 color:var(--text-muted); display:flex; align-items:center; gap:5px; 551} 552 553/* Boutons appairer / history / admin */ 554.btn-pair { 555 display:flex; align-items:center; justify-content:center; gap:8px; 556 padding:10px 14px; font-family:'Orbitron',sans-serif; font-size:0.6rem; 557 letter-spacing:1px; text-transform:uppercase; cursor:pointer; 558 transition:0.3s; background:transparent; 559 border:1px solid rgba(0,255,157,0.3); color:var(--primary); 560 width:100%; margin-top:6px; 561} 562.btn-pair:hover { background:rgba(0,255,157,0.07); box-shadow:0 0 10px rgba(0,255,157,0.2); } 563 564.btn-history { 565 display:flex; align-items:center; justify-content:center; gap:8px; 566 padding:11px 20px; font-family:'Orbitron',sans-serif; font-size:0.7rem; 567 text-transform:uppercase; letter-spacing:1.5px; 568 border:1px solid rgba(255,255,255,0.08); color:var(--text-muted); 569 text-decoration:none; transition:0.3s; width:100%; 570} 571.btn-history:hover { border-color:var(--secondary); color:var(--secondary); background:rgba(0,240,255,0.04); } 572 573.btn-admin { 574 display:flex; align-items:center; justify-content:center; gap:8px; 575 padding:10px 20px; font-family:'Orbitron',sans-serif; font-size:0.7rem; 576 text-transform:uppercase; letter-spacing:1.5px; 577 border:1px solid rgba(255,200,0,0.3); color:var(--warning); 578 text-decoration:none; transition:0.3s; width:100%; margin-top:8px; 579 background:rgba(255,200,0,0.04); 580} 581.btn-admin:hover { border-color:var(--warning); box-shadow:0 0 10px rgba(255,200,0,0.3); } 582 583/* ── Modal appairage ───────────────────────────────────────── */ 584.pair-modal { 585 position:fixed; inset:0; z-index:500; 586 background:rgba(0,0,0,0.9); backdrop-filter:blur(8px); 587 display:flex; align-items:center; justify-content:center; padding:1.5rem; 588} 589.pair-modal-box { 590 background:var(--bg-panel); border:1px solid rgba(0,255,157,0.3); 591 width:100%; max-width:380px; padding:2rem; position:relative; 592 clip-path: polygon(0 0, 100% 0, 100% 95%, 96% 100%, 0 100%); 593 box-shadow:0 0 40px rgba(0,255,157,0.1); 594} 595.pair-modal-box::before { 596 content:''; position:absolute; top:0; left:0; right:0; height:2px; 597 background:linear-gradient(90deg, var(--primary), var(--secondary), transparent); 598} 599.pair-modal-title { font-family:'Orbitron',sans-serif; font-size:0.9rem; font-weight:900; letter-spacing:3px; color:#fff; margin-bottom:0.4rem; } 600.pair-modal-sub { color:var(--text-muted); font-size:0.8rem; margin-bottom:1.5rem; line-height:1.5; } 601.pair-close { 602 position:absolute; top:1rem; right:1rem; 603 background:transparent; border:1px solid rgba(255,42,109,0.3); color:var(--danger); 604 width:28px; height:28px; display:grid; place-items:center; 605 cursor:pointer; font-size:1rem; transition:0.2s; 606} 607.pair-close:hover { background:var(--danger); color:#fff; } 608.code-hint { background:rgba(0,255,157,0.04); border:1px solid rgba(0,255,157,0.1); padding:0.875rem; margin-bottom:1.25rem; } 609.code-hint-label { font-family:'Orbitron',sans-serif; font-size:0.55rem; letter-spacing:2px; color:var(--text-muted); margin-bottom:0.5rem; } 610.code-hint-list { display:flex; flex-direction:column; gap:4px; } 611.code-hint-item { font-family:'Orbitron',sans-serif; font-size:0.72rem; color:var(--primary); letter-spacing:1px; cursor:pointer; transition:0.2s; padding:2px 0; } 612.code-hint-item:hover { color:#fff; } 613.code-hint-item span { color:var(--text-muted); font-family:'Rajdhani',sans-serif; font-size:0.78rem; margin-left:8px; } 614.btn-pair-submit { 615 width:100%; padding:1rem; border:none; 616 font-family:'Orbitron',sans-serif; font-weight:700; 617 text-transform:uppercase; letter-spacing:2px; 618 cursor:pointer; transition:0.3s; font-size:0.85rem; 619 display:flex; align-items:center; justify-content:center; gap:10px; 620 clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px); 621 background:var(--primary); color:#000; 622 box-shadow:0 0 15px rgba(0,255,157,0.3); margin-top:10px; 623} 624.btn-pair-submit:hover { background:#fff; } 625 626/* ════════════════════════════════════════════════════════════ 627 15. LOCKER PAGE (sport / school) 628 ════════════════════════════════════════════════════════════ */ 629 630.locker-hero { text-align:center; padding:1.75rem 0 1.25rem; position:relative; } 631.locker-icon-wrap { 632 position:relative; width:120px; height:120px; margin:0 auto 1.5rem; 633 display:flex; align-items:center; justify-content:center; 634} 635.locker-ring { position:absolute; inset:0; border-radius:50%; border:1.5px solid rgba(0,255,157,0.12); } 636.locker-ring-spin { position:absolute; inset:10px; border-radius:50%; border:1px dashed rgba(0,255,157,0.2); animation:spin 8s linear infinite; } 637.locker-ring-spin2 { position:absolute; inset:24px; border-radius:50%; border:1px dashed rgba(0,240,255,0.15); animation:spin 12s linear infinite reverse; } 638 639.locker-core { 640 width:62px; height:62px; border-radius:50%; 641 background:radial-gradient(circle, rgba(0,255,157,0.12) 0%, rgba(0,255,157,0.03) 100%); 642 border:1.5px solid rgba(0,255,157,0.35); 643 display:grid; place-items:center; position:relative; z-index:1; 644 transition:0.4s; 645} 646.locker-core i { font-size:1.6rem; color:var(--primary); transition:0.3s; } 647.locker-core.open { 648 border-color:var(--primary); background:rgba(0,255,157,0.15); 649 box-shadow:0 0 30px rgba(0,255,157,0.4); 650} 651.locker-core.closed { 652 border-color:rgba(255,42,109,0.4); 653 background:radial-gradient(circle, rgba(255,42,109,0.1) 0%, rgba(255,42,109,0.03) 100%); 654} 655.locker-core.closed i { color:var(--danger); } 656.locker-core.opening { 657 border-color:var(--warning); background:rgba(255,200,0,0.1); 658 box-shadow:0 0 25px rgba(255,200,0,0.4); 659 animation:pulse-opening 1s ease-in-out infinite; 660} 661.locker-core.opening i { color:var(--warning); } 662 663.locker-name { 664 font-family:'Orbitron',sans-serif; font-size:1.1rem; font-weight:900; 665 letter-spacing:3px; color:#fff; margin-bottom:6px; 666} 667.locker-id-pill { 668 display:inline-flex; align-items:center; gap:6px; 669 font-family:'Orbitron',sans-serif; font-size:0.65rem; 670 color:var(--primary); letter-spacing:1.5px; 671 background:rgba(0,255,157,0.07); border:1px solid rgba(0,255,157,0.3); 672 padding:4px 10px; margin-bottom:10px; 673} 674.locker-loc { 675 color:var(--text-muted); font-size:0.78rem; 676 display:flex; align-items:center; justify-content:center; gap:6px; 677} 678 679/* Status bar (grande, en haut) */ 680.status-bar { 681 display:flex; align-items:center; justify-content:center; gap:12px; 682 padding:1.1rem 1rem; margin:1.5rem 0; 683 font-family:'Orbitron',sans-serif; font-size:0.95rem; letter-spacing:2.5px; 684 border:1px solid; transition:0.4s; 685 position:relative; overflow:hidden; 686} 687.status-bar::before { 688 content:''; position:absolute; top:0; left:0; right:0; height:1px; 689 background:linear-gradient(90deg, transparent, currentColor, transparent); 690 opacity:0.3; 691} 692.status-bar.locked { border-color:rgba(255,42,109,0.5); color:var(--danger); background:rgba(255,42,109,0.05); text-shadow:0 0 8px var(--danger); } 693.status-bar.unlocked { border-color:rgba(0,255,157,0.5); color:var(--primary); background:rgba(0,255,157,0.05); text-shadow:0 0 8px var(--primary); } 694.status-bar.opening { border-color:rgba(255,200,0,0.5); color:var(--warning); background:rgba(255,200,0,0.05); text-shadow:0 0 8px var(--warning); animation:pulse-opening 1.2s ease-in-out infinite; } 695.status-bar.occupied { border-color:rgba(255,42,109,0.5); color:var(--danger); background:rgba(255,42,109,0.05); } 696 697/* ── Bandeau HORS-LIGNE (Raspberry Pi eteint) ────────────────── */ 698.offline-banner { 699 display: flex; align-items: center; gap: 14px; 700 background: linear-gradient(135deg, rgba(255, 42, 109, 0.08) 0%, rgba(255, 42, 109, 0.03) 100%); 701 border: 1px solid rgba(255, 42, 109, 0.45); 702 border-left: 4px solid var(--danger); 703 padding: 14px 18px; 704 margin-bottom: 1.25rem; 705 border-radius: 6px; 706 position: relative; overflow: hidden; 707} 708.offline-banner::after { 709 content: ''; 710 position: absolute; inset: 0; 711 background: repeating-linear-gradient( 712 45deg, transparent 0, transparent 10px, 713 rgba(255, 42, 109, 0.05) 10px, rgba(255, 42, 109, 0.05) 12px 714 ); 715 pointer-events: none; 716} 717.offline-banner-icon { 718 width: 44px; height: 44px; 719 border-radius: 50%; 720 background: rgba(255, 42, 109, 0.12); 721 border: 1.5px solid rgba(255, 42, 109, 0.5); 722 display: flex; align-items: center; justify-content: center; 723 color: var(--danger); font-size: 1.2rem; 724 flex-shrink: 0; 725 animation: offlinePulse 1.8s ease-in-out infinite; 726 z-index: 1; 727} 728@keyframes offlinePulse { 729 0%,100% { box-shadow: 0 0 0 0 rgba(255, 42, 109, 0.4); } 730 50% { box-shadow: 0 0 0 8px rgba(255, 42, 109, 0); } 731} 732.offline-banner-text { flex: 1; z-index: 1; } 733.offline-banner-title { 734 font-family: 'Orbitron', sans-serif; 735 font-size: 0.78rem; font-weight: 900; 736 letter-spacing: 2.5px; color: var(--danger); 737 text-transform: uppercase; 738 margin-bottom: 4px; 739} 740.offline-banner-sub { 741 font-family: 'Rajdhani', sans-serif; 742 font-size: 0.88rem; color: var(--text-muted); 743 line-height: 1.4; 744} 745 746/* Bouton "INFO CASIER" — révèle les détails (ID, etc.) au clic */ 747.btn-locker-info { 748 width: 100%; 749 background: rgba(0, 240, 255, 0.05); 750 border: 1px solid rgba(0, 240, 255, 0.25); 751 color: var(--secondary, #00f0ff); 752 font-family: 'Orbitron', sans-serif; 753 font-size: 0.72rem; font-weight: 700; letter-spacing: 2px; 754 text-transform: uppercase; 755 padding: 12px 16px; 756 border-radius: 6px; 757 cursor: pointer; 758 display: flex; align-items: center; justify-content: center; gap: 10px; 759 margin-bottom: 1rem; 760 transition: 0.25s; 761} 762.btn-locker-info:hover { 763 background: rgba(0, 240, 255, 0.12); 764 border-color: rgba(0, 240, 255, 0.5); 765 box-shadow: 0 0 18px rgba(0, 240, 255, 0.25); 766} 767.btn-locker-info:active { transform: scale(0.98); } 768.btn-locker-info .fa-chevron-down { transition: transform 0.3s ease; font-size: 0.7rem; } 769 770/* Info grid (2 colonnes) */ 771.info-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:1.5rem; } 772.info-block { 773 background:rgba(0,0,0,0.25); border:1px solid rgba(255,255,255,0.04); 774 padding:0.95rem 1rem; 775} 776.info-block .ilabel { 777 font-family:'Orbitron',sans-serif; font-size:0.55rem; letter-spacing:2px; 778 color:var(--text-muted); text-transform:uppercase; margin-bottom:6px; 779} 780.info-block .ival { 781 font-family:'Orbitron',sans-serif; font-size:0.9rem; color:#fff; letter-spacing:1px; 782} 783 784/* Indicateur live (sur la page locker_sport) */ 785.live-indicator { 786 display:flex; align-items:center; gap:10px; 787 padding:0.85rem 1.1rem; margin-bottom:1.5rem; 788 background:rgba(0,0,0,0.25); border:1px solid rgba(0,255,157,0.15); 789 font-family:'Orbitron',sans-serif; font-size:0.7rem; letter-spacing:1.5px; 790 transition:0.3s; 791} 792.live-indicator.locking { border-color:rgba(255,200,0,0.4); color:var(--warning); } 793.live-indicator.occupied { border-color:rgba(255,42,109,0.4); color:var(--danger); } 794.live-indicator.unlocked { border-color:rgba(0,255,157,0.4); color:var(--primary); } 795.live-indicator.opening { border-color:rgba(255,200,0,0.5); color:var(--warning); animation:pulse-opening 1s ease-in-out infinite; } 796 797.live-indicator .live-dot { 798 width:8px; height:8px; border-radius:50%; background:currentColor; 799 box-shadow:0 0 8px currentColor; 800 animation:pulse-dot 1.5s ease-in-out infinite; 801 flex-shrink:0; 802} 803.live-indicator .live-text { flex:1; } 804.live-indicator .live-time { 805 font-family:'Orbitron',sans-serif; font-weight:900; font-size:0.85rem; 806 margin-left:auto; 807} 808 809/* Notice info */ 810.info-notice { 811 display:flex; align-items:center; gap:10px; 812 padding:0.85rem 1rem; margin-bottom:1.25rem; 813 background:rgba(0,240,255,0.04); border:1px solid rgba(0,240,255,0.2); 814 font-family:'Orbitron',sans-serif; font-size:0.65rem; letter-spacing:1px; 815 color:var(--secondary); 816} 817.info-notice i { color:var(--secondary); font-size:0.85rem; flex-shrink:0; } 818.info-notice span { color:#fff; font-weight:700; } 819 820/* Boutons d'action principaux (locker page) */ 821.btn-main { 822 width:100%; padding:1.1rem; border:none; 823 font-family:'Orbitron',sans-serif; font-weight:700; text-transform:uppercase; 824 letter-spacing:2.5px; cursor:pointer; transition:0.3s; 825 font-size:0.95rem; display:flex; align-items:center; justify-content:center; gap:12px; 826 clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px); 827} 828.btn-unlock { background:var(--primary); color:#000; box-shadow:0 0 20px rgba(0,255,157,0.4); } 829.btn-unlock:hover { background:#fff; } 830.btn-unlock:disabled { opacity:0.5; cursor:not-allowed; } 831.btn-lock { background:rgba(255,42,109,0.1); border:1px solid var(--danger); color:var(--danger); } 832.btn-lock:hover { background:var(--danger); color:#fff; } 833.btn-disabled { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); color:var(--text-muted); cursor:not-allowed; } 834 835/* Premium wall */ 836.premium-wall { 837 border:1px solid rgba(255,200,0,0.3); 838 background:linear-gradient(135deg, rgba(255,200,0,0.06) 0%, rgba(255,200,0,0.02) 100%); 839 padding:2rem 1.5rem; text-align:center; 840} 841.premium-wall i { 842 font-size:2.5rem; color:var(--warning); margin-bottom:1rem; 843 display:block; filter:drop-shadow(0 0 12px rgba(255,200,0,0.5)); 844} 845.premium-wall h3 { font-family:'Orbitron',sans-serif; font-size:0.9rem; color:var(--warning); letter-spacing:2px; margin-bottom:0.6rem; } 846.premium-wall p { color:var(--text-muted); font-size:0.85rem; line-height:1.6; } 847 848/* ════════════════════════════════════════════════════════════ 849 16. TOAST MESSAGES 850 ════════════════════════════════════════════════════════════ */ 851.toast { 852 background:rgba(0,255,157,0.08); border:1px solid var(--primary); 853 color:var(--primary); padding:11px 18px; 854 font-family:'Rajdhani',sans-serif; font-size:0.95rem; 855 display:flex; align-items:center; gap:10px; margin-bottom:12px; 856} 857.toast.toast-success { background:rgba(0,255,157,0.08); border-color:var(--primary); color:var(--primary); } 858.toast.toast-error { background:rgba(255,42,109,0.08); border-color:var(--danger); color:var(--danger); } 859.toast.err { background:rgba(255,42,109,0.08); border-color:var(--danger); color:var(--danger); } 860.toast.warning { background:rgba(255,200,0,0.08); border-color:var(--warning); color:var(--warning); } 861 862/* Flash (legacy) */ 863.flash-container { 864 position:fixed; top:80px; left:50%; transform:translateX(-50%); 865 z-index:2000; display:flex; flex-direction:column; gap:10px; 866 width:90%; max-width:420px; pointer-events:none; 867} 868.flash-message { 869 padding:12px 20px; border-radius:var(--radius); 870 display:flex; align-items:center; gap:10px; 871 font-family:'Rajdhani',sans-serif; font-size:1rem; 872 transition:opacity 0.5s, transform 0.5s; backdrop-filter:blur(10px); 873} 874.flash-success { background:rgba(0,255,157,0.1); border:1px solid var(--primary); color:var(--primary); } 875.flash-error { background:rgba(255,42,109,0.1); border:1px solid var(--danger); color:var(--danger); } 876 877/* ════════════════════════════════════════════════════════════ 878 17. OFFLINE OVERLAY 879 ════════════════════════════════════════════════════════════ */ 880#offline-overlay { 881 display:none; position:fixed; inset:0; z-index:9999; 882 background:rgba(2,4,10,0.97); backdrop-filter:blur(8px); 883 flex-direction:column; align-items:center; justify-content:center; 884 text-align:center; padding:2rem; 885} 886.offline-icon-wrap { position:relative; width:90px; height:90px; margin:0 auto 2rem; } 887.offline-ring { position:absolute; inset:0; border-radius:50%; border:1px solid rgba(255,42,109,0.3); animation:ring-p 2s ease-in-out infinite; } 888.offline-ring-2 { position:absolute; inset:-12px; border-radius:50%; border:1px solid rgba(255,42,109,0.15); animation:ring-p 2s ease-in-out infinite; animation-delay:0.4s; } 889.offline-core { 890 position:absolute; inset:0; border-radius:50%; 891 background:rgba(255,42,109,0.07); border:1px solid rgba(255,42,109,0.4); 892 display:grid; place-items:center; font-size:2.2rem; color:var(--danger); 893} 894.offline-title { font-family:'Orbitron',sans-serif; font-size:1.3rem; font-weight:900; letter-spacing:3px; color:#fff; margin-bottom:0.75rem; } 895.offline-title span { color:var(--danger); } 896.offline-sub { color:var(--text-muted); font-size:0.88rem; line-height:1.6; max-width:260px; } 897 898/* ════════════════════════════════════════════════════════════ 899 18. ANIMATIONS 900 ════════════════════════════════════════════════════════════ */ 901@keyframes shine { 0%{background-position:200% center} 100%{background-position:-200% center} } 902@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} } 903@keyframes ring-p { 0%,100%{transform:scale(1);opacity:1} 50%{transform:scale(1.06);opacity:0.4} } 904@keyframes pulse-dot { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.4;transform:scale(0.6)} } 905@keyframes spin { from{transform:rotate(0deg)} to{transform:rotate(360deg)} } 906@keyframes pulse-opening{ 0%,100%{opacity:1} 50%{opacity:0.6} } 907 908/* ════════════════════════════════════════════════════════════ 909 19. UTILITAIRES 910 ════════════════════════════════════════════════════════════ */ 911.hidden { display:none !important; } 912.input-error { color:var(--danger); font-size:0.8rem; margin-top:5px; display:none; } 913.input-error.show { display:block; } 914 915.text-center { text-align:center; } 916.text-muted { color:var(--text-muted); } 917 918.mt-1 { margin-top:0.5rem; } 919.mt-2 { margin-top:1rem; } 920.mb-1 { margin-bottom:0.5rem; } 921.mb-2 { margin-bottom:1rem; } 922 923/* ════════════════════════════════════════════════════════════ 924 20. RESPONSIVE 925 ════════════════════════════════════════════════════════════ */ 926@media (max-width: 520px) { 927 .app-container { margin:20px auto; } 928 .app-card { padding:1.5rem 1.25rem; } 929 .card-title { font-size:1.2rem; } 930 .navbar { padding:12px 15px; } 931 .nav-logo { font-size:1rem; } 932 .stats-grid { gap:10px; } 933 .stat-val { font-size:1.6rem; } 934 .stat-label { font-size:0.65rem; letter-spacing:1.4px; } 935 .chart-title { font-size:0.78rem; } 936 .chart-title .chart-total { font-size:0.85rem; } 937 .info-grid { grid-template-columns:1fr 1fr; } 938}
static/notifications.js
544 lignes
1/* ═══════════════════════════════════════════════════════════════════════════ 2 SMARTLOCKER — SYSTEME DE NOTIFICATIONS ROBUSTE 3 ═══════════════════════════════════════════════════════════════════════════ 4 Multi-couche pour garantir que l'utilisateur soit averti meme quand FCM ne 5 passe pas (Safari, mode prive, push refuse, token expire, etc.). 6 7 Couches actives : 8 1) FCM Foreground (onMessage) — app au premier plan + Chrome/Edge 9 2) Service Worker Background — app fermee, push direct du serveur 10 3) Polling /api/notifications/poll (toutes les 5s) — fallback universel 11 4) Audio + vibration + popup + toast pour chaque event detecte 12 5) Deduplication via localStorage (event_id = kind:locker_id:time) 13 6) Wake Lock pour empecher le telephone de se mettre en veille (best effort) 14 15 API publique : 16 - window.smartLockerNotify.showAlert(event) -> declenche manuellement 17 - window.smartLockerNotify.markSeen(eventId) -> deduplique 18 ═══════════════════════════════════════════════════════════════════════════ */ 19 20(function() { 21 'use strict'; 22 23 // ── CONFIG ───────────────────────────────────────────────────────────── 24 const POLL_INTERVAL_MS = 5000; // 5s : compromis batterie/reactivite 25 const POLL_INTERVAL_BG_MS = 20000; // 20s en arriere-plan 26 const SEEN_RETENTION_MS = 24 * 3600 * 1000; // garde 24h de deduplication 27 const SEEN_KEY = 'smartlocker_seen_events'; 28 29 // Sons d'alerte (data-URI ultra-leger - bip 800Hz) 30 const ALERT_BEEP_DATA_URI = 'data:audio/wav;base64,UklGRrYDAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YZIDAACA/3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//'; 31 32 // ── DEDUP STORAGE ────────────────────────────────────────────────────── 33 function loadSeen() { 34 try { 35 const raw = localStorage.getItem(SEEN_KEY); 36 if (!raw) return {}; 37 const obj = JSON.parse(raw); 38 // Purge les anciens evenements 39 const now = Date.now(); 40 const cleaned = {}; 41 for (const [k, ts] of Object.entries(obj)) { 42 if (now - ts < SEEN_RETENTION_MS) cleaned[k] = ts; 43 } 44 return cleaned; 45 } catch(e) { return {}; } 46 } 47 function saveSeen(seen) { 48 try { localStorage.setItem(SEEN_KEY, JSON.stringify(seen)); } catch(e) {} 49 } 50 let seen = loadSeen(); 51 52 function isSeen(eventId) { return !!seen[eventId]; } 53 function markSeen(eventId) { 54 seen[eventId] = Date.now(); 55 saveSeen(seen); 56 } 57 58 // ── UI : POPUP + TOAST ───────────────────────────────────────────────── 59 function ensureContainer() { 60 let c = document.getElementById('sl-notif-stack'); 61 if (c) return c; 62 c = document.createElement('div'); 63 c.id = 'sl-notif-stack'; 64 c.style.cssText = 'position:fixed;top:80px;right:16px;left:16px;z-index:99999;display:flex;flex-direction:column;gap:10px;pointer-events:none;max-width:480px;margin:0 auto;'; 65 document.body.appendChild(c); 66 return c; 67 } 68 69 function colorFor(severity) { 70 switch (severity) { 71 case 'critical': return { bg: 'linear-gradient(135deg,#ff3333,#cc0000)', border: '#ff3333' }; 72 case 'warning': return { bg: 'linear-gradient(135deg,#ff9500,#ff6b00)', border: '#ff9500' }; 73 case 'info': return { bg: 'linear-gradient(135deg,#00b8ff,#0080cc)', border: '#00b8ff' }; 74 default: return { bg: 'linear-gradient(135deg,#00ff9d,#00cc7e)', border: '#00ff9d' }; 75 } 76 } 77 78 function iconFor(kind) { 79 switch (kind) { 80 case 'intrusion': return 'fa-triangle-exclamation'; 81 case 'reminder': return 'fa-clock'; 82 case 'object_removed': return 'fa-box-open'; 83 case 'offline': return 'fa-plug-circle-xmark'; 84 default: return 'fa-bell'; 85 } 86 } 87 88 function titleFor(event) { 89 switch (event.kind) { 90 case 'intrusion': 91 if (event.message === 'porte_ouverte_trop_longtemps') 92 return 'CASIER VERROUILLE — ADMIN'; 93 return 'INTRUSION DETECTEE'; 94 case 'reminder': return 'FERMEZ VOTRE CASIER'; 95 case 'object_removed': return 'OBJET RETIRE'; 96 case 'offline': return 'CASIER HORS-LIGNE'; 97 default: return 'NOTIFICATION'; 98 } 99 } 100 101 function bodyFor(event) { 102 const ln = event.locker_name || event.locker_id || 'votre casier'; 103 switch (event.kind) { 104 case 'intrusion': 105 if (event.message === 'porte_ouverte_trop_longtemps') 106 return `Le casier ${ln} est reste ouvert trop longtemps malgre les rappels. Un administrateur va intervenir.`; 107 return `Tentative d'ouverture forcee detectee sur ${ln} ! Un administrateur va intervenir.`; 108 case 'reminder': 109 return `Votre casier ${ln} est reste ouvert ! Pensez a bien le refermer.`; 110 case 'object_removed': 111 return `Un objet vient d'etre retire de ${ln}.`; 112 case 'offline': 113 return `${ln} ne repond plus. Verifiez qu'il soit allume.`; 114 default: 115 return event.message || ''; 116 } 117 } 118 119 function vibratePatternFor(kind) { 120 switch (kind) { 121 case 'intrusion': return [500, 200, 500, 200, 1000]; 122 case 'reminder': return [300, 100, 300, 100, 300]; 123 case 'object_removed': return [200, 100, 200]; 124 case 'offline': return [400, 200, 400]; 125 default: return [200, 100, 200]; 126 } 127 } 128 129 function showInAppPopup(event) { 130 const c = ensureContainer(); 131 const col = colorFor(event.severity); 132 const card = document.createElement('div'); 133 card.style.cssText = ` 134 pointer-events:auto; 135 background:${col.bg}; 136 border:2px solid ${col.border}; 137 border-radius:12px; 138 box-shadow:0 8px 32px rgba(0,0,0,0.5), 0 0 24px ${col.border}55; 139 color:#fff; 140 padding:16px 18px; 141 font-family:'Inter',sans-serif; 142 display:flex;align-items:flex-start;gap:14px; 143 animation:slNotifSlide 0.4s cubic-bezier(0.34,1.56,0.64,1); 144 max-width:100%; 145 `; 146 card.innerHTML = ` 147 <div style="font-size:1.6rem;line-height:1;flex-shrink:0;margin-top:2px;"> 148 <i class="fa-solid ${iconFor(event.kind)}"></i> 149 </div> 150 <div style="flex:1;min-width:0;"> 151 <div style="font-family:'Orbitron',sans-serif;font-weight:800;font-size:0.85rem;letter-spacing:1.2px;text-transform:uppercase;margin-bottom:4px;"> 152 ${titleFor(event)} 153 </div> 154 <div style="font-size:0.88rem;line-height:1.4;font-weight:400;opacity:0.95;"> 155 ${bodyFor(event)} 156 </div> 157 </div> 158 <button class="sl-notif-close" style="background:rgba(0,0,0,0.25);border:none;color:#fff;width:28px;height:28px;border-radius:50%;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;"> 159 <i class="fa-solid fa-xmark"></i> 160 </button> 161 `; 162 card.querySelector('.sl-notif-close').addEventListener('click', () => { 163 card.style.transition = 'opacity 0.3s, transform 0.3s'; 164 card.style.opacity = '0'; 165 card.style.transform = 'translateX(120%)'; 166 setTimeout(() => card.remove(), 300); 167 }); 168 c.appendChild(card); 169 170 // Auto-dismiss apres 12s pour les non-critiques 171 if (event.severity !== 'critical') { 172 setTimeout(() => { 173 if (card.parentNode) { 174 card.style.transition = 'opacity 0.5s, transform 0.5s'; 175 card.style.opacity = '0'; 176 card.style.transform = 'translateY(-20px)'; 177 setTimeout(() => card.remove(), 500); 178 } 179 }, 12000); 180 } 181 } 182 183 // ── SON D'ALERTE (WebAudio API - fonctionne meme avec son media coupe) ── 184 let audioCtx = null; 185 function playAlertSound(severity) { 186 try { 187 if (!audioCtx) { 188 audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 189 } 190 if (audioCtx.state === 'suspended') audioCtx.resume(); 191 192 const playBeep = (freq, duration, delay) => { 193 setTimeout(() => { 194 const osc = audioCtx.createOscillator(); 195 const gain = audioCtx.createGain(); 196 osc.type = 'sine'; 197 osc.frequency.value = freq; 198 gain.gain.setValueAtTime(0.001, audioCtx.currentTime); 199 gain.gain.exponentialRampToValueAtTime(0.3, audioCtx.currentTime + 0.01); 200 gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration); 201 osc.connect(gain).connect(audioCtx.destination); 202 osc.start(); 203 osc.stop(audioCtx.currentTime + duration); 204 }, delay); 205 }; 206 207 if (severity === 'critical') { 208 playBeep(880, 0.2, 0); 209 playBeep(660, 0.2, 250); 210 playBeep(880, 0.3, 500); 211 } else if (severity === 'warning') { 212 playBeep(660, 0.15, 0); 213 playBeep(660, 0.15, 200); 214 } else { 215 playBeep(523, 0.12, 0); 216 } 217 } catch(e) { /* WebAudio bloque ou non supporte */ } 218 } 219 220 // ── NOTIFICATION NATIVE (system) ───────────────────────────────────── 221 function showSystemNotification(event) { 222 try { 223 if (!("Notification" in window)) return; 224 if (Notification.permission !== "granted") return; 225 const n = new Notification(titleFor(event), { 226 body: bodyFor(event), 227 icon: "/static/icons/icon-192.png", 228 badge: "/static/icons/icon-192.png", 229 vibrate: vibratePatternFor(event.kind), 230 tag: `smartlocker-${event.kind}-${event.locker_id}`, 231 renotify: true, 232 requireInteraction: (event.severity === 'critical' || event.severity === 'warning'), 233 data: { url: '/', event: event } 234 }); 235 n.onclick = function() { 236 window.focus(); 237 this.close(); 238 }; 239 } catch(e) { console.warn('[notify] system:', e); } 240 } 241 242 // ── PIPELINE PRINCIPAL D'UN EVENT ───────────────────────────────────── 243 function handleEvent(event) { 244 if (!event || !event.kind || !event.time) return; 245 const eventId = `${event.kind}:${event.locker_id || event.locker_key || ''}:${event.time}`; 246 if (isSeen(eventId)) return; 247 markSeen(eventId); 248 249 // 1) Popup in-app 250 try { showInAppPopup(event); } catch(e) { console.warn(e); } 251 252 // 2) Vibration 253 try { 254 if ('vibrate' in navigator) navigator.vibrate(vibratePatternFor(event.kind)); 255 } catch(e) {} 256 257 // 3) Son 258 try { playAlertSound(event.severity); } catch(e) {} 259 260 // 4) Notification systeme (fonctionne meme app en background) 261 showSystemNotification(event); 262 263 // 5) Custom event pour le code page (ex: popup specifique sport) 264 try { 265 window.dispatchEvent(new CustomEvent('smartlocker:notify', { detail: event })); 266 } catch(e) {} 267 } 268 269 // ── POLLING ROBUSTE ──────────────────────────────────────────────────── 270 let pollHandle = null; 271 let consecutiveErrors = 0; 272 let currentInterval = POLL_INTERVAL_MS; 273 274 async function pollOnce() { 275 try { 276 const r = await fetch('/api/notifications/poll?t=' + Date.now(), { 277 cache: 'no-store', 278 credentials: 'same-origin' 279 }); 280 if (!r.ok) throw new Error('HTTP ' + r.status); 281 const data = await r.json(); 282 consecutiveErrors = 0; 283 if (data && Array.isArray(data.events)) { 284 data.events.forEach(handleEvent); 285 } 286 } catch(e) { 287 consecutiveErrors++; 288 // Si trop d'erreurs, espacer le polling (backoff) 289 if (consecutiveErrors > 5) { 290 console.warn('[notify] polling backoff, errors=', consecutiveErrors); 291 } 292 } 293 } 294 295 function startPolling() { 296 if (pollHandle) return; 297 pollOnce(); // immediat 298 pollHandle = setInterval(pollOnce, currentInterval); 299 } 300 function stopPolling() { 301 if (pollHandle) { clearInterval(pollHandle); pollHandle = null; } 302 } 303 function adjustInterval(newInterval) { 304 if (newInterval === currentInterval) return; 305 currentInterval = newInterval; 306 stopPolling(); 307 startPolling(); 308 } 309 310 // En arriere-plan : on espace, sans arreter (on veut quand meme detecter) 311 document.addEventListener('visibilitychange', () => { 312 adjustInterval(document.hidden ? POLL_INTERVAL_BG_MS : POLL_INTERVAL_MS); 313 }); 314 315 // Resume du polling au retour online 316 window.addEventListener('online', () => { startPolling(); }); 317 window.addEventListener('offline', () => { /* on garde le timer, fetch echouera et sera retente */ }); 318 319 // ── WAKE LOCK (best effort - empeche le tel de s'endormir si pertinent) ── 320 let wakeLock = null; 321 async function requestWakeLock() { 322 try { 323 if ('wakeLock' in navigator) { 324 wakeLock = await navigator.wakeLock.request('screen'); 325 wakeLock.addEventListener('release', () => { wakeLock = null; }); 326 } 327 } catch(e) { /* perm refuse ou non supporte */ } 328 } 329 // Seulement quand l'utilisateur a clique (sinon bloque par browser) 330 document.addEventListener('click', requestWakeLock, { once: true }); 331 332 // ── CSS GLOBAL POUR ANIMATIONS ──────────────────────────────────────── 333 (function injectCss() { 334 if (document.getElementById('sl-notif-css')) return; 335 const style = document.createElement('style'); 336 style.id = 'sl-notif-css'; 337 style.textContent = ` 338 @keyframes slNotifSlide { 339 from { opacity:0; transform:translateY(-20px) scale(0.95); } 340 to { opacity:1; transform:translateY(0) scale(1); } 341 } 342 #sl-notif-stack { font-family:'Inter',sans-serif; } 343 @media (max-width: 600px) { 344 #sl-notif-stack { top:70px; right:8px; left:8px; } 345 } 346 `; 347 document.head.appendChild(style); 348 })(); 349 350 // ── BANNIERE NOTIFICATIONS OBLIGATOIRES ───────────────────────────── 351 // Si l'utilisateur a refuse ou n'a pas encore accepte les notifications, 352 // on affiche une banniere persistante en haut de la page avec un bouton 353 // qui relance la demande. 354 function ensurePermissionBanner() { 355 if (!("Notification" in window)) return; 356 const perm = Notification.permission; 357 // OK si deja accepte 358 if (perm === 'granted') { 359 const b = document.getElementById('sl-perm-banner'); 360 if (b) b.remove(); 361 return; 362 } 363 // Eviter doublon 364 if (document.getElementById('sl-perm-banner')) return; 365 366 const banner = document.createElement('div'); 367 banner.id = 'sl-perm-banner'; 368 banner.style.cssText = ` 369 position:fixed;top:0;left:0;right:0; 370 z-index:100000; 371 background:linear-gradient(135deg,#ff3333,#cc0000); 372 color:#fff; 373 padding:14px 18px; 374 box-shadow:0 4px 20px rgba(0,0,0,0.4); 375 font-family:'Inter',sans-serif; 376 display:flex;align-items:center;gap:14px; 377 border-bottom:2px solid #ff6666; 378 animation:slBannerSlide 0.4s ease-out; 379 `; 380 const isDenied = (perm === 'denied'); 381 banner.innerHTML = ` 382 <i class="fa-solid fa-bell-slash" style="font-size:1.6rem;flex-shrink:0;animation:slBannerPulse 1.5s ease-in-out infinite;"></i> 383 <div style="flex:1;min-width:0;"> 384 <div style="font-family:'Orbitron',sans-serif;font-weight:800;font-size:0.88rem;letter-spacing:1.2px;text-transform:uppercase;margin-bottom:3px;"> 385 Notifications obligatoires 386 </div> 387 <div style="font-size:0.82rem;line-height:1.4;opacity:0.95;"> 388 ${ isDenied 389 ? "Vous avez refusé les notifications. Pour votre sécurité, vous devez les activer dans les paramètres de votre navigateur (icône cadenas dans la barre d'adresse → autoriser les notifications)." 390 : "Pour être alerté en cas d'intrusion ou de problème sur votre casier, vous devez activer les notifications." 391 } 392 </div> 393 </div> 394 <button id="sl-perm-btn" style=" 395 background:#fff;color:#cc0000; 396 border:none;border-radius:8px; 397 padding:10px 18px; 398 font-family:'Orbitron',sans-serif;font-weight:700; 399 font-size:0.82rem;letter-spacing:1px;text-transform:uppercase; 400 cursor:pointer;flex-shrink:0; 401 box-shadow:0 4px 12px rgba(0,0,0,0.25); 402 transition:transform 0.15s;"> 403 <i class="fa-solid fa-bell"></i> ${ isDenied ? 'Voir comment' : 'Activer' } 404 </button> 405 `; 406 document.body.appendChild(banner); 407 408 document.getElementById('sl-perm-btn').addEventListener('click', async (e) => { 409 e.target.style.transform = 'scale(0.95)'; 410 setTimeout(() => { if (e.target) e.target.style.transform = ''; }, 150); 411 if (Notification.permission === 'denied') { 412 // Le navigateur ne laissera plus afficher la popup : on guide l'utilisateur 413 showHowToEnableModal(); 414 return; 415 } 416 try { 417 const result = await Notification.requestPermission(); 418 if (result === 'granted') { 419 // Reenregistrer le token FCM si la fonction est dispo 420 if (typeof window.requestPushPermission === 'function') { 421 try { await window.requestPushPermission(); } catch(_) {} 422 } 423 ensurePermissionBanner(); 424 } else { 425 // L'utilisateur a re-refuse : on actualise la banniere 426 ensurePermissionBanner(); 427 } 428 } catch(err) { console.warn(err); } 429 }); 430 431 // Repousse le contenu de la page pour ne pas masquer la navbar 432 document.body.style.paddingTop = (banner.offsetHeight + 8) + 'px'; 433 } 434 435 function showHowToEnableModal() { 436 if (document.getElementById('sl-howto-modal')) return; 437 const modal = document.createElement('div'); 438 modal.id = 'sl-howto-modal'; 439 modal.style.cssText = ` 440 position:fixed;inset:0;z-index:100001; 441 background:rgba(0,0,0,0.85); 442 display:flex;align-items:center;justify-content:center; 443 padding:20px; 444 animation:slBannerSlide 0.3s ease-out; 445 `; 446 modal.innerHTML = ` 447 <div style=" 448 background:linear-gradient(135deg,#0a1929,#050a14); 449 border:2px solid #00ff9d; 450 border-radius:16px; 451 padding:32px 28px; 452 max-width:480px;width:100%; 453 box-shadow:0 20px 60px rgba(0,0,0,0.6),0 0 30px rgba(0,255,157,0.3); 454 color:#fff;font-family:'Inter',sans-serif; 455 text-align:center;"> 456 <i class="fa-solid fa-circle-info" style="font-size:3rem;color:#00ff9d;margin-bottom:16px;"></i> 457 <h2 style="font-family:'Orbitron',sans-serif;font-size:1.3rem;margin:0 0 14px;letter-spacing:1.5px;"> 458 ACTIVER LES NOTIFICATIONS 459 </h2> 460 <p style="font-size:0.92rem;line-height:1.55;color:#cbd5e1;margin:0 0 18px;text-align:left;"> 461 Les notifications ont été bloquées par votre navigateur. 462 Pour les réactiver : 463 </p> 464 <ol style="text-align:left;color:#e2e8f0;font-size:0.88rem;line-height:1.7;padding-left:24px;margin:0 0 22px;"> 465 <li>Cliquez sur l'icône <b>🔒 cadenas</b> à gauche de l'adresse du site</li> 466 <li>Trouvez l'option <b>"Notifications"</b></li> 467 <li>Sélectionnez <b>"Autoriser"</b></li> 468 <li>Rechargez la page</li> 469 </ol> 470 <div style="display:flex;gap:10px;"> 471 <button id="sl-howto-reload" style=" 472 flex:1;background:linear-gradient(135deg,#00ff9d,#00cc7e); 473 border:none;color:#000;border-radius:10px;padding:12px; 474 font-family:'Orbitron',sans-serif;font-weight:700;font-size:0.85rem; 475 letter-spacing:1px;text-transform:uppercase;cursor:pointer;"> 476 <i class="fa-solid fa-rotate-right"></i> Recharger 477 </button> 478 <button id="sl-howto-close" style=" 479 flex:1;background:transparent;border:1px solid #64748b;color:#94a3b8; 480 border-radius:10px;padding:12px; 481 font-family:'Orbitron',sans-serif;font-weight:600;font-size:0.85rem; 482 letter-spacing:1px;text-transform:uppercase;cursor:pointer;"> 483 Fermer 484 </button> 485 </div> 486 </div> 487 `; 488 document.body.appendChild(modal); 489 document.getElementById('sl-howto-reload').onclick = () => location.reload(); 490 document.getElementById('sl-howto-close').onclick = () => modal.remove(); 491 } 492 493 // Verification au chargement + apres chaque clic (pour les browsers qui demandent une activation user) 494 function initPermissionCheck() { 495 ensurePermissionBanner(); 496 // Re-verifier periodiquement si la permission a change (ex: user a accepte via les params) 497 setInterval(ensurePermissionBanner, 5000); 498 } 499 500 // Injecter aussi les animations specifiques de la banniere 501 (function injectBannerCss() { 502 if (document.getElementById('sl-banner-css')) return; 503 const style = document.createElement('style'); 504 style.id = 'sl-banner-css'; 505 style.textContent = ` 506 @keyframes slBannerSlide { 507 from { opacity:0; transform:translateY(-20px); } 508 to { opacity:1; transform:translateY(0); } 509 } 510 @keyframes slBannerPulse { 511 0%,100% { transform:scale(1); } 512 50% { transform:scale(1.15); } 513 } 514 @media (max-width: 600px) { 515 #sl-perm-banner { flex-wrap:wrap; padding:12px 14px; gap:10px; } 516 #sl-perm-banner > div:nth-child(2) { flex:1 1 100%; order:2; } 517 #sl-perm-btn { order:3; width:100%; } 518 } 519 `; 520 document.head.appendChild(style); 521 })(); 522 523 // ── API PUBLIQUE ────────────────────────────────────────────────────── 524 window.smartLockerNotify = { 525 handleEvent: handleEvent, 526 markSeen: markSeen, 527 startPolling: startPolling, 528 stopPolling: stopPolling, 529 playSound: playAlertSound, 530 checkPermission: ensurePermissionBanner, 531 showHowToEnableModal: showHowToEnableModal 532 }; 533 534 // ── DEMARRAGE AUTO ──────────────────────────────────────────────────── 535 function bootstrap() { 536 startPolling(); 537 initPermissionCheck(); 538 } 539 if (document.readyState === 'loading') { 540 document.addEventListener('DOMContentLoaded', bootstrap); 541 } else { 542 bootstrap(); 543 } 544})();
static/service-worker.js
92 lignes
1// ════════════════════════════════════════════════════════════════ 2// SMARTLOCKER — SERVICE WORKER 3// Stratégie : network-first pour CSS/JS (fraîcheur), cache-first pour images 4// Bumper la version à chaque déploiement pour forcer la mise à jour 5// ════════════════════════════════════════════════════════════════ 6const CACHE = 'smartlocker-v6'; 7 8const STATIC_ASSETS = [ 9 '/static/app.css', 10 '/static/icons/icon-192.png', 11 '/static/icons/icon-512.png', 12 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css', 13 'https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Rajdhani:wght@300;400;600&display=swap' 14]; 15 16// ── Install : pré-cache des assets, ignore les erreurs réseau pour ne pas bloquer 17self.addEventListener('install', (e) => { 18 e.waitUntil( 19 caches.open(CACHE) 20 .then((c) => Promise.all( 21 STATIC_ASSETS.map((url) => 22 c.add(url).catch((err) => console.warn('[SW] Skip cache:', url, err)) 23 ) 24 )) 25 .then(() => self.skipWaiting()) 26 ); 27}); 28 29// ── Activate : on supprime les vieux caches 30self.addEventListener('activate', (e) => { 31 e.waitUntil( 32 caches.keys() 33 .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) 34 .then(() => self.clients.claim()) 35 ); 36}); 37 38// ── Fetch : stratégie hybride 39self.addEventListener('fetch', (e) => { 40 const req = e.request; 41 42 // Ignorer les requêtes non-GET 43 if (req.method !== 'GET') return; 44 45 // Ne pas intercepter les pages HTML (navigate) — toujours passer par le réseau 46 if (req.mode === 'navigate') return; 47 48 const url = new URL(req.url); 49 50 // Ignorer Firebase / Stripe / API → pas de cache 51 if (url.host.includes('firebaseio.com') || 52 url.host.includes('googleapis.com') && url.pathname.includes('identitytoolkit') || 53 url.host.includes('stripe.com') || 54 url.pathname.startsWith('/api/')) { 55 return; // Laisse passer normalement 56 } 57 58 // Stratégie network-first pour CSS/JS (pour avoir les mises à jour rapidement) 59 if (url.pathname.endsWith('.css') || url.pathname.endsWith('.js')) { 60 e.respondWith( 61 fetch(req) 62 .then((res) => { 63 if (res && res.ok) { 64 const clone = res.clone(); 65 caches.open(CACHE).then((c) => c.put(req, clone)).catch(() => {}); 66 } 67 return res; 68 }) 69 .catch(() => caches.match(req)) 70 ); 71 return; 72 } 73 74 // Stratégie cache-first pour le reste (images, polices, etc.) 75 e.respondWith( 76 caches.match(req).then((cached) => { 77 if (cached) return cached; 78 return fetch(req).then((res) => { 79 if (res && res.ok && res.type !== 'opaque') { 80 const clone = res.clone(); 81 caches.open(CACHE).then((c) => c.put(req, clone)).catch(() => {}); 82 } 83 return res; 84 }).catch(() => cached); // Fallback réseau échoué 85 }) 86 ); 87}); 88 89// ── Permettre le force-update depuis l'app 90self.addEventListener('message', (e) => { 91 if (e.data === 'SKIP_WAITING') self.skipWaiting(); 92});
static/firebase-messaging-sw.js
48 lignes
1importScripts('https://www.gstatic.com/firebasejs/10.9.0/firebase-app-compat.js'); 2importScripts('https://www.gstatic.com/firebasejs/10.9.0/firebase-messaging-compat.js'); 3 4const firebaseConfig = { 5 apiKey: "AIzaSyCQHsEjxGFa-MtsG8ogCCWEmarppA4BHC0", 6 projectId: "smartlocker-94e11", 7 messagingSenderId: "224254595209", 8 appId: "1:224254595209:web:82804d2f2c7fd83046f658" 9}; 10 11firebase.initializeApp(firebaseConfig); 12 13const messaging = firebase.messaging(); 14 15// Gestionnaire principal pour les messages DATA en arrière-plan 16messaging.onBackgroundMessage(function(payload) { 17 console.log('[SW] onBackgroundMessage reçu:', payload); 18 19 // Si Firebase inclut un bloc 'notification', le navigateur le gère en natif. 20 // Pas besoin de créer une notification manuelle, sinon on aura un doublon. 21 if (payload.notification) { 22 console.log('[SW] Ignoré par onBackgroundMessage car bloc notification natif présent.'); 23 return; 24 } 25 26 const title = (payload.data && payload.data.title) || 'ALERTE SMARTLOCKER'; 27 const body = (payload.data && payload.data.body) || 'Nouvel événement !'; 28 29 return self.registration.showNotification(title, { 30 body: body, 31 icon: '/static/icons/icon-192.png', 32 vibrate: [500, 200, 500, 200, 1000], 33 requireInteraction: true, 34 tag: 'smartlocker-alert' 35 }); 36}); 37 38// Le gestionnaire de secours brut a été retiré car les notifications 39// natives (bloc 'notification') sont gérées automatiquement par le navigateur. 40// Cela empêche la création de doublons. 41 42// Quand l'utilisateur clique sur la notification, ouvrir l'appli 43self.addEventListener('notificationclick', function(event) { 44 event.notification.close(); 45 event.waitUntil( 46 clients.openWindow('/') 47 ); 48});
static/manifest.json
50 lignes
1{ 2 "name": "SmartLocker", 3 "short_name": "SmartLocker", 4 "description": "Contrôlez votre casier connecté depuis votre téléphone.", 5 "lang": "fr", 6 "dir": "ltr", 7 "start_url": "/", 8 "scope": "/", 9 "id": "/", 10 "display": "standalone", 11 "display_override": ["standalone", "fullscreen", "minimal-ui"], 12 "orientation": "portrait", 13 "background_color": "#02040a", 14 "theme_color": "#02040a", 15 "categories": ["utilities", "productivity"], 16 "prefer_related_applications": false, 17 "icons": [ 18 { 19 "src": "/static/icons/icon-192.png", 20 "sizes": "192x192", 21 "type": "image/png", 22 "purpose": "any" 23 }, 24 { 25 "src": "/static/icons/icon-192.png", 26 "sizes": "192x192", 27 "type": "image/png", 28 "purpose": "maskable" 29 }, 30 { 31 "src": "/static/icons/icon-512.png", 32 "sizes": "512x512", 33 "type": "image/png", 34 "purpose": "any" 35 }, 36 { 37 "src": "/static/icons/icon-512.png", 38 "sizes": "512x512", 39 "type": "image/png", 40 "purpose": "maskable" 41 } 42 ], 43 "shortcuts": [ 44 { 45 "name": "Mon casier", 46 "url": "/", 47 "description": "Accéder au dashboard" 48 } 49 ] 50}