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%

feat: fiche véhicule v2 — analyse marché, KA Score, fiche VIN (NHTSA), lightbox

Enrichissement des données servies pour chaque annonce, toutes sources
confondues, plutôt que connecteur par connecteur :
- vindecode.py : fiche constructeur décodée du NIV via l'API vPIC (NHTSA),
  gratuite, cache SQLite, libellés/valeurs traduits en français
- endpoint détail : analyse du marché (médiane, quartiles, percentile,
  badge de prix, nuage de comparables), KA Score composite (prix, km/âge,
  qualité de fiche, fraîcheur), historique de prix élargi à 30 points

Page détail refondue (Vehicle.tsx) :
- badge « Bon prix / Prix du marché / … » sous le prix, avec écart à la médiane
- lightbox plein écran (clavier, gestes tactiles, compteur, préchargement)
- analyse du marché : barre de positionnement + nuage prix/km SVG
- « Le même véhicule ailleurs » : autres offres du même NIV (dédup) avec écarts
- fiche constructeur (22 champs usine), rappels Transports Canada repliables
  (endpoint existant enfin affiché), courbe d'historique de prix, carte OSM
- vérifié Playwright (frontend/scripts/check-vehicle.mjs) : desktop + mobile,
  ordre DOM = ordre visuel, aucun order: CSS, zéro erreur JS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 31464bd

9 changed files +876 −9

