SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
12 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 016309b

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&nbsp;ID</b> — l'identifiant de membre
116 + du Groupe&nbsp;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