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>
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 | |