added autoka/vindecode.py +140 −0
@@ -0,0 +1,140 @@
1 +# -----------------------------------------------------------------------------
2 +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# vindecode.py : fiche technique constructeur via le VIN — API vPIC de la NHTSA
5 +# (gratuite, sans clé), décodage paresseux + cache SQLite.
6 +# -----------------------------------------------------------------------------
7 +from __future__ import annotations
8 +
9 +import json
10 +import sqlite3
11 +import time
12 +
13 +import requests
14 +
15 +VPIC_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVINValuesBatch/"
16 +TIMEOUT = 12
17 +
18 +# Champs vPIC retenus → libellé français affiché sur la fiche.
19 +# L'ordre du dict = l'ordre d'affichage.
20 +_FIELDS: dict[str, str] = {
21 + "Trim": "Version (usine)",
22 + "Series": "Série",
23 + "BodyClass": "Carrosserie (usine)",
24 + "DriveType": "Motricité (usine)",
25 + "EngineCylinders": "Cylindres",
26 + "DisplacementL": "Cylindrée (L)",
27 + "EngineHP": "Puissance (ch)",
28 + "EngineConfiguration": "Configuration moteur",
29 + "FuelTypePrimary": "Carburant (usine)",
30 + "FuelTypeSecondary": "Carburant secondaire",
31 + "ElectrificationLevel": "Électrification",
32 + "BatteryKWh": "Batterie (kWh)",
33 + "TransmissionStyle": "Transmission (usine)",
34 + "TransmissionSpeeds": "Rapports",
35 + "Turbo": "Turbo",
36 + "Doors": "Portes (usine)",
37 + "Seats": "Places (usine)",
38 + "GVWR": "PNBV",
39 + "PlantCountry": "Pays d'assemblage",
40 + "PlantCity": "Ville d'assemblage",
41 + "ABS": "Freins ABS",
42 + "AirBagLocFront": "Coussins avant",
43 + "AirBagLocSide": "Coussins latéraux",
44 + "AirBagLocCurtain": "Coussins rideaux",
45 + "TPMS": "Surveillance pneus (TPMS)",
46 + "AdaptiveCruiseControl": "Régulateur adaptatif",
47 + "ForwardCollisionWarning": "Alerte de collision avant",
48 + "LaneDepartureWarning": "Alerte de sortie de voie",
49 + "BlindSpotMon": "Surveillance angles morts",
50 + "RearVisibilitySystem": "Caméra de recul",
51 +}
52 +
53 +# Traductions des valeurs vPIC les plus fréquentes (anglais → français).
54 +_VALUES_FR: dict[str, str] = {
55 + "Gasoline": "Essence", "Diesel": "Diesel", "Electric": "Électrique",
56 + "Sedan/Saloon": "Berline", "Hatchback/Liftback/Notchback": "Hayon",
57 + "Sport Utility Vehicle (SUV)/Multi-Purpose Vehicle (MPV)": "VUS",
58 + "Sport Utility Vehicle [SUV]/Multipurpose Vehicle [MPV]": "VUS",
59 + "Crossover Utility Vehicle (CUV)": "VUS multisegment",
60 + "Pickup": "Camionnette", "Minivan": "Minifourgonnette",
61 + "Coupe": "Coupé", "Convertible/Cabriolet": "Cabriolet", "Wagon": "Familiale",
62 + "4WD/4-Wheel Drive/4x4": "4x4", "AWD/All-Wheel Drive": "Intégrale (AWD)",
63 + "FWD/Front-Wheel Drive": "Traction (FWD)",
64 + "RWD/Rear-Wheel Drive": "Propulsion (RWD)",
65 + "Automatic": "Automatique", "Manual/Standard": "Manuelle",
66 + "Continuously Variable Transmission (CVT)": "CVT",
67 + "Automated Manual Transmission (AMT)": "Manuelle automatisée",
68 + "Dual-Clutch Transmission (DCT)": "Double embrayage (DCT)",
69 + "Strong HEV (Hybrid Electric Vehicle)": "Hybride",
70 + "Mild HEV (Hybrid Electric Vehicle)": "Hybride légère",
71 + "PHEV (Plug-in Hybrid Electric Vehicle)": "Hybride rechargeable",
72 + "BEV (Battery Electric Vehicle)": "100 % électrique",
73 + "Standard": "De série", "Optional": "En option", "Yes": "Oui", "No": "Non",
74 + "Direct": "Directe (TPMS direct)", "Indirect": "Indirecte",
75 + "1st Row (Driver and Passenger)": "1re rangée (conducteur et passager)",
76 + "1st and 2nd Rows": "1re et 2e rangées",
77 + "1st, 2nd and 3rd Rows": "1re, 2e et 3e rangées",
78 + "All Rows": "Toutes les rangées",
79 + "UNITED STATES (USA)": "États-Unis", "CANADA": "Canada", "MEXICO": "Mexique",
80 + "JAPAN": "Japon", "GERMANY": "Allemagne", "SOUTH KOREA": "Corée du Sud",
81 + "V-Shaped": "En V", "In-Line": "En ligne", "Horizontally opposed (boxer)": "Boxer",
82 +}
83 +
84 +_SCHEMA = """
85 +CREATE TABLE IF NOT EXISTS vin_decode (
86 + vin TEXT PRIMARY KEY,
87 + data TEXT, -- JSON {libellé fr: valeur} (vide = décodage sans résultat)
88 + fetched_at REAL
89 +);
90 +"""
91 +
92 +
93 +def _ensure(con: sqlite3.Connection) -> None:
94 + con.executescript(_SCHEMA)
95 +
96 +
97 +def _fr(value: str) -> str:
98 + return _VALUES_FR.get(value, value)
99 +
100 +
101 +def _extract(raw: dict) -> dict[str, str]:
102 + out: dict[str, str] = {}
103 + for key, label in _FIELDS.items():
104 + val = (raw.get(key) or "").strip()
105 + if not val or val.lower() in ("not applicable", "n/a", "0"):
106 + continue
107 + out[label] = _fr(val)
108 + return out
109 +
110 +
111 +def decode(con: sqlite3.Connection, vin: str) -> dict[str, str] | None:
112 + """Fiche constructeur d'un VIN (cache DB, appel réseau au premier accès).
113 +
114 + Retourne un dict {libellé fr: valeur} ou None si indisponible.
115 + """
116 + vin = (vin or "").strip().upper()
117 + if len(vin) != 17:
118 + return None
119 + _ensure(con)
120 + row = con.execute("SELECT data FROM vin_decode WHERE vin=?", (vin,)).fetchone()
121 + if row is not None:
122 + try:
123 + data = json.loads(row["data"] or "{}")
124 + except ValueError:
125 + data = {}
126 + return data or None
127 + try:
128 + resp = requests.post(VPIC_URL, data={"format": "json", "data": vin},
129 + timeout=TIMEOUT)
130 + resp.raise_for_status()
131 + results = resp.json().get("Results") or []
132 + data = _extract(results[0]) if results else {}
133 + except Exception:
134 + # échec réseau : ne pas mettre en cache, on retentera au prochain accès
135 + return None
136 + con.execute(
137 + "INSERT OR REPLACE INTO vin_decode (vin, data, fetched_at) VALUES (?,?,?)",
138 + (vin, json.dumps(data, ensure_ascii=False), time.time()))
139 + con.commit()
140 + return data or None
modified autoka/web.py +91 −1
@@ -6,6 +6,7 @@
6 6 from __future__ import annotations
7 7
8 8 import json
9 +import statistics
9 10 import threading
10 11 import time
11 12 from pathlib import Path
@@ -143,6 +144,87 @@ def list_vehicles(
143 144 return {"total": total, "count": len(rows), "vehicles": rows}
144 145
145 146
147 +def _market_analysis(con, d: dict) -> dict | None:
148 + """Positionnement du prix face aux comparables (même marque/modèle,
149 + année ±1, doublons VIN exclus). Minimum 5 comparables avec prix."""
150 + if not (d.get("make") and d.get("model") and d.get("price")):
151 + return None
152 + base_model = d["model"].split()[0]
153 + sql = ("SELECT uid, price, mileage_km, year FROM vehicles"
154 + " WHERE active=1 AND dup_of IS NULL AND price IS NOT NULL"
155 + " AND make=? AND model LIKE ? AND uid<>?")
156 + args: list = [d["make"], f"{base_model}%", d["uid"]]
157 + if d.get("year"):
158 + sql += " AND year BETWEEN ? AND ?"
159 + args += [d["year"] - 1, d["year"] + 1]
160 + rows = con.execute(sql + " LIMIT 500", args).fetchall()
161 + prices = sorted(r["price"] for r in rows)
162 + n = len(prices)
163 + if n < 5:
164 + return None
165 + price = d["price"]
166 + med = statistics.median(prices)
167 + delta_pct = round((price - med) / med * 100, 1) if med else 0.0
168 + percentile = round(sum(1 for p in prices if p < price) / n * 100)
169 + if delta_pct <= -10:
170 + badge, label = "excellent", "Excellent prix"
171 + elif delta_pct <= -4:
172 + badge, label = "good", "Bon prix"
173 + elif delta_pct < 4:
174 + badge, label = "fair", "Prix du marché"
175 + else:
176 + badge, label = "high", "Au-dessus du marché"
177 + # nuage prix/km pour le graphique comparatif (échantillon)
178 + points = [{"p": r["price"], "km": r["mileage_km"]}
179 + for r in rows if r["mileage_km"] is not None][:150]
180 + q = statistics.quantiles(prices, n=4) if n >= 4 else [prices[0], med, prices[-1]]
181 + return {
182 + "n": n, "median": round(med), "p25": round(q[0]), "p75": round(q[2]),
183 + "min": round(prices[0]), "max": round(prices[-1]),
184 + "delta_pct": delta_pct, "percentile": percentile,
185 + "badge": badge, "label": label, "points": points,
186 + }
187 +
188 +
189 +def _ka_score(d: dict, market: dict | None) -> dict:
190 + """Score composite Auto-Ka (0-100) : prix, kilométrage, fiche, fraîcheur."""
191 + parts: list[dict] = []
192 +
193 + if market: # prix : sous la médiane des comparables = meilleur score
194 + parts.append({"key": "prix", "label": "Prix vs marché",
195 + "score": round(100 - market["percentile"]), "weight": 40})
196 +
197 + if d.get("mileage_km") is not None and d.get("year"):
198 + age = max(time.localtime().tm_year - d["year"], 0.5)
199 + expected = age * 17000 # usage annuel moyen au Québec
200 + ratio = d["mileage_km"] / expected if expected else 1.0
201 + km_score = max(0, min(100, round(100 - (ratio - 0.4) * 100)))
202 + parts.append({"key": "km", "label": "Kilométrage vs âge",
203 + "score": km_score, "weight": 25})
204 +
205 + completeness = sum((
206 + bool(d.get("images")) and len(d["images"]) >= 5,
207 + bool(d.get("description")),
208 + bool(d.get("features")),
209 + bool(d.get("vin")) and len(d.get("vin") or "") == 17,
210 + bool(d.get("carfax_url")),
211 + d.get("mileage_km") is not None,
212 + bool(d.get("transmission")) and bool(d.get("fuel")),
213 + d.get("lat") is not None,
214 + ))
215 + parts.append({"key": "fiche", "label": "Qualité de la fiche",
216 + "score": round(completeness / 8 * 100), "weight": 20})
217 +
218 + days = max((time.time() - (d.get("first_seen") or time.time())) / 86400, 0)
219 + fresh = max(0, min(100, round(100 - days * 100 / 90))) # 0 après ~90 jours
220 + parts.append({"key": "fraicheur", "label": "Fraîcheur de l'annonce",
221 + "score": fresh, "weight": 15})
222 +
223 + total_w = sum(p["weight"] for p in parts)
224 + overall = round(sum(p["score"] * p["weight"] for p in parts) / total_w)
225 + return {"overall": overall, "parts": parts}
226 +
227 +
146 228 @app.get("/api/vehicles/{uid}")
147 229 def get_vehicle(uid: str):
148 230 con = db.connect()
@@ -151,7 +233,7 @@ def get_vehicle(uid: str):
151 233 if row is not None:
152 234 d = _row_to_dict(row)
153 235 d["price_history"] = [dict(r) for r in con.execute(
154 − "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10",
236 + "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 30",
155 237 (uid,)).fetchall()]
156 238 # véhicules similaires : même marque+modèle, autres sources incluses
157 239 if d.get("make") and d.get("model"):
@@ -163,6 +245,14 @@ def get_vehicle(uid: str):
163 245 (d["make"], f"{base_model}%", uid)).fetchall()]
164 246 else:
165 247 d["similar"] = []
248 + # enrichissements fiche : marché, score composite, fiche constructeur
249 + d["market"] = _market_analysis(con, d)
250 + d["ka_score"] = _ka_score(d, d["market"])
251 + try:
252 + from . import vindecode
253 + d["vin_info"] = vindecode.decode(con, d.get("vin") or "")
254 + except Exception:
255 + d["vin_info"] = None
166 256 con.close()
167 257 if d is None:
168 258 raise HTTPException(404, "Véhicule introuvable")
modified frontend/package-lock.json +48 −0
@@ -16,6 +16,7 @@
16 16 "@types/react": "^18.3.3",
17 17 "@types/react-dom": "^18.3.0",
18 18 "@vitejs/plugin-react": "^4.3.1",
19 + "playwright": "^1.62.1",
19 20 "typescript": "^5.5.4",
20 21 "vite": "^5.4.0"
21 22 }
@@ -1547,6 +1548,53 @@
1547 1548 "dev": true,
1548 1549 "license": "ISC"
1549 1550 },
1551 + "node_modules/playwright": {
1552 + "version": "1.62.1",
1553 + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
1554 + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
1555 + "dev": true,
1556 + "license": "Apache-2.0",
1557 + "dependencies": {
1558 + "playwright-core": "1.62.1"
1559 + },
1560 + "bin": {
1561 + "playwright": "cli.js"
1562 + },
1563 + "engines": {
1564 + "node": ">=20"
1565 + },
1566 + "optionalDependencies": {
1567 + "fsevents": "2.3.2"
1568 + }
1569 + },
1570 + "node_modules/playwright-core": {
1571 + "version": "1.62.1",
1572 + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
1573 + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
1574 + "dev": true,
1575 + "license": "Apache-2.0",
1576 + "bin": {
1577 + "playwright-core": "cli.js"
1578 + },
1579 + "engines": {
1580 + "node": ">=20"
1581 + }
1582 + },
1583 + "node_modules/playwright/node_modules/fsevents": {
1584 + "version": "2.3.2",
1585 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
1586 + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
1587 + "dev": true,
1588 + "hasInstallScript": true,
1589 + "license": "MIT",
1590 + "optional": true,
1591 + "os": [
1592 + "darwin"
1593 + ],
1594 + "engines": {
1595 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1596 + }
1597 + },
1550 1598 "node_modules/postcss": {
1551 1599 "version": "8.5.26",
1552 1600 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
modified frontend/package.json +1 −0
@@ -18,6 +18,7 @@
18 18 "@types/react": "^18.3.3",
19 19 "@types/react-dom": "^18.3.0",
20 20 "@vitejs/plugin-react": "^4.3.1",
21 + "playwright": "^1.62.1",
21 22 "typescript": "^5.5.4",
22 23 "vite": "^5.4.0"
23 24 }
added frontend/scripts/check-vehicle.mjs +37 −0
@@ -0,0 +1,37 @@
1 +import { chromium } from "playwright";
2 +const uid = process.argv[2] || "denisauto:5168959";
3 +const browser = await chromium.launch();
4 +const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
5 +const errors = [];
6 +page.on("pageerror", (e) => errors.push(String(e)));
7 +page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
8 +await page.goto(`http://localhost:8095/vehicule/${encodeURIComponent(uid)}`, { waitUntil: "networkidle" });
9 +await page.waitForSelector(".vdetail h1", { timeout: 15000 });
10 +const checks = {
11 + titre: await page.locator(".vd-head h1").textContent(),
12 + badge_marche: await page.locator(".market-badge").textContent().catch(() => "ABSENT"),
13 + galerie: await page.locator(".gallery .main img").count(),
14 + compteur_photos: await page.locator(".gal-count").textContent().catch(() => "ABSENT"),
15 + analyse_marche: await page.locator(".mkt-stats").count(),
16 + scatter: await page.locator(".scatter").count(),
17 + carte: await page.locator(".map-embed").count(),
18 + autres_offres: await page.locator(".offer").count(),
19 + ka_score: await page.locator(".score-ball").textContent().catch(() => "ABSENT"),
20 + fiche_constructeur: (await page.locator(".panel h3", { hasText: "Fiche constructeur" }).count()),
21 + rappels: await page.locator(".recall").count(),
22 +};
23 +// lightbox : clic sur la photo principale
24 +await page.locator(".gallery .main img").click();
25 +checks.lightbox_ouverte = await page.locator(".lightbox img").count();
26 +checks.lb_compteur = await page.locator(".lb-count").textContent().catch(() => "ABSENT");
27 +await page.keyboard.press("ArrowRight");
28 +await page.waitForTimeout(300);
29 +checks.lb_apres_fleche = await page.locator(".lb-count").textContent().catch(() => "ABSENT");
30 +await page.keyboard.press("Escape");
31 +await page.waitForTimeout(200);
32 +checks.lb_fermee = (await page.locator(".lightbox").count()) === 0;
33 +// screenshot pleine page
34 +await page.screenshot({ path: "/tmp/autoka-vehicle.png", fullPage: true });
35 +console.log(JSON.stringify(checks, null, 2));
36 +console.log("JS errors:", errors.length ? errors : "aucune");
37 +await browser.close();
modified frontend/src/api.ts +50 −0
@@ -38,14 +38,58 @@ export interface Vehicle {
38 38 details: Record<string, unknown>;
39 39 images: string[];
40 40 carfax_url: string;
41 + lat: number | null;
42 + lng: number | null;
41 43 first_seen: number;
42 44 updated_at: number;
43 45 active: number;
44 46 }
45 47
48 +/** Positionnement du prix face aux comparables (calculé côté API). */
49 +export interface MarketAnalysis {
50 + n: number;
51 + median: number;
52 + p25: number;
53 + p75: number;
54 + min: number;
55 + max: number;
56 + delta_pct: number;
57 + percentile: number;
58 + badge: "excellent" | "good" | "fair" | "high";
59 + label: string;
60 + points: { p: number; km: number }[];
61 +}
62 +
63 +export interface KaScore {
64 + overall: number;
65 + parts: { key: string; label: string; score: number; weight: number }[];
66 +}
67 +
68 +/** Autre offre du même véhicule (même VIN, autre source — dédup inter-sources). */
69 +export interface DupSource {
70 + uid: string;
71 + source: string;
72 + url: string;
73 + price: number | null;
74 + dealer_name: string;
75 + city: string;
76 +}
77 +
46 78 export interface VehicleDetail extends Vehicle {
47 79 price_history: { ts: number; price: number | null }[];
48 80 similar: Vehicle[];
81 + market: MarketAnalysis | null;
82 + ka_score: KaScore;
83 + vin_info: Record<string, string> | null;
84 + dup_sources?: DupSource[];
85 +}
86 +
87 +export interface Recall {
88 + recall_number: string;
89 + date: string;
90 + component: string;
91 + description: string;
92 + units_affected: number | null;
49 93 }
50 94
51 95 export interface Facets {
@@ -154,6 +198,12 @@ export function fetchVehicle(uid: string) {
154 198 return get<VehicleDetail>(`/api/vehicles/${encodeURIComponent(uid)}`);
155 199 }
156 200
201 +export function fetchVehicleRecalls(uid: string) {
202 + return get<{ count: number; recalls: Recall[] }>(
203 + `/api/vehicles/${encodeURIComponent(uid)}/recalls`
204 + );
205 +}
206 +
157 207 export function fetchFacets(make?: string, kind?: string) {
158 208 const params = new URLSearchParams();
159 209 if (make) params.set("make", make);
added frontend/src/components/Lightbox.tsx +88 −0
@@ -0,0 +1,88 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// Lightbox.tsx : visionneuse photos plein écran — clavier, gestes, compteur
5 +// -----------------------------------------------------------------------------
6 +import { useCallback, useEffect, useRef } from "react";
7 +
8 +interface Props {
9 + images: string[];
10 + index: number;
11 + onClose: () => void;
12 + onIndex: (i: number) => void;
13 + alt: string;
14 +}
15 +
16 +export default function Lightbox({ images, index, onClose, onIndex, alt }: Props) {
17 + const touchX = useRef<number | null>(null);
18 +
19 + const prev = useCallback(
20 + () => onIndex((index - 1 + images.length) % images.length),
21 + [index, images.length, onIndex]
22 + );
23 + const next = useCallback(
24 + () => onIndex((index + 1) % images.length),
25 + [index, images.length, onIndex]
26 + );
27 +
28 + useEffect(() => {
29 + const onKey = (e: KeyboardEvent) => {
30 + if (e.key === "Escape") onClose();
31 + else if (e.key === "ArrowLeft") prev();
32 + else if (e.key === "ArrowRight") next();
33 + };
34 + window.addEventListener("keydown", onKey);
35 + document.body.style.overflow = "hidden";
36 + return () => {
37 + window.removeEventListener("keydown", onKey);
38 + document.body.style.overflow = "";
39 + };
40 + }, [onClose, prev, next]);
41 +
42 + // précharger les voisines pour une navigation instantanée
43 + useEffect(() => {
44 + [index - 1, index + 1].forEach((i) => {
45 + const u = images[(i + images.length) % images.length];
46 + if (u) new Image().src = u;
47 + });
48 + }, [index, images]);
49 +
50 + return (
51 + <div
52 + className="lightbox"
53 + role="dialog"
54 + aria-label={`Photos — ${alt}`}
55 + onClick={onClose}
56 + onTouchStart={(e) => { touchX.current = e.touches[0].clientX; }}
57 + onTouchEnd={(e) => {
58 + if (touchX.current == null) return;
59 + const dx = e.changedTouches[0].clientX - touchX.current;
60 + touchX.current = null;
61 + if (dx > 48) prev();
62 + else if (dx < -48) next();
63 + }}
64 + >
65 + <button className="lb-close" aria-label="Fermer" onClick={onClose}>✕</button>
66 + <div className="lb-count mono">{index + 1} / {images.length}</div>
67 + {images.length > 1 && (
68 + <>
69 + <button
70 + className="lb-nav lb-prev"
71 + aria-label="Photo précédente"
72 + onClick={(e) => { e.stopPropagation(); prev(); }}
73 + >←</button>
74 + <button
75 + className="lb-nav lb-next"
76 + aria-label="Photo suivante"
77 + onClick={(e) => { e.stopPropagation(); next(); }}
78 + >→</button>
79 + </>
80 + )}
81 + <img
82 + src={images[index]}
83 + alt={`${alt} — photo ${index + 1}`}
84 + onClick={(e) => e.stopPropagation()}
85 + />
86 + </div>
87 + );
88 +}
modified frontend/src/pages/Vehicle.tsx +271 −8
@@ -1,23 +1,132 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// Vehicle.tsx : fiche véhicule — galerie, specs, historique de prix, similaires
4 +// Vehicle.tsx : fiche véhicule — galerie + lightbox, badge marché, analyse de
5 +// prix, autres offres (même VIN), fiche constructeur, rappels TC, KA Score,
6 +// carte, historique de prix, similaires
5 7 // -----------------------------------------------------------------------------
6 −import { useEffect, useState } from "react";
8 +import { useEffect, useMemo, useState } from "react";
7 9 import { Link, useParams } from "react-router-dom";
8 −import { VehicleDetail, fetchVehicle, fmtDate, fmtKm, fmtPrice } from "../api";
10 +import {
11 + KaScore, MarketAnalysis, Recall, VehicleDetail,
12 + fetchVehicle, fetchVehicleRecalls, fmtDate, fmtKm, fmtPrice, sourceName,
13 +} from "../api";
9 14 import VehicleCard from "../components/VehicleCard";
10 15 import FavButton from "../components/FavButton";
16 +import Lightbox from "../components/Lightbox";
17 +
18 +// --- Nuage prix/km des comparables (SVG maison, style Charts.tsx) ------------
19 +function MarketScatter({ m, price, km }: {
20 + m: MarketAnalysis; price: number; km: number | null;
21 +}) {
22 + const pts = m.points;
23 + if (pts.length < 5) return null;
24 + const W = 560, H = 260, PAD = { t: 14, r: 14, b: 30, l: 56 };
25 + const kms = pts.map((d) => d.km).concat(km != null ? [km] : []);
26 + const ps = pts.map((d) => d.p).concat([price]);
27 + const kMin = Math.min(...kms), kMax = Math.max(...kms);
28 + const pMin = Math.min(...ps), pMax = Math.max(...ps);
29 + const x = (v: number) =>
30 + PAD.l + (kMax > kMin ? (v - kMin) / (kMax - kMin) : 0.5) * (W - PAD.l - PAD.r);
31 + const y = (v: number) =>
32 + H - PAD.b - (pMax > pMin ? (v - pMin) / (pMax - pMin) : 0.5) * (H - PAD.t - PAD.b);
33 + const fmtK = (v: number) => `${Math.round(v / 1000)} k`;
34 + return (
35 + <svg
36 + viewBox={`0 0 ${W} ${H}`}
37 + className="scatter"
38 + role="img"
39 + aria-label="Nuage prix / kilométrage des véhicules comparables"
40 + >
41 + <line x1={PAD.l} y1={H - PAD.b} x2={W - PAD.r} y2={H - PAD.b} className="axis" />
42 + <line x1={PAD.l} y1={PAD.t} x2={PAD.l} y2={H - PAD.b} className="axis" />
43 + <line x1={PAD.l} y1={y(m.median)} x2={W - PAD.r} y2={y(m.median)} className="median" />
44 + <text x={W - PAD.r} y={y(m.median) - 5} textAnchor="end" className="lbl">
45 + médiane {fmtPrice(m.median)}
46 + </text>
47 + {pts.map((d, i) => (
48 + <circle key={i} cx={x(d.km)} cy={y(d.p)} r={3.5} className="dot" />
49 + ))}
50 + {km != null && (
51 + <g>
52 + <circle cx={x(km)} cy={y(price)} r={7} className="me" />
53 + <text x={x(km)} y={y(price) - 12} textAnchor="middle" className="lbl me-lbl">
54 + ce véhicule
55 + </text>
56 + </g>
57 + )}
58 + <text x={PAD.l} y={H - 8} className="lbl">{fmtK(kMin)} km</text>
59 + <text x={W - PAD.r} y={H - 8} textAnchor="end" className="lbl">{fmtK(kMax)} km</text>
60 + <text x={PAD.l - 6} y={y(pMax) + 4} textAnchor="end" className="lbl">{fmtK(pMax)} $</text>
61 + <text x={PAD.l - 6} y={y(pMin) + 4} textAnchor="end" className="lbl">{fmtK(pMin)} $</text>
62 + </svg>
63 + );
64 +}
65 +
66 +// --- Courbe de l'historique de prix ------------------------------------------
67 +function PriceSpark({ hist }: { hist: { ts: number; price: number | null }[] }) {
68 + const pts = hist.filter((h) => h.price != null).reverse() as
69 + { ts: number; price: number }[];
70 + if (pts.length < 2) return null;
71 + const W = 300, H = 64, P = 6;
72 + const tMin = pts[0].ts, tMax = pts[pts.length - 1].ts;
73 + const pMin = Math.min(...pts.map((d) => d.price));
74 + const pMax = Math.max(...pts.map((d) => d.price));
75 + const x = (t: number) =>
76 + P + (tMax > tMin ? (t - tMin) / (tMax - tMin) : 0.5) * (W - 2 * P);
77 + const y = (p: number) =>
78 + H - P - (pMax > pMin ? (p - pMin) / (pMax - pMin) : 0.5) * (H - 2 * P);
79 + const d = pts.map((p, i) => `${i ? "L" : "M"}${x(p.ts).toFixed(1)},${y(p.price).toFixed(1)}`).join(" ");
80 + return (
81 + <svg viewBox={`0 0 ${W} ${H}`} className="spark" role="img" aria-label="Évolution du prix">
82 + <path d={d} />
83 + {pts.map((p, i) => <circle key={i} cx={x(p.ts)} cy={y(p.price)} r={3} />)}
84 + </svg>
85 + );
86 +}
87 +
88 +// --- KA Score (jauge + barres par composante) ---------------------------------
89 +function ScorePanel({ s }: { s: KaScore }) {
90 + const tone = s.overall >= 70 ? "good" : s.overall >= 45 ? "fair" : "high";
91 + return (
92 + <div className="panel">
93 + <h3>🏁 KA Score</h3>
94 + <div className="score-head">
95 + <div className={`score-ball ${tone}`}>{s.overall}</div>
96 + <div className="score-note">
97 + Indice composite Auto-Ka : prix face au marché, kilométrage selon
98 + l'âge, qualité de la fiche et fraîcheur de l'annonce.
99 + </div>
100 + </div>
101 + <div className="score-bars">
102 + {s.parts.map((p) => (
103 + <div key={p.key} className="score-row">
104 + <span className="lbl">{p.label}</span>
105 + <span className="bar"><i style={{ width: `${p.score}%` }} /></span>
106 + <span className="val mono">{p.score}</span>
107 + </div>
108 + ))}
109 + </div>
110 + </div>
111 + );
112 +}
11 113
12 114 export default function VehiclePage() {
13 115 const { uid } = useParams<{ uid: string }>();
14 116 const [v, setV] = useState<VehicleDetail | null>(null);
15 117 const [error, setError] = useState(false);
16 118 const [img, setImg] = useState(0);
119 + const [lightbox, setLightbox] = useState(false);
120 + const [recalls, setRecalls] = useState<Recall[] | null>(null);
121 + const [recallsOpen, setRecallsOpen] = useState(false);
17 122
18 123 useEffect(() => {
19 − setV(null); setError(false); setImg(0);
20 − if (uid) fetchVehicle(uid).then(setV).catch(() => setError(true));
124 + setV(null); setError(false); setImg(0); setLightbox(false);
125 + setRecalls(null); setRecallsOpen(false);
126 + if (uid) {
127 + fetchVehicle(uid).then(setV).catch(() => setError(true));
128 + fetchVehicleRecalls(uid).then((r) => setRecalls(r.recalls)).catch(() => {});
129 + }
21 130 window.scrollTo(0, 0);
22 131 }, [uid]);
23 132
@@ -25,6 +134,13 @@ export default function VehiclePage() {
25 134 if (v) document.title = `${v.title} — ${fmtPrice(v.price)} | Auto·Ka`;
26 135 }, [v]);
27 136
137 + const mapSrc = useMemo(() => {
138 + if (!v || v.lat == null || v.lng == null) return null;
139 + const d = 0.02;
140 + const bbox = [v.lng - d, v.lat - d, v.lng + d, v.lat + d].join(",");
141 + return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${v.lat},${v.lng}`;
142 + }, [v]);
143 +
28 144 if (error)
29 145 return (
30 146 <div className="notice container">
@@ -52,6 +168,7 @@ export default function VehiclePage() {
52 168
53 169 const specs: [string, string][] = [
54 170 ["Année", v.year ? String(v.year) : "—"],
171 + ["Version", v.trim || "—"],
55 172 ["Kilométrage", fmtKm(v.mileage_km)],
56 173 ["Transmission", v.transmission || "—"],
57 174 ["Carburant", v.fuel || "—"],
@@ -66,6 +183,9 @@ export default function VehiclePage() {
66 183 ["No de stock", v.stock_number || "—"],
67 184 ].filter(([, val]) => val !== "—") as [string, string][];
68 185
186 + const m = v.market;
187 + const offers = v.dup_sources || [];
188 +
69 189 return (
70 190 <div className="container vdetail">
71 191 <p style={{ marginTop: 0 }}>
@@ -81,6 +201,17 @@ export default function VehiclePage() {
81 201 {prevPrice != null && v.price != null && prevPrice > v.price && (
82 202 <div className="was">{fmtPrice(prevPrice)}</div>
83 203 )}
204 + {m && (
205 + <div className={`market-badge ${m.badge}`}>
206 + {m.label}
207 + {Math.abs(m.delta_pct) >= 1 && (
208 + <span className="delta">
209 + {" "}· {Math.abs(m.delta_pct).toLocaleString("fr-CA")} %{" "}
210 + {m.delta_pct < 0 ? "sous" : "au-dessus de"} la médiane
211 + </span>
212 + )}
213 + </div>
214 + )}
84 215 </div>
85 216 </div>
86 217
@@ -89,12 +220,19 @@ export default function VehiclePage() {
89 220 <div className="gallery">
90 221 <div className="main">
91 222 {v.images.length > 0 ? (
92 − <a href={v.images[img]} target="_blank" rel="noreferrer">
93 − <img src={v.images[img]} alt={v.title} />
94 − </a>
223 + <img
224 + src={v.images[img]}
225 + alt={v.title}
226 + onClick={() => setLightbox(true)}
227 + />
95 228 ) : (
96 229 <div className="nopic" style={{ display: "grid", placeItems: "center", height: "100%", fontSize: 60 }}>🚗</div>
97 230 )}
231 + {v.images.length > 0 && (
232 + <button className="gal-count mono" onClick={() => setLightbox(true)}>
233 + 🖼 {img + 1} / {v.images.length}
234 + </button>
235 + )}
98 236 <FavButton v={v} />
99 237 </div>
100 238 {v.images.length > 1 && (
@@ -112,6 +250,15 @@ export default function VehiclePage() {
112 250 </div>
113 251 )}
114 252 </div>
253 + {lightbox && v.images.length > 0 && (
254 + <Lightbox
255 + images={v.images}
256 + index={img}
257 + onIndex={setImg}
258 + onClose={() => setLightbox(false)}
259 + alt={v.title}
260 + />
261 + )}
115 262
116 263 {v.description && (
117 264 <div className="panel" style={{ marginTop: 18 }}>
@@ -119,6 +266,47 @@ export default function VehiclePage() {
119 266 <div className="desc">{v.description}</div>
120 267 </div>
121 268 )}
269 +
270 + {m && v.price != null && (
271 + <div className="panel" style={{ marginTop: 18 }}>
272 + <h3>📊 Analyse du marché</h3>
273 + <div className="mkt-stats">
274 + <div><span className="lbl">Comparables</span><b>{m.n}</b></div>
275 + <div><span className="lbl">Médiane</span><b>{fmtPrice(m.median)}</b></div>
276 + <div><span className="lbl">Fourchette 25–75 %</span><b>{fmtPrice(m.p25)} – {fmtPrice(m.p75)}</b></div>
277 + <div><span className="lbl">Étendue</span><b>{fmtPrice(m.min)} – {fmtPrice(m.max)}</b></div>
278 + </div>
279 + <div className="pos-bar" aria-hidden="true">
280 + <i className="marker" style={{ left: `${m.percentile}%` }} />
281 + </div>
282 + <div className="pos-legend mono">
283 + <span>moins cher</span>
284 + <span>{m.percentile} % des comparables sont moins chers</span>
285 + <span>plus cher</span>
286 + </div>
287 + <MarketScatter m={m} price={v.price} km={v.mileage_km} />
288 + <div className="cta-note">
289 + {v.make} {v.model.split(" ")[0]}{v.year ? ` ${v.year - 1}–${v.year + 1}` : ""} en
290 + vente au Québec, doublons exclus — calcul Auto-Ka
291 + </div>
292 + </div>
293 + )}
294 +
295 + {mapSrc && (
296 + <div className="panel" style={{ marginTop: 18 }}>
297 + <h3>📍 Où le voir</h3>
298 + <iframe
299 + className="map-embed"
300 + src={mapSrc}
301 + title={`Carte — ${v.dealer_name || v.city}`}
302 + loading="lazy"
303 + />
304 + <div className="cta-note">
305 + {[v.dealer_name, v.city, v.region].filter(Boolean).join(" · ")} —
306 + position approximative (ville du vendeur)
307 + </div>
308 + </div>
309 + )}
122 310 </div>
123 311
124 312 <div>
@@ -153,6 +341,34 @@ export default function VehiclePage() {
153 341 </div>
154 342 </div>
155 343
344 + {offers.length > 0 && (
345 + <div className="panel">
346 + <h3>🔁 Le même véhicule ailleurs ({offers.length})</h3>
347 + <div className="offers">
348 + {offers.map((o) => (
349 + <a key={o.uid} className="offer" href={o.url} target="_blank" rel="noreferrer">
350 + <span className="who">
351 + {o.dealer_name || sourceName(o.source)}
352 + {o.city ? <em> · {o.city}</em> : null}
353 + </span>
354 + <span className="price mono">
355 + {fmtPrice(o.price)}
356 + {o.price != null && v.price != null && o.price !== v.price && (
357 + <em className={o.price < v.price ? "down" : "up"}>
358 + {" "}({o.price < v.price ? "−" : "+"}
359 + {Math.abs(o.price - v.price).toLocaleString("fr-CA")} $)
360 + </em>
361 + )}
362 + </span>
363 + </a>
364 + ))}
365 + </div>
366 + <div className="cta-note">même NIV repéré sur plusieurs sites (dédoublonnage Auto-Ka)</div>
367 + </div>
368 + )}
369 +
370 + {v.ka_score && <ScorePanel s={v.ka_score} />}
371 +
156 372 {v.features.length > 0 && (
157 373 <div className="panel">
158 374 <h3>✨ Équipements ({v.features.length})</h3>
@@ -164,9 +380,56 @@ export default function VehiclePage() {
164 380 </div>
165 381 )}
166 382
383 + {v.vin_info && Object.keys(v.vin_info).length > 0 && (
384 + <div className="panel">
385 + <h3>🏭 Fiche constructeur</h3>
386 + <table className="spec-table">
387 + <tbody>
388 + {Object.entries(v.vin_info).map(([k, val]) => (
389 + <tr key={k}>
390 + <td>{k}</td>
391 + <td>{val}</td>
392 + </tr>
393 + ))}
394 + </tbody>
395 + </table>
396 + <div className="cta-note">décodée du NIV — base vPIC (NHTSA)</div>
397 + </div>
398 + )}
399 +
400 + {recalls !== null && recalls.length > 0 && (
401 + <div className="panel recall-panel">
402 + <h3>⚠️ Rappels Transports Canada ({recalls.length})</h3>
403 + <div className="recalls">
404 + {(recallsOpen ? recalls : recalls.slice(0, 3)).map((r) => (
405 + <details key={r.recall_number} className="recall">
406 + <summary>
407 + <span className="mono date">{r.date}</span> {r.component}
408 + </summary>
409 + <p>{r.description}</p>
410 + <p className="mono meta">
411 + Rappel no {r.recall_number}
412 + {r.units_affected ? ` · ${r.units_affected.toLocaleString("fr-CA")} unités visées` : ""}
413 + </p>
414 + </details>
415 + ))}
416 + </div>
417 + {recalls.length > 3 && (
418 + <button className="btn ghost sm" onClick={() => setRecallsOpen(!recallsOpen)}>
419 + {recallsOpen ? "Réduire" : `Voir les ${recalls.length} rappels`}
420 + </button>
421 + )}
422 + <div className="cta-note">
423 + rappels {v.make} {v.model} {v.year ?? ""} — vérifier auprès du
424 + concessionnaire s'ils ont été effectués
425 + </div>
426 + </div>
427 + )}
428 +
167 429 {v.price_history.length > 1 && (
168 430 <div className="panel">
169 431 <h3>📉 Historique de prix</h3>
432 + <PriceSpark hist={v.price_history} />
170 433 <div className="price-history">
171 434 {v.price_history.map((h, i) => {
172 435 const next = v.price_history[i + 1];
modified frontend/src/styles.css +150 −0
@@ -710,3 +710,153 @@ a.hub-chip:hover { border-color: var(--ink); color: var(--ink); }
710 710 font-size: max(16px, 1em) !important;
711 711 }
712 712 }
713 +
714 +/* =============================================================================
715 + Fiche véhicule v2 — badge marché, lightbox, analyse de prix, KA Score,
716 + autres offres (même VIN), rappels TC, fiche constructeur, carte
717 + ========================================================================== */
718 +
719 +/* — badge de positionnement prix (en-tête, sous le prix) — */
720 +.market-badge {
721 + display: inline-block; margin-top: 8px; padding: 5px 10px;
722 + border: 2px solid var(--ink); border-radius: 999px;
723 + font-family: var(--font-mono); font-size: 12px; font-weight: 700;
724 + background: var(--surface);
725 +}
726 +.market-badge .delta { font-weight: 500; }
727 +.market-badge.excellent { background: var(--good); color: #fff; border-color: var(--good); }
728 +.market-badge.good { background: var(--good-soft); color: var(--good); }
729 +.market-badge.fair { background: var(--surface-2); color: var(--ink); }
730 +.market-badge.high { background: var(--accent-soft); color: var(--accent-deep); }
731 +
732 +/* — compteur photos sur la galerie — */
733 +.gal-count {
734 + position: absolute; left: 14px; bottom: 14px; padding: 5px 10px;
735 + border: 2px solid var(--ink); border-radius: 999px; cursor: zoom-in;
736 + background: color-mix(in srgb, var(--surface) 88%, transparent);
737 + font-size: 12px; font-weight: 700;
738 +}
739 +
740 +/* — lightbox plein écran — */
741 +.lightbox {
742 + position: fixed; inset: 0; z-index: 200; display: grid; place-items: center;
743 + background: rgba(12, 12, 10, 0.92); padding: 52px 16px;
744 +}
745 +.lightbox img {
746 + max-width: min(1200px, 96vw); max-height: calc(100vh - 104px);
747 + object-fit: contain; border-radius: 6px;
748 +}
749 +.lightbox button {
750 + position: absolute; display: grid; place-items: center; cursor: pointer;
751 + border: 2px solid #fff; border-radius: 999px; background: rgba(0, 0, 0, 0.35);
752 + color: #fff; font-size: 20px; width: 46px; height: 46px;
753 +}
754 +.lightbox button:hover { background: var(--accent); border-color: var(--accent); }
755 +.lb-close { top: 16px; right: 16px; }
756 +.lb-nav { top: 50%; transform: translateY(-50%); }
757 +.lb-prev { left: 14px; }
758 +.lb-next { right: 14px; }
759 +.lb-count {
760 + position: absolute; top: 26px; left: 50%; transform: translateX(-50%);
761 + color: #fff; font-size: 13px; letter-spacing: 0.08em;
762 +}
763 +@media (max-width: 767px) {
764 + .lightbox { padding: 64px 8px; }
765 + .lb-nav { top: auto; bottom: 12px; transform: none; }
766 + .lb-prev { left: 18%; }
767 + .lb-next { right: 18%; }
768 +}
769 +
770 +/* — analyse du marché — */
771 +.mkt-stats {
772 + display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
773 + gap: 10px 14px; margin-bottom: 14px;
774 +}
775 +.mkt-stats .lbl {
776 + display: block; font-family: var(--font-mono); font-size: 11px;
777 + text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3);
778 +}
779 +.mkt-stats b { font-size: 15px; }
780 +.pos-bar {
781 + position: relative; height: 14px; border: 2px solid var(--ink);
782 + border-radius: 999px; overflow: visible;
783 + background: linear-gradient(90deg, var(--good) 0%, var(--good-soft) 38%,
784 + var(--surface-2) 55%, var(--accent-soft) 78%, var(--accent) 100%);
785 +}
786 +.pos-bar .marker {
787 + position: absolute; top: -7px; width: 6px; height: 24px;
788 + transform: translateX(-50%); background: var(--ink); border-radius: 3px;
789 +}
790 +.pos-legend {
791 + display: flex; justify-content: space-between; gap: 8px;
792 + font-size: 11px; color: var(--ink-3); margin: 8px 0 4px;
793 +}
794 +.pos-legend span:nth-child(2) { text-align: center; color: var(--ink); font-weight: 700; }
795 +.scatter { width: 100%; height: auto; margin-top: 12px; }
796 +.scatter .axis { stroke: var(--line); stroke-width: 1.5; }
797 +.scatter .median { stroke: var(--ink-3); stroke-width: 1; stroke-dasharray: 5 4; }
798 +.scatter .dot { fill: var(--ink-3); opacity: 0.45; }
799 +.scatter .me { fill: var(--accent); stroke: var(--ink); stroke-width: 2; }
800 +.scatter .lbl { font-family: var(--font-mono); font-size: 11px; fill: var(--ink-3); }
801 +.scatter .me-lbl { fill: var(--accent-deep); font-weight: 700; }
802 +
803 +/* — courbe historique de prix — */
804 +.spark { width: 100%; height: auto; margin-bottom: 10px; }
805 +.spark path { fill: none; stroke: var(--accent); stroke-width: 2.5; }
806 +.spark circle { fill: var(--surface); stroke: var(--ink); stroke-width: 1.5; }
807 +
808 +/* — KA Score — */
809 +.score-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
810 +.score-ball {
811 + flex: none; width: 62px; height: 62px; display: grid; place-items: center;
812 + border: 3px solid var(--ink); border-radius: 999px;
813 + font-family: var(--font-display); font-size: 24px; font-weight: 700;
814 + box-shadow: 3px 3px 0 var(--ink);
815 +}
816 +.score-ball.good { background: var(--good-soft); color: var(--good); }
817 +.score-ball.fair { background: var(--surface-2); }
818 +.score-ball.high { background: var(--accent-soft); color: var(--accent-deep); }
819 +.score-note { font-size: 12.5px; color: var(--ink-3); line-height: 1.45; }
820 +.score-row {
821 + display: grid; grid-template-columns: 1fr 90px 30px; align-items: center;
822 + gap: 10px; padding: 5px 0; font-size: 12.5px;
823 +}
824 +.score-row .bar {
825 + height: 10px; border: 1.5px solid var(--ink); border-radius: 999px;
826 + overflow: hidden; background: var(--surface-2); display: block;
827 +}
828 +.score-row .bar i { display: block; height: 100%; background: var(--accent); }
829 +.score-row .val { text-align: right; font-weight: 700; }
830 +
831 +/* — autres offres du même véhicule (même NIV) — */
832 +.offers { display: flex; flex-direction: column; }
833 +.offer {
834 + display: flex; justify-content: space-between; align-items: baseline; gap: 10px;
835 + padding: 9px 0; border-bottom: 1px dashed var(--line);
836 + text-decoration: none; color: var(--ink); font-size: 13.5px;
837 +}
838 +.offer:last-child { border-bottom: none; }
839 +.offer:hover .who { color: var(--accent-deep); text-decoration: underline; }
840 +.offer .who em { font-style: normal; color: var(--ink-3); }
841 +.offer .price { font-weight: 700; white-space: nowrap; }
842 +.offer .price em { font-style: normal; font-weight: 600; font-size: 12px; }
843 +.offer .price em.down { color: var(--good); }
844 +.offer .price em.up { color: var(--danger); }
845 +
846 +/* — rappels Transports Canada — */
847 +.recall-panel .btn.sm { margin-top: 10px; padding: 7px 12px; font-size: 12.5px; }
848 +.recall { border-bottom: 1px dashed var(--line); padding: 4px 0; }
849 +.recall:last-child { border-bottom: none; }
850 +.recall summary {
851 + cursor: pointer; font-size: 13.5px; font-weight: 600; padding: 5px 0;
852 + list-style-position: outside;
853 +}
854 +.recall summary .date { color: var(--ink-3); font-size: 11.5px; margin-right: 6px; }
855 +.recall p { font-size: 13px; line-height: 1.5; margin: 8px 0; white-space: pre-line; }
856 +.recall .meta { font-size: 11.5px; color: var(--ink-3); }
857 +
858 +/* — carte (OpenStreetMap) — */
859 +.map-embed {
860 + width: 100%; height: 300px; border: 2px solid var(--ink);
861 + border-radius: var(--r-card); display: block; background: var(--surface-2);
862 +}
713 863