SEO : rendu serveur complet + référencement programmatique
- autoka/seo.py : HTML complet servi dès la 1re requête (meta uniques,
canonical, og:, hreflang fr-CA, JSON-LD Car/Offer/ItemList/BreadcrumbList,
contenu dans #root que React remplace au montage)
- Pages programmatiques : /usagees/{marque}[/{modele}], /region, /ville,
/carrosserie (liste blanche), /motos/{marque}, /scooters/{marque}
- Fiches : URL lisible /vehicule/{uid}/{slug} (301 depuis l'uid nu),
410 Gone si retiré/vendu, 404 réels partout (fin des soft-404)
- sitemap.xml dynamique (index + pages + fiches par 10 000, lastmod)
+ robots.txt ; GZipMiddleware
- Frontend : routes SPA miroirs (SeoListing + __SEO_CTX__), slugs
partagés Python/TS, document.title dynamique, filtres verrouillés
- Inclut aussi le travail SSO KA ID + favoris hub déjà en prod
(auth.py, hubfav.py, hubprofile.py, account.tsx, FavButton, Profil)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 changed files +2,424 −27
added
autoka/auth.py
+275 −0
@@ -0,0 +1,275 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (groupe-ka.com) | |
| 5 | +# | |
| 6 | +# Flot : /api/auth/ka/login redirige vers le hub {KA_HUB_URL}/sso/authorize ; | |
| 7 | +# le hub authentifie le membre puis rappelle /api/auth/ka/callback avec un | |
| 8 | +# `ka_token` (JWT HS256 signé du secret partagé KA_SSO_SECRET). On vérifie | |
| 9 | +# signature + alg + iss + aud + exp, on upsert l'utilisateur (clé = ka_id, | |
| 10 | +# LE MÊME identifiant sur toutes les plateformes du groupe), puis on pose la | |
| 11 | +# session locale : cookie httpOnly signé HMAC (SESSION_SECRET), 30 jours. | |
| 12 | +# Session = profil dans le cookie (aucune lecture DB par requête). | |
| 13 | +# | |
| 14 | +# Zéro dépendance : hmac/hashlib/base64/json de la stdlib uniquement. | |
| 15 | +# ----------------------------------------------------------------------------- | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import base64 | |
| 19 | +import hashlib | |
| 20 | +import hmac | |
| 21 | +import json | |
| 22 | +import os | |
| 23 | +import time | |
| 24 | +import urllib.parse | |
| 25 | + | |
| 26 | +from fastapi import APIRouter, Request | |
| 27 | +from fastapi.responses import JSONResponse, RedirectResponse | |
| 28 | + | |
| 29 | +from . import db, hubfav | |
| 30 | +from .hubprofile import fetch_hub_profile | |
| 31 | + | |
| 32 | +router = APIRouter(prefix="/api/auth") | |
| 33 | + | |
| 34 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 35 | +KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "") | |
| 36 | +KA_CLIENT_ID = "auto-ka" | |
| 37 | + | |
| 38 | +# base publique de CETTE app (l'URL de rappel en découle) | |
| 39 | +BASE_URL = os.environ.get("AUTOKA_BASE_URL", "http://localhost:8095").rstrip("/") | |
| 40 | +SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256( | |
| 41 | + (KA_SSO_SECRET or "autoka-dev").encode()).hexdigest() | |
| 42 | +COOKIE = "autoka_session" | |
| 43 | +SESSION_DAYS = 30 | |
| 44 | + | |
| 45 | + | |
| 46 | +# -- JWT HS256 minimal (aucune dépendance) ----------------------------------- | |
| 47 | +def _b64(d: bytes) -> str: | |
| 48 | + return base64.urlsafe_b64encode(d).rstrip(b"=").decode() | |
| 49 | + | |
| 50 | + | |
| 51 | +def _unb64(s: str) -> bytes: | |
| 52 | + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) | |
| 53 | + | |
| 54 | + | |
| 55 | +def jwt_encode(payload: dict) -> str: | |
| 56 | + head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) | |
| 57 | + body = _b64(json.dumps(payload, separators=(",", ":")).encode()) | |
| 58 | + sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) | |
| 59 | + return f"{head}.{body}.{sig}" | |
| 60 | + | |
| 61 | + | |
| 62 | +def jwt_decode(token: str) -> dict | None: | |
| 63 | + try: | |
| 64 | + head, body, sig = token.split(".") | |
| 65 | + good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) | |
| 66 | + if not hmac.compare_digest(sig, good): | |
| 67 | + return None | |
| 68 | + payload = json.loads(_unb64(body)) | |
| 69 | + if payload.get("exp", 0) < time.time(): | |
| 70 | + return None | |
| 71 | + return payload | |
| 72 | + except Exception: | |
| 73 | + return None | |
| 74 | + | |
| 75 | + | |
| 76 | +def _ka_verify(token: str) -> dict | None: | |
| 77 | + """Vérifie un JWT HS256 émis par le hub KA (stdlib seulement).""" | |
| 78 | + if not KA_SSO_SECRET: | |
| 79 | + return None | |
| 80 | + try: | |
| 81 | + head, body, sig = token.split(".") | |
| 82 | + good = _b64(hmac.new(KA_SSO_SECRET.encode(), | |
| 83 | + f"{head}.{body}".encode(), hashlib.sha256).digest()) | |
| 84 | + if not hmac.compare_digest(sig, good): | |
| 85 | + return None | |
| 86 | + if json.loads(_unb64(head)).get("alg") != "HS256": | |
| 87 | + return None | |
| 88 | + claims = json.loads(_unb64(body)) | |
| 89 | + if claims.get("iss") != KA_HUB_URL: | |
| 90 | + return None | |
| 91 | + if claims.get("aud") != KA_CLIENT_ID: | |
| 92 | + return None | |
| 93 | + if claims.get("exp", 0) < time.time(): | |
| 94 | + return None | |
| 95 | + return claims | |
| 96 | + except Exception: | |
| 97 | + return None | |
| 98 | + | |
| 99 | + | |
| 100 | +# -- table users (clé = ka_id, l'identifiant partagé du Groupe KA) ------------ | |
| 101 | +def _ensure_table(con) -> None: | |
| 102 | + con.execute("""CREATE TABLE IF NOT EXISTS users ( | |
| 103 | + ka_id TEXT PRIMARY KEY, -- KA-ID du groupe (ex. ka-0123456789) | |
| 104 | + email TEXT, | |
| 105 | + name TEXT, | |
| 106 | + picture TEXT, | |
| 107 | + provider TEXT, | |
| 108 | + created REAL, | |
| 109 | + last_login REAL | |
| 110 | + )""") | |
| 111 | + | |
| 112 | + | |
| 113 | +def _upsert_user(info: dict) -> dict: | |
| 114 | + con = db.connect() | |
| 115 | + try: | |
| 116 | + _ensure_table(con) | |
| 117 | + now = time.time() | |
| 118 | + con.execute( | |
| 119 | + "INSERT INTO users (ka_id, email, name, picture, provider, created, last_login)" | |
| 120 | + " VALUES (?,?,?,?,?,?,?)" | |
| 121 | + " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email," | |
| 122 | + " name=excluded.name, picture=excluded.picture," | |
| 123 | + " provider=excluded.provider, last_login=excluded.last_login", | |
| 124 | + (info["ka_id"], info.get("email", ""), info.get("name", ""), | |
| 125 | + info.get("picture", ""), info.get("provider", ""), now, now)) | |
| 126 | + created = con.execute("SELECT created FROM users WHERE ka_id=?", | |
| 127 | + (info["ka_id"],)).fetchone()["created"] | |
| 128 | + con.commit() | |
| 129 | + finally: | |
| 130 | + con.close() | |
| 131 | + return {"ka_id": info["ka_id"], "email": info.get("email", ""), | |
| 132 | + "name": info.get("name", ""), "picture": info.get("picture", ""), | |
| 133 | + "provider": info.get("provider", ""), "created": created} | |
| 134 | + | |
| 135 | + | |
| 136 | +def current_user(request: Request) -> dict | None: | |
| 137 | + payload = jwt_decode(request.cookies.get(COOKIE, "")) | |
| 138 | + return payload.get("user") if payload else None | |
| 139 | + | |
| 140 | + | |
| 141 | +# -- routes -------------------------------------------------------------------- | |
| 142 | +@router.get("/ka/login") | |
| 143 | +def ka_login(next: str = "/"): | |
| 144 | + """Redirige vers le hub KA ID (groupe-ka.com) — SSO du Groupe KA.""" | |
| 145 | + if not KA_SSO_SECRET: | |
| 146 | + return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"}, | |
| 147 | + status_code=503) | |
| 148 | + if not next.startswith("/"): # destination interne uniquement | |
| 149 | + next = "/" | |
| 150 | + state = jwt_encode({"next": next[:200], "exp": time.time() + 600}) | |
| 151 | + params = { | |
| 152 | + "client_id": KA_CLIENT_ID, | |
| 153 | + "redirect_uri": f"{BASE_URL}/api/auth/ka/callback", | |
| 154 | + "state": state, | |
| 155 | + } | |
| 156 | + return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}") | |
| 157 | + | |
| 158 | + | |
| 159 | +@router.get("/ka/callback") | |
| 160 | +def ka_callback(ka_token: str = "", state: str = ""): | |
| 161 | + """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session.""" | |
| 162 | + st = jwt_decode(state) or {} | |
| 163 | + dest = st.get("next") or "/" | |
| 164 | + claims = _ka_verify(ka_token) if ka_token else None | |
| 165 | + if not st or claims is None: | |
| 166 | + return RedirectResponse("/?auth=echec") | |
| 167 | + # clé stable = KA-ID du groupe (créé par le hub, identique partout) | |
| 168 | + ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}") | |
| 169 | + user = _upsert_user({ | |
| 170 | + "ka_id": ka_id, | |
| 171 | + "email": claims.get("email", ""), | |
| 172 | + "name": claims.get("name", ""), | |
| 173 | + "picture": claims.get("picture") or "", | |
| 174 | + "provider": claims.get("provider") or "ka-id", | |
| 175 | + }) | |
| 176 | + session = jwt_encode({"user": user, "iss": "groupe-ka", | |
| 177 | + "exp": time.time() + SESSION_DAYS * 86400}) | |
| 178 | + resp = RedirectResponse(dest) | |
| 179 | + resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, | |
| 180 | + samesite="lax", secure=BASE_URL.startswith("https"), path="/") | |
| 181 | + return resp | |
| 182 | + | |
| 183 | + | |
| 184 | +@router.get("/me") | |
| 185 | +def me(request: Request): | |
| 186 | + """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si | |
| 187 | + la connexion KA ID est configurée (le frontend masque le bouton sinon). | |
| 188 | + | |
| 189 | + Le HUB Groupe KA est la source de vérité du profil : s'il connaît ce | |
| 190 | + KA ID, ses champs (bio, ville, emploi, entreprise, âge, site, réseaux, | |
| 191 | + statut) ENRICHISSENT la réponse (profile_source = "groupe-ka") — | |
| 192 | + l'édition se fait sur groupe-ka.com/compte. Hub injoignable ou 404 : | |
| 193 | + réponse locale inchangée (profile_source = "local").""" | |
| 194 | + user = current_user(request) | |
| 195 | + if user is not None: | |
| 196 | + user = dict(user) | |
| 197 | + user["profile_source"] = "local" | |
| 198 | + hub = fetch_hub_profile(str(user.get("ka_id") or "")) | |
| 199 | + if hub is not None: | |
| 200 | + hub_name = (hub.get("name") or "").strip() | |
| 201 | + user.update({ | |
| 202 | + "name": hub_name or user.get("name", ""), | |
| 203 | + "bio": hub.get("bio") or "", | |
| 204 | + "city": hub.get("city") or "", | |
| 205 | + "job_title": hub.get("job_title") or "", | |
| 206 | + "company": hub.get("company") or "", | |
| 207 | + "age": hub.get("age"), | |
| 208 | + "website": hub.get("website") or "", | |
| 209 | + "socials": hub.get("socials") or {}, | |
| 210 | + "role_label": hub.get("role_label") or "", | |
| 211 | + "public_url": hub.get("public_url") or "", | |
| 212 | + "profile_source": "groupe-ka", | |
| 213 | + }) | |
| 214 | + if hub.get("picture"): | |
| 215 | + user["picture"] = hub["picture"] | |
| 216 | + return {"user": user, "enabled": bool(KA_SSO_SECRET)} | |
| 217 | + | |
| 218 | + | |
| 219 | +@router.post("/logout") | |
| 220 | +def logout(): | |
| 221 | + resp = JSONResponse({"ok": True}) | |
| 222 | + resp.delete_cookie(COOKIE, path="/") | |
| 223 | + return resp | |
| 224 | + | |
| 225 | + | |
| 226 | +# -- favoris « Mon univers Ka » ------------------------------------------------ | |
| 227 | +# Le hub Groupe KA est le MAGASIN CENTRAL des favoris (aucun stockage | |
| 228 | +# local) : GET relit la liste au hub (cache 30 s), POST /toggle pousse | |
| 229 | +# add/remove en synchrone puis invalide le cache. 401 sans session. | |
| 230 | +fav_router = APIRouter(prefix="/api/favorites") | |
| 231 | + | |
| 232 | +# champs acceptés d'un item de favori -> longueur maximale (miroir du hub) | |
| 233 | +_ITEM_FIELDS = {"item_id": 200, "title": 200, "subtitle": 200, | |
| 234 | + "price_label": 60, "image_url": 500, "url": 500} | |
| 235 | + | |
| 236 | + | |
| 237 | +def _clean_item(raw: dict) -> dict: | |
| 238 | + return {k: str(raw.get(k) or "").strip()[:n] for k, n in _ITEM_FIELDS.items()} | |
| 239 | + | |
| 240 | + | |
| 241 | +@fav_router.get("") | |
| 242 | +def list_favorites(request: Request): | |
| 243 | + """Favoris Auto·Ka du membre connecté, lus au hub Groupe KA. | |
| 244 | + -> {ids: [item_id…], items: [{item_id, title, subtitle, …}…]}""" | |
| 245 | + user = current_user(request) | |
| 246 | + if user is None: | |
| 247 | + return JSONResponse({"error": "Connexion requise"}, status_code=401) | |
| 248 | + items = hubfav.hub_list(str(user.get("ka_id") or "")) | |
| 249 | + return {"ids": [i.get("item_id") for i in items if isinstance(i, dict)], | |
| 250 | + "items": items} | |
| 251 | + | |
| 252 | + | |
| 253 | +@fav_router.post("/toggle") | |
| 254 | +async def toggle_favorite(request: Request): | |
| 255 | + """Corps {on: bool, item: {item_id, title, …}} : pousse add/remove au hub | |
| 256 | + (synchrone) et invalide le cache de lecture. 502 si le hub refuse.""" | |
| 257 | + user = current_user(request) | |
| 258 | + if user is None: | |
| 259 | + return JSONResponse({"error": "Connexion requise"}, status_code=401) | |
| 260 | + try: | |
| 261 | + body = await request.json() | |
| 262 | + except Exception: | |
| 263 | + body = {} | |
| 264 | + if not isinstance(body, dict): | |
| 265 | + body = {} | |
| 266 | + on = bool(body.get("on")) | |
| 267 | + item = _clean_item(body.get("item") or {}) | |
| 268 | + if not item["item_id"] or (on and not item["title"]): | |
| 269 | + return JSONResponse({"error": "item_id et title requis"}, | |
| 270 | + status_code=400) | |
| 271 | + ka_id = str(user.get("ka_id") or "") | |
| 272 | + if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): | |
| 273 | + return JSONResponse({"error": "hub Groupe KA injoignable"}, | |
| 274 | + status_code=502) | |
| 275 | + return {"ok": True, "on": on} | |
added
autoka/hubfav.py
+91 −0
@@ -0,0 +1,91 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# hubfav.py : favoris unifiés « Mon univers Ka » — le hub Groupe KA | |
| 5 | +# (groupe-ka.com) est le MAGASIN CENTRAL des favoris du groupe : Auto·Ka ne | |
| 6 | +# stocke RIEN localement, chaque ♥ est poussé (POST signé, synchrone) et | |
| 7 | +# relu (GET signé) au hub. Signature HMAC-SHA256 du secret SSO partagé : | |
| 8 | +# sig = HMAC(KA_SSO_SECRET, "auto-ka.<ka_id>.<ts>") en hex, ts ±5 min côté | |
| 9 | +# hub. Lecture avec cache mémoire 30 s (invalidé à chaque toggle) ; liste | |
| 10 | +# vide sur toute erreur — le hub reste la seule source de vérité. | |
| 11 | +# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel). | |
| 12 | +# ----------------------------------------------------------------------------- | |
| 13 | +from __future__ import annotations | |
| 14 | + | |
| 15 | +import hashlib | |
| 16 | +import hmac | |
| 17 | +import os | |
| 18 | +import threading | |
| 19 | +import time | |
| 20 | + | |
| 21 | +import requests | |
| 22 | + | |
| 23 | +CLIENT_ID = "auto-ka" | |
| 24 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 25 | +CACHE_TTL = 30 # secondes — lecture des favoris | |
| 26 | +TIMEOUT = 6 # secondes — chaque appel au hub | |
| 27 | + | |
| 28 | +_cache: dict[str, tuple[float, list[dict]]] = {} | |
| 29 | +_lock = threading.Lock() | |
| 30 | + | |
| 31 | + | |
| 32 | +def _sig(ka_id: str, ts: int) -> str | None: | |
| 33 | + secret = os.environ.get("KA_SSO_SECRET") | |
| 34 | + if not secret: | |
| 35 | + return None | |
| 36 | + return hmac.new(secret.encode(), | |
| 37 | + f"{CLIENT_ID}.{ka_id}.{ts}".encode(), | |
| 38 | + hashlib.sha256).hexdigest() | |
| 39 | + | |
| 40 | + | |
| 41 | +def invalidate(ka_id: str) -> None: | |
| 42 | + """Oublie le cache de lecture de ce membre (après un toggle).""" | |
| 43 | + with _lock: | |
| 44 | + _cache.pop(ka_id, None) | |
| 45 | + | |
| 46 | + | |
| 47 | +def hub_toggle(ka_id: str, action: str, item: dict) -> bool: | |
| 48 | + """Pousse un ♥ au hub (action « add » ou « remove »), en SYNCHRONE : | |
| 49 | + True si le hub confirme, False sinon (réseau, signature, membre inconnu). | |
| 50 | + Le cache de lecture du membre est invalidé dans tous les cas.""" | |
| 51 | + ts = int(time.time()) | |
| 52 | + sig = _sig(ka_id, ts) | |
| 53 | + if not sig or not ka_id: | |
| 54 | + return False | |
| 55 | + ok = False | |
| 56 | + try: | |
| 57 | + r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, | |
| 58 | + json={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 59 | + "ts": str(ts), "sig": sig, | |
| 60 | + "action": action, "item": item}) | |
| 61 | + ok = r.status_code == 200 | |
| 62 | + except Exception: | |
| 63 | + ok = False | |
| 64 | + invalidate(ka_id) | |
| 65 | + return ok | |
| 66 | + | |
| 67 | + | |
| 68 | +def hub_list(ka_id: str) -> list[dict]: | |
| 69 | + """Favoris Auto·Ka du membre, lus au hub (cache 30 s, [] sur erreur).""" | |
| 70 | + ts_now = time.time() | |
| 71 | + with _lock: | |
| 72 | + hit = _cache.get(ka_id) | |
| 73 | + if hit and ts_now - hit[0] < CACHE_TTL: | |
| 74 | + return hit[1] | |
| 75 | + sig = _sig(ka_id, int(ts_now)) | |
| 76 | + if not sig or not ka_id: | |
| 77 | + return [] | |
| 78 | + try: | |
| 79 | + r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, | |
| 80 | + params={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 81 | + "ts": int(ts_now), "sig": sig}) | |
| 82 | + if r.status_code != 200: | |
| 83 | + return [] | |
| 84 | + favs = r.json().get("favorites") | |
| 85 | + if not isinstance(favs, list): | |
| 86 | + return [] | |
| 87 | + except Exception: | |
| 88 | + return [] | |
| 89 | + with _lock: | |
| 90 | + _cache[ka_id] = (ts_now, favs) | |
| 91 | + return favs | |
added
autoka/hubprofile.py
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# hubprofile.py : profil membre lu depuis le HUB Groupe KA (groupe-ka.com) | |
| 5 | +# Le hub est LA source de vérité du profil (bio, ville, emploi, entreprise, | |
| 6 | +# site web, réseaux sociaux, photo, statut, profil public) — l'édition se | |
| 7 | +# fait sur groupe-ka.com/compte, Auto·Ka ne fait qu'afficher. | |
| 8 | +# GET {hub}/api/sso/profile?client_id=auto-ka&ka_id=…&ts=…&sig=… | |
| 9 | +# avec sig = HMAC-SHA256(KA_SSO_SECRET, "auto-ka.<ka_id>.<ts>") en hex | |
| 10 | +# (même secret que le SSO). Cache mémoire 60 s ; None sur toute erreur | |
| 11 | +# (réseau, 401, 404 : compte inconnu du hub) -> l'appelant retombe sur | |
| 12 | +# les données locales. | |
| 13 | +# ----------------------------------------------------------------------------- | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import hashlib | |
| 17 | +import hmac | |
| 18 | +import os | |
| 19 | +import threading | |
| 20 | +import time | |
| 21 | +from datetime import datetime, timezone | |
| 22 | + | |
| 23 | +import requests | |
| 24 | + | |
| 25 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 26 | +CLIENT_ID = "auto-ka" | |
| 27 | +CACHE_TTL = 60 # secondes | |
| 28 | +TIMEOUT = 5 # secondes | |
| 29 | + | |
| 30 | +_cache: dict[str, tuple[float, dict | None]] = {} | |
| 31 | +_lock = threading.Lock() | |
| 32 | + | |
| 33 | + | |
| 34 | +def fetch_hub_profile(ka_id: str) -> dict | None: | |
| 35 | + """Profil du membre au hub Groupe KA, ou None (inconnu ou injoignable).""" | |
| 36 | + secret = os.environ.get("KA_SSO_SECRET") | |
| 37 | + if not secret or not ka_id: | |
| 38 | + return None | |
| 39 | + now = time.time() | |
| 40 | + with _lock: | |
| 41 | + hit = _cache.get(ka_id) | |
| 42 | + if hit and now - hit[0] < CACHE_TTL: | |
| 43 | + return hit[1] | |
| 44 | + data: dict | None = None | |
| 45 | + try: | |
| 46 | + ts = int(now) | |
| 47 | + sig = hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(), | |
| 48 | + hashlib.sha256).hexdigest() | |
| 49 | + r = requests.get( | |
| 50 | + f"{KA_HUB_URL}/api/sso/profile", | |
| 51 | + params={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 52 | + "ts": ts, "sig": sig}, | |
| 53 | + timeout=TIMEOUT) | |
| 54 | + if r.status_code == 200: | |
| 55 | + data = r.json() | |
| 56 | + if not isinstance(data, dict): | |
| 57 | + return None | |
| 58 | + elif r.status_code != 404: | |
| 59 | + return None # erreur transitoire (401, 5xx…) : pas de cache | |
| 60 | + except Exception: | |
| 61 | + return None # réseau/JSON : pas de cache | |
| 62 | + with _lock: | |
| 63 | + _cache[ka_id] = (now, data) # 200 -> data ; 404 -> None (négatif) | |
| 64 | + return data | |
| 65 | + | |
| 66 | + | |
| 67 | +def to_epoch(v) -> float | None: | |
| 68 | + """created_at du hub (epoch OU chaîne ISO) -> epoch secondes, sinon None.""" | |
| 69 | + if isinstance(v, (int, float)): | |
| 70 | + return float(v) | |
| 71 | + if isinstance(v, str) and v: | |
| 72 | + try: | |
| 73 | + dt = datetime.fromisoformat(v.replace("Z", "+00:00")) | |
| 74 | + if dt.tzinfo is None: # chaîne naïve du hub = UTC | |
| 75 | + dt = dt.replace(tzinfo=timezone.utc) | |
| 76 | + return dt.timestamp() | |
| 77 | + except ValueError: | |
| 78 | + return None | |
| 79 | + return None | |
added
autoka/seo.py
+1034 −0
@@ -0,0 +1,1034 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# seo.py : rendu serveur des pages publiques — le HTML complet (meta uniques, | |
| 5 | +# JSON-LD schema.org, contenu dans #root) est servi dès la première | |
| 6 | +# requête, puis React monte par-dessus et reprend la main. | |
| 7 | +# Inclut : pages programmatiques (marque, modèle, région, ville, | |
| 8 | +# carrosserie), sitemap.xml dynamique, robots.txt, 410 pour les | |
| 9 | +# véhicules retirés, 404 réels. | |
| 10 | +# ----------------------------------------------------------------------------- | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import html as html_lib | |
| 14 | +import json | |
| 15 | +import math | |
| 16 | +import re | |
| 17 | +import time | |
| 18 | +import unicodedata | |
| 19 | +from datetime import datetime, timezone | |
| 20 | +from pathlib import Path | |
| 21 | +from urllib.parse import quote | |
| 22 | + | |
| 23 | +from fastapi import APIRouter, Response | |
| 24 | +from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse | |
| 25 | + | |
| 26 | +from . import db | |
| 27 | + | |
| 28 | +BASE_URL = "https://www.auto-ka.com" | |
| 29 | +ROOT = Path(__file__).resolve().parent.parent | |
| 30 | +INDEX_PATH = ROOT / "frontend" / "dist" / "index.html" | |
| 31 | +SOURCES_PATH = ROOT / "data" / "sources.json" | |
| 32 | + | |
| 33 | +SITEMAP_CHUNK = 10_000 # URLs par sous-sitemap véhicules | |
| 34 | +LIST_SIZE = 24 # fiches listées en HTML sur les pages de recherche | |
| 35 | +MAPS_TTL = 300 # secondes de cache des dictionnaires slug -> valeur | |
| 36 | + | |
| 37 | +router = APIRouter() | |
| 38 | + | |
| 39 | +esc = html_lib.escape | |
| 40 | + | |
| 41 | + | |
| 42 | +# --- utilitaires -------------------------------------------------------------- | |
| 43 | + | |
| 44 | +def slugify(text: str, max_len: int = 70) -> str: | |
| 45 | + """Slug URL — DOIT rester synchrone avec slugify() de frontend/src/api.ts.""" | |
| 46 | + t = unicodedata.normalize("NFKD", text or "") | |
| 47 | + t = "".join(c for c in t if not unicodedata.combining(c)) | |
| 48 | + t = re.sub(r"[^a-zA-Z0-9]+", "-", t).strip("-").lower() | |
| 49 | + t = t[:max_len].rstrip("-") | |
| 50 | + return t or "x" | |
| 51 | + | |
| 52 | + | |
| 53 | +def fmt_price(p) -> str: | |
| 54 | + if p is None: | |
| 55 | + return "Prix sur demande" | |
| 56 | + return f"{int(round(p)):,}".replace(",", " ") + " $" | |
| 57 | + | |
| 58 | + | |
| 59 | +def fmt_km(km) -> str: | |
| 60 | + if km is None: | |
| 61 | + return "" | |
| 62 | + return f"{int(round(km)):,}".replace(",", " ") + " km" | |
| 63 | + | |
| 64 | + | |
| 65 | +def _date(ts) -> str: | |
| 66 | + try: | |
| 67 | + return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d") | |
| 68 | + except (TypeError, ValueError): | |
| 69 | + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") | |
| 70 | + | |
| 71 | + | |
| 72 | +_cache: dict[str, tuple[float, object]] = {} | |
| 73 | + | |
| 74 | + | |
| 75 | +def _cached(key: str, ttl: float, fn): | |
| 76 | + now = time.time() | |
| 77 | + hit = _cache.get(key) | |
| 78 | + if hit is not None and now - hit[0] < ttl: | |
| 79 | + return hit[1] | |
| 80 | + val = fn() | |
| 81 | + _cache[key] = (now, val) | |
| 82 | + return val | |
| 83 | + | |
| 84 | + | |
| 85 | +# --- dictionnaires slug -> valeur (base des pages programmatiques) ------------- | |
| 86 | + | |
| 87 | +# Seules les carrosseries « propres » ont droit à une page dédiée : les valeurs | |
| 88 | +# brutes des concessionnaires contiennent du bruit (« 4 P0RTES », « 2dr Car »…) | |
| 89 | +BODY_WHITELIST = {"VUS", "Berline", "Camionnette", "Fourgonnette", "Cabriolet", | |
| 90 | + "Coupé", "Hayon", "Familiale", "Compacte", "Sport"} | |
| 91 | + | |
| 92 | + | |
| 93 | +def _put(dct: dict, slug: str, info: dict) -> None: | |
| 94 | + """Insère en fusionnant les variantes de casse (RAV4 / Rav4 → même slug).""" | |
| 95 | + prev = dct.get(slug) | |
| 96 | + if prev is None: | |
| 97 | + dct[slug] = info | |
| 98 | + return | |
| 99 | + keep = info if info["n"] >= prev["n"] else prev | |
| 100 | + other = prev if keep is info else info | |
| 101 | + keep = dict(keep) | |
| 102 | + keep["n"] += other["n"] | |
| 103 | + keep["lm"] = max(keep["lm"] or 0, other["lm"] or 0) | |
| 104 | + dct[slug] = keep | |
| 105 | + | |
| 106 | + | |
| 107 | +def _load_maps() -> dict: | |
| 108 | + con = db.connect() | |
| 109 | + m: dict = {"make": {}, "model": {}, "region": {}, "city": {}, "body": {}} | |
| 110 | + for r in con.execute( | |
| 111 | + "SELECT kind, make, COUNT(*) n, MAX(updated_at) lm FROM vehicles" | |
| 112 | + " WHERE active=1 AND make<>'' GROUP BY kind, make"): | |
| 113 | + _put(m["make"].setdefault(r["kind"], {}), slugify(r["make"]), | |
| 114 | + {"value": r["make"], "n": r["n"], "lm": r["lm"]}) | |
| 115 | + for r in con.execute( | |
| 116 | + "SELECT make, model, COUNT(*) n, MAX(updated_at) lm FROM vehicles" | |
| 117 | + " WHERE active=1 AND kind='auto' AND make<>'' AND model<>''" | |
| 118 | + " GROUP BY make, model"): | |
| 119 | + _put(m["model"].setdefault(slugify(r["make"]), {}), slugify(r["model"]), | |
| 120 | + {"make": r["make"], "value": r["model"], "n": r["n"], "lm": r["lm"]}) | |
| 121 | + for r in con.execute( | |
| 122 | + "SELECT region, COUNT(*) n, MAX(updated_at) lm FROM vehicles" | |
| 123 | + " WHERE active=1 AND kind='auto' AND region<>'' GROUP BY region"): | |
| 124 | + m["region"][slugify(r["region"])] = {"value": r["region"], "n": r["n"], "lm": r["lm"]} | |
| 125 | + for r in con.execute( | |
| 126 | + "SELECT city, MAX(region) region, COUNT(*) n, MAX(updated_at) lm" | |
| 127 | + " FROM vehicles WHERE active=1 AND kind='auto' AND city<>'' GROUP BY city"): | |
| 128 | + m["city"][slugify(r["city"])] = { | |
| 129 | + "value": r["city"], "region": r["region"], "n": r["n"], "lm": r["lm"]} | |
| 130 | + for r in con.execute( | |
| 131 | + "SELECT body_type, COUNT(*) n, MAX(updated_at) lm FROM vehicles" | |
| 132 | + " WHERE active=1 AND kind='auto' AND body_type<>'' GROUP BY body_type"): | |
| 133 | + if r["body_type"] in BODY_WHITELIST: | |
| 134 | + m["body"][slugify(r["body_type"])] = { | |
| 135 | + "value": r["body_type"], "n": r["n"], "lm": r["lm"]} | |
| 136 | + con.close() | |
| 137 | + return m | |
| 138 | + | |
| 139 | + | |
| 140 | +def _maps() -> dict: | |
| 141 | + return _cached("maps", MAPS_TTL, _load_maps) | |
| 142 | + | |
| 143 | + | |
| 144 | +# --- gabarit HTML (frontend/dist/index.html) ----------------------------------- | |
| 145 | + | |
| 146 | +def _shell() -> str: | |
| 147 | + """index.html du build Vite, relu quand le fichier change (cache mtime).""" | |
| 148 | + mtime = INDEX_PATH.stat().st_mtime | |
| 149 | + hit = _cache.get("shell") | |
| 150 | + if hit is not None and hit[0] == mtime: | |
| 151 | + return hit[1] # type: ignore[return-value] | |
| 152 | + text = INDEX_PATH.read_text(encoding="utf-8") | |
| 153 | + _cache["shell"] = (mtime, text) | |
| 154 | + return text | |
| 155 | + | |
| 156 | + | |
| 157 | +def render_page(*, path: str, title: str, description: str, body: str = "", | |
| 158 | + robots: str | None = None, og_image: str | None = None, | |
| 159 | + jsonld: list[dict] | None = None, seo_ctx: dict | None = None, | |
| 160 | + og_type: str = "website", status: int = 200) -> HTMLResponse: | |
| 161 | + """Injecte meta + contenu dans le index.html du build (React reprend ensuite).""" | |
| 162 | + shell = _shell() | |
| 163 | + canonical = BASE_URL + path | |
| 164 | + shell = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", shell, | |
| 165 | + count=1, flags=re.S) | |
| 166 | + shell = re.sub(r'<meta name="description" content="[^"]*" />', | |
| 167 | + f'<meta name="description" content="{esc(description, quote=True)}" />', | |
| 168 | + shell, count=1) | |
| 169 | + extra: list[str] = [f'<link rel="canonical" href="{esc(canonical, quote=True)}" />'] | |
| 170 | + if robots: | |
| 171 | + extra.append(f'<meta name="robots" content="{robots}" />') | |
| 172 | + extra += [ | |
| 173 | + f'<link rel="alternate" hreflang="fr-ca" href="{esc(canonical, quote=True)}" />', | |
| 174 | + f'<link rel="alternate" hreflang="x-default" href="{esc(canonical, quote=True)}" />', | |
| 175 | + f'<meta property="og:title" content="{esc(title, quote=True)}" />', | |
| 176 | + f'<meta property="og:description" content="{esc(description, quote=True)}" />', | |
| 177 | + f'<meta property="og:url" content="{esc(canonical, quote=True)}" />', | |
| 178 | + f'<meta property="og:type" content="{og_type}" />', | |
| 179 | + '<meta property="og:site_name" content="Auto·Ka" />', | |
| 180 | + '<meta property="og:locale" content="fr_CA" />', | |
| 181 | + ] | |
| 182 | + if og_image: | |
| 183 | + extra.append(f'<meta property="og:image" content="{esc(og_image, quote=True)}" />') | |
| 184 | + extra.append('<meta name="twitter:card" content="summary_large_image" />') | |
| 185 | + for ld in (jsonld or []): | |
| 186 | + blob = json.dumps(ld, ensure_ascii=False).replace("</", "<\\/") | |
| 187 | + extra.append(f'<script type="application/ld+json">{blob}</script>') | |
| 188 | + if seo_ctx is not None: | |
| 189 | + blob = json.dumps(seo_ctx, ensure_ascii=False).replace("</", "<\\/") | |
| 190 | + extra.append(f"<script>window.__SEO_CTX__={blob}</script>") | |
| 191 | + shell = shell.replace("</head>", " " + "\n ".join(extra) + "\n </head>", 1) | |
| 192 | + shell = shell.replace('<div id="root"></div>', f'<div id="root">{body}</div>', 1) | |
| 193 | + return HTMLResponse(shell, status_code=status, | |
| 194 | + headers={"Cache-Control": "no-cache, must-revalidate"}) | |
| 195 | + | |
| 196 | + | |
| 197 | +# --- fragments HTML ------------------------------------------------------------- | |
| 198 | + | |
| 199 | +def _veh_slug(v: dict) -> str: | |
| 200 | + base = " ".join(str(x) for x in | |
| 201 | + [v.get("year"), v.get("make"), v.get("model")] | |
| 202 | + if x) or (v.get("title") or "vehicule") | |
| 203 | + return slugify(base) | |
| 204 | + | |
| 205 | + | |
| 206 | +def _veh_path(v: dict) -> str: | |
| 207 | + return f"/vehicule/{quote(v['uid'], safe='')}/{_veh_slug(v)}" | |
| 208 | + | |
| 209 | + | |
| 210 | +def _first_image(v: dict) -> str | None: | |
| 211 | + imgs = v.get("images") | |
| 212 | + if isinstance(imgs, str): | |
| 213 | + imgs = json.loads(imgs or "[]") | |
| 214 | + return imgs[0] if imgs else None | |
| 215 | + | |
| 216 | + | |
| 217 | +def _card(v: dict) -> str: | |
| 218 | + img = _first_image(v) | |
| 219 | + photo = (f'<img src="{esc(img, quote=True)}" alt="{esc(v.get("title") or "", quote=True)}"' | |
| 220 | + ' loading="lazy" decoding="async" width="640" height="420" />' | |
| 221 | + if img else '<div class="nopic" aria-hidden="true">🚗</div>') | |
| 222 | + year = f'<span class="year-chip">{v["year"]}</span>' if v.get("year") else "" | |
| 223 | + chip_vals = [c for c in (fmt_km(v.get("mileage_km")), v.get("transmission"), | |
| 224 | + v.get("fuel"), v.get("body_type")) if c][:4] | |
| 225 | + chips = "".join(f'<span class="spec-chip">{esc(c)}</span>' for c in chip_vals) | |
| 226 | + return ( | |
| 227 | + f'<a class="vcard" href="{_veh_path(v)}">' | |
| 228 | + f'<div class="photo">{photo}{year}</div>' | |
| 229 | + f'<div class="body"><h3>{esc(v.get("title") or "")}</h3>' | |
| 230 | + f'<div class="price">{fmt_price(v.get("price"))}</div>' | |
| 231 | + f'<div class="specs">{chips}</div>' | |
| 232 | + f'<div class="foot"><span>{esc(v.get("dealer_name") or v.get("source") or "")}</span>' | |
| 233 | + f'<span>{esc(v.get("city") or "")}</span></div>' | |
| 234 | + f"</div></a>") | |
| 235 | + | |
| 236 | + | |
| 237 | +def _crumbs_html(crumbs: list[tuple[str, str | None]]) -> str: | |
| 238 | + """crumbs = [(label, path|None)] — le dernier élément n'est pas un lien.""" | |
| 239 | + items = [] | |
| 240 | + for label, path in crumbs: | |
| 241 | + if path: | |
| 242 | + items.append(f'<a href="{esc(path, quote=True)}">{esc(label)}</a>') | |
| 243 | + else: | |
| 244 | + items.append(f"<span>{esc(label)}</span>") | |
| 245 | + return ('<nav class="breadcrumbs mono" aria-label="Fil d\'Ariane">' | |
| 246 | + + " › ".join(items) + "</nav>") | |
| 247 | + | |
| 248 | + | |
| 249 | +def _links_block(title: str, links: list[tuple[str, str]]) -> str: | |
| 250 | + if not links: | |
| 251 | + return "" | |
| 252 | + inner = "".join(f'<a class="spec-chip" href="{esc(p, quote=True)}">{esc(t)}</a>' | |
| 253 | + for t, p in links) | |
| 254 | + return (f'<section class="seo-block"><h2>{esc(title)}</h2>' | |
| 255 | + f'<div class="seo-links">{inner}</div></section>') | |
| 256 | + | |
| 257 | + | |
| 258 | +def _listing_body(*, h1: str, kicker: str, intro: str, st: dict, | |
| 259 | + vehicles: list[dict], sections: list[tuple[str, list[tuple[str, str]]]], | |
| 260 | + crumbs: list[tuple[str, str | None]] | None = None) -> str: | |
| 261 | + parts = ['<div class="container">'] | |
| 262 | + if crumbs: | |
| 263 | + parts.append(_crumbs_html(crumbs)) | |
| 264 | + stats_html = "" | |
| 265 | + if st.get("n"): | |
| 266 | + cells = [f'<div class="hstat"><b>{st["n"]:,}</b> en vente</div>'.replace(",", " ")] | |
| 267 | + if st.get("avg_p"): | |
| 268 | + cells.append(f'<div class="hstat">prix moyen <b>{fmt_price(st["avg_p"])}</b></div>') | |
| 269 | + if st.get("med_p"): | |
| 270 | + cells.append(f'<div class="hstat">prix médian <b>{fmt_price(st["med_p"])}</b></div>') | |
| 271 | + if st.get("ymin") and st.get("ymax"): | |
| 272 | + cells.append(f'<div class="hstat">années <b>{st["ymin"]}–{st["ymax"]}</b></div>') | |
| 273 | + stats_html = f'<div class="hero-stats">{"".join(cells)}</div>' | |
| 274 | + parts.append( | |
| 275 | + f'<section class="hero"><span class="kicker">{esc(kicker)}</span>' | |
| 276 | + f"<h1>{esc(h1)}</h1>" | |
| 277 | + f'<p class="sub">{esc(intro)}</p>{stats_html}</section>') | |
| 278 | + if vehicles: | |
| 279 | + shown = min(len(vehicles), LIST_SIZE) | |
| 280 | + label = f'{st["n"]:,}'.replace(",", " ") if st.get("n") else str(shown) | |
| 281 | + parts.append(f'<div class="result-bar"><h2><span class="count">{label}</span>' | |
| 282 | + " véhicules à vendre</h2></div>") | |
| 283 | + parts.append('<div class="vgrid">' + "".join(_card(v) for v in vehicles[:LIST_SIZE]) | |
| 284 | + + "</div>") | |
| 285 | + for title, links in sections: | |
| 286 | + parts.append(_links_block(title, links)) | |
| 287 | + parts.append("</div>") | |
| 288 | + return "".join(parts) | |
| 289 | + | |
| 290 | + | |
| 291 | +# --- requêtes ------------------------------------------------------------------ | |
| 292 | + | |
| 293 | +def _stats(con, where: str, args: list) -> dict: | |
| 294 | + row = con.execute( | |
| 295 | + f"SELECT COUNT(*) n, ROUND(AVG(price)) avg_p, MIN(year) ymin, MAX(year) ymax" | |
| 296 | + f" FROM vehicles WHERE active=1 AND {where}", args).fetchone() | |
| 297 | + st = dict(row) | |
| 298 | + n_priced = con.execute( | |
| 299 | + f"SELECT COUNT(*) c FROM vehicles WHERE active=1 AND price IS NOT NULL" | |
| 300 | + f" AND {where}", args).fetchone()["c"] | |
| 301 | + st["med_p"] = None | |
| 302 | + if n_priced: | |
| 303 | + st["med_p"] = con.execute( | |
| 304 | + f"SELECT price FROM vehicles WHERE active=1 AND price IS NOT NULL AND {where}" | |
| 305 | + f" ORDER BY price LIMIT 1 OFFSET ?", args + [(n_priced - 1) // 2], | |
| 306 | + ).fetchone()["price"] | |
| 307 | + return st | |
| 308 | + | |
| 309 | + | |
| 310 | +def _fetch(con, where: str, args: list, limit: int = LIST_SIZE) -> list[dict]: | |
| 311 | + rows = con.execute( | |
| 312 | + f"SELECT * FROM vehicles WHERE active=1 AND {where}" | |
| 313 | + f" ORDER BY price IS NULL, price ASC LIMIT ?", args + [limit]).fetchall() | |
| 314 | + return [dict(r) for r in rows] | |
| 315 | + | |
| 316 | + | |
| 317 | +# --- JSON-LD -------------------------------------------------------------------- | |
| 318 | + | |
| 319 | +def _ld_itemlist(name: str, n: int, vehicles: list[dict]) -> dict: | |
| 320 | + return { | |
| 321 | + "@context": "https://schema.org", "@type": "ItemList", "name": name, | |
| 322 | + "numberOfItems": n, | |
| 323 | + "itemListElement": [ | |
| 324 | + {"@type": "ListItem", "position": i + 1, "url": BASE_URL + _veh_path(v)} | |
| 325 | + for i, v in enumerate(vehicles[:LIST_SIZE])], | |
| 326 | + } | |
| 327 | + | |
| 328 | + | |
| 329 | +def _ld_crumbs(crumbs: list[tuple[str, str | None]]) -> dict: | |
| 330 | + return { | |
| 331 | + "@context": "https://schema.org", "@type": "BreadcrumbList", | |
| 332 | + "itemListElement": [ | |
| 333 | + {"@type": "ListItem", "position": i + 1, "name": label, | |
| 334 | + **({"item": BASE_URL + path} if path else {})} | |
| 335 | + for i, (label, path) in enumerate(crumbs)], | |
| 336 | + } | |
| 337 | + | |
| 338 | + | |
| 339 | +def _ld_vehicle(v: dict, canonical: str) -> dict: | |
| 340 | + vtype = {"auto": "Car", "moto": "Motorcycle", "scooter": "Motorcycle"}.get( | |
| 341 | + v.get("kind") or "auto", "Vehicle") | |
| 342 | + imgs = v.get("images") | |
| 343 | + if isinstance(imgs, str): | |
| 344 | + imgs = json.loads(imgs or "[]") | |
| 345 | + ld: dict = { | |
| 346 | + "@context": "https://schema.org", "@type": vtype, | |
| 347 | + "name": v.get("title") or "", "url": canonical, | |
| 348 | + "itemCondition": "https://schema.org/UsedCondition", | |
| 349 | + } | |
| 350 | + if v.get("make"): | |
| 351 | + ld["brand"] = {"@type": "Brand", "name": v["make"]} | |
| 352 | + if v.get("model"): | |
| 353 | + ld["model"] = v["model"] | |
| 354 | + if v.get("year"): | |
| 355 | + ld["vehicleModelDate"] = str(v["year"]) | |
| 356 | + if v.get("mileage_km") is not None: | |
| 357 | + ld["mileageFromOdometer"] = {"@type": "QuantitativeValue", | |
| 358 | + "value": int(v["mileage_km"]), "unitCode": "KMT"} | |
| 359 | + if v.get("transmission"): | |
| 360 | + ld["vehicleTransmission"] = v["transmission"] | |
| 361 | + if v.get("fuel"): | |
| 362 | + ld["fuelType"] = v["fuel"] | |
| 363 | + if v.get("drivetrain"): | |
| 364 | + ld["driveWheelConfiguration"] = v["drivetrain"] | |
| 365 | + if v.get("body_type"): | |
| 366 | + ld["bodyType"] = v["body_type"] | |
| 367 | + if v.get("exterior_color"): | |
| 368 | + ld["color"] = v["exterior_color"] | |
| 369 | + if v.get("doors"): | |
| 370 | + ld["numberOfDoors"] = v["doors"] | |
| 371 | + if v.get("seats"): | |
| 372 | + ld["seatingCapacity"] = v["seats"] | |
| 373 | + if v.get("vin"): | |
| 374 | + ld["vehicleIdentificationNumber"] = v["vin"] | |
| 375 | + if v.get("description"): | |
| 376 | + ld["description"] = (v["description"] or "")[:400] | |
| 377 | + if imgs: | |
| 378 | + ld["image"] = imgs[:5] | |
| 379 | + if v.get("price") is not None: | |
| 380 | + ld["offers"] = { | |
| 381 | + "@type": "Offer", "price": int(round(v["price"])), "priceCurrency": "CAD", | |
| 382 | + "availability": "https://schema.org/InStock", | |
| 383 | + "itemCondition": "https://schema.org/UsedCondition", "url": canonical, | |
| 384 | + "seller": {"@type": "AutoDealer", | |
| 385 | + "name": v.get("dealer_name") or v.get("source") or "", | |
| 386 | + "address": {"@type": "PostalAddress", | |
| 387 | + "addressLocality": v.get("city") or "", | |
| 388 | + "addressRegion": "QC", "addressCountry": "CA"}}, | |
| 389 | + } | |
| 390 | + return ld | |
| 391 | + | |
| 392 | + | |
| 393 | +# --- blocs de liens partagés ----------------------------------------------------- | |
| 394 | + | |
| 395 | +def _links_makes(kind: str = "auto", limit: int = 30, skip: str | None = None) -> list[tuple[str, str]]: | |
| 396 | + prefix = {"auto": "/usagees/", "moto": "/motos/", "scooter": "/scooters/"}[kind] | |
| 397 | + makes = _maps()["make"].get(kind, {}) | |
| 398 | + out = [] | |
| 399 | + for slug, info in sorted(makes.items(), key=lambda kv: -kv[1]["n"])[: limit + 1]: | |
| 400 | + if skip and info["value"] == skip: | |
| 401 | + continue | |
| 402 | + out.append((f'{info["value"]} ({info["n"]})', prefix + slug)) | |
| 403 | + return out[:limit] | |
| 404 | + | |
| 405 | + | |
| 406 | +def _links_regions(skip: str | None = None) -> list[tuple[str, str]]: | |
| 407 | + return [(f'{i["value"]} ({i["n"]})', "/region/" + s) | |
| 408 | + for s, i in sorted(_maps()["region"].items(), key=lambda kv: -kv[1]["n"]) | |
| 409 | + if i["value"] != skip] | |
| 410 | + | |
| 411 | + | |
| 412 | +def _links_cities(region: str | None = None, limit: int = 30, | |
| 413 | + skip: str | None = None) -> list[tuple[str, str]]: | |
| 414 | + out = [] | |
| 415 | + for s, i in sorted(_maps()["city"].items(), key=lambda kv: -kv[1]["n"]): | |
| 416 | + if region and i["region"] != region: | |
| 417 | + continue | |
| 418 | + if skip and i["value"] == skip: | |
| 419 | + continue | |
| 420 | + out.append((f'{i["value"]} ({i["n"]})', "/ville/" + s)) | |
| 421 | + return out[:limit] | |
| 422 | + | |
| 423 | + | |
| 424 | +def _links_bodies(skip: str | None = None) -> list[tuple[str, str]]: | |
| 425 | + return [(f'{i["value"]} ({i["n"]})', "/carrosserie/" + s) | |
| 426 | + for s, i in sorted(_maps()["body"].items(), key=lambda kv: -kv[1]["n"]) | |
| 427 | + if i["value"] != skip] | |
| 428 | + | |
| 429 | + | |
| 430 | +def _links_models(make_slug: str, limit: int = 40, | |
| 431 | + skip: str | None = None) -> list[tuple[str, str]]: | |
| 432 | + models = _maps()["model"].get(make_slug, {}) | |
| 433 | + out = [] | |
| 434 | + for s, i in sorted(models.items(), key=lambda kv: -kv[1]["n"]): | |
| 435 | + if skip and i["value"] == skip: | |
| 436 | + continue | |
| 437 | + out.append((f'{i["value"]} ({i["n"]})', f"/usagees/{make_slug}/{s}")) | |
| 438 | + return out[:limit] | |
| 439 | + | |
| 440 | + | |
| 441 | +# --- page 404 --------------------------------------------------------------------- | |
| 442 | + | |
| 443 | +def render_404(path: str = "/introuvable") -> HTMLResponse: | |
| 444 | + body = ('<div class="container"><div class="notice"><div class="big">🧭</div>' | |
| 445 | + "<h1>Page introuvable</h1><p>Le lien demandé n'existe pas ou plus.</p>" | |
| 446 | + '<p><a class="btn ghost" href="/">← Toutes les voitures usagées</a></p>' | |
| 447 | + "</div></div>") | |
| 448 | + return render_page(path=path, title="Page introuvable | Auto·Ka", | |
| 449 | + description="Cette page n'existe pas. Retrouvez toutes les " | |
| 450 | + "voitures usagées à vendre au Québec sur Auto-Ka.", | |
| 451 | + body=body, robots="noindex, follow", status=404) | |
| 452 | + | |
| 453 | + | |
| 454 | +# --- routes : accueils --------------------------------------------------------------- | |
| 455 | + | |
| 456 | +_KIND_HOME = { | |
| 457 | + "auto": { | |
| 458 | + "path": "/", "who": "voitures usagées", | |
| 459 | + "title": "Voitures usagées à vendre au Québec — {n} véhicules | Auto·Ka", | |
| 460 | + "h1": "Toutes les voitures usagées à vendre au Québec. Un seul endroit.", | |
| 461 | + "intro": ("Auto-Ka visite les sites des concessionnaires et marchands " | |
| 462 | + "d'occasion de toutes les régions du Québec, normalise chaque " | |
| 463 | + "annonce et détecte les nouveautés, les ventes et les baisses " | |
| 464 | + "de prix — automatiquement."), | |
| 465 | + }, | |
| 466 | + "moto": { | |
| 467 | + "path": "/motos", "who": "motos usagées", | |
| 468 | + "title": "Motos usagées à vendre au Québec — {n} motos | Auto·Ka", | |
| 469 | + "h1": "Toutes les motos usagées à vendre au Québec. Un seul endroit.", | |
| 470 | + "intro": ("Les inventaires des concessionnaires moto du Québec — " | |
| 471 | + "Harley-Davidson, Honda, Yamaha, Kawasaki, BMW et plus — " | |
| 472 | + "agrégés à la source et tenus à jour automatiquement."), | |
| 473 | + }, | |
| 474 | + "scooter": { | |
| 475 | + "path": "/scooters", "who": "scooters usagés", | |
| 476 | + "title": "Scooters usagés à vendre au Québec — {n} scooters | Auto·Ka", | |
| 477 | + "h1": "Tous les scooters usagés à vendre au Québec. Un seul endroit.", | |
| 478 | + "intro": ("Les scooters usagés des concessionnaires du Québec — Vespa, " | |
| 479 | + "Honda, Yamaha, Kymco et plus — agrégés à la source et tenus " | |
| 480 | + "à jour automatiquement."), | |
| 481 | + }, | |
| 482 | +} | |
| 483 | + | |
| 484 | + | |
| 485 | +def _home(kind: str) -> HTMLResponse: | |
| 486 | + cfg = _KIND_HOME[kind] | |
| 487 | + con = db.connect() | |
| 488 | + st = _stats(con, "kind=?", [kind]) | |
| 489 | + n_sources = con.execute( | |
| 490 | + "SELECT COUNT(DISTINCT source) c FROM vehicles WHERE active=1 AND kind=?", | |
| 491 | + [kind]).fetchone()["c"] | |
| 492 | + vehicles = _fetch(con, "kind=?", [kind]) | |
| 493 | + con.close() | |
| 494 | + n_txt = f'{st["n"]:,}'.replace(",", " ") | |
| 495 | + title = cfg["title"].format(n=n_txt) | |
| 496 | + description = (f"{n_txt} {cfg['who']} à vendre chez {n_sources} concessionnaires " | |
| 497 | + f"du Québec, agrégées directement depuis leurs sites." | |
| 498 | + + (f" Prix moyen {fmt_price(st['avg_p'])}." if st.get("avg_p") else "") | |
| 499 | + + " Mise à jour continue.") | |
| 500 | + sections: list[tuple[str, list[tuple[str, str]]]] = [ | |
| 501 | + ("Par marque", _links_makes(kind)), | |
| 502 | + ] | |
| 503 | + if kind == "auto": | |
| 504 | + sections += [ | |
| 505 | + ("Par région", _links_regions()), | |
| 506 | + ("Par carrosserie", _links_bodies()), | |
| 507 | + ("Par ville", _links_cities()), | |
| 508 | + ("Aussi sur Auto-Ka", [("Motos usagées", "/motos"), | |
| 509 | + ("Scooters usagés", "/scooters"), | |
| 510 | + ("Statistiques du marché", "/stats"), | |
| 511 | + ("Concessionnaires sources", "/sources")]), | |
| 512 | + ] | |
| 513 | + body = _listing_body(h1=cfg["h1"], kicker="Agrégateur indépendant — direct des concessionnaires", | |
| 514 | + intro=cfg["intro"], st=st, vehicles=vehicles, sections=sections) | |
| 515 | + jsonld = [_ld_itemlist(title, st["n"], vehicles)] | |
| 516 | + if kind == "auto": | |
| 517 | + jsonld.insert(0, { | |
| 518 | + "@context": "https://schema.org", "@type": "WebSite", | |
| 519 | + "name": "Auto·Ka", "url": BASE_URL + "/", | |
| 520 | + "potentialAction": {"@type": "SearchAction", | |
| 521 | + "target": f"{BASE_URL}/?q={{search_term_string}}", | |
| 522 | + "query-input": "required name=search_term_string"}, | |
| 523 | + }) | |
| 524 | + return render_page(path=cfg["path"], title=title, description=description, | |
| 525 | + body=body, jsonld=jsonld) | |
| 526 | + | |
| 527 | + | |
| 528 | +@router.get("/", response_model=None, include_in_schema=False) | |
| 529 | +def home_auto(): | |
| 530 | + return _home("auto") | |
| 531 | + | |
| 532 | + | |
| 533 | +@router.get("/motos", response_model=None, include_in_schema=False) | |
| 534 | +def home_moto(): | |
| 535 | + return _home("moto") | |
| 536 | + | |
| 537 | + | |
| 538 | +@router.get("/scooters", response_model=None, include_in_schema=False) | |
| 539 | +def home_scooter(): | |
| 540 | + return _home("scooter") | |
| 541 | + | |
| 542 | + | |
| 543 | +# --- routes : pages programmatiques ---------------------------------------------- | |
| 544 | + | |
| 545 | +def _listing_page(*, path: str, kind: str, where: str, args: list, h1: str, | |
| 546 | + title_fmt: str, desc_intro: str, filters: dict, | |
| 547 | + sections: list[tuple[str, list[tuple[str, str]]]], | |
| 548 | + crumbs: list[tuple[str, str | None]]) -> HTMLResponse: | |
| 549 | + con = db.connect() | |
| 550 | + st = _stats(con, where, args) | |
| 551 | + vehicles = _fetch(con, where, args) | |
| 552 | + con.close() | |
| 553 | + if not st["n"]: | |
| 554 | + return render_404(path) | |
| 555 | + n_txt = f'{st["n"]:,}'.replace(",", " ") | |
| 556 | + title = title_fmt.format(n=n_txt) | |
| 557 | + description = (f"{desc_intro} {n_txt} en inventaire" | |
| 558 | + + (f", prix moyen {fmt_price(st['avg_p'])}" if st.get("avg_p") else "") | |
| 559 | + + (f", médian {fmt_price(st['med_p'])}" if st.get("med_p") else "") | |
| 560 | + + ". Annonces agrégées directement des sites des concessionnaires," | |
| 561 | + " mises à jour en continu.") | |
| 562 | + intro = (f"{n_txt} annonces vérifiées chez les concessionnaires du Québec." | |
| 563 | + + (f" Prix moyen {fmt_price(st['avg_p'])}, médian {fmt_price(st['med_p'])}." | |
| 564 | + if st.get("avg_p") and st.get("med_p") else "") | |
| 565 | + + " Chaque fiche renvoie vers l'annonce originale du commerçant.") | |
| 566 | + seo_ctx = {"path": path, "kind": kind, "h1": h1, "intro": intro, "filters": filters} | |
| 567 | + body = _listing_body(h1=h1, kicker="Voitures usagées — direct des concessionnaires" | |
| 568 | + if kind == "auto" else "Direct des concessionnaires", | |
| 569 | + intro=intro, st=st, vehicles=vehicles, | |
| 570 | + sections=sections, crumbs=crumbs) | |
| 571 | + return render_page(path=path, title=title, description=description, body=body, | |
| 572 | + jsonld=[_ld_crumbs(crumbs), _ld_itemlist(h1, st["n"], vehicles)], | |
| 573 | + seo_ctx=seo_ctx) | |
| 574 | + | |
| 575 | + | |
| 576 | +@router.get("/usagees/{make_slug}", response_model=None, include_in_schema=False) | |
| 577 | +def page_make(make_slug: str): | |
| 578 | + info = _maps()["make"].get("auto", {}).get(make_slug) | |
| 579 | + if not info: | |
| 580 | + return render_404(f"/usagees/{make_slug}") | |
| 581 | + make = info["value"] | |
| 582 | + return _listing_page( | |
| 583 | + path=f"/usagees/{make_slug}", kind="auto", | |
| 584 | + where="kind='auto' AND make=? COLLATE NOCASE", args=[make], | |
| 585 | + h1=f"{make} usagées à vendre au Québec", | |
| 586 | + title_fmt=f"{make} usagées à vendre au Québec — {{n}} en inventaire | Auto·Ka", | |
| 587 | + desc_intro=f"Toutes les {make} usagées à vendre chez les concessionnaires du Québec.", | |
| 588 | + filters={"make": make}, | |
| 589 | + sections=[(f"Modèles {make}", _links_models(make_slug)), | |
| 590 | + ("Autres marques", _links_makes("auto", 20, skip=make)), | |
| 591 | + ("Par région", _links_regions())], | |
| 592 | + crumbs=[("Accueil", "/"), (f"{make} usagées", None)]) | |
| 593 | + | |
| 594 | + | |
| 595 | +@router.get("/usagees/{make_slug}/{model_slug}", response_model=None, include_in_schema=False) | |
| 596 | +def page_model(make_slug: str, model_slug: str): | |
| 597 | + info = _maps()["model"].get(make_slug, {}).get(model_slug) | |
| 598 | + if not info: | |
| 599 | + return render_404(f"/usagees/{make_slug}/{model_slug}") | |
| 600 | + make, model = info["make"], info["value"] | |
| 601 | + return _listing_page( | |
| 602 | + path=f"/usagees/{make_slug}/{model_slug}", kind="auto", | |
| 603 | + where="kind='auto' AND make=? COLLATE NOCASE AND model LIKE ?", args=[make, f"{model}%"], | |
| 604 | + h1=f"{make} {model} usagés à vendre au Québec", | |
| 605 | + title_fmt=f"{make} {model} usagés à vendre au Québec — {{n}} en inventaire | Auto·Ka", | |
| 606 | + desc_intro=f"Tous les {make} {model} usagés à vendre chez les concessionnaires du Québec.", | |
| 607 | + filters={"make": make, "model": model}, | |
| 608 | + sections=[(f"Autres modèles {make}", _links_models(make_slug, skip=model)), | |
| 609 | + ("Autres marques", _links_makes("auto", 15, skip=make))], | |
| 610 | + crumbs=[("Accueil", "/"), (f"{make} usagées", f"/usagees/{make_slug}"), | |
| 611 | + (f"{make} {model}", None)]) | |
| 612 | + | |
| 613 | + | |
| 614 | +@router.get("/motos/{make_slug}", response_model=None, include_in_schema=False) | |
| 615 | +def page_moto_make(make_slug: str): | |
| 616 | + info = _maps()["make"].get("moto", {}).get(make_slug) | |
| 617 | + if not info: | |
| 618 | + return render_404(f"/motos/{make_slug}") | |
| 619 | + make = info["value"] | |
| 620 | + return _listing_page( | |
| 621 | + path=f"/motos/{make_slug}", kind="moto", | |
| 622 | + where="kind='moto' AND make=? COLLATE NOCASE", args=[make], | |
| 623 | + h1=f"Motos {make} usagées à vendre au Québec", | |
| 624 | + title_fmt=f"Motos {make} usagées à vendre au Québec — {{n}} en inventaire | Auto·Ka", | |
| 625 | + desc_intro=f"Toutes les motos {make} usagées chez les concessionnaires du Québec.", | |
| 626 | + filters={"make": make}, | |
| 627 | + sections=[("Autres marques de motos", _links_makes("moto", 20, skip=make)), | |
| 628 | + ("Aussi sur Auto-Ka", [("Voitures usagées", "/"), | |
| 629 | + ("Scooters usagés", "/scooters")])], | |
| 630 | + crumbs=[("Accueil", "/"), ("Motos", "/motos"), (make, None)]) | |
| 631 | + | |
| 632 | + | |
| 633 | +@router.get("/scooters/{make_slug}", response_model=None, include_in_schema=False) | |
| 634 | +def page_scooter_make(make_slug: str): | |
| 635 | + info = _maps()["make"].get("scooter", {}).get(make_slug) | |
| 636 | + if not info: | |
| 637 | + return render_404(f"/scooters/{make_slug}") | |
| 638 | + make = info["value"] | |
| 639 | + return _listing_page( | |
| 640 | + path=f"/scooters/{make_slug}", kind="scooter", | |
| 641 | + where="kind='scooter' AND make=? COLLATE NOCASE", args=[make], | |
| 642 | + h1=f"Scooters {make} usagés à vendre au Québec", | |
| 643 | + title_fmt=f"Scooters {make} usagés à vendre au Québec — {{n}} en inventaire | Auto·Ka", | |
| 644 | + desc_intro=f"Tous les scooters {make} usagés chez les concessionnaires du Québec.", | |
| 645 | + filters={"make": make}, | |
| 646 | + sections=[("Autres marques de scooters", _links_makes("scooter", 20, skip=make)), | |
| 647 | + ("Aussi sur Auto-Ka", [("Voitures usagées", "/"), ("Motos usagées", "/motos")])], | |
| 648 | + crumbs=[("Accueil", "/"), ("Scooters", "/scooters"), (make, None)]) | |
| 649 | + | |
| 650 | + | |
| 651 | +@router.get("/region/{slug}", response_model=None, include_in_schema=False) | |
| 652 | +def page_region(slug: str): | |
| 653 | + info = _maps()["region"].get(slug) | |
| 654 | + if not info: | |
| 655 | + return render_404(f"/region/{slug}") | |
| 656 | + region = info["value"] | |
| 657 | + return _listing_page( | |
| 658 | + path=f"/region/{slug}", kind="auto", | |
| 659 | + where="kind='auto' AND region=?", args=[region], | |
| 660 | + h1=f"Voitures usagées à vendre — {region}", | |
| 661 | + title_fmt=f"Voitures usagées à vendre — {region} ({{n}}) | Auto·Ka", | |
| 662 | + desc_intro=f"Toutes les voitures usagées à vendre dans la région {region}.", | |
| 663 | + filters={"region": region}, | |
| 664 | + sections=[(f"Villes — {region}", _links_cities(region=region)), | |
| 665 | + ("Autres régions", _links_regions(skip=region)), | |
| 666 | + ("Marques populaires", _links_makes("auto", 15))], | |
| 667 | + crumbs=[("Accueil", "/"), (region, None)]) | |
| 668 | + | |
| 669 | + | |
| 670 | +@router.get("/ville/{slug}", response_model=None, include_in_schema=False) | |
| 671 | +def page_city(slug: str): | |
| 672 | + info = _maps()["city"].get(slug) | |
| 673 | + if not info: | |
| 674 | + return render_404(f"/ville/{slug}") | |
| 675 | + city, region = info["value"], info["region"] | |
| 676 | + region_slug = slugify(region) if region else None | |
| 677 | + crumbs: list[tuple[str, str | None]] = [("Accueil", "/")] | |
| 678 | + if region: | |
| 679 | + crumbs.append((region, f"/region/{region_slug}")) | |
| 680 | + crumbs.append((city, None)) | |
| 681 | + return _listing_page( | |
| 682 | + path=f"/ville/{slug}", kind="auto", | |
| 683 | + where="kind='auto' AND city=?", args=[city], | |
| 684 | + h1=f"Voitures usagées à vendre à {city}", | |
| 685 | + title_fmt=f"Voitures usagées à vendre à {city} ({{n}}) | Auto·Ka", | |
| 686 | + desc_intro=f"Toutes les voitures usagées à vendre chez les concessionnaires de {city}.", | |
| 687 | + filters={"city": city}, | |
| 688 | + sections=([(f"Autres villes — {region}", _links_cities(region=region, skip=city))] | |
| 689 | + if region else []) | |
| 690 | + + [("Marques populaires", _links_makes("auto", 15)), | |
| 691 | + ("Par région", _links_regions())], | |
| 692 | + crumbs=crumbs) | |
| 693 | + | |
| 694 | + | |
| 695 | +@router.get("/carrosserie/{slug}", response_model=None, include_in_schema=False) | |
| 696 | +def page_body(slug: str): | |
| 697 | + info = _maps()["body"].get(slug) | |
| 698 | + if not info: | |
| 699 | + return render_404(f"/carrosserie/{slug}") | |
| 700 | + body_type = info["value"] | |
| 701 | + return _listing_page( | |
| 702 | + path=f"/carrosserie/{slug}", kind="auto", | |
| 703 | + where="kind='auto' AND body_type=?", args=[body_type], | |
| 704 | + h1=f"{body_type} usagés à vendre au Québec", | |
| 705 | + title_fmt=f"{body_type} usagés à vendre au Québec ({{n}}) | Auto·Ka", | |
| 706 | + desc_intro=f"Tous les {body_type} usagés à vendre chez les concessionnaires du Québec.", | |
| 707 | + filters={"body_type": body_type}, | |
| 708 | + sections=[("Autres carrosseries", _links_bodies(skip=body_type)), | |
| 709 | + ("Marques populaires", _links_makes("auto", 15))], | |
| 710 | + crumbs=[("Accueil", "/"), (body_type, None)]) | |
| 711 | + | |
| 712 | + | |
| 713 | +# --- routes : fiches véhicule ------------------------------------------------------ | |
| 714 | + | |
| 715 | +def _get_vehicle(uid: str) -> dict | None: | |
| 716 | + con = db.connect() | |
| 717 | + row = con.execute("SELECT * FROM vehicles WHERE uid=?", (uid,)).fetchone() | |
| 718 | + v = dict(row) if row is not None else None | |
| 719 | + if v is not None: | |
| 720 | + v["images"] = json.loads(v.get("images") or "[]") | |
| 721 | + v["features"] = json.loads(v.get("features") or "[]") | |
| 722 | + if v.get("make") and v.get("model"): | |
| 723 | + v["similar"] = [dict(r) for r in con.execute( | |
| 724 | + "SELECT * FROM vehicles WHERE active=1 AND make=? AND model LIKE ?" | |
| 725 | + " AND uid<>? ORDER BY price IS NULL, price ASC LIMIT 6", | |
| 726 | + (v["make"], f'{v["model"].split()[0]}%', uid)).fetchall()] | |
| 727 | + else: | |
| 728 | + v["similar"] = [] | |
| 729 | + con.close() | |
| 730 | + return v | |
| 731 | + | |
| 732 | + | |
| 733 | +def _fiche_crumbs(v: dict) -> list[tuple[str, str | None]]: | |
| 734 | + crumbs: list[tuple[str, str | None]] = [("Accueil", "/")] | |
| 735 | + if v.get("kind") == "moto": | |
| 736 | + crumbs = [("Accueil", "/"), ("Motos", "/motos")] | |
| 737 | + if v.get("make"): | |
| 738 | + crumbs.append((v["make"], f'/motos/{slugify(v["make"])}')) | |
| 739 | + elif v.get("kind") == "scooter": | |
| 740 | + crumbs = [("Accueil", "/"), ("Scooters", "/scooters")] | |
| 741 | + if v.get("make"): | |
| 742 | + crumbs.append((v["make"], f'/scooters/{slugify(v["make"])}')) | |
| 743 | + else: | |
| 744 | + if v.get("make"): | |
| 745 | + crumbs.append((f'{v["make"]} usagées', f'/usagees/{slugify(v["make"])}')) | |
| 746 | + if v.get("model"): | |
| 747 | + crumbs.append((f'{v["make"]} {v["model"]}', | |
| 748 | + f'/usagees/{slugify(v["make"])}/{slugify(v["model"])}')) | |
| 749 | + crumbs.append((v.get("title") or "Fiche", None)) | |
| 750 | + return crumbs | |
| 751 | + | |
| 752 | + | |
| 753 | +def _fiche_parent_path(v: dict) -> str: | |
| 754 | + """Page catégorie la plus précise pour un véhicule (cible des liens 410).""" | |
| 755 | + crumbs = _fiche_crumbs(v) | |
| 756 | + for label, path in reversed(crumbs): | |
| 757 | + if path: | |
| 758 | + return path | |
| 759 | + return "/" | |
| 760 | + | |
| 761 | + | |
| 762 | +def _render_410(v: dict, path: str) -> HTMLResponse: | |
| 763 | + parent = _fiche_parent_path(v) | |
| 764 | + links = [f'<a class="btn ghost" href="{esc(parent, quote=True)}">' | |
| 765 | + f'Voir les véhicules similaires →</a>'] | |
| 766 | + if v.get("make"): | |
| 767 | + links.append(f'<a class="btn ghost" href="/usagees/{slugify(v["make"])}">' | |
| 768 | + f'Toutes les {esc(v["make"])} usagées</a>') | |
| 769 | + body = ('<div class="container"><div class="notice"><div class="big">🚗</div>' | |
| 770 | + f'<h1>{esc(v.get("title") or "Véhicule")} — vendu ou retiré</h1>' | |
| 771 | + "<p>Cette annonce n'est plus en ligne chez le concessionnaire. " | |
| 772 | + "L'inventaire évolue chaque jour — voici où continuer :</p>" | |
| 773 | + f'<p>{" ".join(links)}</p></div></div>') | |
| 774 | + return render_page(path=path, | |
| 775 | + title=f'{v.get("title") or "Véhicule"} — vendu ou retiré | Auto·Ka', | |
| 776 | + description="Cette annonce a été retirée. Consultez les véhicules " | |
| 777 | + "similaires à vendre au Québec sur Auto-Ka.", | |
| 778 | + body=body, robots="noindex, follow", status=410) | |
| 779 | + | |
| 780 | + | |
| 781 | +def _spec_rows(v: dict) -> list[tuple[str, str]]: | |
| 782 | + rows = [ | |
| 783 | + ("Année", str(v["year"]) if v.get("year") else ""), | |
| 784 | + ("Kilométrage", fmt_km(v.get("mileage_km"))), | |
| 785 | + ("Transmission", v.get("transmission") or ""), | |
| 786 | + ("Carburant", v.get("fuel") or ""), | |
| 787 | + ("Motricité", v.get("drivetrain") or ""), | |
| 788 | + ("Carrosserie", v.get("body_type") or ""), | |
| 789 | + ("Moteur", v.get("engine") or ""), | |
| 790 | + ("Couleur ext.", v.get("exterior_color") or ""), | |
| 791 | + ("Couleur int.", v.get("interior_color") or ""), | |
| 792 | + ("Portes", str(v["doors"]) if v.get("doors") else ""), | |
| 793 | + ("Places", str(v["seats"]) if v.get("seats") else ""), | |
| 794 | + ("NIV (VIN)", v.get("vin") or ""), | |
| 795 | + ("No de stock", v.get("stock_number") or ""), | |
| 796 | + ] | |
| 797 | + return [(k, val) for k, val in rows if val] | |
| 798 | + | |
| 799 | + | |
| 800 | +@router.get("/vehicule/{uid}", response_model=None, include_in_schema=False) | |
| 801 | +def fiche_redirect(uid: str): | |
| 802 | + v = _get_vehicle(uid) | |
| 803 | + if v is None: | |
| 804 | + return render_404(f"/vehicule/{quote(uid, safe='')}") | |
| 805 | + return RedirectResponse(_veh_path(v), status_code=301) | |
| 806 | + | |
| 807 | + | |
| 808 | +@router.get("/vehicule/{uid}/{slug}", response_model=None, include_in_schema=False) | |
| 809 | +def fiche(uid: str, slug: str): | |
| 810 | + v = _get_vehicle(uid) | |
| 811 | + if v is None: | |
| 812 | + return render_404(f"/vehicule/{quote(uid, safe='')}/{slug}") | |
| 813 | + canonical_path = _veh_path(v) | |
| 814 | + if slug != _veh_slug(v): | |
| 815 | + return RedirectResponse(canonical_path, status_code=301) | |
| 816 | + if not v.get("active"): | |
| 817 | + return _render_410(v, canonical_path) | |
| 818 | + | |
| 819 | + crumbs = _fiche_crumbs(v) | |
| 820 | + where = " · ".join(x for x in [v.get("city"), v.get("region")] if x) | |
| 821 | + dealer = v.get("dealer_name") or v.get("source") or "" | |
| 822 | + img = _first_image(v) | |
| 823 | + main_img = (f'<img src="{esc(img, quote=True)}" alt="{esc(v.get("title") or "", quote=True)}"' | |
| 824 | + ' width="960" height="640" fetchpriority="high" />' | |
| 825 | + if img else '<div class="nopic">🚗</div>') | |
| 826 | + thumbs = "".join( | |
| 827 | + f'<img src="{esc(u, quote=True)}" alt="{esc(v.get("title") or "", quote=True)} — photo {i + 2}"' | |
| 828 | + ' loading="lazy" decoding="async" width="160" height="107" />' | |
| 829 | + for i, u in enumerate(v["images"][1:8])) | |
| 830 | + spec_html = "".join(f"<tr><td>{esc(k)}</td><td>{esc(val)}</td></tr>" | |
| 831 | + for k, val in _spec_rows(v)) | |
| 832 | + feats = "".join(f'<span class="spec-chip">{esc(f)}</span>' for f in v["features"][:50]) | |
| 833 | + desc = "" | |
| 834 | + if v.get("description"): | |
| 835 | + desc = (f'<div class="panel" style="margin-top:18px"><h2>Description du concessionnaire</h2>' | |
| 836 | + f'<div class="desc">{esc(v["description"])}</div></div>') | |
| 837 | + similar = "" | |
| 838 | + if v["similar"]: | |
| 839 | + similar = (f'<section style="margin-top:40px"><h2>{esc(v.get("make") or "")} ' | |
| 840 | + f'{esc(v.get("model") or "")} similaires au Québec</h2><div class="vgrid">' | |
| 841 | + + "".join(_card(s) for s in v["similar"]) + "</div></section>") | |
| 842 | + body = ( | |
| 843 | + '<div class="container vdetail">' | |
| 844 | + + _crumbs_html(crumbs) | |
| 845 | + + '<div class="vd-head"><div>' | |
| 846 | + f'<span class="kicker">{esc(dealer)}{" — " + esc(where) if where else ""}</span>' | |
| 847 | + f'<h1>{esc(v.get("title") or "")}</h1></div>' | |
| 848 | + f'<div class="vd-price"><div class="p">{fmt_price(v.get("price"))}</div></div></div>' | |
| 849 | + '<div class="vd-cols"><div>' | |
| 850 | + f'<div class="gallery"><div class="main">{main_img}</div>' | |
| 851 | + + (f'<div class="thumbs">{thumbs}</div>' if thumbs else "") | |
| 852 | + + "</div>" + desc + "</div><div>" | |
| 853 | + '<div class="panel"><h2>Caractéristiques</h2>' | |
| 854 | + f'<table class="spec-table"><tbody>{spec_html}</tbody></table>' | |
| 855 | + + (f'<a class="cta-source" href="{esc(v["url"], quote=True)}" rel="nofollow noreferrer">' | |
| 856 | + f"Voir chez {esc(dealer or 'le concessionnaire')} →</a>" if v.get("url") else "") | |
| 857 | + + '<div class="cta-note">annonce originale — prix et disponibilité confirmés à la source</div></div>' | |
| 858 | + + (f'<div class="panel"><h2>Équipements ({len(v["features"])})</h2>' | |
| 859 | + f'<div class="feat-list">{feats}</div></div>' if feats else "") | |
| 860 | + + "</div></div>" + similar + "</div>") | |
| 861 | + | |
| 862 | + bits = [str(v["year"]) if v.get("year") else None, | |
| 863 | + fmt_km(v.get("mileage_km")) or None, | |
| 864 | + v.get("transmission") or None, v.get("fuel") or None] | |
| 865 | + description = (f'{v.get("title") or ""} à vendre : {", ".join(b for b in bits if b)}. ' | |
| 866 | + f'{fmt_price(v.get("price"))} chez {dealer}' | |
| 867 | + + (f" à {v['city']}" if v.get("city") else "") | |
| 868 | + + (f" ({v['region']})" if v.get("region") else "") | |
| 869 | + + ". Fiche complète, historique de prix et annonce originale sur Auto-Ka.") | |
| 870 | + title = (f'{v.get("title") or "Véhicule"} — {fmt_price(v.get("price"))}' | |
| 871 | + + (f' | {v["city"]}' if v.get("city") else "") + " | Auto·Ka") | |
| 872 | + return render_page(path=canonical_path, title=title, description=description, | |
| 873 | + body=body, og_image=img, og_type="product", | |
| 874 | + jsonld=[_ld_crumbs(crumbs), | |
| 875 | + _ld_vehicle(v, BASE_URL + canonical_path)]) | |
| 876 | + | |
| 877 | + | |
| 878 | +# --- routes : stats, sources, profil ------------------------------------------------- | |
| 879 | + | |
| 880 | +@router.get("/stats", response_model=None, include_in_schema=False) | |
| 881 | +def page_stats(): | |
| 882 | + con = db.connect() | |
| 883 | + st = _stats(con, "kind='auto'", []) | |
| 884 | + by_region = [dict(r) for r in con.execute( | |
| 885 | + "SELECT region, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles" | |
| 886 | + " WHERE active=1 AND kind='auto' AND region<>'' GROUP BY region ORDER BY n DESC")] | |
| 887 | + by_make = [dict(r) for r in con.execute( | |
| 888 | + "SELECT make, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles" | |
| 889 | + " WHERE active=1 AND kind='auto' AND make<>'' GROUP BY make ORDER BY n DESC LIMIT 20")] | |
| 890 | + con.close() | |
| 891 | + n_txt = f'{st["n"]:,}'.replace(",", " ") | |
| 892 | + | |
| 893 | + def _table(rows, key, href): | |
| 894 | + trs = "".join( | |
| 895 | + f'<tr><td><a href="{href(r)}">{esc(r[key])}</a></td>' | |
| 896 | + f'<td>{r["n"]}</td><td>{fmt_price(r["avg_price"])}</td></tr>' for r in rows) | |
| 897 | + return ('<table class="spec-table"><thead><tr><th></th><th>véhicules</th>' | |
| 898 | + f"<th>prix moyen</th></tr></thead><tbody>{trs}</tbody></table>") | |
| 899 | + | |
| 900 | + body = ( | |
| 901 | + '<div class="container"><section class="hero">' | |
| 902 | + '<span class="kicker">Statistiques en direct</span>' | |
| 903 | + "<h1>Le marché des voitures usagées au Québec, en chiffres</h1>" | |
| 904 | + f'<p class="sub">{n_txt} voitures usagées en vente en ce moment. ' | |
| 905 | + f'Prix moyen {fmt_price(st["avg_p"])}, médian {fmt_price(st["med_p"])}.</p></section>' | |
| 906 | + '<div class="panel"><h2>Par région</h2>' | |
| 907 | + + _table(by_region, "region", lambda r: "/region/" + slugify(r["region"])) | |
| 908 | + + '</div><div class="panel" style="margin-top:18px"><h2>Marques les plus offertes</h2>' | |
| 909 | + + _table(by_make, "make", lambda r: "/usagees/" + slugify(r["make"])) | |
| 910 | + + "</div></div>") | |
| 911 | + return render_page( | |
| 912 | + path="/stats", | |
| 913 | + title="Statistiques du marché des voitures usagées au Québec | Auto·Ka", | |
| 914 | + description=f"Le marché de l'occasion au Québec en direct : {n_txt} véhicules, " | |
| 915 | + f"prix moyen {fmt_price(st['avg_p'])}, répartition par région, marque et " | |
| 916 | + "carrosserie. Données agrégées des concessionnaires.", | |
| 917 | + body=body) | |
| 918 | + | |
| 919 | + | |
| 920 | +@router.get("/sources", response_model=None, include_in_schema=False) | |
| 921 | +def page_sources(): | |
| 922 | + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] | |
| 923 | + con = db.connect() | |
| 924 | + counts = {r["source"]: r["n"] for r in con.execute( | |
| 925 | + "SELECT source, COUNT(*) n FROM vehicles WHERE active=1 GROUP BY source")} | |
| 926 | + con.close() | |
| 927 | + items = "".join( | |
| 928 | + f'<li>{esc(s["name"])}' | |
| 929 | + + (f' — {esc(s["city"])}' if s.get("city") else "") | |
| 930 | + + f' <span class="mono">({counts.get(s["id"], 0)})</span></li>' | |
| 931 | + for s in registry) | |
| 932 | + body = ( | |
| 933 | + '<div class="container"><section class="hero">' | |
| 934 | + '<span class="kicker">Transparence des sources</span>' | |
| 935 | + f"<h1>{len(registry)} concessionnaires connectés à Auto-Ka</h1>" | |
| 936 | + '<p class="sub">Les annonces proviennent directement des sites publics des ' | |
| 937 | + "concessionnaires — pas des plateformes d'annonces — et sont rafraîchies " | |
| 938 | + "automatiquement. Chaque fiche renvoie vers l'annonce originale.</p></section>" | |
| 939 | + f'<div class="panel"><ul class="src-list">{items}</ul></div></div>') | |
| 940 | + return render_page( | |
| 941 | + path="/sources", | |
| 942 | + title=f"{len(registry)} concessionnaires sources | Auto·Ka", | |
| 943 | + description=f"Auto-Ka agrège les inventaires de {len(registry)} concessionnaires " | |
| 944 | + "du Québec, directement depuis leurs sites. Liste complète des sources et " | |
| 945 | + "nombre d'annonces actives.", | |
| 946 | + body=body) | |
| 947 | + | |
| 948 | + | |
| 949 | +@router.get("/profil", response_model=None, include_in_schema=False) | |
| 950 | +def page_profil(): | |
| 951 | + return render_page(path="/profil", title="Mon profil | Auto·Ka", | |
| 952 | + description="Votre profil Auto-Ka.", robots="noindex, nofollow") | |
| 953 | + | |
| 954 | + | |
| 955 | +# --- robots.txt et sitemaps ----------------------------------------------------------- | |
| 956 | + | |
| 957 | +@router.get("/robots.txt", response_model=None, include_in_schema=False) | |
| 958 | +def robots_txt(): | |
| 959 | + return PlainTextResponse( | |
| 960 | + "User-agent: *\n" | |
| 961 | + "Allow: /\n" | |
| 962 | + "Disallow: /api/\n" | |
| 963 | + "Disallow: /profil\n" | |
| 964 | + "\n" | |
| 965 | + f"Sitemap: {BASE_URL}/sitemap.xml\n") | |
| 966 | + | |
| 967 | + | |
| 968 | +def _xml(content: str) -> Response: | |
| 969 | + return Response(content=content, media_type="application/xml", | |
| 970 | + headers={"Cache-Control": "no-cache, must-revalidate"}) | |
| 971 | + | |
| 972 | + | |
| 973 | +@router.get("/sitemap.xml", response_model=None, include_in_schema=False) | |
| 974 | +def sitemap_index(): | |
| 975 | + con = db.connect() | |
| 976 | + row = con.execute("SELECT COUNT(*) n, MAX(updated_at) lm FROM vehicles" | |
| 977 | + " WHERE active=1").fetchone() | |
| 978 | + con.close() | |
| 979 | + lastmod = _date(row["lm"]) | |
| 980 | + chunks = max(1, math.ceil(row["n"] / SITEMAP_CHUNK)) | |
| 981 | + entries = [f"{BASE_URL}/sitemaps/pages.xml"] + [ | |
| 982 | + f"{BASE_URL}/sitemaps/vehicules-{i}.xml" for i in range(1, chunks + 1)] | |
| 983 | + body = "".join(f"<sitemap><loc>{esc(u)}</loc><lastmod>{lastmod}</lastmod></sitemap>" | |
| 984 | + for u in entries) | |
| 985 | + return _xml('<?xml version="1.0" encoding="UTF-8"?>' | |
| 986 | + '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' | |
| 987 | + + body + "</sitemapindex>") | |
| 988 | + | |
| 989 | + | |
| 990 | +@router.get("/sitemaps/pages.xml", response_model=None, include_in_schema=False) | |
| 991 | +def sitemap_pages(): | |
| 992 | + m = _maps() | |
| 993 | + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") | |
| 994 | + urls: list[tuple[str, str]] = [("/", today), ("/motos", today), ("/scooters", today), | |
| 995 | + ("/stats", today), ("/sources", today)] | |
| 996 | + for kind, prefix in (("auto", "/usagees/"), ("moto", "/motos/"), ("scooter", "/scooters/")): | |
| 997 | + for slug, info in m["make"].get(kind, {}).items(): | |
| 998 | + urls.append((prefix + slug, _date(info["lm"]))) | |
| 999 | + for make_slug, models in m["model"].items(): | |
| 1000 | + for slug, info in models.items(): | |
| 1001 | + if info["n"] >= 2: | |
| 1002 | + urls.append((f"/usagees/{make_slug}/{slug}", _date(info["lm"]))) | |
| 1003 | + for slug, info in m["region"].items(): | |
| 1004 | + urls.append((f"/region/{slug}", _date(info["lm"]))) | |
| 1005 | + for slug, info in m["city"].items(): | |
| 1006 | + if info["n"] >= 2: | |
| 1007 | + urls.append((f"/ville/{slug}", _date(info["lm"]))) | |
| 1008 | + for slug, info in m["body"].items(): | |
| 1009 | + urls.append((f"/carrosserie/{slug}", _date(info["lm"]))) | |
| 1010 | + body = "".join(f"<url><loc>{esc(BASE_URL + p)}</loc><lastmod>{lm}</lastmod></url>" | |
| 1011 | + for p, lm in urls) | |
| 1012 | + return _xml('<?xml version="1.0" encoding="UTF-8"?>' | |
| 1013 | + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' | |
| 1014 | + + body + "</urlset>") | |
| 1015 | + | |
| 1016 | + | |
| 1017 | +@router.get("/sitemaps/vehicules-{num}.xml", response_model=None, include_in_schema=False) | |
| 1018 | +def sitemap_vehicles(num: int): | |
| 1019 | + if num < 1: | |
| 1020 | + return render_404(f"/sitemaps/vehicules-{num}.xml") | |
| 1021 | + con = db.connect() | |
| 1022 | + rows = con.execute( | |
| 1023 | + "SELECT uid, year, make, model, trim, title, updated_at FROM vehicles" | |
| 1024 | + " WHERE active=1 ORDER BY uid LIMIT ? OFFSET ?", | |
| 1025 | + (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall() | |
| 1026 | + con.close() | |
| 1027 | + if not rows: | |
| 1028 | + return render_404(f"/sitemaps/vehicules-{num}.xml") | |
| 1029 | + body = "".join( | |
| 1030 | + f"<url><loc>{esc(BASE_URL + _veh_path(dict(r)))}</loc>" | |
| 1031 | + f"<lastmod>{_date(r['updated_at'])}</lastmod></url>" for r in rows) | |
| 1032 | + return _xml('<?xml version="1.0" encoding="UTF-8"?>' | |
| 1033 | + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' | |
| 1034 | + + body + "</urlset>") | |
modified
autoka/web.py
+14 −6
@@ -11,10 +11,11 @@ from pathlib import Path | ||
| 11 | 11 | |
| 12 | 12 | from fastapi import BackgroundTasks, FastAPI, HTTPException, Query |
| 13 | 13 | from fastapi.middleware.cors import CORSMiddleware |
| 14 | +from fastapi.middleware.gzip import GZipMiddleware | |
| 14 | 15 | from fastapi.responses import FileResponse |
| 15 | 16 | from fastapi.staticfiles import StaticFiles |
| 16 | 17 | |
| 17 | −from . import db, ingest | |
| 18 | +from . import auth, db, ingest, seo | |
| 18 | 19 | |
| 19 | 20 | ROOT = Path(__file__).resolve().parent.parent |
| 20 | 21 | SOURCES_PATH = ROOT / "data" / "sources.json" |
@@ -24,6 +25,7 @@ app = FastAPI(title="Auto-Ka API", version="1.0", | ||
| 24 | 25 | description="Agrégateur de voitures usagées — province de Québec") |
| 25 | 26 | app.add_middleware(CORSMiddleware, allow_origins=["*"], |
| 26 | 27 | allow_methods=["*"], allow_headers=["*"]) |
| 28 | +app.add_middleware(GZipMiddleware, minimum_size=1000) | |
| 27 | 29 | |
| 28 | 30 | _sync_lock = threading.Lock() |
| 29 | 31 | |
@@ -279,6 +281,14 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None): | ||
| 279 | 281 | return {"status": "démarré", "source": source or "toutes"} |
| 280 | 282 | |
| 281 | 283 | |
| 284 | +# --- Connexion KA ID (SSO Groupe KA) — AVANT le catch-all du SPA ------------- | |
| 285 | +app.include_router(auth.router) | |
| 286 | +app.include_router(auth.fav_router) # favoris « Mon univers Ka » (hub central) | |
| 287 | + | |
| 288 | +# --- SEO : rendu serveur des pages publiques, sitemaps, robots.txt ----------- | |
| 289 | +app.include_router(seo.router) | |
| 290 | + | |
| 291 | + | |
| 282 | 292 | # --- Frontend React (build Vite) -------------------------------------------- |
| 283 | 293 | if FRONTEND_DIST.exists(): |
| 284 | 294 | app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") |
@@ -288,8 +298,6 @@ if FRONTEND_DIST.exists(): | ||
| 288 | 298 | target = FRONTEND_DIST / full_path |
| 289 | 299 | if full_path and target.is_file(): |
| 290 | 300 | return FileResponse(target) |
| 291 | − # index.html jamais mis en cache : les téléphones reçoivent toujours | |
| 292 | − # la dernière version (les bundles /assets sont hachés, eux se cachent) | |
| 293 | − return FileResponse( | |
| 294 | − FRONTEND_DIST / "index.html", | |
| 295 | − headers={"Cache-Control": "no-cache, must-revalidate"}) | |
| 301 | + # Chemin inconnu : vrai 404 (le HTML embarque quand même le SPA, | |
| 302 | + # l'utilisateur voit la page « introuvable » de React) | |
| 303 | + return seo.render_404("/" + full_path) | |
modified
frontend/src/App.tsx
+97 −4
@@ -4,12 +4,17 @@ | ||
| 4 | 4 | // App.tsx : layout global (header + ticker en direct + footer encre) et routage |
| 5 | 5 | // ----------------------------------------------------------------------------- |
| 6 | 6 | import { useEffect, useState } from "react"; |
| 7 | −import { NavLink, Route, Routes } from "react-router-dom"; | |
| 8 | −import { fetchFacets, fetchSources, fetchStats, registerSourceNames } from "./api"; | |
| 7 | +import { NavLink, Route, Routes, useLocation, useNavigate } from "react-router-dom"; | |
| 8 | +import { | |
| 9 | + fetchFacets, fetchSources, fetchStats, logout, registerSourceNames, | |
| 10 | +} from "./api"; | |
| 11 | +import { AccountProvider, useAccount } from "./account"; | |
| 9 | 12 | import Home from "./pages/Home"; |
| 10 | 13 | import VehiclePage from "./pages/Vehicle"; |
| 11 | 14 | import SourcesPage from "./pages/Sources"; |
| 12 | 15 | import StatsPage from "./pages/Stats"; |
| 16 | +import ProfilPage from "./pages/Profil"; | |
| 17 | +import SeoListing from "./pages/SeoListing"; | |
| 13 | 18 | |
| 14 | 19 | function Ticker() { |
| 15 | 20 | const [items, setItems] = useState<string[]>([]); |
@@ -47,6 +52,83 @@ function Ticker() { | ||
| 47 | 52 | ); |
| 48 | 53 | } |
| 49 | 54 | |
| 55 | +/** Bouton « Connexion » (SSO KA ID) ou avatar + menu compte si connecté */ | |
| 56 | +function AccountMenu() { | |
| 57 | + const { me, enabled } = useAccount(); | |
| 58 | + const [menuOpen, setMenuOpen] = useState(false); | |
| 59 | + const loc = useLocation(); | |
| 60 | + const nav = useNavigate(); | |
| 61 | + | |
| 62 | + if (!enabled) return null; | |
| 63 | + if (!me) { | |
| 64 | + const next = encodeURIComponent(loc.pathname + loc.search); | |
| 65 | + return ( | |
| 66 | + <a className="login-btn" href={`/api/auth/ka/login?next=${next}`}> | |
| 67 | + <span className="login-ka" aria-hidden="true">KA</span> | |
| 68 | + Connexion | |
| 69 | + </a> | |
| 70 | + ); | |
| 71 | + } | |
| 72 | + return ( | |
| 73 | + <div className="account"> | |
| 74 | + <button | |
| 75 | + className="account-btn" | |
| 76 | + onClick={() => setMenuOpen(!menuOpen)} | |
| 77 | + aria-expanded={menuOpen} | |
| 78 | + aria-label={`Compte : ${me.name || me.email}`} | |
| 79 | + > | |
| 80 | + {me.picture | |
| 81 | + ? <img src={me.picture} alt="" referrerPolicy="no-referrer" /> | |
| 82 | + : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>} | |
| 83 | + </button> | |
| 84 | + {menuOpen && ( | |
| 85 | + <> | |
| 86 | + <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" /> | |
| 87 | + <div className="account-menu" role="menu"> | |
| 88 | + <div className="account-id"> | |
| 89 | + <b>{me.name || "Mon compte"}</b> | |
| 90 | + <span>{me.email}</span> | |
| 91 | + {me.ka_id && <span className="account-kaid">{me.ka_id}</span>} | |
| 92 | + <span className="account-note"> | |
| 93 | + Compte Groupe KA — le même KA-ID sur toutes les plateformes | |
| 94 | + </span> | |
| 95 | + </div> | |
| 96 | + <NavLink | |
| 97 | + to="/profil" | |
| 98 | + role="menuitem" | |
| 99 | + className="account-link" | |
| 100 | + onClick={() => setMenuOpen(false)} | |
| 101 | + > | |
| 102 | + Mon profil | |
| 103 | + </NavLink> | |
| 104 | + <a | |
| 105 | + href="https://www.groupe-ka.com/compte" | |
| 106 | + role="menuitem" | |
| 107 | + className="account-link" | |
| 108 | + target="_blank" | |
| 109 | + rel="noreferrer" | |
| 110 | + onClick={() => setMenuOpen(false)} | |
| 111 | + > | |
| 112 | + Mes favoris — Mon univers Ka ↗ | |
| 113 | + </a> | |
| 114 | + <button | |
| 115 | + role="menuitem" | |
| 116 | + onClick={async () => { | |
| 117 | + await logout(); | |
| 118 | + setMenuOpen(false); | |
| 119 | + nav("/"); | |
| 120 | + window.location.reload(); | |
| 121 | + }} | |
| 122 | + > | |
| 123 | + Se déconnecter | |
| 124 | + </button> | |
| 125 | + </div> | |
| 126 | + </> | |
| 127 | + )} | |
| 128 | + </div> | |
| 129 | + ); | |
| 130 | +} | |
| 131 | + | |
| 50 | 132 | function Header() { |
| 51 | 133 | return ( |
| 52 | 134 | <> |
@@ -73,6 +155,7 @@ function Header() { | ||
| 73 | 155 | Sources |
| 74 | 156 | </NavLink> |
| 75 | 157 | </nav> |
| 158 | + <AccountMenu /> | |
| 76 | 159 | </div> |
| 77 | 160 | </header> |
| 78 | 161 | <Ticker /> |
@@ -104,16 +187,26 @@ function Footer() { | ||
| 104 | 187 | |
| 105 | 188 | export default function App() { |
| 106 | 189 | return ( |
| 107 | − <> | |
| 190 | + <AccountProvider> | |
| 108 | 191 | <Header /> |
| 109 | 192 | <main> |
| 110 | 193 | <Routes> |
| 111 | 194 | <Route path="/" element={<Home kind="auto" />} /> |
| 112 | 195 | <Route path="/motos" element={<Home kind="moto" />} /> |
| 113 | 196 | <Route path="/scooters" element={<Home kind="scooter" />} /> |
| 197 | + {/* pages programmatiques (SEO) — servies pré-rendues par le serveur */} | |
| 198 | + <Route path="/usagees/:makeSlug" element={<SeoListing dim="make" />} /> | |
| 199 | + <Route path="/usagees/:makeSlug/:modelSlug" element={<SeoListing dim="model" />} /> | |
| 200 | + <Route path="/motos/:makeSlug" element={<SeoListing dim="moto-make" />} /> | |
| 201 | + <Route path="/scooters/:makeSlug" element={<SeoListing dim="scooter-make" />} /> | |
| 202 | + <Route path="/region/:slug" element={<SeoListing dim="region" />} /> | |
| 203 | + <Route path="/ville/:slug" element={<SeoListing dim="ville" />} /> | |
| 204 | + <Route path="/carrosserie/:slug" element={<SeoListing dim="carrosserie" />} /> | |
| 114 | 205 | <Route path="/vehicule/:uid" element={<VehiclePage />} /> |
| 206 | + <Route path="/vehicule/:uid/:slug" element={<VehiclePage />} /> | |
| 115 | 207 | <Route path="/stats" element={<StatsPage />} /> |
| 116 | 208 | <Route path="/sources" element={<SourcesPage />} /> |
| 209 | + <Route path="/profil" element={<ProfilPage />} /> | |
| 117 | 210 | <Route |
| 118 | 211 | path="*" |
| 119 | 212 | element={ |
@@ -127,6 +220,6 @@ export default function App() { | ||
| 127 | 220 | </Routes> |
| 128 | 221 | </main> |
| 129 | 222 | <Footer /> |
| 130 | − </> | |
| 223 | + </AccountProvider> | |
| 131 | 224 | ); |
| 132 | 225 | } |
added
frontend/src/account.tsx
+83 −0
@@ -0,0 +1,83 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// account.tsx : contexte de compte partagé — profil connecté + favoris. | |
| 5 | +// Les favoris vivent au HUB Groupe KA (magasin central « Mon univers Ka », | |
| 6 | +// groupe-ka.com) : aucun stockage local, chaque ♥ est poussé au hub via | |
| 7 | +// l'API d'Auto·Ka et la liste est relue du hub. Un seul fetch au montage ; | |
| 8 | +// le cœur des cartes, la fiche et le menu compte lisent le même état | |
| 9 | +// (toggle optimiste, re-synchronisé sur erreur). Non connecté : le clic | |
| 10 | +// sur un ♥ envoie vers la connexion KA ID en gardant la page courante. | |
| 11 | +// ----------------------------------------------------------------------------- | |
| 12 | +import { | |
| 13 | + ReactNode, createContext, useCallback, useContext, useEffect, useState, | |
| 14 | +} from "react"; | |
| 15 | +import { | |
| 16 | + Me, Vehicle, fetchFavorites, fetchMe, toggleFavorite, vehicleFavItem, | |
| 17 | +} from "./api"; | |
| 18 | + | |
| 19 | +interface AccountState { | |
| 20 | + me: Me | null; | |
| 21 | + enabled: boolean; // SSO KA ID configuré côté serveur | |
| 22 | + loaded: boolean; // premier fetch terminé | |
| 23 | + favs: Set<string>; // uids des véhicules favoris (au hub) | |
| 24 | + toggleFav: (v: Vehicle) => void; | |
| 25 | + refresh: () => void; // re-fetch profil + favoris | |
| 26 | +} | |
| 27 | + | |
| 28 | +const Ctx = createContext<AccountState>({ | |
| 29 | + me: null, enabled: false, loaded: false, favs: new Set(), | |
| 30 | + toggleFav: () => {}, refresh: () => {}, | |
| 31 | +}); | |
| 32 | + | |
| 33 | +export const useAccount = () => useContext(Ctx); | |
| 34 | + | |
| 35 | +export function AccountProvider({ children }: { children: ReactNode }) { | |
| 36 | + const [me, setMe] = useState<Me | null>(null); | |
| 37 | + const [enabled, setEnabled] = useState(false); | |
| 38 | + const [loaded, setLoaded] = useState(false); | |
| 39 | + const [favs, setFavs] = useState<Set<string>>(new Set()); | |
| 40 | + | |
| 41 | + const loadFavs = useCallback(() => { | |
| 42 | + fetchFavorites() | |
| 43 | + .then((f) => setFavs(new Set(f.ids))) | |
| 44 | + .catch(() => setFavs(new Set())); | |
| 45 | + }, []); | |
| 46 | + | |
| 47 | + const refresh = useCallback(() => { | |
| 48 | + fetchMe().then((r) => { | |
| 49 | + setMe(r.user); | |
| 50 | + setEnabled(r.enabled); | |
| 51 | + setLoaded(true); | |
| 52 | + if (r.user) loadFavs(); | |
| 53 | + else setFavs(new Set()); | |
| 54 | + }); | |
| 55 | + }, [loadFavs]); | |
| 56 | + | |
| 57 | + useEffect(() => { refresh(); }, [refresh]); | |
| 58 | + | |
| 59 | + const toggleFav = useCallback((v: Vehicle) => { | |
| 60 | + if (!me) { | |
| 61 | + // pas de session : connexion KA ID puis retour sur la page courante | |
| 62 | + const next = encodeURIComponent( | |
| 63 | + window.location.pathname + window.location.search); | |
| 64 | + window.location.href = `/api/auth/ka/login?next=${next}`; | |
| 65 | + return; | |
| 66 | + } | |
| 67 | + setFavs((prev) => { | |
| 68 | + const next = new Set(prev); | |
| 69 | + const on = !next.has(v.uid); | |
| 70 | + if (on) next.add(v.uid); | |
| 71 | + else next.delete(v.uid); | |
| 72 | + // pousse au hub (magasin central) ; en cas d'échec on se re-synchronise | |
| 73 | + toggleFavorite(on, vehicleFavItem(v)).catch(loadFavs); | |
| 74 | + return next; | |
| 75 | + }); | |
| 76 | + }, [me, loadFavs]); | |
| 77 | + | |
| 78 | + return ( | |
| 79 | + <Ctx.Provider value={{ me, enabled, loaded, favs, toggleFav, refresh }}> | |
| 80 | + {children} | |
| 81 | + </Ctx.Provider> | |
| 82 | + ); | |
| 83 | +} | |
modified
frontend/src/api.ts
+109 −0
@@ -100,6 +100,7 @@ export interface VehicleQuery { | ||
| 100 | 100 | transmission?: string; |
| 101 | 101 | drivetrain?: string; |
| 102 | 102 | region?: string; |
| 103 | + city?: string; | |
| 103 | 104 | source?: string; |
| 104 | 105 | year_min?: number; |
| 105 | 106 | year_max?: number; |
@@ -112,6 +113,27 @@ export interface VehicleQuery { | ||
| 112 | 113 | offset?: number; |
| 113 | 114 | } |
| 114 | 115 | |
| 116 | +/** Slug URL — DOIT rester synchrone avec slugify() de autoka/seo.py. */ | |
| 117 | +export function slugify(text: string, maxLen = 70): string { | |
| 118 | + const t = (text || "") | |
| 119 | + .normalize("NFKD") | |
| 120 | + .replace(/[\u0300-\u036f]/g, "") | |
| 121 | + .replace(/[^a-zA-Z0-9]+/g, "-") | |
| 122 | + .replace(/^-+|-+$/g, "") | |
| 123 | + .toLowerCase() | |
| 124 | + .slice(0, maxLen) | |
| 125 | + .replace(/-+$/g, ""); | |
| 126 | + return t || "x"; | |
| 127 | +} | |
| 128 | + | |
| 129 | +/** URL canonique d'une fiche : /vehicule/{uid}/{slug lisible}. */ | |
| 130 | +export function vehiclePath(v: Vehicle): string { | |
| 131 | + const base = | |
| 132 | + [v.year, v.make, v.model].filter(Boolean).join(" ") || | |
| 133 | + v.title || "vehicule"; | |
| 134 | + return `/vehicule/${encodeURIComponent(v.uid)}/${slugify(base)}`; | |
| 135 | +} | |
| 136 | + | |
| 115 | 137 | async function get<T>(path: string): Promise<T> { |
| 116 | 138 | const res = await fetch(path); |
| 117 | 139 | if (!res.ok) throw new Error(`API ${res.status}`); |
@@ -159,6 +181,93 @@ export function sourceName(id: string): string { | ||
| 159 | 181 | return names[id] ?? id; |
| 160 | 182 | } |
| 161 | 183 | |
| 184 | +// -- compte (SSO KA ID — Groupe KA) -------------------------------------------- | |
| 185 | +export interface Socials { | |
| 186 | + instagram?: string; | |
| 187 | + facebook?: string; | |
| 188 | + x?: string; | |
| 189 | + linkedin?: string; | |
| 190 | + tiktok?: string; | |
| 191 | + youtube?: string; | |
| 192 | +} | |
| 193 | + | |
| 194 | +export interface Me { | |
| 195 | + ka_id: string; | |
| 196 | + email: string; | |
| 197 | + name: string; | |
| 198 | + picture: string; | |
| 199 | + provider: string; | |
| 200 | + created: number | null; | |
| 201 | + // — profil Groupe KA (source de vérité : le hub groupe-ka.com) — | |
| 202 | + bio?: string; | |
| 203 | + city?: string; | |
| 204 | + job_title?: string; | |
| 205 | + company?: string; | |
| 206 | + age?: number | null; | |
| 207 | + website?: string; | |
| 208 | + socials?: Socials; | |
| 209 | + role_label?: string; | |
| 210 | + public_url?: string; | |
| 211 | + profile_source?: "groupe-ka" | "local"; | |
| 212 | +} | |
| 213 | + | |
| 214 | +export async function fetchMe(): Promise<{ user: Me | null; enabled: boolean }> { | |
| 215 | + try { | |
| 216 | + const res = await fetch("/api/auth/me"); | |
| 217 | + if (!res.ok) return { user: null, enabled: false }; | |
| 218 | + return res.json(); | |
| 219 | + } catch { | |
| 220 | + return { user: null, enabled: false }; | |
| 221 | + } | |
| 222 | +} | |
| 223 | + | |
| 224 | +export async function logout(): Promise<void> { | |
| 225 | + await fetch("/api/auth/logout", { method: "POST" }); | |
| 226 | +} | |
| 227 | + | |
| 228 | +// -- favoris « Mon univers Ka » (magasin central : le hub groupe-ka.com) ------- | |
| 229 | +export interface FavItem { | |
| 230 | + item_id: string; | |
| 231 | + title: string; | |
| 232 | + subtitle?: string; | |
| 233 | + price_label?: string; | |
| 234 | + image_url?: string; | |
| 235 | + url?: string; | |
| 236 | + app?: string; | |
| 237 | + created_at?: string; | |
| 238 | +} | |
| 239 | + | |
| 240 | +export function fetchFavorites() { | |
| 241 | + return get<{ ids: string[]; items: FavItem[] }>("/api/favorites"); | |
| 242 | +} | |
| 243 | + | |
| 244 | +export async function toggleFavorite(on: boolean, item: FavItem): Promise<void> { | |
| 245 | + const res = await fetch("/api/favorites/toggle", { | |
| 246 | + method: "POST", | |
| 247 | + headers: { "Content-Type": "application/json" }, | |
| 248 | + body: JSON.stringify({ on, item }), | |
| 249 | + }); | |
| 250 | + if (!res.ok) throw new Error(`API ${res.status}`); | |
| 251 | +} | |
| 252 | + | |
| 253 | +/** Transforme un véhicule en item de favori pour le hub Groupe KA. */ | |
| 254 | +export function vehicleFavItem(v: Vehicle): FavItem { | |
| 255 | + const title = | |
| 256 | + [v.year, v.make, v.model].filter(Boolean).join(" ") || v.title || "Véhicule"; | |
| 257 | + const where = | |
| 258 | + [v.dealer_name || sourceName(v.source), v.region || v.city] | |
| 259 | + .filter(Boolean).join(" · ") || | |
| 260 | + (v.mileage_km != null ? fmtKm(v.mileage_km) : ""); | |
| 261 | + return { | |
| 262 | + item_id: v.uid, | |
| 263 | + title, | |
| 264 | + subtitle: where, | |
| 265 | + price_label: fmtPrice(v.price), | |
| 266 | + image_url: v.images && v.images.length > 0 ? v.images[0] : "", | |
| 267 | + url: `https://www.auto-ka.com${vehiclePath(v)}`, | |
| 268 | + }; | |
| 269 | +} | |
| 270 | + | |
| 162 | 271 | // -- formatteurs --------------------------------------------------------------- |
| 163 | 272 | export const fmtPrice = (p: number | null | undefined) => |
| 164 | 273 | p == null ? "Prix sur demande" : `${Math.round(p).toLocaleString("fr-CA")} $`; |
added
frontend/src/components/FavButton.tsx
+34 −0
@@ -0,0 +1,34 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// FavButton.tsx : cœur rond « Mon univers Ka » en coin de photo — ajoute ou | |
| 5 | +// retire le véhicule des favoris du hub Groupe KA (magasin central). | |
| 6 | +// Non connecté : le clic redirige vers la connexion KA ID. | |
| 7 | +// ----------------------------------------------------------------------------- | |
| 8 | +import { Vehicle } from "../api"; | |
| 9 | +import { useAccount } from "../account"; | |
| 10 | + | |
| 11 | +export default function FavButton({ v }: { v: Vehicle }) { | |
| 12 | + const { enabled, favs, toggleFav } = useAccount(); | |
| 13 | + if (!enabled) return null; | |
| 14 | + const fav = favs.has(v.uid); | |
| 15 | + return ( | |
| 16 | + <button | |
| 17 | + type="button" | |
| 18 | + className={`fav-btn ${fav ? "on" : ""}`} | |
| 19 | + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} | |
| 20 | + aria-pressed={fav} | |
| 21 | + title={fav ? "Retirer des favoris" : "Ajouter aux favoris — Mon univers Ka"} | |
| 22 | + onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(v); }} | |
| 23 | + > | |
| 24 | + <svg | |
| 25 | + width="16" height="16" viewBox="0 0 24 24" | |
| 26 | + fill={fav ? "currentColor" : "none"} stroke="currentColor" | |
| 27 | + strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" | |
| 28 | + aria-hidden="true" | |
| 29 | + > | |
| 30 | + <path d="M12 20.5C7 16.5 3.5 13 3.5 9.3 3.5 6.8 5.5 5 7.8 5c1.7 0 3.2 1 4.2 2.6C13 6 14.5 5 16.2 5c2.3 0 4.3 1.8 4.3 4.3 0 3.7-3.5 7.2-8.5 11.2z" /> | |
| 31 | + </svg> | |
| 32 | + </button> | |
| 33 | + ); | |
| 34 | +} | |
modified
frontend/src/components/VehicleCard.tsx
+4 −2
@@ -4,7 +4,8 @@ | ||
| 4 | 4 | // VehicleCard.tsx : carte véhicule de la grille de résultats |
| 5 | 5 | // ----------------------------------------------------------------------------- |
| 6 | 6 | import { Link } from "react-router-dom"; |
| 7 | −import { Vehicle, fmtKm, fmtPrice } from "../api"; | |
| 7 | +import { Vehicle, fmtKm, fmtPrice, vehiclePath } from "../api"; | |
| 8 | +import FavButton from "./FavButton"; | |
| 8 | 9 | |
| 9 | 10 | export default function VehicleCard({ v }: { v: Vehicle }) { |
| 10 | 11 | const chips = [ |
@@ -16,7 +17,7 @@ export default function VehicleCard({ v }: { v: Vehicle }) { | ||
| 16 | 17 | ].filter(Boolean) as string[]; |
| 17 | 18 | |
| 18 | 19 | return ( |
| 19 | − <Link to={`/vehicule/${encodeURIComponent(v.uid)}`} className="vcard"> | |
| 20 | + <Link to={vehiclePath(v)} className="vcard"> | |
| 20 | 21 | <div className="photo"> |
| 21 | 22 | {v.images.length > 0 ? ( |
| 22 | 23 | <img src={v.images[0]} alt={v.title} loading="lazy" /> |
@@ -24,6 +25,7 @@ export default function VehicleCard({ v }: { v: Vehicle }) { | ||
| 24 | 25 | <div className="nopic" aria-hidden="true">🚗</div> |
| 25 | 26 | )} |
| 26 | 27 | {v.year != null && <span className="year-chip">{v.year}</span>} |
| 28 | + <FavButton v={v} /> | |
| 27 | 29 | </div> |
| 28 | 30 | <div className="body"> |
| 29 | 31 | <h3>{v.title}</h3> |
modified
frontend/src/pages/Home.tsx
+70 −15
@@ -41,7 +41,15 @@ const KIND_COPY: Record<string, { label: string; hero: string; sub: string }> = | ||
| 41 | 41 | }, |
| 42 | 42 | }; |
| 43 | 43 | |
| 44 | −export default function Home({ kind = "auto" }: { kind?: string }) { | |
| 44 | +export interface HomeProps { | |
| 45 | + kind?: string; | |
| 46 | + /** Filtres imposés par une page SEO (marque, modèle, région, ville…). */ | |
| 47 | + fixed?: Partial<VehicleQuery>; | |
| 48 | + seoH1?: string; | |
| 49 | + seoIntro?: string; | |
| 50 | +} | |
| 51 | + | |
| 52 | +export default function Home({ kind = "auto", fixed, seoH1, seoIntro }: HomeProps) { | |
| 45 | 53 | const [params, setParams] = useSearchParams(); |
| 46 | 54 | const [facets, setFacets] = useState<Facets | null>(null); |
| 47 | 55 | const [vehicles, setVehicles] = useState<Vehicle[]>([]); |
@@ -61,15 +69,26 @@ export default function Home({ kind = "auto" }: { kind?: string }) { | ||
| 61 | 69 | sort: g("sort") || "price_asc", |
| 62 | 70 | limit: PAGE_SIZE, |
| 63 | 71 | offset: (Math.max(1, gn("page") || 1) - 1) * PAGE_SIZE, |
| 72 | + ...fixed, | |
| 64 | 73 | }; |
| 65 | − }, [params, kind]); | |
| 74 | + }, [params, kind, fixed]); | |
| 66 | 75 | |
| 67 | 76 | const page = Math.max(1, Number(params.get("page") || 1)); |
| 68 | 77 | const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); |
| 69 | 78 | |
| 70 | 79 | useEffect(() => { |
| 71 | − fetchFacets(params.get("make") || undefined, kind).then(setFacets).catch(() => {}); | |
| 72 | − }, [params.get("make"), kind]); | |
| 80 | + const title = seoH1 | |
| 81 | + ? `${seoH1} | Auto·Ka` | |
| 82 | + : `Auto·Ka — ${(KIND_COPY[kind]?.label ?? "véhicules") | |
| 83 | + .replace(/^./, (c) => c.toUpperCase())} à vendre au Québec`; | |
| 84 | + document.title = title; | |
| 85 | + }, [seoH1, kind]); | |
| 86 | + | |
| 87 | + useEffect(() => { | |
| 88 | + fetchFacets(fixed?.make ?? params.get("make") ?? undefined, kind) | |
| 89 | + .then(setFacets) | |
| 90 | + .catch(() => {}); | |
| 91 | + }, [fixed?.make, params.get("make"), kind]); | |
| 73 | 92 | |
| 74 | 93 | useEffect(() => { |
| 75 | 94 | fetchStats() |
@@ -106,13 +125,17 @@ export default function Home({ kind = "auto" }: { kind?: string }) { | ||
| 106 | 125 | <div className="container"> |
| 107 | 126 | <section className="hero"> |
| 108 | 127 | <span className="kicker">Agrégateur indépendant — direct des concessionnaires</span> |
| 109 | − <h1> | |
| 110 | − {kind === "scooter" ? "Tous les " : "Toutes les "} | |
| 111 | − <em>{KIND_COPY[kind]?.hero ?? "véhicules"}</em> à vendre au Québec. | |
| 112 | − Un seul endroit. | |
| 113 | − </h1> | |
| 114 | − <p className="sub">{KIND_COPY[kind]?.sub}</p> | |
| 115 | − {kind === "auto" && heroStats && ( | |
| 128 | + {seoH1 ? ( | |
| 129 | + <h1>{seoH1}</h1> | |
| 130 | + ) : ( | |
| 131 | + <h1> | |
| 132 | + {kind === "scooter" ? "Tous les " : "Toutes les "} | |
| 133 | + <em>{KIND_COPY[kind]?.hero ?? "véhicules"}</em> à vendre au Québec. | |
| 134 | + Un seul endroit. | |
| 135 | + </h1> | |
| 136 | + )} | |
| 137 | + <p className="sub">{seoIntro ?? KIND_COPY[kind]?.sub}</p> | |
| 138 | + {kind === "auto" && !seoH1 && heroStats && ( | |
| 116 | 139 | <div className="hero-stats"> |
| 117 | 140 | <div className="hstat"><b>{heroStats.total.toLocaleString("fr-CA")}</b> véhicules en vente</div> |
| 118 | 141 | <div className="hstat"><b>{heroStats.sources}</b> concessionnaires</div> |
@@ -136,8 +159,16 @@ export default function Home({ kind = "auto" }: { kind?: string }) { | ||
| 136 | 159 | </div> |
| 137 | 160 | <div className="f-field"> |
| 138 | 161 | <label htmlFor="f-make">Marque</label> |
| 139 | − <select id="f-make" value={params.get("make") || ""} onChange={(e) => setFilter("make", e.target.value)}> | |
| 162 | + <select | |
| 163 | + id="f-make" | |
| 164 | + value={fixed?.make ?? params.get("make") ?? ""} | |
| 165 | + disabled={!!fixed?.make} | |
| 166 | + onChange={(e) => setFilter("make", e.target.value)} | |
| 167 | + > | |
| 140 | 168 | <option value="">Toutes</option> |
| 169 | + {fixed?.make && !facets?.makes.some((m) => m.make === fixed.make) && ( | |
| 170 | + <option value={fixed.make}>{fixed.make}</option> | |
| 171 | + )} | |
| 141 | 172 | {facets?.makes.map((m) => ( |
| 142 | 173 | <option key={m.make} value={m.make}>{m.make} ({m.n})</option> |
| 143 | 174 | ))} |
@@ -145,8 +176,16 @@ export default function Home({ kind = "auto" }: { kind?: string }) { | ||
| 145 | 176 | </div> |
| 146 | 177 | <div className="f-field"> |
| 147 | 178 | <label htmlFor="f-model">Modèle</label> |
| 148 | − <select id="f-model" value={params.get("model") || ""} onChange={(e) => setFilter("model", e.target.value)}> | |
| 179 | + <select | |
| 180 | + id="f-model" | |
| 181 | + value={fixed?.model ?? params.get("model") ?? ""} | |
| 182 | + disabled={!!fixed?.model} | |
| 183 | + onChange={(e) => setFilter("model", e.target.value)} | |
| 184 | + > | |
| 149 | 185 | <option value="">Tous</option> |
| 186 | + {fixed?.model && !facets?.models.some((m) => m.model === fixed.model) && ( | |
| 187 | + <option value={fixed.model}>{fixed.model}</option> | |
| 188 | + )} | |
| 150 | 189 | {facets?.models.map((m) => ( |
| 151 | 190 | <option key={m.model} value={m.model}>{m.model} ({m.n})</option> |
| 152 | 191 | ))} |
@@ -154,8 +193,16 @@ export default function Home({ kind = "auto" }: { kind?: string }) { | ||
| 154 | 193 | </div> |
| 155 | 194 | <div className="f-field"> |
| 156 | 195 | <label htmlFor="f-region">Région</label> |
| 157 | − <select id="f-region" value={params.get("region") || ""} onChange={(e) => setFilter("region", e.target.value)}> | |
| 196 | + <select | |
| 197 | + id="f-region" | |
| 198 | + value={fixed?.region ?? params.get("region") ?? ""} | |
| 199 | + disabled={!!fixed?.region} | |
| 200 | + onChange={(e) => setFilter("region", e.target.value)} | |
| 201 | + > | |
| 158 | 202 | <option value="">Toutes</option> |
| 203 | + {fixed?.region && !facets?.regions.some((r) => r.region === fixed.region) && ( | |
| 204 | + <option value={fixed.region}>{fixed.region}</option> | |
| 205 | + )} | |
| 159 | 206 | {facets?.regions.map((r) => ( |
| 160 | 207 | <option key={r.region} value={r.region}>{r.region} ({r.n})</option> |
| 161 | 208 | ))} |
@@ -163,8 +210,16 @@ export default function Home({ kind = "auto" }: { kind?: string }) { | ||
| 163 | 210 | </div> |
| 164 | 211 | <div className="f-field"> |
| 165 | 212 | <label htmlFor="f-body">Carrosserie</label> |
| 166 | − <select id="f-body" value={params.get("body") || ""} onChange={(e) => setFilter("body", e.target.value)}> | |
| 213 | + <select | |
| 214 | + id="f-body" | |
| 215 | + value={fixed?.body_type ?? params.get("body") ?? ""} | |
| 216 | + disabled={!!fixed?.body_type} | |
| 217 | + onChange={(e) => setFilter("body", e.target.value)} | |
| 218 | + > | |
| 167 | 219 | <option value="">Toutes</option> |
| 220 | + {fixed?.body_type && !facets?.body_types.includes(fixed.body_type) && ( | |
| 221 | + <option value={fixed.body_type}>{fixed.body_type}</option> | |
| 222 | + )} | |
| 168 | 223 | {facets?.body_types.map((b) => ( |
| 169 | 224 | <option key={b} value={b}>{b}</option> |
| 170 | 225 | ))} |
added
frontend/src/pages/Profil.tsx
+211 −0
@@ -0,0 +1,211 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Profil.tsx : profil utilisateur — carte de membre Groupe KA (KA-ID), | |
| 5 | +// profil Groupe KA (lu du hub, lecture seule), informations du compte, | |
| 6 | +// déconnexion | |
| 7 | +// ----------------------------------------------------------------------------- | |
| 8 | +import { useEffect, useState } from "react"; | |
| 9 | +import { useNavigate } from "react-router-dom"; | |
| 10 | +import { Me, Socials, fetchMe, logout } from "../api"; | |
| 11 | + | |
| 12 | +const fmtEpoch = (ts: number | null | undefined): string => { | |
| 13 | + if (!ts) return "—"; | |
| 14 | + return new Date(ts * 1000).toLocaleDateString("fr-CA", { | |
| 15 | + day: "numeric", month: "long", year: "numeric", | |
| 16 | + }).replace(/^1 /, "1ᵉʳ "); | |
| 17 | +}; | |
| 18 | + | |
| 19 | +const SOCIAL_BASE: Record<string, string> = { | |
| 20 | + instagram: "https://www.instagram.com/", | |
| 21 | + facebook: "https://www.facebook.com/", | |
| 22 | + x: "https://x.com/", | |
| 23 | + linkedin: "https://www.linkedin.com/in/", | |
| 24 | + tiktok: "https://www.tiktok.com/@", | |
| 25 | + youtube: "https://www.youtube.com/@", | |
| 26 | +}; | |
| 27 | + | |
| 28 | +const SOCIAL_FIELDS: { key: keyof Socials; label: string }[] = [ | |
| 29 | + { key: "instagram", label: "Instagram" }, | |
| 30 | + { key: "facebook", label: "Facebook" }, | |
| 31 | + { key: "x", label: "X (Twitter)" }, | |
| 32 | + { key: "linkedin", label: "LinkedIn" }, | |
| 33 | + { key: "tiktok", label: "TikTok" }, | |
| 34 | + { key: "youtube", label: "YouTube" }, | |
| 35 | +]; | |
| 36 | + | |
| 37 | +/** « @pseudo » ou « pseudo » -> URL complète de la plateforme ; URL laissée telle quelle */ | |
| 38 | +function socialUrl(key: string, value: string): string { | |
| 39 | + const v = value.trim(); | |
| 40 | + if (/^https?:\/\//i.test(v)) return v; | |
| 41 | + if (key === "website") return `https://${v}`; | |
| 42 | + return (SOCIAL_BASE[key] ?? "https://") + v.replace(/^@/, ""); | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** Profil géré au HUB Groupe KA — LECTURE SEULE (l'édition se fait sur | |
| 46 | + * groupe-ka.com/compte, une seule saisie pour toutes les plateformes). */ | |
| 47 | +function HubProfileSection({ me }: { me: Me }) { | |
| 48 | + const socialLinks = SOCIAL_FIELDS.filter((s) => (me.socials?.[s.key] ?? "").trim()); | |
| 49 | + return ( | |
| 50 | + <section className="hub-profile"> | |
| 51 | + <h3>Mon profil Groupe KA</h3> | |
| 52 | + {me.bio && <p className="hub-bio">{me.bio}</p>} | |
| 53 | + <div className="hub-meta"> | |
| 54 | + {(me.job_title || me.company) && ( | |
| 55 | + <span className="hub-chip"> | |
| 56 | + {[me.job_title, me.company].filter(Boolean).join(" · ")} | |
| 57 | + </span> | |
| 58 | + )} | |
| 59 | + {me.city && <span className="hub-chip">{me.city}</span>} | |
| 60 | + {me.age != null && <span className="hub-chip">{me.age} ans</span>} | |
| 61 | + {me.website && ( | |
| 62 | + <a className="hub-chip" href={socialUrl("website", me.website)} | |
| 63 | + target="_blank" rel="noopener noreferrer">Site web ↗</a> | |
| 64 | + )} | |
| 65 | + {socialLinks.map((s) => ( | |
| 66 | + <a key={s.key} className="hub-chip" | |
| 67 | + href={socialUrl(s.key, me.socials![s.key]!)} | |
| 68 | + target="_blank" rel="noopener noreferrer" title={s.label}> | |
| 69 | + {s.label} | |
| 70 | + </a> | |
| 71 | + ))} | |
| 72 | + </div> | |
| 73 | + <div> | |
| 74 | + <a className="btn" href="https://www.groupe-ka.com/compte" | |
| 75 | + target="_blank" rel="noopener noreferrer"> | |
| 76 | + Modifier mon profil sur groupe-ka.com ↗ | |
| 77 | + </a> | |
| 78 | + </div> | |
| 79 | + <p className="hub-hint"> | |
| 80 | + Votre profil est géré au niveau du groupe : une seule saisie, visible | |
| 81 | + sur les huit plateformes du groupe. | |
| 82 | + </p> | |
| 83 | + </section> | |
| 84 | + ); | |
| 85 | +} | |
| 86 | + | |
| 87 | +export default function ProfilPage() { | |
| 88 | + const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = chargement | |
| 89 | + const [copied, setCopied] = useState(false); | |
| 90 | + const nav = useNavigate(); | |
| 91 | + | |
| 92 | + useEffect(() => { | |
| 93 | + fetchMe().then((r) => setMe(r.user)); | |
| 94 | + }, []); | |
| 95 | + | |
| 96 | + const copyKaId = async () => { | |
| 97 | + if (!me?.ka_id) return; | |
| 98 | + try { | |
| 99 | + await navigator.clipboard.writeText(me.ka_id); | |
| 100 | + setCopied(true); | |
| 101 | + setTimeout(() => setCopied(false), 1800); | |
| 102 | + } catch { /* presse-papiers indisponible : tant pis */ } | |
| 103 | + }; | |
| 104 | + | |
| 105 | + if (me === undefined) { | |
| 106 | + return <div className="container profil"><div className="notice">Chargement…</div></div>; | |
| 107 | + } | |
| 108 | + | |
| 109 | + if (me === null) { | |
| 110 | + return ( | |
| 111 | + <div className="container profil"> | |
| 112 | + <span className="kicker">Mon compte</span> | |
| 113 | + <h1>Connectez-vous pour <em>votre profil</em>.</h1> | |
| 114 | + <p className="lede"> | |
| 115 | + Connectez-vous avec votre <b>KA ID</b> — l'identifiant de membre | |
| 116 | + du Groupe KA, le même sur toutes les plateformes du groupe. | |
| 117 | + </p> | |
| 118 | + <a className="btn login-cta" href="/api/auth/ka/login?next=%2Fprofil"> | |
| 119 | + Se connecter avec KA ID | |
| 120 | + </a> | |
| 121 | + </div> | |
| 122 | + ); | |
| 123 | + } | |
| 124 | + | |
| 125 | + return ( | |
| 126 | + <div className="container profil"> | |
| 127 | + <span className="kicker">Mon compte</span> | |
| 128 | + <h1> | |
| 129 | + {me.name ? <>Salut, <em>{me.name.split(" ")[0]}</em>.</> | |
| 130 | + : <>Votre <em>profil</em>.</>} | |
| 131 | + </h1> | |
| 132 | + | |
| 133 | + {/* ——— Carte de membre Groupe KA ——— */} | |
| 134 | + <div className="pc" role="img" aria-label={`Carte de membre ${me.ka_id}`}> | |
| 135 | + <div className="pc-watermark" aria-hidden="true">KA</div> | |
| 136 | + <div className="pc-head"> | |
| 137 | + <span className="pc-brand">Groupe <span className="pc-ka">KA</span></span> | |
| 138 | + <span className="pc-label">Carte de membre · Groupe KA</span> | |
| 139 | + </div> | |
| 140 | + <div className="pc-id-block"> | |
| 141 | + <span className="pc-id-label">KA-ID</span> | |
| 142 | + <span className="pc-id">{me.ka_id}</span> | |
| 143 | + </div> | |
| 144 | + <div className="pc-foot"> | |
| 145 | + <div className="pc-holder"> | |
| 146 | + <span className="pc-holder-name">{me.name || me.email}</span> | |
| 147 | + {me.role_label && ( | |
| 148 | + <span className="pc-role-badge">{me.role_label}</span> | |
| 149 | + )} | |
| 150 | + <span className="pc-holder-since">Membre depuis le {fmtEpoch(me.created)}</span> | |
| 151 | + </div> | |
| 152 | + {me.picture && ( | |
| 153 | + <img className="pc-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" /> | |
| 154 | + )} | |
| 155 | + </div> | |
| 156 | + <div className="pc-strip" aria-hidden="true"> | |
| 157 | + {Array.from({ length: 28 }).map((_, i) => <i key={i} />)} | |
| 158 | + </div> | |
| 159 | + </div> | |
| 160 | + | |
| 161 | + <button className={`btn ghost pc-copy ${copied ? "ok" : ""}`} onClick={copyKaId}> | |
| 162 | + {copied ? "✓ Copié" : "Copier mon KA-ID"} | |
| 163 | + </button> | |
| 164 | + | |
| 165 | + {/* ——— Informations ——— */} | |
| 166 | + <section className="profil-grid"> | |
| 167 | + <div className="pg-item"> | |
| 168 | + <span className="pg-label">Nom</span> | |
| 169 | + <span className="pg-value">{me.name || "—"}</span> | |
| 170 | + </div> | |
| 171 | + <div className="pg-item"> | |
| 172 | + <span className="pg-label">Courriel</span> | |
| 173 | + <span className="pg-value">{me.email || "—"}</span> | |
| 174 | + </div> | |
| 175 | + <div className="pg-item"> | |
| 176 | + <span className="pg-label">Identifiant membre</span> | |
| 177 | + <span className="pg-value mono">{me.ka_id}</span> | |
| 178 | + </div> | |
| 179 | + <div className="pg-item"> | |
| 180 | + <span className="pg-label">Connexion</span> | |
| 181 | + <span className="pg-value">KA ID (groupe-ka.com)</span> | |
| 182 | + </div> | |
| 183 | + <div className="pg-item"> | |
| 184 | + <span className="pg-label">Membre depuis</span> | |
| 185 | + <span className="pg-value">{fmtEpoch(me.created)}</span> | |
| 186 | + </div> | |
| 187 | + </section> | |
| 188 | + | |
| 189 | + {me.profile_source === "groupe-ka" && <HubProfileSection me={me} />} | |
| 190 | + | |
| 191 | + <p className="profil-note"> | |
| 192 | + Votre <b>KA-ID</b> est votre identifiant unique dans l'écosystème{" "} | |
| 193 | + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"> | |
| 194 | + Groupe KA | |
| 195 | + </a>{" "} | |
| 196 | + — il vous suit sur toutes les plateformes du groupe. Auto-Ka ne conserve | |
| 197 | + que les informations de ce profil ; rien d'autre, et jamais revendues. | |
| 198 | + </p> | |
| 199 | + | |
| 200 | + {/* ——— Actions ——— */} | |
| 201 | + <div className="profil-actions"> | |
| 202 | + <button | |
| 203 | + className="btn ghost" | |
| 204 | + onClick={async () => { await logout(); nav("/"); window.location.reload(); }} | |
| 205 | + > | |
| 206 | + Se déconnecter | |
| 207 | + </button> | |
| 208 | + </div> | |
| 209 | + </div> | |
| 210 | + ); | |
| 211 | +} | |
added
frontend/src/pages/SeoListing.tsx
+142 −0
@@ -0,0 +1,142 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// SeoListing.tsx : pages programmatiques (/usagees/:marque, /region/:r, …). | |
| 5 | +// Le serveur (autoka/seo.py) rend le HTML complet et injecte | |
| 6 | +// window.__SEO_CTX__ (valeurs exactes des filtres + H1) ; ce composant le | |
| 7 | +// récupère au montage et rend <Home> avec les filtres imposés. En cas de | |
| 8 | +// navigation purement client (rare), il résout le slug via les facettes. | |
| 9 | +// ----------------------------------------------------------------------------- | |
| 10 | +import { useEffect, useState } from "react"; | |
| 11 | +import { useLocation, useParams } from "react-router-dom"; | |
| 12 | +import { VehicleQuery, fetchFacets, slugify } from "../api"; | |
| 13 | +import Home from "./Home"; | |
| 14 | + | |
| 15 | +export type SeoDim = | |
| 16 | + | "make" | "model" | "moto-make" | "scooter-make" | |
| 17 | + | "region" | "ville" | "carrosserie"; | |
| 18 | + | |
| 19 | +interface SeoCtx { | |
| 20 | + path: string; | |
| 21 | + kind: string; | |
| 22 | + h1: string; | |
| 23 | + intro?: string; | |
| 24 | + filters: Partial<VehicleQuery>; | |
| 25 | +} | |
| 26 | + | |
| 27 | +declare global { | |
| 28 | + interface Window { __SEO_CTX__?: SeoCtx } | |
| 29 | +} | |
| 30 | + | |
| 31 | +const DIM_KIND: Record<SeoDim, string> = { | |
| 32 | + make: "auto", model: "auto", "moto-make": "moto", "scooter-make": "scooter", | |
| 33 | + region: "auto", ville: "auto", carrosserie: "auto", | |
| 34 | +}; | |
| 35 | + | |
| 36 | +/** Résolution client d'un slug vers la valeur exacte, via les facettes. */ | |
| 37 | +async function resolveCtx( | |
| 38 | + dim: SeoDim, | |
| 39 | + params: Record<string, string | undefined>, | |
| 40 | + path: string | |
| 41 | +): Promise<SeoCtx | null> { | |
| 42 | + const kind = DIM_KIND[dim]; | |
| 43 | + const facets = await fetchFacets(undefined, kind); | |
| 44 | + const bySlug = <T,>(list: T[], get: (x: T) => string, slug?: string) => | |
| 45 | + list.find((x) => slugify(get(x)) === slug); | |
| 46 | + | |
| 47 | + if (dim === "make" || dim === "moto-make" || dim === "scooter-make") { | |
| 48 | + const m = bySlug(facets.makes, (x) => x.make, params.makeSlug); | |
| 49 | + if (!m) return null; | |
| 50 | + const h1 = | |
| 51 | + dim === "make" | |
| 52 | + ? `${m.make} usagées à vendre au Québec` | |
| 53 | + : dim === "moto-make" | |
| 54 | + ? `Motos ${m.make} usagées à vendre au Québec` | |
| 55 | + : `Scooters ${m.make} usagés à vendre au Québec`; | |
| 56 | + return { path, kind, h1, filters: { make: m.make } }; | |
| 57 | + } | |
| 58 | + if (dim === "model") { | |
| 59 | + const m = bySlug(facets.makes, (x) => x.make, params.makeSlug); | |
| 60 | + if (!m) return null; | |
| 61 | + const withModels = await fetchFacets(m.make, kind); | |
| 62 | + const mo = bySlug(withModels.models, (x) => x.model, params.modelSlug); | |
| 63 | + if (!mo) return null; | |
| 64 | + return { | |
| 65 | + path, kind, | |
| 66 | + h1: `${m.make} ${mo.model} usagés à vendre au Québec`, | |
| 67 | + filters: { make: m.make, model: mo.model }, | |
| 68 | + }; | |
| 69 | + } | |
| 70 | + if (dim === "region") { | |
| 71 | + const r = bySlug(facets.regions, (x) => x.region, params.slug); | |
| 72 | + if (!r) return null; | |
| 73 | + return { | |
| 74 | + path, kind, | |
| 75 | + h1: `Voitures usagées à vendre — ${r.region}`, | |
| 76 | + filters: { region: r.region }, | |
| 77 | + }; | |
| 78 | + } | |
| 79 | + if (dim === "carrosserie") { | |
| 80 | + const b = bySlug(facets.body_types, (x) => x, params.slug); | |
| 81 | + if (!b) return null; | |
| 82 | + return { | |
| 83 | + path, kind, | |
| 84 | + h1: `${b} usagés à vendre au Québec`, | |
| 85 | + filters: { body_type: b }, | |
| 86 | + }; | |
| 87 | + } | |
| 88 | + // ville : pas de facette ville — meilleure approximation côté client | |
| 89 | + const cityText = (params.slug || "").replace(/-/g, " "); | |
| 90 | + return { | |
| 91 | + path, kind, | |
| 92 | + h1: `Voitures usagées à vendre à ${cityText}`, | |
| 93 | + filters: { city: cityText }, | |
| 94 | + }; | |
| 95 | +} | |
| 96 | + | |
| 97 | +export default function SeoListing({ dim }: { dim: SeoDim }) { | |
| 98 | + const loc = useLocation(); | |
| 99 | + const params = useParams(); | |
| 100 | + const injected = | |
| 101 | + typeof window !== "undefined" && | |
| 102 | + window.__SEO_CTX__ && | |
| 103 | + window.__SEO_CTX__.path === loc.pathname | |
| 104 | + ? window.__SEO_CTX__ | |
| 105 | + : null; | |
| 106 | + const [ctx, setCtx] = useState<SeoCtx | null>(injected); | |
| 107 | + const [failed, setFailed] = useState(false); | |
| 108 | + | |
| 109 | + useEffect(() => { | |
| 110 | + if (ctx && ctx.path === loc.pathname) return; | |
| 111 | + setCtx(null); | |
| 112 | + setFailed(false); | |
| 113 | + resolveCtx(dim, params as Record<string, string | undefined>, loc.pathname) | |
| 114 | + .then((c) => (c ? setCtx(c) : setFailed(true))) | |
| 115 | + .catch(() => setFailed(true)); | |
| 116 | + }, [loc.pathname]); | |
| 117 | + | |
| 118 | + if (failed) | |
| 119 | + return ( | |
| 120 | + <div className="notice container"> | |
| 121 | + <div className="big">🧭</div> | |
| 122 | + <h2>Page introuvable</h2> | |
| 123 | + <p>Le lien demandé n'existe pas.</p> | |
| 124 | + </div> | |
| 125 | + ); | |
| 126 | + | |
| 127 | + if (!ctx) | |
| 128 | + return ( | |
| 129 | + <div className="container"> | |
| 130 | + <div className="skeleton" style={{ height: 220, marginTop: 24 }} /> | |
| 131 | + </div> | |
| 132 | + ); | |
| 133 | + | |
| 134 | + return ( | |
| 135 | + <Home | |
| 136 | + kind={ctx.kind} | |
| 137 | + fixed={ctx.filters} | |
| 138 | + seoH1={ctx.h1} | |
| 139 | + seoIntro={ctx.intro} | |
| 140 | + /> | |
| 141 | + ); | |
| 142 | +} | |
modified
frontend/src/pages/Vehicle.tsx
+6 −0
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; | ||
| 7 | 7 | import { Link, useParams } from "react-router-dom"; |
| 8 | 8 | import { VehicleDetail, fetchVehicle, fmtDate, fmtKm, fmtPrice } from "../api"; |
| 9 | 9 | import VehicleCard from "../components/VehicleCard"; |
| 10 | +import FavButton from "../components/FavButton"; | |
| 10 | 11 | |
| 11 | 12 | export default function VehiclePage() { |
| 12 | 13 | const { uid } = useParams<{ uid: string }>(); |
@@ -20,6 +21,10 @@ export default function VehiclePage() { | ||
| 20 | 21 | window.scrollTo(0, 0); |
| 21 | 22 | }, [uid]); |
| 22 | 23 | |
| 24 | + useEffect(() => { | |
| 25 | + if (v) document.title = `${v.title} — ${fmtPrice(v.price)} | Auto·Ka`; | |
| 26 | + }, [v]); | |
| 27 | + | |
| 23 | 28 | if (error) |
| 24 | 29 | return ( |
| 25 | 30 | <div className="notice container"> |
@@ -90,6 +95,7 @@ export default function VehiclePage() { | ||
| 90 | 95 | ) : ( |
| 91 | 96 | <div className="nopic" style={{ display: "grid", placeItems: "center", height: "100%", fontSize: 60 }}>🚗</div> |
| 92 | 97 | )} |
| 98 | + <FavButton v={v} /> | |
| 93 | 99 | </div> |
| 94 | 100 | {v.images.length > 1 && ( |
| 95 | 101 | <div className="thumbs"> |
modified
frontend/src/styles.css
+175 −0
@@ -213,6 +213,22 @@ img { display: block; } | ||
| 213 | 213 | background: var(--good); color: #fff; font-family: var(--font-mono); |
| 214 | 214 | font-size: 10.5px; font-weight: 600; padding: 3px 9px; border-radius: 999px; |
| 215 | 215 | } |
| 216 | + | |
| 217 | +/* ===== Favoris — cœur rond « Mon univers Ka » en coin de photo ===== */ | |
| 218 | +.fav-btn { | |
| 219 | + position: absolute; right: 10px; bottom: 10px; z-index: 2; | |
| 220 | + width: 34px; height: 34px; padding: 0; | |
| 221 | + display: flex; align-items: center; justify-content: center; | |
| 222 | + background: rgba(255, 255, 255, 0.94); border: 2px solid var(--ink); | |
| 223 | + border-radius: 999px; cursor: pointer; color: var(--ink); | |
| 224 | + transition: transform 0.12s ease, background 0.12s ease; | |
| 225 | +} | |
| 226 | +.fav-btn:hover { transform: scale(1.12); } | |
| 227 | +.fav-btn.on { background: var(--accent-soft); color: var(--accent-deep); } | |
| 228 | +.fav-btn.on svg { filter: drop-shadow(0 0 3px rgba(204, 63, 22, 0.35)); } | |
| 229 | +.gallery .main { position: relative; } | |
| 230 | +.gallery .main .fav-btn { right: 14px; bottom: 14px; width: 42px; height: 42px; } | |
| 231 | +.gallery .main .fav-btn svg { width: 20px; height: 20px; } | |
| 216 | 232 | .vcard .body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 8px; flex: 1; } |
| 217 | 233 | .vcard h3 { font-size: 16.5px; line-height: 1.25; } |
| 218 | 234 | .vcard .price { |
@@ -444,6 +460,154 @@ img { display: block; } | ||
| 444 | 460 | } |
| 445 | 461 | @keyframes sk { from { background-position: 130% 0; } to { background-position: -30% 0; } } |
| 446 | 462 | |
| 463 | +/* ================= Compte — connexion KA ID (en-tête) ================= */ | |
| 464 | +.login-btn { | |
| 465 | + display: inline-flex; align-items: center; gap: 8px; | |
| 466 | + font-family: var(--font-display); font-weight: 600; font-size: 13.5px; | |
| 467 | + border: 2px solid var(--ink); border-radius: 999px; | |
| 468 | + background: var(--surface); color: var(--ink); | |
| 469 | + padding: 8px 15px; min-height: 40px; flex: none; | |
| 470 | + transition: transform 0.12s ease, box-shadow 0.12s ease; | |
| 471 | +} | |
| 472 | +.login-btn:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--accent); } | |
| 473 | +.login-ka { | |
| 474 | + display: inline-flex; align-items: center; justify-content: center; | |
| 475 | + width: 18px; height: 16px; border-radius: 4px; | |
| 476 | + background: #141814; color: #d9f26b; | |
| 477 | + font: 700 9px/1 var(--font-display); letter-spacing: -0.02em; | |
| 478 | + transform: rotate(-2deg); | |
| 479 | +} | |
| 480 | +.account { position: relative; flex: none; } | |
| 481 | +.account-btn { | |
| 482 | + width: 40px; height: 40px; padding: 0; cursor: pointer; | |
| 483 | + border: 2px solid var(--ink); border-radius: 999px; overflow: hidden; | |
| 484 | + background: var(--surface); display: flex; align-items: center; justify-content: center; | |
| 485 | + transition: transform 0.12s ease, box-shadow 0.12s ease; | |
| 486 | +} | |
| 487 | +.account-btn:hover { transform: translate(-1px, -1px); box-shadow: 3px 3px 0 var(--accent); } | |
| 488 | +.account-btn img { width: 100%; height: 100%; object-fit: cover; } | |
| 489 | +.account-initial { | |
| 490 | + font-family: var(--font-display); font-weight: 700; font-size: 17px; | |
| 491 | + background: var(--accent); color: #fff; width: 100%; height: 100%; | |
| 492 | + display: flex; align-items: center; justify-content: center; | |
| 493 | +} | |
| 494 | +.account-backdrop { position: fixed; inset: 0; z-index: 60; } | |
| 495 | +.account-menu { | |
| 496 | + position: absolute; right: 0; top: calc(100% + 10px); z-index: 61; | |
| 497 | + min-width: 250px; background: var(--surface); | |
| 498 | + border: 2px solid var(--ink); border-radius: var(--r-card); | |
| 499 | + box-shadow: 6px 6px 0 rgba(23, 24, 28, 0.16); overflow: hidden; | |
| 500 | +} | |
| 501 | +.account-id { display: flex; flex-direction: column; gap: 2px; padding: 12px 14px; border-bottom: 1.5px solid var(--line); } | |
| 502 | +.account-id b { font-size: 14px; } | |
| 503 | +.account-id > span { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); word-break: break-all; } | |
| 504 | +.account-kaid { font-family: var(--font-mono); font-size: 10.5px; color: var(--accent-deep) !important; letter-spacing: 0.08em; } | |
| 505 | +.account-note { font-family: var(--font-body) !important; font-size: 10.5px !important; color: var(--ink-3); margin-top: 4px; line-height: 1.4; } | |
| 506 | +.account-link { | |
| 507 | + display: block; width: 100%; text-align: left; padding: 11px 14px; | |
| 508 | + font-weight: 600; font-size: 13.5px; color: var(--ink); | |
| 509 | + border-bottom: 1.5px solid var(--line); | |
| 510 | +} | |
| 511 | +.account-link:hover { background: var(--accent-soft); } | |
| 512 | +.account-menu button { | |
| 513 | + width: 100%; text-align: left; padding: 11px 14px; border: 0; cursor: pointer; | |
| 514 | + background: transparent; font-weight: 600; font-size: 13.5px; color: var(--danger); | |
| 515 | +} | |
| 516 | +.account-menu button:hover { background: var(--accent-soft); color: var(--ink); } | |
| 517 | +@media (max-width: 640px) { .login-btn { padding: 7px 11px; font-size: 12.5px; } } | |
| 518 | + | |
| 519 | +/* ================= Profil — carte de membre Groupe KA ================= | |
| 520 | + La carte garde l'identité du groupe : fond encre + lime (#d9f26b), | |
| 521 | + identique sur toutes les plateformes du Groupe KA. */ | |
| 522 | +.profil { padding-top: 40px; padding-bottom: 60px; } | |
| 523 | +.profil h1 { font-size: clamp(30px, 5vw, 44px); margin: 10px 0 26px; } | |
| 524 | +.profil h1 em { font-style: normal; color: var(--accent-deep); } | |
| 525 | +.profil .lede { max-width: 520px; color: var(--ink-2); margin-bottom: 22px; } | |
| 526 | +.login-cta { display: inline-flex; align-items: center; } | |
| 527 | + | |
| 528 | +.pc { | |
| 529 | + --pc-ink: #141814; --pc-paper: #f5f3ee; --pc-lime: #d9f26b; | |
| 530 | + position: relative; max-width: 520px; overflow: hidden; | |
| 531 | + background: var(--pc-ink); color: var(--pc-paper); | |
| 532 | + border: 2px solid var(--pc-ink); border-radius: 16px; | |
| 533 | + padding: 24px 26px 0; box-shadow: 10px 10px 0 rgba(20, 24, 20, 0.18); | |
| 534 | +} | |
| 535 | +.pc-watermark { | |
| 536 | + position: absolute; right: -18px; top: -34px; pointer-events: none; | |
| 537 | + font-family: var(--font-display); font-weight: 700; font-size: 170px; | |
| 538 | + letter-spacing: -0.06em; color: rgba(217, 242, 107, 0.07); | |
| 539 | + transform: rotate(-8deg); line-height: 1; | |
| 540 | +} | |
| 541 | +.pc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; } | |
| 542 | +.pc-brand { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.04em; } | |
| 543 | +.pc-ka { background: var(--pc-lime); color: var(--pc-ink); padding: 1px 6px 3px; border-radius: 5px; margin-left: 3px; display: inline-block; transform: rotate(-2deg); } | |
| 544 | +.pc-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.16em; color: rgba(245, 243, 238, 0.55); } | |
| 545 | +.pc-id-block { margin: 26px 0 22px; display: flex; flex-direction: column; gap: 4px; } | |
| 546 | +.pc-id-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.2em; color: rgba(217, 242, 107, 0.65); } | |
| 547 | +.pc-id { | |
| 548 | + font-family: var(--font-mono); font-weight: 700; color: var(--pc-lime); | |
| 549 | + font-size: clamp(22px, 6vw, 34px); letter-spacing: 0.14em; | |
| 550 | + text-shadow: 0 0 24px rgba(217, 242, 107, 0.35); | |
| 551 | +} | |
| 552 | +.pc-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; padding-bottom: 18px; } | |
| 553 | +.pc-holder { display: flex; flex-direction: column; gap: 2px; min-width: 0; } | |
| 554 | +.pc-holder-name { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 555 | +.pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(245, 243, 238, 0.5); letter-spacing: 0.06em; } | |
| 556 | +.pc-avatar { width: 52px; height: 52px; border-radius: 999px; border: 2px solid var(--pc-lime); flex: none; } | |
| 557 | +.pc-strip { display: flex; gap: 5px; margin: 0 -26px; padding: 9px 18px; background: rgba(217, 242, 107, 0.1); border-top: 1px solid rgba(217, 242, 107, 0.25); overflow: hidden; } | |
| 558 | +.pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--pc-lime); opacity: 0.7; } | |
| 559 | +.pc-strip i:nth-child(3n) { height: 14px; opacity: 0.35; } | |
| 560 | +.pc-strip i:nth-child(3n+1) { height: 9px; } | |
| 561 | +.pc-strip i:nth-child(3n+2) { height: 17px; opacity: 0.9; } | |
| 562 | +.pc-copy { margin-top: 16px; } | |
| 563 | +.pc-copy.ok { background: #d9f26b; border-color: var(--ink); color: var(--ink); } | |
| 564 | + | |
| 565 | +.profil-grid { | |
| 566 | + display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); | |
| 567 | + gap: 12px; margin-top: 30px; max-width: 760px; | |
| 568 | +} | |
| 569 | +.pg-item { | |
| 570 | + background: var(--surface); border: 2px solid var(--ink); | |
| 571 | + border-radius: var(--r-card); padding: 14px 16px; | |
| 572 | + display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-flat); | |
| 573 | +} | |
| 574 | +.pg-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); } | |
| 575 | +.pg-value { font-weight: 600; font-size: 14.5px; word-break: break-word; } | |
| 576 | +.pg-value.mono { font-family: var(--font-mono); color: var(--accent-deep); } | |
| 577 | + | |
| 578 | +/* — badge statut Groupe KA sur la carte (chip lime, rotaté -1°) — */ | |
| 579 | +.pc-role-badge { | |
| 580 | + align-self: flex-start; margin: 3px 0 1px; | |
| 581 | + background: var(--pc-lime); color: var(--pc-ink); | |
| 582 | + font-family: var(--font-mono); font-size: 10px; font-weight: 700; | |
| 583 | + text-transform: uppercase; letter-spacing: 0.12em; | |
| 584 | + padding: 2px 9px 3px; border-radius: 999px; | |
| 585 | + display: inline-block; transform: rotate(-1deg); | |
| 586 | +} | |
| 587 | + | |
| 588 | +/* — « Mon profil Groupe KA » (lu du hub groupe-ka.com, lecture seule) — */ | |
| 589 | +.hub-profile { | |
| 590 | + margin-top: 30px; max-width: 760px; | |
| 591 | + background: var(--surface); border: 2px solid var(--ink); | |
| 592 | + border-radius: var(--r-card); padding: 20px 22px 18px; | |
| 593 | + box-shadow: var(--shadow-flat); | |
| 594 | +} | |
| 595 | +.hub-profile h3 { font-size: 18px; margin-bottom: 10px; } | |
| 596 | +.hub-bio { color: var(--ink-2); font-size: 14px; line-height: 1.55; max-width: 620px; margin-bottom: 12px; } | |
| 597 | +.hub-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } | |
| 598 | +.hub-chip { | |
| 599 | + font-family: var(--font-mono); font-size: 11.5px; | |
| 600 | + background: var(--surface-2); border: 1px solid var(--line); | |
| 601 | + padding: 4px 11px; border-radius: 999px; color: var(--ink-2); | |
| 602 | + text-decoration: none; | |
| 603 | +} | |
| 604 | +a.hub-chip:hover { border-color: var(--ink); color: var(--ink); } | |
| 605 | +.hub-hint { color: var(--ink-3); font-size: 12.5px; margin-top: 12px; } | |
| 606 | + | |
| 607 | +.profil-note { max-width: 640px; color: var(--ink-2); font-size: 13.5px; margin-top: 22px; } | |
| 608 | +.profil-note a { text-decoration: underline; text-underline-offset: 3px; } | |
| 609 | +.profil-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 26px; } | |
| 610 | + | |
| 447 | 611 | /* ================= Footer ================= */ |
| 448 | 612 | .footer { |
| 449 | 613 | background: var(--ink); color: #c9cbd1; margin-top: 30px; |
@@ -454,3 +618,14 @@ img { display: block; } | ||
| 454 | 618 | .frow { font-size: 13.5px; max-width: 560px; line-height: 1.6; } |
| 455 | 619 | .fmono { font-family: var(--font-mono); font-size: 11px; color: #82858d; margin-top: 22px; } |
| 456 | 620 | .fmono a { text-decoration: underline; } |
| 621 | + | |
| 622 | +/* --- blocs SEO (pages programmatiques rendues serveur + React) --------------- */ | |
| 623 | +.breadcrumbs { font-size: 12px; color: var(--muted, #82858d); margin: 14px 0 4px; } | |
| 624 | +.breadcrumbs a { text-decoration: underline; } | |
| 625 | +.seo-block { margin: 30px 0; } | |
| 626 | +.seo-block h2 { font-size: 17px; margin: 0 0 12px; } | |
| 627 | +.seo-links { display: flex; flex-wrap: wrap; gap: 8px; } | |
| 628 | +.seo-links a.spec-chip { text-decoration: none; } | |
| 629 | +.seo-links a.spec-chip:hover { border-color: var(--accent); } | |
| 630 | +.src-list { columns: 3; column-gap: 28px; padding-left: 18px; font-size: 13.5px; line-height: 1.9; } | |
| 631 | +@media (max-width: 800px) { .src-list { columns: 1; } } | |
| 457 | 632 | |