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): refonte premium mobile-first de la fiche véhicule (2026-09-07)

Même socle que les fiches Lou-Ka v3 / Immo-Ka v3 (frontend/src/fiche/,
préfixe ak-, tokens --ak-*, fond papier, cartes blanches bord 1 px, orange
brûlé réservé aux CTA/prix/score ; masthead encre conservé en version
compacte) : héro (concessionnaire · ville, prix + ancien prix barré +
capsule marché, titre, résumé année · km · transmission · carburant ·
carrosserie, galerie à balayage + Lightbox existante, actions favoris /
partage / Carfax / Voir l'annonce) ; KA Score en anneau + barres par
composante (poids réels de l'API) ; « En bref » déterministe (marché,
kilométrage vs âge, baisse de prix, même NIV ailleurs, rappels TC,
fraîcheur, Carfax) ; navigation sticky ; « Prix et marché » (KPI, jauge de
percentile, nuage prix/km, PDSF d'origine, historique du prix demandé,
méthodologie) ; « Le véhicule » (grille icône/valeur dont consommation,
rangées NIV/stock/couleurs, description tronquée, équipements 12 + Voir les
N, fiche constructeur vPIC en accordéon) ; Rappels Transports Canada ; Le
même véhicule ailleurs ; Où le voir (OSM) ; Similaires ; Dossier de
l'annonce ; Sources et méthodologie. CTA sticky après le héro, aside desktop
sticky (score en miniature, constats), « Demander à Ka » via le widget KA
Agent. Masthead compact sur /vehicule/ (favoris + partage via
fiche/current.ts, ticker et badge masqués). Cartes véhicule de la grille :
surface blanche, bord 1 px, ombre légère au survol. Icons.tsx (pictos
partagés) ajouté. Aucun order CSS ; pas de scrollIntoView à l'ouverture.
Scripts QA : preview.mjs, check-order.mjs, shots.mjs, interactions.mjs.

Validé sur M4M64b : tsc + vite build, check-order OK sur 2 fiches,
interactions OK, captures 6 largeurs ; dist basculé, aucun changement API.
Simon-Pierre Boucher committed 19 days ago (Sep 7, 2026) parent e7b2f6f

21 changed files +2,432 −461

added frontend/scripts/check-order.mjs +56 −0
@@ -0,0 +1,56 @@
1 +// Validation ordre des sections — fiche Auto-Ka (ordre DOM = ordre visuel)
2 +// Refonte 2026-09-07 : sections `.ak-*` / ids ; vérifie sur mobile que
3 +// l'ordre visuel est croissant, que la page s'ouvre en haut (scrollY 0), que
4 +// le héro (prix + galerie) est le premier contenu, qu'aucune section ne porte
5 +// un `order` CSS et qu'il n'y a pas de débordement horizontal.
6 +// Usage : node frontend/scripts/check-order.mjs <uid> [baseUrl]
7 +import { chromium, devices } from "playwright";
8 +
9 +const UID = process.argv[2];
10 +if (!UID) { console.error("usage: node check-order.mjs <uid> [baseUrl]"); process.exit(2); }
11 +const BASE = process.argv[3] || process.env.BASE || "http://localhost:8095";
12 +const URL = `${BASE}/vehicule/${encodeURIComponent(UID)}`;
13 +
14 +async function check(name, ctxOpts) {
15 + const browser = await chromium.launch();
16 + const ctx = await browser.newContext(ctxOpts);
17 + const page = await ctx.newPage();
18 + const errors = [];
19 + page.on("pageerror", (e) => errors.push(String(e)));
20 + page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
21 + await page.goto(URL, { waitUntil: "networkidle" });
22 + await page.waitForSelector(".ak-hero", { timeout: 20000 });
23 + await page.waitForTimeout(2500);
24 + const data = await page.evaluate(() => {
25 + const sel = [".ak-hero", "#score", "#resume", ".ak-nav", "#prix", "#vehicule", "#description", "#equipements",
26 + "#rappels", "#offres", "#carte", "#similaires", "#dossier", "#sources"];
27 + const out = [];
28 + for (const s of sel) {
29 + const el = document.querySelector(s);
30 + if (!el) { out.push({ s, missing: true }); continue; }
31 + const r = el.getBoundingClientRect();
32 + out.push({ s, hidden: r.height === 0 && r.width === 0, top: Math.round(r.top + window.scrollY),
33 + left: Math.round(r.left), order: getComputedStyle(el).order });
34 + }
35 + return { out, scrollY: window.scrollY, overflow: document.documentElement.scrollWidth - window.innerWidth };
36 + });
37 + console.log(`\n=== ${name} === scrollY initial: ${data.scrollY} · débordement horizontal: ${data.overflow}px`);
38 + for (const b of data.out)
39 + console.log(b.missing ? `${b.s.padEnd(14)} (non rendue)` : b.hidden ? `${b.s.padEnd(14)} (vide/masquée)` :
40 + `${b.s.padEnd(14)} top=${String(b.top).padStart(6)} left=${String(b.left).padStart(4)} order=${b.order}`);
41 + if (errors.length) console.log("console errors:", errors.slice(0, 5));
42 + await browser.close();
43 + return { ...data, errors };
44 +}
45 +
46 +const mob = await check("iPhone 14 (mobile)", { ...devices["iPhone 14"] });
47 +const desk = await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
48 +
49 +const vis = mob.out.filter((b) => !b.missing && !b.hidden);
50 +const sorted = vis.every((b, i) => i === 0 || b.top >= vis[i - 1].top);
51 +const noOrder = vis.every((b) => b.order === "0");
52 +const ok = sorted && mob.scrollY === 0 && vis[0].s === ".ak-hero" && vis[0].top < 200 && noOrder
53 + && mob.overflow <= 0 && desk.overflow <= 0;
54 +console.log(`\nMOBILE: ordre visuel ${sorted ? "CROISSANT ✓" : "DÉSORDONNÉ ✗"} · scrollY=${mob.scrollY} · 1re section=${vis[0].s} top=${vis[0].top} · sans order CSS=${noOrder} · overflow mobile=${mob.overflow} desktop=${desk.overflow}`);
55 +console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC");
56 +process.exit(ok ? 0 : 1);
added frontend/scripts/interactions.mjs +39 −0
@@ -0,0 +1,39 @@
1 +// Parcours d'interactions Playwright sur la fiche (mobile) : galerie plein
2 +// écran, panneau « Voir les N lieux », « Demander à Ka », accordéons — vérifie
3 +// l'absence d'erreur JS et que chaque panneau s'ouvre puis se ferme.
4 +// Usage : node frontend/scripts/interactions.mjs <uid> [baseUrl]
5 +import { chromium, devices } from "playwright";
6 +const UID = process.argv[2]; const BASE = process.argv[3] || "http://127.0.0.1:8095";
7 +if (!UID) { console.error("usage: node interactions.mjs <uid> [baseUrl]"); process.exit(2); }
8 +const browser = await chromium.launch();
9 +const ctx = await browser.newContext({ ...devices["iPhone 14"] }); const page = await ctx.newPage();
10 +const errs = [];
11 +page.on("pageerror", (e) => errs.push("PAGEERROR " + e.message));
12 +page.on("console", (m) => { if (m.type() === "error") errs.push(m.text().slice(0, 160)); });
13 +await page.goto(`${BASE}/vehicule/${encodeURIComponent(UID)}`, { waitUntil: "networkidle" });
14 +await page.waitForSelector(".ak-hero");
15 +const step = async (name, fn) => { try { await fn(); console.log("OK ", name); } catch (e) { console.log("FAIL", name, String(e).split("\n")[0]); } };
16 +await step("galerie → plein écran → fermer", async () => {
17 + await page.click(".ak-gallery-full"); await page.waitForSelector(".lightbox", { timeout: 3000 });
18 + await page.click(".lb-close"); await page.waitForSelector(".lightbox", { state: "detached", timeout: 3000 });
19 +});
20 +await step("accordéon score", async () => { await page.click("#score .ak-acc-btn"); await page.waitForSelector("#score .ak-acc-body.open", { timeout: 2000 }); });
21 +await step("accordéon méthodologie prix", async () => {
22 + const b = page.locator("#prix .ak-acc-btn").first(); await b.scrollIntoViewIfNeeded(); await b.click();
23 + await page.waitForSelector("#prix .ak-acc-body.open", { timeout: 2000 });
24 +});
25 +await step("accordéon dossier", async () => {
26 + const b = page.locator("#dossier .ak-acc-btn").first(); await b.scrollIntoViewIfNeeded(); await b.click();
27 + await page.waitForSelector("#dossier .ak-acc-body.open", { timeout: 2000 });
28 +});
29 +await step("Demander à Ka : sheet", async () => {
30 + await page.evaluate(() => window.scrollTo(0, 1500)); await page.waitForTimeout(500);
31 + await page.click(".ak-ka-btn"); await page.waitForSelector(".ak-sheet", { timeout: 3000 });
32 + await page.click(".ak-sheet-x"); await page.waitForSelector(".ak-sheet", { state: "detached", timeout: 3000 });
33 +});
34 +await step("404", async () => {
35 + await page.goto(`${BASE}/vehicule/inexistant:0`, { waitUntil: "networkidle" }); await page.waitForSelector(".ak-page-error", { timeout: 8000 });
36 +});
37 +console.log("erreurs console:", errs.length, errs.slice(0, 5));
38 +await browser.close();
39 +process.exit(errs.filter((e) => !/Failed to fetch|api-ka|kaa/.test(e)).length ? 1 : 0);
added frontend/scripts/preview.mjs +28 −0
@@ -0,0 +1,28 @@
1 +// Serveur de prévisualisation QA (zéro dépendance) : sert un build Vite
2 +// (dist-next par défaut) et relaie /api vers le backend local — permet de
3 +// valider une fiche avec Playwright SANS toucher au dist/ servi en production.
4 +// Usage : node frontend/scripts/preview.mjs [dir=dist-next] [port=18099] [api=http://127.0.0.1:8095]
5 +import http from "node:http";
6 +import { createReadStream, existsSync, statSync } from "node:fs";
7 +import { extname, join, resolve } from "node:path";
8 +
9 +const DIR = resolve(process.argv[2] || "dist-next");
10 +const PORT = Number(process.argv[3] || 18099);
11 +const API = new URL(process.argv[4] || "http://127.0.0.1:8095");
12 +const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml",
13 + ".png": "image/png", ".json": "application/json", ".geojson": "application/geo+json", ".woff2": "font/woff2", ".ico": "image/x-icon" };
14 +
15 +http.createServer((req, res) => {
16 + const url = new URL(req.url, "http://x");
17 + if (url.pathname.startsWith("/api/")) {
18 + const p = http.request({ host: API.hostname, port: API.port, path: req.url, method: req.method, headers: { ...req.headers, host: API.host } },
19 + (r) => { res.writeHead(r.statusCode, r.headers); r.pipe(res); });
20 + p.on("error", () => { res.writeHead(502); res.end("api down"); });
21 + req.pipe(p);
22 + return;
23 + }
24 + let f = join(DIR, decodeURIComponent(url.pathname));
25 + if (!existsSync(f) || statSync(f).isDirectory()) f = join(DIR, "index.html");
26 + res.writeHead(200, { "content-type": MIME[extname(f)] || "application/octet-stream" });
27 + createReadStream(f).pipe(res);
28 +}).listen(PORT, "127.0.0.1", () => console.log(`preview ${DIR} → http://127.0.0.1:${PORT} (api → ${API.origin})`));
added frontend/scripts/shots.mjs +40 −0
@@ -0,0 +1,40 @@
1 +// Captures Playwright de la fiche propriété à 6 largeurs (QA visuelle).
2 +// Usage : node frontend/scripts/shots.mjs <uid> [baseUrl] [outDir]
3 +import { chromium, devices } from "playwright";
4 +const UID = process.argv[2];
5 +if (!UID) { console.error("usage: node shots.mjs <uid> [baseUrl] [outDir]"); process.exit(2); }
6 +const BASE = process.argv[3] || "http://127.0.0.1:8095";
7 +const OUT = process.argv[4] || "/tmp";
8 +const shots = [
9 + ["iphone14", { ...devices["iPhone 14"] }],
10 + ["w375", { viewport: { width: 375, height: 812 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }],
11 + ["w430", { viewport: { width: 430, height: 932 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }],
12 + ["w768", { viewport: { width: 768, height: 1024 }, deviceScaleFactor: 1 }],
13 + ["w1024", { viewport: { width: 1024, height: 800 }, deviceScaleFactor: 1 }],
14 + ["w1440", { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }],
15 +];
16 +const browser = await chromium.launch();
17 +for (const [name, opts] of shots) {
18 + const ctx = await browser.newContext(opts); const page = await ctx.newPage();
19 + const errs = [];
20 + page.on("pageerror", (e) => errs.push("PAGEERROR " + e.message));
21 + page.on("console", (m) => { if (m.type() === "error") errs.push(m.text().slice(0, 200)); });
22 + await page.goto(`${BASE}/vehicule/${encodeURIComponent(UID)}`, { waitUntil: "networkidle" });
23 + await page.waitForSelector(".ak-hero", { timeout: 20000 });
24 + // html{scroll-behavior:smooth} du site fausse le retour en haut scripté → défilement instantané pour les captures
25 + await page.evaluate(() => { document.documentElement.style.scrollBehavior = "auto"; });
26 + // défilement complet (déclenche les chargements différés) puis retour en haut
27 + await page.evaluate(async () => {
28 + for (let y = 0; y < document.body.scrollHeight; y += 600) { window.scrollTo(0, y); await new Promise((r) => setTimeout(r, 120)); }
29 + window.scrollTo({ top: 0, behavior: "instant" });
30 + });
31 + await page.waitForTimeout(3500);
32 + const ov = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
33 + await page.screenshot({ path: `${OUT}/ak-${name}-full.png`, fullPage: true });
34 + await page.screenshot({ path: `${OUT}/ak-${name}-top.png` });
35 + await page.evaluate(() => window.scrollTo({ top: 900, behavior: "instant" })); await page.waitForTimeout(600);
36 + await page.screenshot({ path: `${OUT}/ak-${name}-scrolled.png` });
37 + console.log(name, "overflow", ov, "errors", errs.length, errs.slice(0, 3).join(" | "));
38 + await ctx.close();
39 +}
40 +await browser.close();
modified frontend/src/App.tsx +35 −3
@@ -9,6 +9,8 @@ import {
9 9 fetchFacets, fetchSources, fetchStats, logout, registerSourceNames,
10 10 } from "./api";
11 11 import { AccountProvider, useAccount } from "./account";
12 +import { Ico, IcoHeart } from "./components/Icons";
13 +import { useCurrentListing } from "./fiche/current";
12 14 import GroupeKaBadge from "./ka/GroupeKaBadge";
13 15 import KaFooter from "./ka/KaFooter";
14 16 import KaTabbar from "./ka/KaTabbar";
@@ -162,9 +164,38 @@ const NAV_LINKS = [
162 164 { to: "/contact", label: "Contact", icon: "✉️", end: false },
163 165 ];
164 166
167 +/** Actions du masthead sur une fiche véhicule : favoris + partage (compactes).
168 + Le véhicule affiché est publié par la page fiche (fiche/current.ts). */
169 +function FicheHeaderActions() {
170 + const v = useCurrentListing();
171 + const { favs, toggleFav } = useAccount();
172 + if (!v) return null;
173 + const fav = favs.has(v.uid);
174 + const share = async () => {
175 + const url = `https://www.auto-ka.com/vehicule/${encodeURIComponent(v.uid)}`;
176 + try {
177 + if (navigator.share) await navigator.share({ title: document.title, url });
178 + else await navigator.clipboard.writeText(url);
179 + } catch { /* annulé */ }
180 + };
181 + return (
182 + <div className="hdr-actions">
183 + <button type="button" className={`hdr-btn ${fav ? "on" : ""}`} aria-pressed={fav}
184 + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={() => toggleFav(v)}>
185 + <IcoHeart size={18} filled={fav} />
186 + </button>
187 + <button type="button" className="hdr-btn" aria-label="Partager" onClick={share}>
188 + <Ico name="share" size={17} />
189 + </button>
190 + </div>
191 + );
192 +}
193 +
165 194 function Header() {
166 195 const [open, setOpen] = useState(false);
167 196 const location = useLocation();
197 + // fiche véhicule : masthead compact (56 px), sans ticker, favoris + partage
198 + const isFiche = location.pathname.startsWith("/vehicule/");
168 199
169 200 // fermer le menu à chaque navigation + verrouiller le défilement en dessous
170 201 useEffect(() => { setOpen(false); }, [location]);
@@ -183,13 +214,14 @@ function Header() {
183 214 <>
184 215 {/* .menu-open remonte le header au-dessus du backdrop (posé HORS du
185 216 header, en frère) */}
186 − <header className={`header ${open ? "menu-open" : ""}`}>
217 + <header className={`header ${open ? "menu-open" : ""} ${isFiche ? "header--fiche" : ""}`}>
187 218 <div className="container header-inner">
188 219 <NavLink to="/" className="brand" aria-label="Auto-Ka — accueil">
189 220 Auto<span className="ka">Ka</span>
190 221 <span className="brand-tag">Voitures usagées · tout le Québec · toujours à jour</span>
191 222 </NavLink>
192 − <GroupeKaBadge />
223 + {!isFiche && <GroupeKaBadge />}
224 + {isFiche && <FicheHeaderActions />}
193 225 <nav className="nav" aria-label="Navigation principale">
194 226 {NAV_LINKS.map((l) => (
195 227 <NavLink
@@ -242,7 +274,7 @@ function Header() {
242 274 </div>
243 275 </header>
244 276 {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
245 − <Ticker />
277 + {!isFiche && <Ticker />}
246 278 {/* variante mobile du badge (le badge du header est masqué < 1024 px) */}
247 279 <div className="gk-mobile">
248 280 <GroupeKaBadge />
added frontend/src/components/Icons.tsx +291 −0
@@ -0,0 +1,291 @@
1 +// -----------------------------------------------------------------------------
2 +// Groupe KA — iconographie partagée (portée depuis Immo-Ka)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/Icons.tsx : iconographie MAISON — traits 1,7 px sur grille 24,
5 +// dessinée pour les plateformes Groupe KA (zéro emoji, zéro pack générique). Chaque icône est
6 +// un tracé « stroke » net qui hérite de la couleur du texte (currentColor).
7 +// -----------------------------------------------------------------------------
8 +import { ReactNode } from "react";
9 +
10 +const P: Record<string, ReactNode> = {
11 + // --- navigation -----------------------------------------------------------
12 + home: (<>
13 + <path d="M3.5 10.6 12 3.4l8.5 7.2" />
14 + <path d="M5.6 9.4V20h12.8V9.4" />
15 + <path d="M10 20v-5.6h4V20" />
16 + </>),
17 + map: (<>
18 + <path d="M3.2 6.4 9 4.2l6 2.2 5.8-2.2v13.4L15 19.8l-6-2.2-5.8 2.2z" />
19 + <path d="M9 4.2v13.4M15 6.4v13.4" />
20 + </>),
21 + chart: (<>
22 + <path d="M4 20h16" />
23 + <path d="M7.2 20v-5.6M12 20V8.8M16.8 20V12" />
24 + </>),
25 + building: (<>
26 + <path d="M5 20V5.6A1.6 1.6 0 0 1 6.6 4h6.2a1.6 1.6 0 0 1 1.6 1.6V20" />
27 + <path d="M14.4 9.4h3.4A1.6 1.6 0 0 1 19.4 11v9" />
28 + <path d="M3.2 20h17.6" />
29 + <path d="M8 8h2.4M8 11.6h2.4M8 15.2h2.4M16.4 13h.9M16.4 16.2h.9" />
30 + </>),
31 +
32 + // --- specs propriété --------------------------------------------------------
33 + bed: (<>
34 + <path d="M3.4 6.8V18.6" />
35 + <path d="M3.4 15.4h17.2v3.2" />
36 + <path d="M3.4 12.2h6.2v3.2" />
37 + <circle cx="6.5" cy="9.9" r="1.35" />
38 + <path d="M11.4 12.2h5.8a3.4 3.4 0 0 1 3.4 3.2" />
39 + </>),
40 + bath: (<>
41 + <path d="M3.6 12.4h16.8v1.4a5.2 5.2 0 0 1-5.2 5.2H8.8a5.2 5.2 0 0 1-5.2-5.2z" />
42 + <path d="M5.8 12.4V5.9a2.1 2.1 0 0 1 4-1" />
43 + <path d="m7 19.4-1 2.1M17 19.4l1 2.1" />
44 + </>),
45 + drop: (<>
46 + <path d="M12 3.6s6 6.4 6 10.6a6 6 0 1 1-12 0C6 10 12 3.6 12 3.6z" />
47 + <path d="M9.4 14.2a2.7 2.7 0 0 0 2 2.6" />
48 + </>),
49 + area: (<>
50 + <path d="M4.4 19.6 19.6 4.4" />
51 + <path d="M4.4 14v5.6H10" />
52 + <path d="M19.6 10V4.4H14" />
53 + </>),
54 + land: (<>
55 + <path d="M4.4 9.6v9.8M9.5 9.6v9.8M14.5 9.6v9.8M19.6 9.6v9.8" />
56 + <path d="M3 12.6h18M3 16.4h18" />
57 + <path d="M4.4 9.6 12 5.2l7.6 4.4" />
58 + </>),
59 + calendar: (<>
60 + <rect x="4" y="5.6" width="16" height="14.8" rx="2" />
61 + <path d="M4 10.2h16M8.2 3.4v4M15.8 3.4v4" />
62 + </>),
63 + tag: (<>
64 + <path d="m12.9 3.6 7.5 7.5a1.8 1.8 0 0 1 0 2.5l-6.8 6.8a1.8 1.8 0 0 1-2.5 0L3.6 12.9V3.6z" />
65 + <circle cx="8" cy="8" r="1.5" />
66 + </>),
67 + camera: (<>
68 + <rect x="3.4" y="7" width="17.2" height="13" rx="2" />
69 + <path d="M8.6 7 10 4.4h4L15.4 7" />
70 + <circle cx="12" cy="13.2" r="3.4" />
71 + </>),
72 +
73 + // --- actions / états ----------------------------------------------------------
74 + search: (<>
75 + <circle cx="10.6" cy="10.6" r="6.2" />
76 + <path d="m15.3 15.3 5.3 5.3" />
77 + </>),
78 + arrow: (<>
79 + <path d="M4 12h15.2" />
80 + <path d="m13.8 6.4 5.4 5.6-5.4 5.6" />
81 + </>),
82 + check: (<path d="m5 12.8 4.3 4.4L19 7.4" />),
83 + alert: (<>
84 + <path d="M12 4.2 2.9 19.4h18.2z" />
85 + <path d="M12 10.2v4.4" />
86 + <path d="M12 17.4v.05" />
87 + </>),
88 + phone: (<path d="M5.2 4h3.6L10.4 8.4 8.3 10.1a12.5 12.5 0 0 0 5.6 5.6l1.7-2.1 4.4 1.6v3.6a1.8 1.8 0 0 1-1.9 1.8A16.4 16.4 0 0 1 3.4 5.9 1.8 1.8 0 0 1 5.2 4z" />),
89 + trendup: (<>
90 + <path d="m3.6 17.4 6-6.4 4 3.6 6.8-7.8" />
91 + <path d="M15.6 6.8h4.8v4.8" />
92 + </>),
93 + trenddown: (<>
94 + <path d="m3.6 6.8 6 6.4 4-3.6 6.8 7.8" />
95 + <path d="M15.6 17.4h4.8v-4.8" />
96 + </>),
97 + external: (<>
98 + <path d="M14.4 4h5.6v5.6" />
99 + <path d="M20 4 11.4 12.6" />
100 + <path d="M18.6 13.6V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7.4a2 2 0 0 1 2-2h4.4" />
101 + </>),
102 + pin: (<>
103 + <path d="M12 21.2S5.4 15.7 5.4 10.8a6.6 6.6 0 0 1 13.2 0c0 4.9-6.6 10.4-6.6 10.4z" />
104 + <circle cx="12" cy="10.6" r="2.3" />
105 + </>),
106 + download: (<>
107 + <path d="M12 3.6v11.2" />
108 + <path d="m7.2 10.4 4.8 4.8 4.8-4.8" />
109 + <path d="M4.4 19.2h15.2" />
110 + </>),
111 +
112 + // --- quartier ---------------------------------------------------------------------
113 + leaf: (<>
114 + <path d="M5 19.2C5 9.6 12 5 20 4.2c0 9-4.2 15-13.2 15z" />
115 + <path d="M5 19.2C7 14 11 10 15 8" />
116 + </>),
117 + thermo: (<>
118 + <path d="M10.4 4.2a1.9 1.9 0 0 1 3.8 0v8.8a4.2 4.2 0 1 1-3.8 0z" />
119 + <path d="M12.3 8.4v7" />
120 + </>),
121 + sun: (<>
122 + <circle cx="12" cy="12" r="4.2" />
123 + <path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.8 5.8l1.4 1.4M16.8 16.8l1.4 1.4M18.2 5.8l-1.4 1.4M7.2 16.8l-1.4 1.4" />
124 + </>),
125 + shield: (<>
126 + <path d="M12 3.4 5.2 5.9v5.9c0 4.6 3 7.6 6.8 8.8 3.8-1.2 6.8-4.2 6.8-8.8V5.9z" />
127 + <path d="m9.2 12 2 2 3.8-4" />
128 + </>),
129 + cart: (<>
130 + <circle cx="9.6" cy="19.6" r="1.35" />
131 + <circle cx="17" cy="19.6" r="1.35" />
132 + <path d="M3.4 4.4h2.2l2.5 10.8h9.6l2.7-7.8H7" />
133 + </>),
134 + bus: (<>
135 + <rect x="4.6" y="3.8" width="14.8" height="13.4" rx="2.4" />
136 + <path d="M4.6 10.4h14.8" />
137 + <path d="M7.6 20.2v-3M16.4 20.2v-3" />
138 + <path d="M8.3 14h.05M15.7 14h.05" />
139 + </>),
140 + tree: (<>
141 + <path d="M12 3.4 6.8 11.4h2.8L5.4 17.8h13.2l-4.2-6.4h2.8z" />
142 + <path d="M12 17.8v3.4" />
143 + </>),
144 + school: (<>
145 + <path d="m12 4.2 9.8 4.4L12 13 2.2 8.6z" />
146 + <path d="M6.6 10.8v5c0 1.6 2.4 3 5.4 3s5.4-1.4 5.4-3v-5" />
147 + <path d="M21.8 8.6v5.4" />
148 + </>),
149 + health: (<>
150 + <circle cx="12" cy="12" r="8.4" />
151 + <path d="M12 8.4v7.2M8.4 12h7.2" />
152 + </>),
153 + pill: (<>
154 + <rect x="3.2" y="8.6" width="17.6" height="6.8" rx="3.4" transform="rotate(-33 12 12)" />
155 + <path d="M12 8.6v6.8" transform="rotate(-33 12 12)" />
156 + </>),
157 + people: (<>
158 + <circle cx="9" cy="8" r="3.2" />
159 + <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" />
160 + <path d="M15.4 5.4a3.2 3.2 0 0 1 0 5.9M17 14.8a5.4 5.4 0 0 1 3.4 5.2" />
161 + </>),
162 +
163 + // --- fiche propriété (refonte premium 2026-09-07) — même trait 1,7, grille 24 ---
164 + chevdown: <path d="m6 9 6 6 6-6" />,
165 + chevleft: <path d="m14.5 5-7 7 7 7" />,
166 + chevright: <path d="m9.5 5 7 7-7 7" />,
167 + close: <path d="M18 6 6 18M6 6l12 12" />,
168 + expand: <path d="M15 3.5h5.5V9M9 20.5H3.5V15M20.5 3.5l-7 7M3.5 20.5l7-7" />,
169 + share: (<>
170 + <circle cx="18" cy="5" r="2.5" /><circle cx="6" cy="12" r="2.5" /><circle cx="18" cy="19" r="2.5" />
171 + <path d="M8.2 10.8 15.8 6.3M8.2 13.2l7.6 4.5" />
172 + </>),
173 + sparkles: (<>
174 + <path d="M12 3l1.9 5.6 5.6 1.9-5.6 1.9L12 18l-1.9-5.6-5.6-1.9 5.6-1.9L12 3z" />
175 + <path d="M19 16l.8 2.2 2.2.8-2.2.8L19 22l-.8-2.2-2.2-.8 2.2-.8L19 16z" />
176 + </>),
177 + scale: <path d="M12 3v18M5 21h14M12 6l7 2-3 7h-4M12 6 5 8l3 7h4" />,
178 + wallet: (<>
179 + <path d="M3 7a2 2 0 0 1 2-2h13v4" />
180 + <rect x="3" y="7" width="18" height="12" rx="2" />
181 + <path d="M16 13h.01" />
182 + </>),
183 + droplets: <path d="M12 3s-6 6.5-6 10.5a6 6 0 0 0 12 0C18 9.5 12 3 12 3z" />,
184 + wind: (<>
185 + <path d="M3 8h10a3 3 0 1 0-3-3" />
186 + <path d="M3 12h15a3 3 0 1 1-3 3" />
187 + <path d="M3 16h7" />
188 + </>),
189 + fuel: (<>
190 + <path d="M4 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16" />
191 + <path d="M3 21h12M6 8h6" />
192 + <path d="M14 10h2a2 2 0 0 1 2 2v5a1.5 1.5 0 0 0 3 0V9l-2.5-2.5" />
193 + </>),
194 + train: (<>
195 + <rect x="4" y="3" width="16" height="14" rx="3" />
196 + <path d="M4 11h16M8 21l2-4M16 21l-2-4M9 7h6" />
197 + <path d="M8.5 14h.01M15.5 14h.01" />
198 + </>),
199 + folder: <path d="M3 6h6l2 2.5h10V19H3z" />,
200 + doc: (<>
201 + <path d="M6 3h8l4 4v14H6z" />
202 + <path d="M14 3v4h4" />
203 + <path d="M9 12h6M9 15.5h6" />
204 + </>),
205 + layers: (<>
206 + <path d="m12 3 9 5-9 5-9-5 9-5z" /><path d="m3 13 9 5 9-5M3 17.5l9 5 9-5" />
207 + </>),
208 + info: (<>
209 + <circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" />
210 + </>),
211 + ruler: (<>
212 + <path d="M3.5 16 16 3.5l4.5 4.5L8 20.5 3.5 16z" />
213 + <path d="M7.5 16l1.5 1.5M10.5 13l1.5 1.5M13.5 10l1.5 1.5M16.5 7 18 8.5" />
214 + </>),
215 + key: (<>
216 + <circle cx="8" cy="15" r="4.5" />
217 + <path d="m11.2 11.8 8.3-8.3M15.5 7.5l3 3M18 5l2 2" />
218 + </>),
219 + bank: (<>
220 + <path d="m3 9.5 9-5.5 9 5.5H3z" />
221 + <path d="M5 9.5v8M9.7 9.5v8M14.3 9.5v8M19 9.5v8M3.5 20.5h17" />
222 + </>),
223 + percent: (<>
224 + <path d="M19 5 5 19" /><circle cx="7" cy="7" r="2.5" /><circle cx="17" cy="17" r="2.5" />
225 + </>),
226 + car: (<>
227 + <path d="M4 16v-4.5l2-5A1.6 1.6 0 0 1 7.5 5.5h9a1.6 1.6 0 0 1 1.5 1l2 5V16" />
228 + <path d="M3 16h18M6.2 16v2.6M17.8 16v2.6M4.5 11.5h15" />
229 + <path d="M7.5 13.8h.01M16.5 13.8h.01" />
230 + </>),
231 + hammer: (<>
232 + <path d="m14.5 3.5 6 6-2.5 2.5-6-6z" />
233 + <path d="M13 8 4.5 16.5a1.8 1.8 0 0 0 2.5 2.5L15.5 10.5" />
234 + </>),
235 + store: (<>
236 + <path d="M3 9 5 4h14l2 5M3 9h18v3a2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-3 2.4V9z" />
237 + <path d="M5 14v6h14v-6M10 20v-4h4v4" />
238 + </>),
239 + coffee: <path d="M4 9h12v6a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9zM16 10h2a2.5 2.5 0 0 1 0 5h-2M7 3v3M11 3v3" />,
240 + book: (<>
241 + <path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2V5z" />
242 + <path d="M4 19a2 2 0 0 0 2 2h13" />
243 + </>),
244 + dumbbell: <path d="M6.7 6.7v10.6M17.3 6.7v10.6M3.5 9.2v5.6M20.5 9.2v5.6M6.7 12h10.6" />,
245 + baby: (<>
246 + <circle cx="12" cy="9" r="5" />
247 + <path d="M7 20a5 5 0 0 1 10 0M10 8.5h.01M14 8.5h.01M10.5 11.5c.8.7 2.2.7 3 0" />
248 + </>),
249 + history: (<>
250 + <path d="M3.5 12a8.5 8.5 0 1 0 2.5-6" />
251 + <path d="M3.5 4v4h4M12 7.5V12l3 2" />
252 + </>),
253 +};
254 +
255 +export type IconName = keyof typeof P;
256 +
257 +export function Ico({ name, size = 18, className = "", stroke = 1.7 }:
258 + { name: IconName | string; size?: number; className?: string; stroke?: number }) {
259 + const paths = P[name as IconName];
260 + if (!paths) return null;
261 + return (
262 + <svg
263 + className={`ico ${className}`}
264 + width={size} height={size} viewBox="0 0 24 24"
265 + fill="none" stroke="currentColor" strokeWidth={stroke}
266 + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
267 + >
268 + {paths}
269 + </svg>
270 + );
271 +}
272 +
273 +/** Cœur (favoris) — plein quand actif ; même grille que le reste. */
274 +export function IcoHeart({ size = 18, filled = false, className = "" }:
275 + { size?: number; filled?: boolean; className?: string }) {
276 + return (
277 + <svg className={`ico ${className}`} width={size} height={size} viewBox="0 0 24 24"
278 + fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth={1.7}
279 + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
280 + <path d="M12 20.5C7 16.5 3.5 13 3.5 9.3 3.5 6.8 5.5 5 7.8 5c1.7 0 3.2 1 4.2 2.6C13 6 14.5 5 16.2 5c2.3 0 4.3 1.8 4.3 4.3 0 3.7-3.5 7.2-8.5 11.2z" />
281 + </svg>
282 + );
283 +}
284 +
285 +/** Version « chaîne HTML » pour les popups MapLibre (hors React). */
286 +export function icoHTML(name: IconName, size = 30): string {
287 + const d: Record<string, string> = {
288 + home: '<path d="M3.5 10.6 12 3.4l8.5 7.2"/><path d="M5.6 9.4V20h12.8V9.4"/><path d="M10 20v-5.6h4V20"/>',
289 + };
290 + return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${d[name] ?? d.home}</svg>`;
291 +}
added frontend/src/fiche/BottomSheet.tsx +68 −0
@@ -0,0 +1,68 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/BottomSheet.tsx : panneau générique — bottom sheet sur mobile (hauteur
5 +// initiale 68 % du viewport, glisser vers le haut = plein écran, glisser
6 +// vers le bas = fermeture), modale centrée à partir de 900 px (CSS).
7 +// Portail à la racine, verrou du défilement (.ka-scroll-lock), Escape,
8 +// focus initial, aria-modal. Utilisé pour lieux, stations, assistant Ka.
9 +// -----------------------------------------------------------------------------
10 +import { ReactNode, useEffect, useRef, useState } from "react";
11 +import { createPortal } from "react-dom";
12 +import { Ico } from "../components/Icons";
13 +
14 +export default function BottomSheet({ open, onClose, title, sub, children, footer, tall = false }: {
15 + open: boolean; onClose: () => void; title: ReactNode; sub?: ReactNode;
16 + children: ReactNode; footer?: ReactNode; tall?: boolean;
17 +}) {
18 + const [full, setFull] = useState(tall);
19 + const panel = useRef<HTMLDivElement>(null);
20 + const drag = useRef<{ y0: number; t0: number } | null>(null);
21 +
22 + useEffect(() => {
23 + if (!open) return;
24 + setFull(tall);
25 + document.documentElement.classList.add("ka-scroll-lock");
26 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
27 + window.addEventListener("keydown", onKey);
28 + const t = setTimeout(() => panel.current?.focus(), 30);
29 + return () => {
30 + document.documentElement.classList.remove("ka-scroll-lock");
31 + window.removeEventListener("keydown", onKey);
32 + clearTimeout(t);
33 + };
34 + }, [open, onClose, tall]);
35 +
36 + if (!open) return null;
37 +
38 + const onDown = (e: React.PointerEvent) => { drag.current = { y0: e.clientY, t0: Date.now() }; };
39 + const onUp = (e: React.PointerEvent) => {
40 + if (!drag.current) return;
41 + const dy = e.clientY - drag.current.y0;
42 + drag.current = null;
43 + if (dy > 90) onClose();
44 + else if (dy < -60) setFull(true);
45 + };
46 +
47 + return createPortal(
48 + <>
49 + <div className="ak-sheet-backdrop" onClick={onClose} aria-hidden="true" />
50 + <div className={`ak-sheet ${full ? "full" : ""}`} role="dialog" aria-modal="true"
51 + aria-label={typeof title === "string" ? title : undefined} ref={panel} tabIndex={-1}>
52 + <div className="ak-sheet-handle" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp} />
53 + <div className="ak-sheet-head" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp}>
54 + <div style={{ minWidth: 0 }}>
55 + <h3 className="ak-sheet-title">{title}</h3>
56 + {sub && <p className="ak-sheet-sub">{sub}</p>}
57 + </div>
58 + <button type="button" className="ak-sheet-x" onClick={onClose} aria-label="Fermer">
59 + <Ico name="close" size={18} />
60 + </button>
61 + </div>
62 + <div className="ak-sheet-body">{children}</div>
63 + {footer && <div className="ak-sheet-foot">{footer}</div>}
64 + </div>
65 + </>,
66 + document.body,
67 + );
68 +}
added frontend/src/fiche/Closing.tsx +127 −0
@@ -0,0 +1,127 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/Closing.tsx : « En bref », CTA sticky mobile, aside desktop et
5 +// « Demander à Ka » (widget KA Agent partagé) de la fiche véhicule.
6 +// -----------------------------------------------------------------------------
7 +import { useEffect, useState } from "react";
8 +import { VehicleDetail, fmtPrice } from "../api";
9 +import { Ico, IcoHeart } from "../components/Icons";
10 +import BottomSheet from "./BottomSheet";
11 +import { ScoreRing, kaLabel } from "./ScoreCard";
12 +import { ComparaisonPrix, Constat } from "./synthese";
13 +import { PriceCapsule } from "./VehicleHero";
14 +import { SectionCard, SkeletonLines, relTime } from "./ui";
15 +
16 +export function BriefList({ items, compact = false }: { items: Constat[]; compact?: boolean }) {
17 + const glyph = (t: Constat["tone"]) => (t === "good" ? "✓" : t === "bad" ? "✕" : t === "warn" ? "!" : "○");
18 + return (
19 + <ul className="ak-brief">
20 + {items.map((c) => (
21 + <li key={c.cle}>
22 + <span className={`ak-brief-ico ${c.tone === "info" ? "neutral" : c.tone}`} aria-hidden="true">{glyph(c.tone)}</span>
23 + <div><div className="ak-brief-t">{c.titre}</div>{!compact && c.detail && <div className="ak-brief-d">{c.detail}</div>}</div>
24 + </li>
25 + ))}
26 + </ul>
27 + );
28 +}
29 +
30 +export function Summary({ items, loading }: { items: Constat[]; loading: boolean }) {
31 + return (
32 + <SectionCard id="resume" title="En bref">
33 + {items.length > 0 ? <BriefList items={items} /> : loading ? <SkeletonLines n={3} /> : <p className="ak-fine">Pas encore de synthèse pour ce véhicule.</p>}
34 + </SectionCard>
35 + );
36 +}
37 +
38 +export function usePastElement(watch: React.RefObject<HTMLElement>): boolean {
39 + const [past, setPast] = useState(false);
40 + useEffect(() => {
41 + const check = () => { const el = watch.current; if (el) setPast(el.getBoundingClientRect().bottom < 0); };
42 + check();
43 + window.addEventListener("scroll", check, { passive: true });
44 + window.addEventListener("resize", check);
45 + return () => { window.removeEventListener("scroll", check); window.removeEventListener("resize", check); };
46 + }, [watch]);
47 + return past;
48 +}
49 +
50 +export function StickyCTA({ v, cmp, show }: { v: VehicleDetail; cmp: ComparaisonPrix | null; show: boolean }) {
51 + return (
52 + <div className={`ak-cta-bar ${show ? "show" : ""}`} aria-hidden={!show}>
53 + <div className="ak-cta-txt">
54 + <div className="ak-cta-price">{fmtPrice(v.price)}</div>
55 + {cmp && <div className={`ak-cta-sub ${cmp.tone === "good" ? "good" : ""}`}>{cmp.court}</div>}
56 + </div>
57 + <a className="ak-btn ak-btn-primary" href={v.url} target="_blank" rel="noreferrer" tabIndex={show ? 0 : -1}>Voir l'annonce <Ico name="external" size={15} /></a>
58 + </div>
59 + );
60 +}
61 +
62 +export function DesktopAside({ v, cmp, brief, fav, onFav, onShare }: {
63 + v: VehicleDetail; cmp: ComparaisonPrix | null; brief: Constat[]; fav: boolean; onFav: () => void; onShare: () => void;
64 +}) {
65 + const maj = relTime(v.updated_at);
66 + const s = v.ka_score;
67 + return (
68 + <aside className="ak-aside" aria-label="Résumé et actions">
69 + <div className="ak-card ak-aside-card">
70 + <div>
71 + <div className="ak-price">{fmtPrice(v.price)}</div>
72 + <div className="ak-price-row" style={{ marginTop: 8 }}><PriceCapsule cmp={cmp} /></div>
73 + </div>
74 + <div className="ak-aside-addr">{v.title}<br />{[v.dealer_name || v.source, v.city].filter(Boolean).join(" · ")}</div>
75 + <a className="ak-btn ak-btn-primary" href={v.url} target="_blank" rel="noreferrer">Voir chez {v.dealer_name || "le concessionnaire"} <Ico name="external" size={16} /></a>
76 + <div className="ak-actions">
77 + <button type="button" className={`ak-btn ak-btn-ghost ${fav ? "on" : ""}`} style={{ flex: 1 }} aria-pressed={fav} onClick={onFav}><IcoHeart size={17} filled={fav} /> {fav ? "Favori" : "Favoris"}</button>
78 + <button type="button" className="ak-btn ak-btn-ghost" style={{ flex: 1 }} onClick={onShare}><Ico name="share" size={16} /> Partager</button>
79 + {v.carfax_url && <a className="ak-btn ak-btn-ghost ak-btn-icon" aria-label="Rapport Carfax" title="Rapport Carfax" href={v.carfax_url} target="_blank" rel="noreferrer"><Ico name="doc" size={17} /></a>}
80 + </div>
81 + <button type="button" className="ak-btn ak-btn-ghost ak-ka-inline" onClick={() => window.dispatchEvent(new Event("ak:askka"))}><Ico name="sparkles" size={16} /> Demander à Ka</button>
82 + {(s || brief.length > 0) && <hr className="ak-aside-sep" />}
83 + {s && (
84 + <div className="ak-aside-score">
85 + <ScoreRing value={s.overall} size={56} />
86 + <div className="ak-aside-score-txt"><b>KA Score · {kaLabel(s.overall)}</b>{s.parts.map((p) => `${p.label.split(" ")[0]} ${p.score}`).join(" · ")}</div>
87 + </div>
88 + )}
89 + {brief.length > 0 && <BriefList items={brief.slice(0, 4)} compact />}
90 + {maj && <div className="ak-aside-meta">Vérifié {maj} · {v.dealer_name || v.source}</div>}
91 + </div>
92 + </aside>
93 + );
94 +}
95 +
96 +const SUGGESTIONS = ["Est-ce un bon prix ?", "Compare-le aux véhicules similaires", "Quels sont les points de vigilance ?", "Quel budget mensuel prévoir ?", "Y a-t-il des rappels connus ?"];
97 +function envoyerAKa(question: string): boolean {
98 + const btn = document.querySelector<HTMLButtonElement>(".kaa-btn");
99 + const ta = document.querySelector<HTMLTextAreaElement>(".kaa-panel textarea");
100 + const send = document.querySelector<HTMLButtonElement>(".kaa-in button");
101 + if (!btn || !ta || !send) return false;
102 + btn.click(); ta.value = question; setTimeout(() => send.click(), 60);
103 + return true;
104 +}
105 +export function KaAssistant({ v, hidden }: { v: VehicleDetail; hidden?: boolean }) {
106 + const [open, setOpen] = useState(false);
107 + const [q, setQ] = useState("");
108 + const [err, setErr] = useState(false);
109 + useEffect(() => { const on = () => setOpen(true); window.addEventListener("ak:askka", on); return () => window.removeEventListener("ak:askka", on); }, []);
110 + const contexte = () => `(Véhicule consulté sur Auto-Ka : ${v.title}${v.mileage_km != null ? ` — ${Math.round(v.mileage_km).toLocaleString("fr-CA")} km` : ""}${v.price != null ? ` — ${fmtPrice(v.price)}` : ""} — ${v.dealer_name || v.source}${v.city ? `, ${v.city}` : ""} — https://www.auto-ka.com/vehicule/${encodeURIComponent(v.uid)})`;
111 + const poser = (question: string) => { const ok = envoyerAKa(`${question}\n\n${contexte()}`); if (ok) { setOpen(false); setQ(""); setErr(false); } else setErr(true); };
112 + return (
113 + <>
114 + <button type="button" className={`ak-ka-btn ${hidden ? "hide" : ""}`} onClick={() => setOpen(true)} aria-label="Demander à Ka, l'assistant Groupe KA"><Ico name="sparkles" size={16} /> Demander à Ka</button>
115 + <BottomSheet open={open} onClose={() => setOpen(false)} title="Demander à Ka" sub="Assistant Groupe KA · connaît cette fiche"
116 + footer={<form className="ak-ka-in" onSubmit={(e) => { e.preventDefault(); if (q.trim()) poser(q.trim()); }}>
117 + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Posez votre question sur ce véhicule…" aria-label="Votre question" />
118 + <button type="submit" aria-label="Envoyer" disabled={!q.trim()}><Ico name="chevright" size={20} /></button></form>}>
119 + <div className="ak-ka-intro"><span className="ak-ka-avatar" aria-hidden="true"><Ico name="sparkles" size={18} /></span>
120 + <p>Je peux situer ce prix dans le marché, expliquer le KA Score, résumer les rappels et chercher des alternatives chez d'autres concessionnaires.</p></div>
121 + <div className="ak-ka-ctx">{v.images?.[0] && <img src={v.images[0]} alt="" />}<span style={{ minWidth: 0 }}><b>{v.title}</b>{[v.dealer_name || v.source, v.price != null ? fmtPrice(v.price) : null, v.mileage_km != null ? `${Math.round(v.mileage_km).toLocaleString("fr-CA")} km` : null].filter(Boolean).join(" · ")}</span></div>
122 + <div className="ak-ka-sugs">{SUGGESTIONS.map((s) => <button type="button" className="ak-ka-sug" key={s} onClick={() => poser(s)}>{s} <Ico name="chevright" size={16} /></button>)}</div>
123 + {err && <p className="ak-note warn">L'assistant n'est pas encore chargé — réessayez dans un instant.</p>}
124 + </BottomSheet>
125 + </>
126 + );
127 +}
added frontend/src/fiche/ContextCards.tsx +162 −0
@@ -0,0 +1,162 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ContextCards.tsx : cartes de contexte —
5 +// · RecallsCard : rappels Transports Canada (3 visibles + « Voir les N ») ;
6 +// · OffersCard : le même véhicule (même NIV) chez d'autres sources ;
7 +// · LocationCard : carte OpenStreetMap (position approximative du vendeur) ;
8 +// · SimilarCard : véhicules similaires (cartes de la grille) ;
9 +// · Dossier : suivi Auto-Ka, concessionnaire, identifiants ;
10 +// · Sources : sources et méthodologie + rangée d'actions.
11 +// -----------------------------------------------------------------------------
12 +import { useMemo, useState } from "react";
13 +import { Recall, VehicleDetail, fmtDate, fmtPrice, sourceName } from "../api";
14 +import { Ico } from "../components/Icons";
15 +import VehicleCard from "../components/VehicleCard";
16 +import { Accordion, ErrorState, MoreButton, SectionCard, SkeletonLines, SourceLine, StatTile, StatusBadge, relTime } from "./ui";
17 +import { Res } from "./useFicheData";
18 +
19 +export function RecallsCard({ v, r, onRetry }: { v: VehicleDetail; r: Res<Recall[]>; onRetry: () => void }) {
20 + const [all, setAll] = useState(false);
21 + if (r.status === "na" || r.status === "idle") return null;
22 + const items = r.status === "ok" ? r.data : null;
23 + return (
24 + <SectionCard id="rappels" title="Rappels Transports Canada" icon={<Ico name="shield" size={18} />}
25 + aside={items && <StatusBadge tone={items.length ? "warn" : "good"} lg>{items.length ? `${items.length} rappel${items.length > 1 ? "s" : ""}` : "Aucun rappel"}</StatusBadge>}>
26 + {r.status === "loading" && <SkeletonLines n={2} />}
27 + {r.status === "error" && <ErrorState onRetry={onRetry}>Base des rappels temporairement indisponible.</ErrorState>}
28 + {items && items.length === 0 && <p className="ak-status-d" style={{ margin: 0 }}>Aucun rappel de sécurité répertorié pour {v.make} {v.model}{v.year ? ` ${v.year}` : ""}.</p>}
29 + {items && items.length > 0 && (
30 + <>
31 + <p className="ak-status-d" style={{ marginTop: 0 }}>Rappels visant {v.make} {v.model}{v.year ? ` ${v.year}` : ""} — un rappel concerne le modèle, pas forcément cet exemplaire ; vérifiez auprès du concessionnaire qu'ils ont été effectués.</p>
32 + {(all ? items : items.slice(0, 3)).map((x) => (
33 + <Accordion key={x.recall_number} small title={<><span style={{ fontFamily: "var(--font-mono)", color: "var(--ak-muted)", marginRight: 8 }}>{x.date}</span>{x.component}</>}>
34 + <p style={{ whiteSpace: "pre-line" }}>{x.description}</p>
35 + <p className="ak-fine">Rappel nº {x.recall_number}{x.units_affected ? ` · ${x.units_affected.toLocaleString("fr-CA")} unités visées` : ""}</p>
36 + </Accordion>
37 + ))}
38 + {items.length > 3 && <MoreButton onClick={() => setAll(!all)} expanded={all}>{all ? "Réduire" : `Voir les ${items.length} rappels`}</MoreButton>}
39 + </>
40 + )}
41 + <SourceLine name="Transports Canada" href="https://tc.canada.ca/fr/transport-routier/rappels-securite-defauts" date="base des rappels de sécurité" />
42 + </SectionCard>
43 + );
44 +}
45 +
46 +export function OffersCard({ v }: { v: VehicleDetail }) {
47 + const offers = v.dup_sources ?? [];
48 + if (offers.length === 0) return null;
49 + return (
50 + <SectionCard id="offres" title="Le même véhicule ailleurs" icon={<Ico name="layers" size={18} />}
51 + sub={`Même NIV repéré sur ${offers.length} autre${offers.length > 1 ? "s" : ""} site${offers.length > 1 ? "s" : ""} (dédoublonnage Auto-Ka)`}>
52 + <ul className="ak-list">
53 + {offers.map((o) => {
54 + const delta = o.price != null && v.price != null ? o.price - v.price : null;
55 + return (
56 + <li className="ak-item" key={o.uid}>
57 + <span className="ak-item-ico" aria-hidden="true"><Ico name="store" size={17} /></span>
58 + <div className="ak-item-main">
59 + <div className="ak-item-t">{o.dealer_name || sourceName(o.source)}</div>
60 + <div className="ak-item-s">{[o.city, sourceName(o.source)].filter(Boolean).join(" · ")}</div>
61 + </div>
62 + <div className="ak-item-r">
63 + <div className="ak-item-v">{fmtPrice(o.price)}</div>
64 + <div className={`ak-item-m ${delta != null && delta < 0 ? "good" : ""}`} style={delta != null && delta < 0 ? { color: "var(--ak-success)", fontWeight: 600 } : undefined}>
65 + {delta == null || delta === 0 ? <a className="ak-link" href={o.url} target="_blank" rel="noreferrer">Voir ↗</a> : `${delta < 0 ? "−" : "+"}${Math.abs(delta).toLocaleString("fr-CA")} $`}
66 + </div>
67 + </div>
68 + </li>
69 + );
70 + })}
71 + </ul>
72 + </SectionCard>
73 + );
74 +}
75 +
76 +export function LocationCard({ v }: { v: VehicleDetail }) {
77 + const src = useMemo(() => {
78 + if (v.lat == null || v.lng == null) return null;
79 + const d = 0.02;
80 + const bbox = [v.lng - d, v.lat - d, v.lng + d, v.lat + d].join(",");
81 + return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${v.lat},${v.lng}`;
82 + }, [v.lat, v.lng]);
83 + if (!src) return null;
84 + return (
85 + <SectionCard id="carte" title="Où le voir" icon={<Ico name="pin" size={18} />} sub={`${[v.dealer_name, v.city, v.region].filter(Boolean).join(" · ")} — position approximative (ville du vendeur)`}>
86 + <div className="ak-map"><iframe className="ak-map-embed" src={src} title={`Carte — ${v.dealer_name || v.city}`} loading="lazy" /></div>
87 + <SourceLine name="OpenStreetMap" href="https://www.openstreetmap.org" />
88 + </SectionCard>
89 + );
90 +}
91 +
92 +export function SimilarCard({ v }: { v: VehicleDetail }) {
93 + if (!v.similar || v.similar.length === 0) return null;
94 + return (
95 + <SectionCard id="similaires" title={`${v.make} ${v.model.split(" ")[0]} similaires`} icon={<Ico name="car" size={18} />} sub="En vente au Québec, du moins cher au plus cher">
96 + <div className="ak-similar vgrid">{v.similar.slice(0, 6).map((s) => <VehicleCard key={s.uid} v={s} />)}</div>
97 + </SectionCard>
98 + );
99 +}
100 +
101 +export function Dossier({ v }: { v: VehicleDetail }) {
102 + const jours = v.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - v.first_seen) / 86400)) : null;
103 + const hist = (v.price_history ?? []).filter((h) => h.price != null);
104 + const site = typeof v.details?.dealer_site === "string" ? (v.details.dealer_site as string) : null;
105 + return (
106 + <SectionCard id="dossier" title="Dossier de l'annonce" icon={<Ico name="folder" size={18} />} sub="Ce qu'Auto-Ka observe réellement : suivi, concessionnaire, identifiants">
107 + <Accordion title={<><Ico name="history" size={15} className="ak-acc-ico" />Suivi Auto-Ka</>} meta={jours != null ? (jours === 0 ? "arrivé aujourd'hui" : `${jours} j en vente`) : undefined}>
108 + <div className="ak-kpis cols-3" style={{ marginBottom: 10 }}>
109 + <StatTile value={fmtDate(v.first_seen)} label="Repéré le" anim={false} />
110 + <StatTile value={fmtDate(v.updated_at)} label="Vérifié le" anim={false} />
111 + <StatTile value={Math.max(0, hist.length - 1)} label="Changement(s) de prix" anim={false} />
112 + </div>
113 + <p className="ak-fine">Statut : {v.active ? "en vente chez le concessionnaire" : "retiré / vendu"}. Première observation et variations sont mesurées par les synchronisations Auto-Ka (plusieurs fois par jour).</p>
114 + </Accordion>
115 + <Accordion title={<><Ico name="store" size={15} className="ak-acc-ico" />Concessionnaire</>} meta={v.dealer_name || sourceName(v.source)}>
116 + <p style={{ margin: "0 0 6px" }}><b style={{ fontSize: 15 }}>{v.dealer_name || sourceName(v.source)}</b>{v.city ? ` — ${v.city}${v.region ? `, ${v.region}` : ""}` : ""}</p>
117 + <p style={{ margin: 0 }}>Source : {sourceName(v.source)}{site && <> · <a href={site} target="_blank" rel="noreferrer">site du concessionnaire ↗</a></>}. Auto-Ka est un agrégateur indépendant : prix, disponibilité et transaction relèvent du vendeur.</p>
118 + </Accordion>
119 + <Accordion title={<><Ico name="tag" size={15} className="ak-acc-ico" />Identifiants</>} meta={v.vin || v.external_id}>
120 + <div className="ak-facts-rows" style={{ marginTop: 0 }}>
121 + {v.vin && <div className="ak-kv"><span className="k">NIV (VIN)</span><span className="v" style={{ fontFamily: "var(--font-mono)", fontSize: 12.5 }}>{v.vin}</span></div>}
122 + {v.stock_number && <div className="ak-kv"><span className="k">Nº de stock</span><span className="v">{v.stock_number}</span></div>}
123 + <div className="ak-kv"><span className="k">Identifiant source</span><span className="v">{v.external_id}</span></div>
124 + <div className="ak-kv"><span className="k">Identifiant Auto-Ka</span><span className="v">{v.uid}</span></div>
125 + <div className="ak-kv"><span className="k">Annonce originale</span><span className="v"><a href={v.url} target="_blank" rel="noreferrer">{sourceName(v.source)} ↗</a></span></div>
126 + </div>
127 + </Accordion>
128 + </SectionCard>
129 + );
130 +}
131 +
132 +export function Sources({ v, onShare }: { v: VehicleDetail; onShare: () => void }) {
133 + const maj = relTime(v.updated_at);
134 + const srcs = [
135 + { n: v.dealer_name || sourceName(v.source), r: "Annonce, prix, photos, équipements et description (source originale)", d: maj ?? undefined, href: v.url },
136 + { n: "Transports Canada", r: "Rappels de sécurité visant le modèle", href: "https://tc.canada.ca/fr/transport-routier/rappels-securite-defauts" },
137 + { n: "NHTSA (vPIC)", r: "Fiche constructeur décodée du NIV" },
138 + ...(v.carfax_url ? [{ n: "Carfax", r: "Rapport d'historique du véhicule (lien du concessionnaire)", href: v.carfax_url }] : []),
139 + { n: "OpenStreetMap", r: "Carte de localisation du vendeur" },
140 + { n: "Auto-Ka", r: "Analyse de marché, KA Score, dédoublonnage NIV, suivi des prix — calculs maison, indicatifs" },
141 + ];
142 + return (
143 + <SectionCard id="sources" title="Sources et méthodologie" icon={<Ico name="folder" size={18} />} sub="Chaque donnée de cette fiche renvoie à sa source ; les calculs Auto-Ka sont indicatifs et documentés">
144 + <ul className="ak-sources">
145 + {srcs.map((s) => (
146 + <li key={s.n}>
147 + <span className="n">{s.href ? <a href={s.href} target="_blank" rel="noreferrer">{s.n}</a> : s.n}</span>
148 + {s.d && <span className="d">{s.d}</span>}
149 + <span className="r">{s.r}</span>
150 + </li>
151 + ))}
152 + </ul>
153 + <div className="ak-end" style={{ marginTop: 14 }}>
154 + <a href="/sources"><Ico name="folder" size={18} />Concessionnaires et sources<small>Toutes les sources Auto-Ka</small></a>
155 + <a href={`/contact?sujet=${encodeURIComponent(`Erreur sur la fiche ${v.uid}`)}`}><Ico name="alert" size={18} />Signaler une erreur<small>Prix, photos, kilométrage…</small></a>
156 + <button type="button" onClick={onShare}><Ico name="share" size={18} />Partager ce véhicule<small>Lien de la fiche</small></button>
157 + <a href="/stats"><Ico name="chart" size={18} />Statistiques du marché<small>Prix, régions, marques</small></a>
158 + </div>
159 + <p className="ak-fine" style={{ marginTop: 12 }}>Les prix et disponibilités sont ceux affichés par le concessionnaire — chaque fiche renvoie à l'annonce originale. Auto-Ka est un agrégateur indépendant.</p>
160 + </SectionCard>
161 + );
162 +}
added frontend/src/fiche/MarketCard.tsx +117 −0
@@ -0,0 +1,117 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/MarketCard.tsx : « Prix et marché » — KPI (prix demandé, médiane des
5 +// comparables, fourchette 25–75 %), jauge de position (percentile), nuage
6 +// prix / kilométrage des comparables (ce véhicule en orange), PDSF d'origine
7 +// si connu, historique du prix demandé, méthodologie.
8 +// -----------------------------------------------------------------------------
9 +import { MarketAnalysis, VehicleDetail, fmtDate, fmtPrice } from "../api";
10 +import { Ico } from "../components/Icons";
11 +import { ComparaisonPrix } from "./synthese";
12 +import { PriceCapsule } from "./VehicleHero";
13 +import { Accordion, SectionCard, StatTile, NBSP } from "./ui";
14 +
15 +function Scatter({ m, price, km }: { m: MarketAnalysis; price: number; km: number | null }) {
16 + const pts = m.points;
17 + if (pts.length < 5) return null;
18 + const W = 520, H = 220, PAD = { t: 16, r: 14, b: 28, l: 52 };
19 + const kms = pts.map((d) => d.km).concat(km != null ? [km] : []);
20 + const ps = pts.map((d) => d.p).concat([price]);
21 + const kMin = Math.min(...kms), kMax = Math.max(...kms), pMin = Math.min(...ps), pMax = Math.max(...ps);
22 + const x = (v: number) => PAD.l + (kMax > kMin ? (v - kMin) / (kMax - kMin) : 0.5) * (W - PAD.l - PAD.r);
23 + const y = (v: number) => H - PAD.b - (pMax > pMin ? (v - pMin) / (pMax - pMin) : 0.5) * (H - PAD.t - PAD.b);
24 + const fmtK = (v: number) => `${Math.round(v / 1000)} k`;
25 + return (
26 + <svg viewBox={`0 0 ${W} ${H}`} className="ak-scatter" role="img" aria-label="Nuage prix / kilométrage des véhicules comparables">
27 + <line x1={PAD.l} y1={H - PAD.b} x2={W - PAD.r} y2={H - PAD.b} className="axis" />
28 + <line x1={PAD.l} y1={PAD.t} x2={PAD.l} y2={H - PAD.b} className="axis" />
29 + <line x1={PAD.l} y1={y(m.median)} x2={W - PAD.r} y2={y(m.median)} className="median" />
30 + <text x={W - PAD.r} y={y(m.median) - 5} textAnchor="end" className="lbl">médiane {fmtPrice(m.median)}</text>
31 + {pts.map((d, i) => <circle key={i} cx={x(d.km)} cy={y(d.p)} r={3.5} className="dot" />)}
32 + {km != null && (
33 + <g>
34 + <circle cx={x(km)} cy={y(price)} r={7} className="me" />
35 + <text x={x(km)} y={y(price) - 12} textAnchor="middle" className="lbl me-lbl">ce véhicule</text>
36 + </g>
37 + )}
38 + <text x={PAD.l} y={H - 8} className="lbl">{fmtK(kMin)} km</text>
39 + <text x={W - PAD.r} y={H - 8} textAnchor="end" className="lbl">{fmtK(kMax)} km</text>
40 + <text x={PAD.l - 6} y={y(pMax) + 4} textAnchor="end" className="lbl">{fmtK(pMax)} $</text>
41 + <text x={PAD.l - 6} y={y(pMin) + 4} textAnchor="end" className="lbl">{fmtK(pMin)} $</text>
42 + </svg>
43 + );
44 +}
45 +
46 +export default function MarketCard({ v, cmp }: { v: VehicleDetail; cmp: ComparaisonPrix | null }) {
47 + if (v.price == null) return null;
48 + const m = v.market;
49 + const hist = (v.price_history ?? []).filter((h) => h.price != null);
50 + const msrp = typeof v.details?.msrp === "number" ? (v.details.msrp as number) : null;
51 + const jours = v.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - v.first_seen) / 86400)) : null;
52 + return (
53 + <SectionCard id="prix" title="Prix et marché" icon={<Ico name="scale" size={18} />} aside={cmp && <PriceCapsule cmp={cmp} short />}>
54 + {m && m.n >= 3 ? (
55 + <>
56 + <div className="ak-kpis cols-3">
57 + <StatTile accent value={fmtPrice(v.price)} label="Prix demandé" />
58 + <StatTile value={fmtPrice(m.median)} label={`Médiane de ${m.n} comparables`} />
59 + <StatTile value={`${fmtPrice(m.p25)} – ${fmtPrice(m.p75)}`} label="Fourchette 25–75 %" />
60 + </div>
61 + <div className="ak-gauge">
62 + <svg viewBox="0 0 320 44" role="img" aria-label={`${m.percentile} % des comparables sont moins chers`}>
63 + <rect className="ak-gauge-track" x="8" y="18" width="304" height="8" rx="4" />
64 + <rect className="ak-gauge-box" x={8 + 304 * 0.25} y="16" width={304 * 0.5} height="12" rx="4" />
65 + <circle className="ak-gauge-me" cx={8 + 304 * Math.max(0, Math.min(100, m.percentile)) / 100} cy="22" r="7" />
66 + <text className="ak-gauge-txt me" x={8 + 304 * Math.max(0, Math.min(100, m.percentile)) / 100} y="42"
67 + textAnchor={m.percentile < 18 ? "start" : m.percentile > 82 ? "end" : "middle"}>{m.percentile}{NBSP}% des comparables sont moins chers</text>
68 + </svg>
69 + <div className="ak-gauge-lbls"><span className="good">moins cher · {fmtPrice(m.min)}</span><span className="bad">plus cher · {fmtPrice(m.max)}</span></div>
70 + </div>
71 + <Scatter m={m} price={v.price} km={v.mileage_km} />
72 + <div className="ak-meta">
73 + <span><b>{m.n}</b> {v.make} {v.model.split(" ")[0]}{v.year ? ` ${v.year - 1}–${v.year + 1}` : ""} en vente au Québec</span>
74 + {msrp != null && msrp > 0 && <span>PDSF d'origine <b>{fmtPrice(msrp)}</b> ({Math.round((1 - v.price / msrp) * 100)}{NBSP}% de moins)</span>}
75 + {jours != null && jours > 0 && <span><b>{jours}</b> jour{jours > 1 ? "s" : ""} en vente (observé)</span>}
76 + </div>
77 + </>
78 + ) : (
79 + <>
80 + <div className="ak-kpis cols-3">
81 + <StatTile accent value={fmtPrice(v.price)} label="Prix demandé" />
82 + {msrp != null && msrp > 0 && <StatTile value={fmtPrice(msrp)} label="PDSF d'origine (neuf)" />}
83 + {jours != null && jours > 0 && <StatTile value={jours} label="Jours en vente (observé)" />}
84 + </div>
85 + <p className="ak-fine meth">Pas assez de {v.make} {v.model.split(" ")[0]} comparables en vente pour situer ce prix.</p>
86 + </>
87 + )}
88 + {hist.length >= 2 && (
89 + <>
90 + <h3 className="ak-card-sub ak-subtitle">Historique du prix demandé</h3>
91 + <ul className="ak-tl">
92 + {hist.slice(0, 8).map((h, i) => {
93 + const prev = hist[i + 1];
94 + const cls = prev && prev.price != null ? (h.price! < prev.price ? "baisse" : h.price! > prev.price ? "hausse" : "") : "";
95 + return (
96 + <li key={h.ts} className={cls}>
97 + <span className="ak-tl-date">{fmtDate(h.ts)}</span>
98 + {prev && prev.price != null && prev.price !== h.price
99 + ? <>{h.price! < prev.price ? "Baissé" : "Monté"} de {fmtPrice(prev.price)} à <b>{fmtPrice(h.price!)}</b></>
100 + : prev ? <>Prix inchangé : <b>{fmtPrice(h.price!)}</b></> : <>Premier prix observé : <b>{fmtPrice(h.price!)}</b></>}
101 + </li>
102 + );
103 + })}
104 + </ul>
105 + </>
106 + )}
107 + <Accordion title="Méthodologie" small>
108 + <p>
109 + Comparables = véhicules de même marque et modèle, année ±1, en vente au Québec dans les sources Auto-Ka, doublons
110 + (même NIV) exclus. La médiane et la fourchette 25–75 % sont recalculées à chaque affichage ; le percentile indique
111 + la part des comparables affichés moins cher. L'état du véhicule, les options et l'historique d'entretien ne sont pas
112 + pris en compte : c'est un repère, pas une évaluation. Le PDSF provient de la fiche constructeur quand il est connu.
113 + </p>
114 + </Accordion>
115 + </SectionCard>
116 + );
117 +}
added frontend/src/fiche/ScoreCard.tsx +65 −0
@@ -0,0 +1,65 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ScoreCard.tsx : carte « KA Score » — anneau 0-100 animé, libellé,
5 +// composantes en barres (prix vs marché, kilométrage vs âge, qualité de la
6 +// fiche, fraîcheur), méthodologie en accordéon. Le score est calculé côté
7 +// API (autoka/web.py _ka_score) ; la carte ne l'invente jamais.
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useState } from "react";
10 +import { KaScore } from "../api";
11 +import { Ico } from "../components/Icons";
12 +import { Accordion, SectionCard } from "./ui";
13 +
14 +export const kaLabel = (s: number) => (s >= 85 ? "Exceptionnel" : s >= 70 ? "Excellent" : s >= 55 ? "Très bon" : s >= 40 ? "Moyen" : "Faible");
15 +
16 +export function ScoreRing({ value, size = 88 }: { value: number | null; size?: number }) {
17 + const [v, setV] = useState(0);
18 + useEffect(() => { const t = requestAnimationFrame(() => setV(value ?? 0)); return () => cancelAnimationFrame(t); }, [value]);
19 + const r = 40, c = 2 * Math.PI * r;
20 + return (
21 + <div className="ak-score-ring" style={{ width: size, height: size }} role="img" aria-label={value != null ? `KA Score ${value} sur 100` : "Score non calculable"}>
22 + <svg viewBox="0 0 100 100" style={{ width: size, height: size }} aria-hidden="true">
23 + <circle className="bg" cx="50" cy="50" r={r} />
24 + <circle className="arc" cx="50" cy="50" r={r} strokeDasharray={`${(c * Math.max(0, Math.min(100, v))) / 100} ${c}`} />
25 + </svg>
26 + <div className="ak-score-val"><div>{value != null ? value : "—"}<small>/ 100</small></div></div>
27 + </div>
28 + );
29 +}
30 +
31 +export default function ScoreCard({ s, resume }: { s: KaScore; resume: string[] }) {
32 + const manque = s.parts.length < 4;
33 + return (
34 + <SectionCard id="score" title="KA Score" icon={<Ico name="sparkles" size={18} />}
35 + sub={manque ? "Score partiel — la composante prix manque (pas assez de comparables)" : undefined}>
36 + <div className="ak-score">
37 + <ScoreRing value={s.overall} />
38 + <div>
39 + <div className="ak-score-lbl">{kaLabel(s.overall)}</div>
40 + {resume.length > 0 && <div className="ak-score-sub">{resume.join(" · ")}</div>}
41 + <div className="ak-score-parts">
42 + {s.parts.map((p) => <span className="ak-score-part" key={p.key}>{p.label} <b>{p.score}</b></span>)}
43 + </div>
44 + </div>
45 + </div>
46 + <div className="ak-rows" style={{ marginTop: 12 }}>
47 + {s.parts.map((p) => (
48 + <div className="ak-row" key={p.key}>
49 + <span className="ak-row-name">{p.label}<small>· poids {p.weight}{" "}%</small></span>
50 + <span className="ak-row-bar" aria-hidden="true"><i className={p.score >= 70 ? "good" : p.score >= 45 ? "warn" : "bad"} style={{ width: `${p.score}%` }} /></span>
51 + <span className="ak-row-val">{p.score}</span>
52 + </div>
53 + ))}
54 + </div>
55 + <Accordion title="Comment est calculé ce score ?" small>
56 + <p>
57 + Indice composite Auto-Ka (0-100) calculé côté serveur : prix face à la médiane des comparables, kilométrage
58 + selon l'âge (référence ≈ 20 000 km/an), qualité de la fiche — photos, description, NIV, équipements — et
59 + fraîcheur de l'annonce. Les poids affichés à côté de chaque composante sont renormalisés quand une composante
60 + manque (par exemple sans comparables pour le prix). Il ne remplace ni l'inspection mécanique ni l'historique du véhicule.
61 + </p>
62 + </Accordion>
63 + </SectionCard>
64 + );
65 +}
added frontend/src/fiche/SectionNav.tsx +60 −0
@@ -0,0 +1,60 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/SectionNav.tsx : navigation sticky par sections — pastilles
5 +// défilables horizontalement, section active suivie au scroll
6 +// (IntersectionObserver), défilement doux au clic sans modifier l'URL
7 +// (pas de #hash : la page s'ouvre toujours en haut).
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useRef, useState } from "react";
10 +
11 +export interface NavItem { id: string; label: string; }
12 +
13 +export default function SectionNav({ items }: { items: NavItem[] }) {
14 + const [active, setActive] = useState(items[0]?.id);
15 + const track = useRef<HTMLDivElement>(null);
16 +
17 + useEffect(() => {
18 + const els = items.map((i) => document.getElementById(i.id)).filter((e): e is HTMLElement => !!e);
19 + if (els.length === 0) return;
20 + const visible = new Map<string, number>();
21 + const io = new IntersectionObserver((entries) => {
22 + for (const e of entries) visible.set((e.target as HTMLElement).id, e.isIntersecting ? e.intersectionRatio : 0);
23 + // section active = la première (ordre DOM) visible sous le header
24 + const first = items.find((i) => (visible.get(i.id) ?? 0) > 0);
25 + if (first) setActive(first.id);
26 + }, { rootMargin: "-120px 0px -55% 0px", threshold: [0, 0.1, 0.5] });
27 + els.forEach((e) => io.observe(e));
28 + return () => io.disconnect();
29 + }, [items]);
30 +
31 + // garder la pastille active visible dans la barre — défilement HORIZONTAL de
32 + // la piste seulement (jamais scrollIntoView : il ferait défiler la page
33 + // verticalement, y compris à l'ouverture)
34 + useEffect(() => {
35 + const t = track.current, a = t?.querySelector<HTMLElement>(".on");
36 + if (!t || !a) return;
37 + const left = a.offsetLeft, right = left + a.offsetWidth;
38 + if (left < t.scrollLeft) t.scrollTo({ left: Math.max(0, left - 12), behavior: "smooth" });
39 + else if (right > t.scrollLeft + t.clientWidth) t.scrollTo({ left: right - t.clientWidth + 12, behavior: "smooth" });
40 + }, [active]);
41 +
42 + const go = (id: string) => (e: React.MouseEvent) => {
43 + e.preventDefault();
44 + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
45 + setActive(id);
46 + };
47 +
48 + return (
49 + <nav className="ak-nav" aria-label="Sections de la fiche">
50 + <div className="ak-nav-track" ref={track}>
51 + {items.map((i) => (
52 + <a key={i.id} href={`#${i.id}`} className={active === i.id ? "on" : ""}
53 + aria-current={active === i.id ? "true" : undefined} onClick={go(i.id)}>
54 + {i.label}
55 + </a>
56 + ))}
57 + </div>
58 + </nav>
59 + );
60 +}
added frontend/src/fiche/VehicleFacts.tsx +87 −0
@@ -0,0 +1,87 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/VehicleFacts.tsx : « Le véhicule » — grille icône/valeur (année, km,
5 +// transmission, carburant, motricité, carrosserie, moteur, portes, places,
6 +// consommation), rangées (couleurs, NIV, stock, concessionnaire), description
7 +// du concessionnaire (tronquée), équipements (12 + « Voir les N »), fiche
8 +// constructeur décodée du NIV en accordéon.
9 +// -----------------------------------------------------------------------------
10 +import { ReactNode, useState } from "react";
11 +import { VehicleDetail, fmtKm } from "../api";
12 +import { Ico } from "../components/Icons";
13 +import { Accordion, MoreButton, SectionCard, NBSP } from "./ui";
14 +
15 +export default function VehicleFacts({ v }: { v: VehicleDetail }) {
16 + const [descOpen, setDescOpen] = useState(false);
17 + const [allFeat, setAllFeat] = useState(false);
18 + const d = (v.details ?? {}) as Record<string, unknown>;
19 + const facts: { ico: ReactNode; v: string; l: string }[] = [];
20 + if (v.year) facts.push({ ico: <Ico name="calendar" size={17} />, v: String(v.year), l: "Année" });
21 + if (v.mileage_km != null) facts.push({ ico: <Ico name="trendup" size={17} />, v: fmtKm(v.mileage_km), l: "Kilométrage" });
22 + if (v.transmission) facts.push({ ico: <Ico name="layers" size={17} />, v: v.transmission, l: "Transmission" });
23 + if (v.fuel) facts.push({ ico: <Ico name="fuel" size={17} />, v: v.fuel, l: "Carburant" });
24 + if (v.drivetrain) facts.push({ ico: <Ico name="car" size={17} />, v: v.drivetrain, l: "Motricité" });
25 + if (v.body_type) facts.push({ ico: <Ico name="car" size={17} />, v: v.body_type, l: "Carrosserie" });
26 + if (v.engine) facts.push({ ico: <Ico name="hammer" size={17} />, v: v.engine, l: "Moteur" });
27 + if (v.doors) facts.push({ ico: <Ico name="home" size={17} />, v: String(v.doors), l: "Portes" });
28 + if (v.seats) facts.push({ ico: <Ico name="people" size={17} />, v: String(v.seats), l: "Places" });
29 + const city = d.fuel_city_l_100km, hwy = d.fuel_hwy_l_100km;
30 + if (city || hwy) facts.push({ ico: <Ico name="drop" size={17} />, v: `${city ?? "—"} / ${hwy ?? "—"}`, l: "L/100 km ville / route" });
31 +
32 + const rows: [string, ReactNode][] = [];
33 + if (v.trim) rows.push(["Version", v.trim]);
34 + if (v.exterior_color) rows.push(["Couleur extérieure", v.exterior_color]);
35 + if (v.interior_color) rows.push(["Couleur intérieure", v.interior_color]);
36 + if (v.vin) rows.push(["NIV (VIN)", <span style={{ fontFamily: "var(--font-mono)", fontSize: 12.5 }}>{v.vin}</span>]);
37 + if (v.stock_number) rows.push(["Nº de stock", v.stock_number]);
38 + rows.push(["Concessionnaire", `${v.dealer_name || v.source}${v.city ? ` · ${v.city}` : ""}`]);
39 +
40 + const texte = (v.description || "").trim();
41 + const long = texte.length > 420;
42 + const feats = v.features ?? [];
43 + const LIMIT = 12;
44 + const vin = v.vin_info && Object.keys(v.vin_info).length > 0 ? Object.entries(v.vin_info) : [];
45 +
46 + return (
47 + <SectionCard id="vehicule" title="Le véhicule">
48 + <div className="ak-facts" role="list">
49 + {facts.map((x) => (
50 + <div className="ak-fact" role="listitem" key={x.l}>
51 + <span className="ak-fact-ico" aria-hidden="true">{x.ico}</span>
52 + <span className="ak-fact-txt"><div className="ak-fact-v" title={x.v}>{x.v}</div><div className="ak-fact-l">{x.l}</div></span>
53 + </div>
54 + ))}
55 + </div>
56 + <div className="ak-facts-rows">
57 + {rows.map(([k, val]) => <div className="ak-kv" key={k}><span className="k">{k}</span><span className="v">{val}</span></div>)}
58 + </div>
59 + <h3 className="ak-card-sub ak-subtitle" id="description">Description du concessionnaire</h3>
60 + {texte ? (
61 + <>
62 + <div className={`ak-desc ${long && !descOpen ? "clamped" : ""}`}><div className="ak-desc-body"><p>{texte}</p></div></div>
63 + {long && <MoreButton onClick={() => setDescOpen(!descOpen)} expanded={descOpen}>{descOpen ? "Réduire" : "Lire la suite"}</MoreButton>}
64 + </>
65 + ) : <p className="ak-fine" style={{ marginTop: 0 }}>Le concessionnaire ne fournit pas de description pour cette annonce.</p>}
66 + {feats.length > 0 && (
67 + <>
68 + <h3 className="ak-card-sub ak-subtitle" id="equipements">Équipements <small>· {feats.length}</small></h3>
69 + <div className="ak-amen" role="list">
70 + {(allFeat ? feats : feats.slice(0, LIMIT)).map((f) => (
71 + <div className="ak-amen-it" role="listitem" key={f}><span className="ak-amen-ico" aria-hidden="true"><Ico name="check" size={14} /></span><span className="ak-amen-txt">{f}</span></div>
72 + ))}
73 + </div>
74 + {feats.length > LIMIT && <MoreButton onClick={() => setAllFeat(!allFeat)} expanded={allFeat}>{allFeat ? "Réduire" : `Voir les ${feats.length} équipements`}</MoreButton>}
75 + </>
76 + )}
77 + {vin.length > 0 && (
78 + <Accordion title="Fiche constructeur (décodée du NIV)" meta={`${vin.length} champs`}>
79 + <div className="ak-facts-rows" style={{ marginTop: 0 }}>
80 + {vin.map(([k, val]) => <div className="ak-kv" key={k}><span className="k">{k}</span><span className="v">{val}</span></div>)}
81 + </div>
82 + <p className="ak-fine">Décodé du NIV — base vPIC (NHTSA). Les données constructeur peuvent différer de la version canadienne.{NBSP}</p>
83 + </Accordion>
84 + )}
85 + </SectionCard>
86 + );
87 +}
added frontend/src/fiche/VehicleHero.tsx +100 −0
@@ -0,0 +1,100 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/VehicleHero.tsx : héro de la fiche — concessionnaire · ville, prix +
5 +// ancien prix barré + capsule marché, titre (H1) + résumé (année · km ·
6 +// transmission · carburant · carrosserie), galerie photo (balayage natif,
7 +// compteur, plein écran via Lightbox existante, vignettes ≥ 768 px), actions
8 +// (favoris · partager · Carfax · Voir chez le concessionnaire).
9 +// -----------------------------------------------------------------------------
10 +import { useRef, useState } from "react";
11 +import { VehicleDetail, fmtPrice } from "../api";
12 +import { Ico, IcoHeart } from "../components/Icons";
13 +import Lightbox from "../components/Lightbox";
14 +import { ComparaisonPrix, ligneResume } from "./synthese";
15 +
16 +export function PriceCapsule({ cmp, short = false }: { cmp: ComparaisonPrix | null; short?: boolean }) {
17 + if (!cmp) return null;
18 + return (
19 + <span className={`ak-capsule ${cmp.tone}`}>
20 + {short ? <b>{cmp.court}</b> : <><b>{cmp.label}</b><span aria-hidden="true">·</span>{cmp.court}</>}
21 + </span>
22 + );
23 +}
24 +
25 +export function VehicleGallery({ images, titre }: { images: string[]; titre: string }) {
26 + const [idx, setIdx] = useState(0);
27 + const [zoom, setZoom] = useState(false);
28 + const [dead, setDead] = useState<Set<string>>(new Set());
29 + const track = useRef<HTMLDivElement>(null);
30 + const alive = images.filter((u) => !dead.has(u));
31 + const cur = Math.min(idx, Math.max(0, alive.length - 1));
32 + const goto = (i: number) => track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
33 + if (alive.length === 0)
34 + return <div className="ak-gallery ak-gallery-empty" aria-label="Photos"><Ico name="car" size={44} /><span>Aucune photo fournie par le concessionnaire</span></div>;
35 + return (
36 + <>
37 + <div className="ak-gallery" aria-roledescription="carrousel" aria-label="Photos du véhicule">
38 + <div className="ak-gallery-track" ref={track} onScroll={() => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)); }}>
39 + {alive.map((u, i) => (
40 + <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async" alt={`${titre} — photo ${i + 1} de ${alive.length}`}
41 + onError={() => setDead((d) => new Set(d).add(u))} onClick={() => setZoom(true)} />
42 + ))}
43 + </div>
44 + <span className="ak-gallery-count" aria-live="polite">{cur + 1} / {alive.length}</span>
45 + <button type="button" className="ak-gallery-full" aria-label="Voir en plein écran" onClick={() => setZoom(true)}><Ico name="expand" size={17} /></button>
46 + {cur > 0 && <button type="button" className="ak-gallery-nav prev" aria-label="Photo précédente" onClick={() => goto(cur - 1)}><Ico name="chevleft" size={20} /></button>}
47 + {cur < alive.length - 1 && <button type="button" className="ak-gallery-nav next" aria-label="Photo suivante" onClick={() => goto(cur + 1)}><Ico name="chevright" size={20} /></button>}
48 + </div>
49 + {alive.length > 1 && (
50 + <div className="ak-thumbs" role="list">
51 + {alive.slice(0, 12).map((u, i) => (
52 + <button type="button" key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`} aria-current={i === cur} role="listitem">
53 + <img src={u} alt="" loading="lazy" decoding="async" />
54 + </button>
55 + ))}
56 + </div>
57 + )}
58 + {zoom && <Lightbox images={alive} index={cur} onIndex={(i) => { setIdx(i); goto(i); }} onClose={() => setZoom(false)} alt={titre} />}
59 + </>
60 + );
61 +}
62 +
63 +export default function VehicleHero({ v, cmp, fav, onFav, onShare, actionsRef }: {
64 + v: VehicleDetail; cmp: ComparaisonPrix | null; fav: boolean;
65 + onFav: () => void; onShare: () => void; actionsRef: React.RefObject<HTMLDivElement>;
66 +}) {
67 + const resume = ligneResume(v);
68 + const hist = (v.price_history ?? []).filter((h) => h.price != null);
69 + const avant = hist.length >= 2 && hist[0].price! < hist[1].price! ? hist[1].price! : null;
70 + return (
71 + <header className="ak-hero" aria-label="Résumé du véhicule">
72 + <div className="ak-kicker">{v.dealer_name || v.source}{v.city ? ` — ${v.city}` : ""}{v.region ? ` · ${v.region}` : ""}</div>
73 + <div className="ak-price">
74 + {fmtPrice(v.price)}
75 + {avant != null && <small style={{ textDecoration: "line-through", opacity: 0.7 }}>{fmtPrice(avant)}</small>}
76 + {v.details?.price_from ? <small>à partir de</small> : null}
77 + </div>
78 + <div className="ak-price-row" style={{ marginTop: 8 }}>
79 + <PriceCapsule cmp={cmp} />
80 + {v.year != null && <span className="ak-capsule neutral">{v.year}</span>}
81 + {v.ka_reco && <span className="ak-capsule brand">Recommandé pour vous</span>}
82 + </div>
83 + <h1 className="ak-h1">
84 + {v.title}
85 + {resume.length > 0 && <span className="ak-city">{resume.join(" · ")}</span>}
86 + </h1>
87 + <VehicleGallery images={v.images ?? []} titre={v.title} />
88 + <div className="ak-actions" ref={actionsRef}>
89 + <button type="button" className={`ak-btn ak-btn-ghost ak-btn-icon ${fav ? "on" : ""}`} aria-pressed={fav}
90 + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={onFav}><IcoHeart size={19} filled={fav} /></button>
91 + <button type="button" className="ak-btn ak-btn-ghost ak-btn-icon" aria-label="Partager" onClick={onShare}><Ico name="share" size={18} /></button>
92 + {v.carfax_url && <a className="ak-btn ak-btn-ghost ak-btn-icon" aria-label="Rapport Carfax" title="Rapport Carfax" href={v.carfax_url} target="_blank" rel="noreferrer"><Ico name="doc" size={18} /></a>}
93 + <a className="ak-btn ak-btn-primary" href={v.url} target="_blank" rel="noreferrer">
94 + Voir l'annonce <Ico name="external" size={16} />
95 + <span className="visually-hidden"> chez {v.dealer_name || "le concessionnaire"}</span>
96 + </a>
97 + </div>
98 + </header>
99 + );
100 +}
added frontend/src/fiche/current.ts +26 −0
@@ -0,0 +1,26 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/current.ts : mini-magasin « fiche affichée » partagé entre la page
5 +// fiche et le header compact (favoris ♥ + partage). Le toggle favoris du
6 +// compte KA exige l'objet Vehicle complet (titre, image, prix pour le hub) :
7 +// la page le publie ici, le header le lit sans re-fetch.
8 +// -----------------------------------------------------------------------------
9 +import { useSyncExternalStore } from "react";
10 +import type { Vehicle } from "../api";
11 +
12 +let current: Vehicle | null = null;
13 +const subs = new Set<() => void>();
14 +
15 +export function setCurrentListing(l: Vehicle | null) {
16 + current = l;
17 + subs.forEach((f) => f());
18 +}
19 +
20 +export function useCurrentListing(): Vehicle | null {
21 + return useSyncExternalStore(
22 + (cb) => { subs.add(cb); return () => { subs.delete(cb); }; },
23 + () => current,
24 + () => null,
25 + );
26 +}
added frontend/src/fiche/fiche.css +702 −0
@@ -0,0 +1,702 @@
1 +/* -----------------------------------------------------------------------------
2 + Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + fiche/fiche.css : fiche véhicule premium (refonte 2026-09-07, même socle que
5 + les fiches Lou-Ka v3 / Immo-Ka v3)
6 + · Tokens `--ak-*` posés sur :root, styles SCOPÉS sous `.ak-fiche` / `.ak-*`.
7 + · Fond papier Groupe KA #F5F3EE, cartes blanches à bord 1 px, ombres
8 + légères, rayons 10/14/18/22 ; ORANGE BRÛLÉ Auto-Ka réservé aux CTA /
9 + prix / score / états actifs. Le masthead encre du site est conservé,
10 + en version compacte (56 px) sur la fiche.
11 + · Mobile-first ; desktop ≥ 1024 px : colonne principale + aside sticky.
12 + · Aucune propriété `order` : ordre DOM = ordre visuel (standard Groupe Ka).
13 +----------------------------------------------------------------------------- */
14 +:root {
15 + --ak-bg: #f5f3ee;
16 + --ak-surface: #ffffff;
17 + --ak-surface-2: #faf9f5;
18 + --ak-border: #e6e3dc;
19 + --ak-border-2: #d3cfc6;
20 + --ak-text: #141814;
21 + --ak-text-2: #4d5551;
22 + --ak-muted: #7c837e;
23 + --ak-accent: #ff5a2a;
24 + --ak-accent-deep: #cc3f16;
25 + --ak-accent-soft: #ffe8de;
26 + --ak-success: #1e7b4a;
27 + --ak-success-soft: #e7f4ec;
28 + --ak-warning: #a8690a;
29 + --ak-warning-soft: #fcf3e1;
30 + --ak-danger: #b3423a;
31 + --ak-danger-soft: #fbe9e7;
32 + --ak-info: #3b5bdb;
33 + --ak-info-soft: #e9edfb;
34 + --ak-r-sm: 10px;
35 + --ak-r-md: 14px;
36 + --ak-r-lg: 18px;
37 + --ak-r-xl: 22px;
38 + --ak-shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.04);
39 + --ak-shadow-md: 0 8px 28px rgba(0, 0, 0, 0.07);
40 + --ak-header-h: 56px;
41 + --ak-nav-h: 52px;
42 +}
43 +
44 +/* ---- page : fond papier, masthead compact, chrome global effacé ---- */
45 +body.ak-fiche-page { background: var(--ak-bg); }
46 +body.ak-fiche-page .ka-tabbar { display: none !important; }
47 +body.ak-fiche-page .gk-mobile { display: none !important; }
48 +body.ak-fiche-page .kaa-btn, body.ak-fiche-page .kaa-hello { display: none !important; } /* remplacé par « Demander à Ka » */
49 +body.ak-fiche-page .header--fiche { border-bottom-width: 2px; }
50 +.header--fiche .header-inner { height: var(--ak-header-h); gap: 10px; }
51 +.header--fiche .brand { font-size: 22px; }
52 +.header--fiche .brand .ka { padding: 1px 6px 3px; }
53 +.header--fiche .brand-tag { display: none; }
54 +.header--fiche .menu-btn { margin-left: 0; }
55 +.header--fiche .nav { margin-left: 0; }
56 +.hdr-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
57 +.hdr-btn {
58 + display: inline-grid; place-items: center; width: 40px; height: 40px;
59 + border-radius: 999px; border: 1px solid var(--mast-line, rgba(245,243,238,0.16)); background: transparent;
60 + color: var(--mast-fg, #f5f3ee); cursor: pointer; padding: 0; transition: background 0.15s, transform 0.15s;
61 +}
62 +.hdr-btn:active { transform: scale(0.95); }
63 +.hdr-btn.on { color: var(--ak-accent); border-color: var(--ak-accent); background: rgba(255, 90, 42, 0.14); }
64 +@media (hover: hover) { .hdr-btn:hover { background: rgba(245, 243, 238, 0.1); } }
65 +.header--fiche .login-btn { min-height: 40px; }
66 +.header--fiche .account-btn { width: 40px; height: 40px; }
67 +@media (max-width: 760px) {
68 + .header--fiche .login-btn { padding: 0; width: 40px; height: 40px; justify-content: center; border-radius: 999px; font-size: 0; gap: 0; }
69 + .header--fiche .login-btn .login-ka { font-size: 9px; }
70 +}
71 +
72 +/* ---- gabarit ---- */
73 +.ak-fiche { padding: 10px 0 110px; color: var(--ak-text); }
74 +.ak-wrap { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
75 +.ak-grid { display: block; }
76 +.ak-main { min-width: 0; display: flex; flex-direction: column; gap: 14px; }
77 +.ak-aside { display: none; }
78 +@media (min-width: 768px) {
79 + .ak-wrap { padding: 0 24px; }
80 + .ak-fiche { padding-top: 18px; }
81 + .ak-main { gap: 16px; }
82 +}
83 +@media (min-width: 1024px) {
84 + .ak-grid { display: grid; grid-template-columns: minmax(0, 1fr) 356px; gap: 32px; align-items: start; }
85 + .ak-aside { display: block; position: sticky; top: calc(var(--ak-header-h) + 16px); }
86 + .ak-fiche { padding-bottom: 80px; }
87 +}
88 +.ak-crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: var(--ak-muted); margin: 0 0 10px; }
89 +.ak-crumbs a { color: var(--ak-text-2); }
90 +.ak-crumbs span:last-child { color: var(--ak-text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60vw; }
91 +.ak-fiche section[id] { scroll-margin-top: calc(var(--ak-header-h) + var(--ak-nav-h) + 10px); }
92 +
93 +/* ---- carte de section ---- */
94 +.ak-card {
95 + background: var(--ak-surface); border: 1px solid var(--ak-border);
96 + border-radius: var(--ak-r-lg); box-shadow: var(--ak-shadow-sm);
97 + padding: 16px; min-width: 0;
98 +}
99 +@media (min-width: 768px) { .ak-card { padding: 20px 22px; } }
100 +.ak-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
101 +.ak-card-title { display: flex; align-items: center; gap: 9px; margin: 0; font-family: var(--font-display); font-weight: 700; font-size: 17px; line-height: 1.2; letter-spacing: -0.02em; color: var(--ak-text); }
102 +.ak-card-title svg { color: var(--ak-accent-deep); flex: none; }
103 +.ak-card-sub { font-size: 12.5px; color: var(--ak-muted); margin: 3px 0 0; }
104 +.ak-card-aside { flex: none; display: flex; align-items: center; gap: 8px; }
105 +.ak-link { background: none; border: 0; padding: 0; color: var(--ak-text-2); font: 600 13px var(--font-body); cursor: pointer; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--ak-border-2); }
106 +.ak-link:hover { color: var(--ak-accent-deep); }
107 +.ak-more {
108 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
109 + width: 100%; min-height: 44px; margin-top: 12px; padding: 8px 14px;
110 + border: 1px solid var(--ak-border); border-radius: var(--ak-r-sm); background: var(--ak-surface);
111 + color: var(--ak-text); font: 600 13.5px var(--font-body); cursor: pointer;
112 + transition: background 0.15s, border-color 0.15s;
113 +}
114 +.ak-more:hover { background: var(--ak-surface-2); border-color: var(--ak-border-2); }
115 +.ak-more svg { color: var(--ak-muted); }
116 +
117 +/* ---- boutons ---- */
118 +.ak-btn {
119 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
120 + min-height: 46px; padding: 10px 16px; border-radius: 12px; border: 1px solid transparent;
121 + font: 600 14.5px var(--font-body); cursor: pointer; text-decoration: none;
122 + transition: transform 0.12s, background 0.15s, box-shadow 0.15s; white-space: nowrap;
123 +}
124 +.ak-btn:active { transform: translateY(1px); }
125 +.ak-btn-primary { background: var(--ak-accent); color: #fff; box-shadow: 0 6px 18px rgba(226, 55, 68, 0.22); }
126 +.ak-btn-primary:hover { background: var(--ak-accent-deep); }
127 +.ak-btn-ghost { background: var(--ak-surface); border-color: var(--ak-border); color: var(--ak-text); }
128 +.ak-btn-ghost:hover { background: var(--ak-surface-2); border-color: var(--ak-border-2); }
129 +.ak-btn-icon { width: 46px; padding: 0; flex: none; }
130 +.ak-btn-icon.on { color: var(--ak-accent); border-color: var(--ak-accent); background: var(--ak-accent-soft); }
131 +
132 +/* ---- héro ---- */
133 +.ak-hero { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 0; }
134 +.ak-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
135 +.ak-price { font-family: var(--font-display); font-weight: 700; font-size: clamp(32px, 8.4vw, 38px); line-height: 1; letter-spacing: -0.035em; color: var(--ak-text); display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; }
136 +.ak-price small { font: 500 14px var(--font-body); color: var(--ak-muted); letter-spacing: 0; }
137 +.ak-price-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
138 +.ak-capsule {
139 + display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
140 + border-radius: 999px; font: 600 12.5px var(--font-body); border: 1px solid transparent; white-space: nowrap;
141 +}
142 +.ak-capsule b { font-weight: 700; }
143 +.ak-capsule.good { background: var(--ak-success-soft); color: var(--ak-success); }
144 +.ak-capsule.ok { background: var(--ak-surface); color: var(--ak-text-2); border-color: var(--ak-border); }
145 +.ak-capsule.high { background: var(--ak-warning-soft); color: var(--ak-warning); }
146 +.ak-capsule.neutral { background: var(--ak-surface-2); color: var(--ak-muted); border-color: var(--ak-border); }
147 +.ak-capsule.brand { background: var(--ak-accent-soft); color: var(--ak-accent-deep); }
148 +.ak-h1 { margin: 0; font-family: var(--font-body); font-weight: 600; font-size: 16px; line-height: 1.35; letter-spacing: 0; color: var(--ak-text); }
149 +.ak-h1 .ak-city { display: block; font-weight: 500; font-size: 14px; color: var(--ak-text-2); }
150 +.ak-summary { margin: 0; font-size: 14px; color: var(--ak-text-2); display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
151 +.ak-summary .sep { color: var(--ak-border-2); }
152 +.ak-actions { display: flex; gap: 8px; align-items: center; }
153 +.ak-actions .ak-btn-primary { flex: 1; }
154 +.ak-hero .ak-actions { margin-top: 2px; }
155 +@media (min-width: 1024px) {
156 + .ak-hero .ak-actions { display: none; } /* actions dans l'aside sticky */
157 +}
158 +
159 +/* ---- galerie ---- */
160 +.ak-gallery { position: relative; border-radius: var(--ak-r-lg); overflow: hidden; background: #ecece8; }
161 +.ak-gallery-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; aspect-ratio: 4 / 3; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
162 +.ak-gallery-track::-webkit-scrollbar { display: none; }
163 +.ak-gallery-track img, .ak-gallery > img { flex: 0 0 100%; width: 100%; height: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; display: block; }
164 +.ak-gallery > img { aspect-ratio: 4 / 3; }
165 +@media (min-width: 768px) { .ak-gallery-track, .ak-gallery > img { aspect-ratio: 16 / 9; } }
166 +.ak-gallery-count {
167 + position: absolute; left: 12px; bottom: 12px; z-index: 2;
168 + background: rgba(20, 24, 20, 0.66); color: #fff; font: 600 12px var(--font-body);
169 + padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
170 + font-variant-numeric: tabular-nums;
171 +}
172 +.ak-gallery-full, .ak-gallery-nav {
173 + position: absolute; z-index: 2; display: grid; place-items: center;
174 + width: 40px; height: 40px; border-radius: 999px; border: 0; cursor: pointer;
175 + background: rgba(255, 255, 255, 0.92); color: var(--ak-text); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
176 +}
177 +.ak-gallery-full { right: 12px; bottom: 12px; }
178 +.ak-gallery-nav { top: 50%; transform: translateY(-50%); display: none; }
179 +.ak-gallery-nav.prev { left: 12px; } .ak-gallery-nav.next { right: 12px; }
180 +@media (hover: hover) and (min-width: 768px) { .ak-gallery-nav { display: grid; } }
181 +.ak-thumbs { display: none; }
182 +@media (min-width: 768px) {
183 + .ak-thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 6px; margin-top: 8px; }
184 + .ak-thumbs button { border: 0; padding: 0; border-radius: 8px; overflow: hidden; aspect-ratio: 4 / 3; background: #ecece8; cursor: pointer; outline-offset: 2px; opacity: 0.75; transition: opacity 0.15s; }
185 + .ak-thumbs button.on, .ak-thumbs button:hover { opacity: 1; }
186 + .ak-thumbs button.on { outline: 2px solid var(--ak-accent); }
187 + .ak-thumbs img { width: 100%; height: 100%; object-fit: cover; }
188 +}
189 +/* lightbox (plein écran) */
190 +.ak-lightbox { position: fixed; inset: 0; z-index: var(--z-modal, 900); background: rgba(12, 14, 12, 0.96); }
191 +.ak-lightbox-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; height: 100%; scrollbar-width: none; touch-action: pan-x pinch-zoom; }
192 +.ak-lightbox-track::-webkit-scrollbar { display: none; }
193 +.ak-lightbox-cell { flex: 0 0 100%; height: 100%; scroll-snap-align: center; display: flex; align-items: center; justify-content: center; padding: max(56px, env(safe-area-inset-top)) 10px max(28px, env(safe-area-inset-bottom)); overflow: hidden; }
194 +.ak-lightbox-cell img { max-width: 100%; max-height: 100%; border-radius: 10px; user-select: none; -webkit-user-drag: none; will-change: transform; }
195 +.ak-lightbox-close { position: fixed; top: max(12px, env(safe-area-inset-top)); right: 12px; z-index: 3; width: 44px; height: 44px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.3); background: rgba(0, 0, 0, 0.5); color: #fff; display: grid; place-items: center; cursor: pointer; }
196 +.ak-lightbox-count { position: fixed; top: max(22px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); z-index: 3; color: #fff; font: 600 13px var(--font-body); background: rgba(0, 0, 0, 0.45); padding: 4px 12px; border-radius: 999px; }
197 +.ak-lightbox .ak-gallery-nav { display: none; }
198 +@media (hover: hover) { .ak-lightbox .ak-gallery-nav { display: grid; position: fixed; } }
199 +
200 +/* ---- Score ---- */
201 +.ak-score { display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: center; }
202 +.ak-score-ring { position: relative; width: 88px; height: 88px; flex: none; }
203 +.ak-score-ring svg { width: 88px; height: 88px; transform: rotate(-90deg); }
204 +.ak-score-ring .bg { fill: none; stroke: var(--ak-surface-2); stroke-width: 7; }
205 +.ak-score-ring .arc { fill: none; stroke: var(--ak-accent); stroke-width: 7; stroke-linecap: round; transition: stroke-dasharray 0.9s cubic-bezier(0.2, 0.8, 0.2, 1); }
206 +.ak-score-ring .arc.partial { stroke: var(--ak-text-2); }
207 +.ak-score-val { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-display); font-weight: 700; font-size: 27px; letter-spacing: -0.03em; line-height: 1; }
208 +.ak-score-val small { display: block; text-align: center; font: 600 10px var(--font-body); color: var(--ak-muted); margin-top: 2px; letter-spacing: 0.04em; }
209 +.ak-score-lbl { font-family: var(--font-display); font-weight: 700; font-size: 17px; letter-spacing: -0.02em; }
210 +.ak-score-sub { font-size: 13px; color: var(--ak-text-2); margin-top: 2px; line-height: 1.4; }
211 +.ak-score-parts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
212 +.ak-score-part { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; padding: 4px 9px; border-radius: 8px; background: var(--ak-surface-2); border: 1px solid var(--ak-border); color: var(--ak-text-2); }
213 +.ak-score-part b { color: var(--ak-text); font-variant-numeric: tabular-nums; }
214 +.ak-score-part.na { color: var(--ak-muted); border-style: dashed; }
215 +
216 +/* ---- En bref ---- */
217 +.ak-brief { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
218 +.ak-brief li { display: grid; grid-template-columns: 24px 1fr; gap: 10px; align-items: start; }
219 +.ak-brief-ico { width: 24px; height: 24px; border-radius: 8px; display: grid; place-items: center; font-size: 12px; font-weight: 700; margin-top: 1px; }
220 +.ak-brief-ico.good { background: var(--ak-success-soft); color: var(--ak-success); }
221 +.ak-brief-ico.warn { background: var(--ak-warning-soft); color: var(--ak-warning); }
222 +.ak-brief-ico.bad { background: var(--ak-danger-soft); color: var(--ak-danger); }
223 +.ak-brief-ico.neutral { background: var(--ak-surface-2); color: var(--ak-muted); border: 1px solid var(--ak-border); }
224 +.ak-brief-t { font-weight: 600; font-size: 14px; line-height: 1.35; }
225 +.ak-brief-d { font-size: 13px; color: var(--ak-text-2); line-height: 1.4; margin-top: 1px; }
226 +
227 +/* ---- navigation par sections (sticky) ---- */
228 +.ak-nav {
229 + position: sticky; top: var(--ak-header-h); z-index: var(--z-sticky, 300);
230 + margin: 0 -16px; padding: 6px 16px;
231 + background: rgba(245, 243, 238, 0.97); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
232 + border-bottom: 1px solid var(--ak-border);
233 +}
234 +@media (min-width: 768px) { .ak-nav { margin: 0 -24px; padding: 6px 24px; } }
235 +@media (min-width: 1024px) { .ak-nav { margin: 0; padding: 6px 0; border-radius: 0; } }
236 +.ak-nav-track { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
237 +.ak-nav-track::-webkit-scrollbar { display: none; }
238 +.ak-nav a {
239 + flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 40px; padding: 8px 13px;
240 + border-radius: 999px; font: 600 13px var(--font-body); color: var(--ak-text-2);
241 + border: 1px solid transparent; transition: background 0.15s, color 0.15s; scroll-snap-align: start;
242 +}
243 +.ak-nav a:hover { background: var(--ak-surface); border-color: var(--ak-border); }
244 +.ak-nav a.on { background: var(--ak-text); color: #fff; border-color: var(--ak-text); }
245 +
246 +/* ---- caractéristiques ---- */
247 +.ak-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
248 +@media (min-width: 560px) { .ak-facts { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
249 +@media (min-width: 900px) { .ak-facts { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
250 +.ak-fact { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--ak-border); border-radius: 12px; background: var(--ak-surface-2); min-width: 0; }
251 +.ak-fact-ico { width: 32px; height: 32px; border-radius: 9px; background: var(--ak-surface); border: 1px solid var(--ak-border); display: grid; place-items: center; color: var(--ak-accent-deep); flex: none; }
252 +.ak-fact-v { font-weight: 700; font-size: 14px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
253 +.ak-fact-l { font-size: 11.5px; color: var(--ak-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
254 +.ak-fact-txt { min-width: 0; }
255 +.ak-facts-rows { margin-top: 12px; }
256 +.ak-kv { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--ak-border); font-size: 13.5px; }
257 +.ak-kv .k { color: var(--ak-muted); } .ak-kv .v { font-weight: 600; text-align: right; }
258 +.ak-note { margin: 10px 0 0; padding: 9px 12px; border-radius: 10px; font-size: 13px; line-height: 1.4; }
259 +.ak-note.good { background: var(--ak-success-soft); color: var(--ak-success); }
260 +.ak-note.info { background: var(--ak-info-soft); color: var(--ak-info); }
261 +.ak-note.warn { background: var(--ak-warning-soft); color: var(--ak-warning); }
262 +
263 +/* ---- description ---- */
264 +.ak-desc { position: relative; }
265 +.ak-desc-body p { font-size: 15px; line-height: 1.6; color: var(--ak-text-2); margin: 0 0 10px; white-space: pre-line; }
266 +.ak-desc-body h4 { margin: 12px 0 3px; font: 700 14px var(--font-body); color: var(--ak-text); }
267 +.ak-desc-lead { font-size: 15.5px !important; color: var(--ak-text) !important; font-weight: 500; }
268 +.ak-desc.clamped .ak-desc-body { max-height: 200px; overflow: hidden; -webkit-mask-image: linear-gradient(#000 62%, transparent); mask-image: linear-gradient(#000 62%, transparent); }
269 +.ak-desc-orig { margin-top: 8px; }
270 +.ak-desc-orig p { font-size: 13.5px; color: var(--ak-muted); }
271 +
272 +/* ---- inclusions ---- */
273 +.ak-amen { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
274 +@media (min-width: 640px) { .ak-amen { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
275 +.ak-amen-it { display: flex; align-items: center; gap: 9px; min-height: 42px; padding: 5px 0; border-bottom: 1px solid var(--ak-border); font-size: 13.5px; min-width: 0; }
276 +.ak-amen-ico { width: 28px; height: 28px; border-radius: 8px; background: var(--ak-accent-soft); color: var(--ak-accent-deep); display: grid; place-items: center; flex: none; }
277 +.ak-amen-it.unconfirmed { color: var(--ak-text-2); }
278 +.ak-amen-it.unconfirmed .ak-amen-ico { background: var(--ak-surface-2); color: var(--ak-muted); border: 1px dashed var(--ak-border-2); }
279 +.ak-amen-txt { min-width: 0; line-height: 1.25; }
280 +.ak-amen-conf { margin-left: auto; color: var(--ak-success); flex: none; }
281 +
282 +/* ---- KPI / tuiles ---- */
283 +.ak-kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
284 +@media (min-width: 600px) { .ak-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } .ak-kpi-v { font-size: 20px; } }
285 +.ak-kpis.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
286 +.ak-kpi { padding: 10px 12px; border: 1px solid var(--ak-border); border-radius: 12px; background: var(--ak-surface); min-width: 0; }
287 +.ak-kpi.accent { background: var(--ak-accent-soft); border-color: #f5c2c6; }
288 +.ak-kpi-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(16px, 4.8vw, 20px); letter-spacing: -0.025em; line-height: 1.1; font-variant-numeric: tabular-nums; color: var(--ak-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
289 +.ak-kpi-v.wrap { white-space: normal; font-size: 16px; line-height: 1.2; }
290 +.ak-kpi-v small { font: 600 11px var(--font-body); color: var(--ak-muted); margin-left: 3px; }
291 +.ak-kpi-l { font-size: 11.5px; color: var(--ak-muted); margin-top: 3px; line-height: 1.3; }
292 +.ak-kpi.anim .ak-kpi-v { animation: ak-rise 0.5s ease both; }
293 +@keyframes ak-rise { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
294 +
295 +/* ---- rangées compactes (accessibilité, mesures) ---- */
296 +.ak-rows { display: flex; flex-direction: column; gap: 4px; }
297 +.ak-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; min-height: 30px; font-size: 13.5px; }
298 +.ak-row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ak-text); }
299 +.ak-row-name small { color: var(--ak-muted); font-size: 12px; margin-left: 4px; }
300 +.ak-row-bar { width: 78px; height: 6px; border-radius: 3px; background: var(--ak-surface-2); border: 1px solid var(--ak-border); overflow: hidden; }
301 +.ak-row-bar i { display: block; height: 100%; background: var(--ak-text); border-radius: 3px; }
302 +.ak-row-bar i.good { background: var(--ak-success); }
303 +.ak-row-bar i.warn { background: var(--ak-warning); }
304 +.ak-row-bar i.bad { background: var(--ak-danger); }
305 +.ak-row-val { font-weight: 700; font-size: 13px; font-variant-numeric: tabular-nums; min-width: 28px; text-align: right; }
306 +.ak-row-lbl { font-size: 12px; color: var(--ak-muted); min-width: 68px; text-align: right; }
307 +.ak-row-lbl.good { color: var(--ak-success); } .ak-row-lbl.warn { color: var(--ak-warning); } .ak-row-lbl.bad { color: var(--ak-danger); }
308 +
309 +/* ---- pastilles d'état ---- */
310 +.ak-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font: 600 12px var(--font-body); border: 1px solid transparent; white-space: nowrap; }
311 +.ak-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
312 +.ak-badge.good { background: var(--ak-success-soft); color: var(--ak-success); }
313 +.ak-badge.warn { background: var(--ak-warning-soft); color: var(--ak-warning); }
314 +.ak-badge.bad { background: var(--ak-danger-soft); color: var(--ak-danger); }
315 +.ak-badge.neutral { background: var(--ak-surface-2); color: var(--ak-text-2); border-color: var(--ak-border); }
316 +.ak-badge.info { background: var(--ak-info-soft); color: var(--ak-info); }
317 +.ak-badge.lg { font-size: 13.5px; padding: 6px 12px; }
318 +.ak-pill { display: inline-block; font: 600 10.5px var(--font-body); text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 6px; margin-left: 6px; vertical-align: 1px; }
319 +.ak-pill.included { background: var(--ak-success-soft); color: var(--ak-success); }
320 +.ak-pill.observed { background: var(--ak-surface-2); color: var(--ak-text-2); border: 1px solid var(--ak-border); }
321 +.ak-pill.estimated { background: var(--ak-accent-soft); color: var(--ak-accent-deep); }
322 +.ak-pill.unknown { background: var(--ak-surface-2); color: var(--ak-muted); border: 1px dashed var(--ak-border-2); }
323 +
324 +/* ---- listes d'items (lieux, comparables, stations) ---- */
325 +.ak-list { list-style: none; margin: 0; padding: 0; }
326 +.ak-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--ak-border); min-width: 0; }
327 +.ak-list > .ak-item:first-child { border-top: 0; padding-top: 2px; }
328 +.ak-item-ico { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; flex: none; background: var(--ak-surface-2); border: 1px solid var(--ak-border); color: var(--ak-text-2); }
329 +.ak-item-ico svg.cm-ico { width: 26px; height: 26px; }
330 +.ak-item-main { flex: 1; min-width: 0; }
331 +.ak-item-t { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
332 +.ak-item-s { font-size: 12.5px; color: var(--ak-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
333 +.ak-item-r { text-align: right; flex: none; }
334 +.ak-item-v { font-weight: 700; font-size: 14px; font-variant-numeric: tabular-nums; }
335 +.ak-item-m { font-size: 12px; color: var(--ak-muted); }
336 +.ak-item-best { color: var(--ak-success); font-weight: 700; font-size: 11.5px; margin-left: 6px; }
337 +
338 +/* ---- carrousel horizontal ---- */
339 +.ak-carousel { display: flex; gap: 10px; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; -webkit-overflow-scrolling: touch; margin: 0 -16px; padding: 2px 16px 6px; }
340 +.ak-carousel::-webkit-scrollbar { display: none; }
341 +@media (min-width: 768px) { .ak-carousel { margin: 0; padding: 2px 0 6px; } }
342 +.ak-ccard { flex: 0 0 44%; min-width: 148px; max-width: 210px; scroll-snap-align: start; border: 1px solid var(--ak-border); border-radius: var(--ak-r-md); padding: 12px; background: var(--ak-surface); display: flex; flex-direction: column; gap: 6px; min-height: 104px; }
343 +@media (min-width: 768px) { .ak-ccard { flex-basis: 176px; } }
344 +.ak-ccard-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
345 +.ak-ccard-c { font: 600 10.5px var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; color: var(--ak-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
346 +.ak-ccard-d { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
347 +.ak-ccard-n { font-size: 12.5px; color: var(--ak-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
348 +.ak-ccard-m { font-size: 11.5px; color: var(--ak-muted); }
349 +
350 +/* ---- filtres (chips) ---- */
351 +.ak-chips { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; padding-bottom: 10px; }
352 +.ak-chips::-webkit-scrollbar { display: none; }
353 +.ak-chip { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px; min-height: 38px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--ak-border); background: var(--ak-surface); color: var(--ak-text-2); font: 600 12.5px var(--font-body); cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; }
354 +.ak-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c, var(--ak-text)); flex: none; }
355 +.ak-chip small { font-weight: 600; opacity: 0.65; }
356 +.ak-chip.on { background: var(--ak-text); color: #fff; border-color: var(--ak-text); }
357 +.ak-chip:disabled { opacity: 0.45; cursor: default; }
358 +
359 +/* ---- carte ---- */
360 +.ak-map { position: relative; height: 340px; border-radius: var(--ak-r-lg); overflow: hidden; border: 1px solid var(--ak-border); background: #ecece8; }
361 +@media (min-width: 768px) { .ak-map { height: 440px; } }
362 +@media (min-width: 1024px) { .ak-map { height: 520px; } }
363 +.ak-map .ka-map {
364 + position: absolute; inset: 0;
365 + --ka-accent: var(--ak-accent); --ka-on-accent: #fff; --ka-surface: var(--ak-surface);
366 + --ka-ink: var(--ak-text); --ka-line: var(--ak-border-2); --ka-radius: 10px;
367 + --ka-shadow: 0 8px 24px rgba(20, 24, 20, 0.14); --ka-font: var(--font-body);
368 +}
369 +.ak-map-legend { position: absolute; left: 10px; bottom: 10px; z-index: 5; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.92); border: 1px solid var(--ak-border); font: 500 11px var(--font-body); color: var(--ak-text-2); pointer-events: none; }
370 +.ak-map-legend i { width: 10px; height: 10px; border-radius: 3px; background: var(--ak-accent); }
371 +.ak-map-skel { position: absolute; inset: 0; }
372 +.ak-map-hint { font-size: 12px; color: var(--ak-muted); margin: 8px 0 0; }
373 +
374 +/* ---- accordéon ---- */
375 +.ak-acc { border-top: 1px solid var(--ak-border); }
376 +.ak-acc:first-child { border-top: 0; }
377 +.ak-acc-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 10px 0; background: none; border: 0; font: 600 14px var(--font-body); color: var(--ak-text); cursor: pointer; text-align: left; }
378 +.ak-acc-btn .ak-acc-meta { font-weight: 500; font-size: 12.5px; color: var(--ak-muted); margin-left: auto; white-space: nowrap; }
379 +.ak-acc-btn .chev { color: var(--ak-muted); flex: none; transition: transform 0.2s ease; }
380 +.ak-acc-btn[aria-expanded="true"] .chev { transform: rotate(180deg); }
381 +.ak-acc-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.22s ease; }
382 +.ak-acc-body.open { grid-template-rows: 1fr; }
383 +.ak-acc-body > div { min-height: 0; overflow: hidden; }
384 +.ak-acc-inner { padding: 0 0 14px; font-size: 13.5px; color: var(--ak-text-2); line-height: 1.5; }
385 +.ak-acc-inner p { margin: 0 0 8px; }
386 +.ak-acc-inner a { color: var(--ak-text-2); text-decoration: underline; text-underline-offset: 2px; }
387 +.ak-acc.sm .ak-acc-btn { min-height: 40px; font-size: 13px; color: var(--ak-text-2); }
388 +
389 +/* ---- bottom sheet (mobile) / modale (desktop) ---- */
390 +.ak-sheet-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay, 800); background: rgba(20, 24, 20, 0.42); animation: ak-fade 0.18s ease; }
391 +.ak-sheet {
392 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
393 + height: var(--ak-sheet-h, 68dvh); max-height: 94dvh;
394 + background: var(--ak-surface); border-radius: 20px 20px 0 0;
395 + display: flex; flex-direction: column; box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.18);
396 + animation: ak-up 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
397 + padding-bottom: env(safe-area-inset-bottom);
398 + touch-action: pan-y;
399 +}
400 +.ak-sheet.full { height: 94dvh; }
401 +.ak-sheet-handle { width: 40px; height: 4px; border-radius: 2px; background: var(--ak-border-2); margin: 8px auto 0; flex: none; }
402 +.ak-sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px 10px; border-bottom: 1px solid var(--ak-border); flex: none; }
403 +.ak-sheet-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; letter-spacing: -0.02em; margin: 0; }
404 +.ak-sheet-sub { font-size: 12.5px; color: var(--ak-muted); margin: 2px 0 0; }
405 +.ak-sheet-x { width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--ak-border); background: var(--ak-surface-2); display: grid; place-items: center; cursor: pointer; color: var(--ak-text); flex: none; }
406 +.ak-sheet-body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: 12px 16px 18px; }
407 +.ak-sheet-foot { flex: none; padding: 10px 16px; border-top: 1px solid var(--ak-border); background: var(--ak-surface); }
408 +@keyframes ak-up { from { transform: translateY(40px); opacity: 0.6; } to { transform: none; opacity: 1; } }
409 +@keyframes ak-fade { from { opacity: 0; } to { opacity: 1; } }
410 +@keyframes ak-pop { from { transform: translate(-50%, -50%) scale(0.97); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
411 +@media (min-width: 900px) {
412 + .ak-sheet, .ak-sheet.full {
413 + left: 50%; right: auto; top: 50%; bottom: auto; transform: translate(-50%, -50%);
414 + width: min(680px, calc(100vw - 48px)); height: auto; max-height: min(82vh, 780px);
415 + border-radius: var(--ak-r-lg); animation: ak-pop 0.18s ease; padding-bottom: 0;
416 + }
417 + .ak-sheet-handle { display: none; }
418 + .ak-sheet-head { padding: 16px 20px 12px; }
419 + .ak-sheet-body { padding: 14px 20px 20px; }
420 +}
421 +.ak-sheet-filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
422 +.ak-seg { display: inline-flex; border: 1px solid var(--ak-border); border-radius: 999px; overflow: hidden; background: var(--ak-surface); }
423 +.ak-seg button { border: 0; background: transparent; padding: 7px 12px; min-height: 36px; font: 600 12.5px var(--font-body); color: var(--ak-text-2); cursor: pointer; }
424 +.ak-seg button.on { background: var(--ak-text); color: #fff; }
425 +.ak-seg button + button { border-left: 1px solid var(--ak-border); }
426 +
427 +/* ---- CTA sticky ---- */
428 +.ak-cta-bar {
429 + position: fixed; left: 0; right: 0; bottom: var(--consent-h, 0px); z-index: var(--z-bottombar, 600);
430 + display: flex; align-items: center; gap: 12px;
431 + padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
432 + background: rgba(255, 255, 255, 0.94); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
433 + border-top: 1px solid var(--ak-border);
434 + transform: translateY(110%); transition: transform 0.25s ease; will-change: transform;
435 +}
436 +.ak-cta-bar.show { transform: none; }
437 +.ak-cta-txt { min-width: 0; flex: 0 1 auto; }
438 +.ak-cta-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; line-height: 1.1; white-space: nowrap; }
439 +.ak-cta-price small { font: 500 11px var(--font-body); color: var(--ak-muted); letter-spacing: 0; }
440 +.ak-cta-sub { font-size: 12px; color: var(--ak-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
441 +.ak-cta-sub.good { color: var(--ak-success); font-weight: 600; }
442 +.ak-cta-bar .ak-btn-primary { flex: 1; min-height: 44px; margin-left: auto; max-width: 260px; }
443 +@media (min-width: 1024px) { .ak-cta-bar { display: none; } }
444 +
445 +/* ---- Demander à Ka ---- */
446 +.ak-ka-btn {
447 + position: fixed; right: 14px; bottom: calc(78px + env(safe-area-inset-bottom) + var(--consent-h, 0px)); z-index: var(--z-dropdown, 700);
448 + display: inline-flex; align-items: center; gap: 7px; min-height: 42px; padding: 8px 14px 8px 12px;
449 + border-radius: 999px; border: 0; background: var(--ak-text); color: #fff;
450 + font: 600 13px var(--font-body); cursor: pointer; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
451 + transition: transform 0.15s, opacity 0.2s;
452 +}
453 +.ak-ka-btn svg { color: var(--ak-accent); }
454 +.ak-ka-btn:active { transform: scale(0.97); }
455 +.ak-ka-btn.hide { opacity: 0; pointer-events: none; transform: translateY(8px); }
456 +@media (min-width: 1024px) { .ak-ka-btn { display: none; } } /* desktop : bouton dans l'aside */
457 +.ak-ka-intro { display: flex; gap: 12px; align-items: flex-start; padding: 4px 0 12px; }
458 +.ak-ka-avatar { width: 38px; height: 38px; border-radius: 12px; background: var(--ak-text); color: var(--ak-accent); display: grid; place-items: center; flex: none; }
459 +.ak-ka-intro p { margin: 0; font-size: 13.5px; color: var(--ak-text-2); line-height: 1.45; }
460 +.ak-ka-ctx { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--ak-surface-2); border: 1px solid var(--ak-border); font-size: 12.5px; color: var(--ak-text-2); margin-bottom: 12px; }
461 +.ak-ka-ctx img { width: 44px; height: 34px; object-fit: cover; border-radius: 6px; flex: none; }
462 +.ak-ka-ctx b { color: var(--ak-text); display: block; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
463 +.ak-ka-sugs { display: flex; flex-direction: column; gap: 6px; }
464 +.ak-ka-sug { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-height: 46px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--ak-border); background: var(--ak-surface); font: 500 14px var(--font-body); color: var(--ak-text); cursor: pointer; text-align: left; transition: background 0.15s, border-color 0.15s; }
465 +.ak-ka-sug:hover { background: var(--ak-accent-soft); border-color: #f5c2c6; }
466 +.ak-ka-sug svg { color: var(--ak-muted); flex: none; }
467 +.ak-ka-in { display: flex; gap: 8px; align-items: center; }
468 +.ak-ka-in input { flex: 1; min-height: 46px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--ak-border); background: var(--ak-surface); font: 400 16px var(--font-body); color: var(--ak-text); }
469 +.ak-ka-in input:focus { outline: 2px solid var(--ak-accent); outline-offset: 1px; }
470 +.ak-ka-in button { width: 46px; height: 46px; border-radius: 12px; border: 0; background: var(--ak-accent); color: #fff; display: grid; place-items: center; cursor: pointer; flex: none; }
471 +.ak-ka-in button:disabled { opacity: 0.4; cursor: default; }
472 +
473 +/* ---- aside desktop ---- */
474 +.ak-aside-card { display: flex; flex-direction: column; gap: 12px; }
475 +.ak-aside .ak-btn-primary { white-space: normal; text-align: center; line-height: 1.25; }
476 +.ak-aside .ak-ka-inline { justify-content: flex-start; }
477 +.ak-aside .ak-ka-inline svg { color: var(--ak-accent); }
478 +.ak-aside .ak-price { font-size: 32px; }
479 +.ak-aside-addr { font-size: 14px; color: var(--ak-text-2); line-height: 1.4; }
480 +.ak-aside-sep { border: 0; border-top: 1px solid var(--ak-border); margin: 4px 0; }
481 +.ak-aside-score { display: flex; align-items: center; gap: 12px; }
482 +.ak-aside-score .ak-score-ring { width: 56px; height: 56px; }
483 +.ak-aside-score .ak-score-ring svg { width: 56px; height: 56px; }
484 +.ak-aside-score .ak-score-val { font-size: 18px; }
485 +.ak-aside-score .ak-score-val small { display: none; }
486 +.ak-aside-score-txt { font-size: 13px; color: var(--ak-text-2); line-height: 1.4; }
487 +.ak-aside-score-txt b { display: block; color: var(--ak-text); font-size: 14px; }
488 +.ak-aside .ak-brief { gap: 8px; }
489 +.ak-aside .ak-brief-t { font-size: 13.5px; }
490 +.ak-aside .ak-brief-d { display: none; }
491 +.ak-aside-meta { font-size: 12px; color: var(--ak-muted); text-align: center; }
492 +
493 +/* ---- source & méthodologie (pied de section) ---- */
494 +.ak-source { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--ak-border); font-size: 12px; color: var(--ak-muted); }
495 +.ak-source b { font-weight: 600; color: var(--ak-text-2); }
496 +.ak-source a, .ak-source button { color: var(--ak-text-2); background: none; border: 0; padding: 0; font: inherit; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: var(--ak-border-2); cursor: pointer; }
497 +.ak-source a:hover, .ak-source button:hover { color: var(--ak-accent-deep); }
498 +.ak-fine { font-size: 12.5px; color: var(--ak-muted); line-height: 1.5; margin: 8px 0 0; }
499 +.ak-fine a { color: var(--ak-text-2); text-decoration: underline; text-underline-offset: 2px; }
500 +.ak-fine.meth { margin-top: 0; }
501 +
502 +/* ---- états : squelettes, vides, erreurs ---- */
503 +.ak-skel { border-radius: 10px; background: linear-gradient(90deg, #ecece8 25%, #f4f4f1 50%, #ecece8 75%); background-size: 400% 100%; animation: ak-shimmer 1.3s infinite linear; }
504 +@keyframes ak-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
505 +.ak-skel-lines { display: flex; flex-direction: column; gap: 8px; }
506 +.ak-skel-lines .ak-skel { height: 12px; }
507 +.ak-skel-lines .ak-skel.short { width: 55%; }
508 +.ak-empty { padding: 14px; border-radius: 12px; background: var(--ak-surface-2); border: 1px dashed var(--ak-border-2); color: var(--ak-muted); font-size: 13.5px; text-align: center; line-height: 1.45; }
509 +.ak-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; border-radius: 12px; background: var(--ak-warning-soft); color: var(--ak-warning); font-size: 13.5px; }
510 +.ak-error button { border: 1px solid currentColor; background: transparent; color: inherit; border-radius: 999px; padding: 6px 12px; font: 600 12.5px var(--font-body); cursor: pointer; min-height: 36px; }
511 +.ak-page-error { max-width: 520px; margin: 60px auto; text-align: center; padding: 0 16px; }
512 +.ak-page-error h2 { font-family: var(--font-display); letter-spacing: -0.02em; }
513 +.ak-page-error p { color: var(--ak-text-2); }
514 +.ak-toast { position: fixed; left: 50%; bottom: calc(96px + env(safe-area-inset-bottom) + var(--consent-h, 0px)); transform: translateX(-50%); z-index: var(--z-toast, 950); background: var(--ak-text); color: #fff; font: 600 13px var(--font-body); padding: 10px 16px; border-radius: 999px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); animation: ak-up 0.25s ease; max-width: calc(100vw - 32px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
515 +
516 +/* ---- prix / marché ---- */
517 +.ak-market-head { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: start; }
518 +.ak-market-big { font-family: var(--font-display); font-weight: 700; font-size: 28px; letter-spacing: -0.03em; line-height: 1; }
519 +.ak-market-big small { font: 500 13px var(--font-body); color: var(--ak-muted); margin-left: 4px; letter-spacing: 0; }
520 +.ak-market-sub { font-size: 13px; color: var(--ak-text-2); margin-top: 4px; }
521 +.ak-histo { display: block; width: 100%; max-width: 520px; height: auto; margin: 12px 0 4px; }
522 +.ak-histo-bar { fill: #dedfd9; }
523 +.ak-histo-bar.on { fill: var(--ak-accent); }
524 +.ak-histo-lbl { font: 700 10px var(--font-body); fill: var(--ak-text); }
525 +.ak-histo-lbl.fv { fill: var(--ak-text-2); }
526 +.ak-histo-axis { font: 500 9.5px var(--font-body); fill: var(--ak-muted); }
527 +.ak-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12.5px; color: var(--ak-text-2); margin: 6px 0 0; }
528 +.ak-meta b { color: var(--ak-text); }
529 +
530 +/* jauge du registre des loyers */
531 +.ak-gauge { margin: 14px 0 6px; }
532 +.ak-gauge-lbls { display: flex; justify-content: space-between; font: 600 11px var(--font-body); color: var(--ak-muted); margin-bottom: 6px; }
533 +.ak-gauge-lbls .good { color: var(--ak-success); } .ak-gauge-lbls .bad { color: var(--ak-danger); }
534 +.ak-gauge svg { display: block; width: 100%; max-width: 520px; height: auto; overflow: visible; }
535 +.ak-gauge, .ak-gauge-lbls { max-width: 520px; }
536 +.ak-gauge-track { fill: var(--ak-surface-2); stroke: var(--ak-border); }
537 +.ak-gauge-box { fill: #ecece8; }
538 +.ak-gauge-med { stroke: var(--ak-text-2); stroke-width: 1.5; }
539 +.ak-gauge-me { fill: var(--ak-accent); stroke: #fff; stroke-width: 2.5; }
540 +.ak-gauge-txt { font: 600 10.5px var(--font-body); fill: var(--ak-muted); }
541 +.ak-gauge-txt.me { fill: var(--ak-text); font-weight: 700; }
542 +.ak-bars { display: block; width: 100%; max-width: 520px; height: auto; margin: 6px 0 0; }
543 +.ak-bar { fill: #dedfd9; }
544 +.ak-bar.on { fill: var(--ak-accent); }
545 +.ak-bar-val { font: 600 10px var(--font-body); fill: var(--ak-text-2); }
546 +.ak-bar-cat { font: 600 11px var(--font-body); fill: var(--ak-text-2); }
547 +.ak-bar-cat.on { fill: var(--ak-text); font-weight: 700; }
548 +.ak-bar-n { font: 500 9.5px var(--font-body); fill: var(--ak-muted); }
549 +.ak-bars-axe { stroke: var(--ak-border); }
550 +
551 +/* ---- risques / environnement ---- */
552 +.ak-status { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
553 +.ak-status-t { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; }
554 +.ak-status-d { font-size: 13.5px; color: var(--ak-text-2); margin: 8px 0 0; line-height: 1.5; }
555 +.ak-air { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
556 +@media (max-width: 400px) { .ak-air { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
557 +.ak-air-c { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--ak-border); background: var(--ak-surface-2); min-width: 0; }
558 +.ak-air-p { font: 600 11.5px var(--font-body); color: var(--ak-muted); text-transform: uppercase; letter-spacing: 0.05em; }
559 +.ak-air-v { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; margin-top: 2px; font-variant-numeric: tabular-nums; white-space: nowrap; }
560 +.ak-air-v small { font: 500 11px var(--font-body); color: var(--ak-muted); margin-left: 3px; }
561 +.ak-air-s { font-size: 11.5px; margin-top: 3px; line-height: 1.3; }
562 +.ak-air-s.good { color: var(--ak-success); } .ak-air-s.warn { color: var(--ak-warning); } .ak-air-s.bad { color: var(--ak-danger); } .ak-air-s.neutral { color: var(--ak-muted); }
563 +
564 +/* ---- coût réel ---- */
565 +.ak-cost { list-style: none; margin: 0; padding: 0; }
566 +.ak-cost li { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 8px 0; border-top: 1px solid var(--ak-border); font-size: 13.5px; }
567 +.ak-cost li:first-child { border-top: 0; }
568 +.ak-cost .n { color: var(--ak-text-2); min-width: 0; }
569 +.ak-cost .v { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
570 +.ak-cost .v.na { color: var(--ak-muted); font-weight: 500; }
571 +.ak-cost li.total { border-top: 1px solid var(--ak-border-2); margin-top: 4px; padding-top: 10px; font-size: 15px; }
572 +.ak-cost li.total .n, .ak-cost li.total .v { color: var(--ak-text); font-weight: 700; }
573 +.ak-cost li.annuel { font-size: 12.5px; color: var(--ak-muted); border-top: 0; padding-top: 0; }
574 +.ak-cost-note { font-size: 12.5px; color: var(--ak-text-2); margin: 8px 0 0; }
575 +
576 +/* ---- dossier de l'immeuble (accordéons) ---- */
577 +.ak-tl { list-style: none; margin: 4px 0 0; padding: 0 0 0 12px; border-left: 2px solid var(--ak-border); display: flex; flex-direction: column; gap: 8px; }
578 +.ak-tl li { position: relative; font-size: 13px; color: var(--ak-text-2); }
579 +.ak-tl li::before { content: ""; position: absolute; left: -17.5px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--ak-surface); border: 2px solid var(--ak-border-2); }
580 +.ak-tl li.prix::before { border-color: var(--ak-accent); }
581 +.ak-tl li.disparition::before { border-color: var(--ak-danger); }
582 +.ak-tl li.reapparition::before { border-color: var(--ak-success); }
583 +.ak-tl-date { display: inline-block; min-width: 84px; font: 600 11.5px var(--font-body); color: var(--ak-muted); }
584 +.ak-star { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: var(--ak-text); display: inline-flex; align-items: center; gap: 5px; }
585 +.ak-star svg { color: #e0a800; }
586 +.ak-quote { margin: 10px 0 0; padding: 8px 12px; font-size: 13px; color: var(--ak-text-2); border-left: 3px solid var(--ak-border-2); background: var(--ak-surface-2); border-radius: 8px; }
587 +.ak-quote footer { margin-top: 4px; font-size: 11.5px; color: var(--ak-muted); }
588 +.ak-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 4px; }
589 +.ak-tag { font: 600 11.5px var(--font-body); padding: 2px 8px; border-radius: 999px; background: var(--ak-danger-soft); color: var(--ak-danger); }
590 +.ak-tag.n { background: var(--ak-surface-2); color: var(--ak-text-2); border: 1px solid var(--ak-border); }
591 +
592 +/* ---- KA Scores (cercles compacts) ---- */
593 +.ak-ks { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
594 +@media (max-width: 400px) { .ak-ks { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
595 +.ak-ks-c { text-align: center; padding: 8px 4px; border-radius: 12px; border: 1px solid var(--ak-border); background: var(--ak-surface-2); min-width: 0; }
596 +.ak-ks-c svg { width: 52px; height: 52px; transform: rotate(-90deg); }
597 +.ak-ks-c .bg { fill: none; stroke: #e6e6e1; stroke-width: 5; }
598 +.ak-ks-c .arc { fill: none; stroke: var(--ak-text); stroke-width: 5; stroke-linecap: round; }
599 +.ak-ks-c.haut .arc { stroke: var(--ak-success); } .ak-ks-c.bon .arc { stroke: var(--ak-text); } .ak-ks-c.moyen .arc { stroke: var(--ak-warning); } .ak-ks-c.bas .arc { stroke: var(--ak-danger); }
600 +.ak-ks-wrap { position: relative; width: 52px; height: 52px; margin: 0 auto; }
601 +.ak-ks-v { position: absolute; inset: 0; display: grid; place-items: center; font: 700 15px var(--font-display); letter-spacing: -0.02em; }
602 +.ak-ks-n { font: 600 11.5px var(--font-body); margin-top: 6px; color: var(--ak-text); }
603 +.ak-ks-l { font-size: 10.5px; color: var(--ak-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
604 +.ak-ks-detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px 18px; }
605 +.ak-ks-detail h4 { margin: 8px 0 4px; font: 700 12.5px var(--font-body); color: var(--ak-text); }
606 +.ak-ks-detail ul { margin: 0; padding-left: 16px; font-size: 13px; color: var(--ak-text-2); }
607 +.ak-ks-detail li { margin: 2px 0; }
608 +
609 +/* ---- fin de fiche ---- */
610 +.ak-end { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
611 +@media (min-width: 640px) { .ak-end { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
612 +.ak-end a, .ak-end button { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; padding: 12px; border-radius: 12px; border: 1px solid var(--ak-border); background: var(--ak-surface); color: var(--ak-text); font: 600 13px var(--font-body); cursor: pointer; text-align: left; min-height: 64px; }
613 +.ak-end a:hover, .ak-end button:hover { background: var(--ak-surface-2); }
614 +.ak-end svg { color: var(--ak-accent-deep); }
615 +.ak-end small { font-weight: 500; color: var(--ak-muted); font-size: 12px; }
616 +.ak-sources { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
617 +.ak-sources li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; padding: 9px 0; border-top: 1px solid var(--ak-border); font-size: 13px; align-items: center; }
618 +.ak-sources li:first-child { border-top: 0; }
619 +.ak-sources .n { font-weight: 600; grid-column: 1; grid-row: 1; }
620 +.ak-sources .r { font-size: 12.5px; color: var(--ak-text-2); grid-column: 1; grid-row: 2; }
621 +.ak-sources .d { font-size: 12px; color: var(--ak-muted); grid-column: 2; grid-row: 1 / span 2; text-align: right; white-space: nowrap; align-self: start; }
622 +.ak-sources a { color: var(--ak-text-2); text-decoration: underline; text-underline-offset: 2px; }
623 +
624 +
625 +/* ---- ajouts Immo-Ka : héro, galerie, formulaire, tableaux, rôle ---- */
626 +.ak-kicker { font-family: var(--font-mono); font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ak-muted); margin-bottom: 4px; }
627 +.ak-gallery-empty { display: grid; place-items: center; aspect-ratio: 4 / 3; }
628 +@media (min-width: 768px) { .ak-gallery-empty { aspect-ratio: 16 / 9; } }
629 +.ak-gallery-empty .type-fallback { position: static; width: 100%; height: 100%; }
630 +.ak-gallery-caption {
631 + position: absolute; left: 12px; top: 12px; z-index: 2; max-width: calc(100% - 24px);
632 + background: rgba(20, 24, 20, 0.62); color: #fff; font: 500 12px var(--font-body);
633 + padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
634 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
635 +}
636 +.ak-subtitle { margin: 14px 0 8px !important; font-weight: 600 !important; color: var(--ak-text-2) !important; font-size: 13px !important; }
637 +.ak-subtitle small { font-weight: 500; color: var(--ak-muted); }
638 +.ak-more .flip { transform: rotate(180deg); }
639 +.ak-acc-ico { vertical-align: -2px; margin-right: 8px; color: var(--ak-muted); }
640 +.ak-role { margin-top: 14px; }
641 +.ak-tl li.baisse::before { border-color: var(--ak-success); }
642 +.ak-tl li.hausse::before { border-color: var(--ak-warning); }
643 +/* formulaire du calculateur */
644 +.ak-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
645 +@media (min-width: 640px) { .ak-form { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
646 +.ak-form label { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
647 +.ak-form label span { font-size: 11.5px; color: var(--ak-muted); font-weight: 600; }
648 +.ak-form input, .ak-form select {
649 + min-height: 42px; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--ak-border); background: var(--ak-surface);
650 + font: 500 15px var(--font-body); color: var(--ak-text); width: 100%; min-width: 0; appearance: none; -webkit-appearance: none;
651 +}
652 +.ak-form select { background-image: linear-gradient(45deg, transparent 50%, var(--ak-muted) 50%), linear-gradient(135deg, var(--ak-muted) 50%, transparent 50%); background-position: calc(100% - 16px) 50%, calc(100% - 11px) 50%; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 28px; }
653 +.ak-form input:focus, .ak-form select:focus { outline: 2px solid var(--ak-accent); outline-offset: 1px; }
654 +/* tableaux (pièces, banques, amortissement) */
655 +.ak-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 0 -4px; padding: 0 4px; }
656 +.ak-table { width: 100%; border-collapse: collapse; font-size: 13px; }
657 +.ak-table th { text-align: left; font: 600 11.5px var(--font-body); color: var(--ak-muted); text-transform: uppercase; letter-spacing: 0.04em; padding: 6px 8px; border-bottom: 1px solid var(--ak-border); white-space: nowrap; }
658 +.ak-table td { padding: 8px 8px; border-bottom: 1px solid var(--ak-border); vertical-align: top; color: var(--ak-text-2); }
659 +.ak-table td:first-child { color: var(--ak-text); font-weight: 500; }
660 +.ak-table tr:last-child td { border-bottom: 0; }
661 +.ak-table a { color: var(--ak-text-2); text-decoration: underline; text-underline-offset: 2px; }
662 +/* aside : courtier */
663 +.ak-aside-broker { display: flex; flex-direction: column; gap: 2px; font-size: 13.5px; color: var(--ak-text-2); }
664 +.ak-aside-broker-k { font: 600 11px var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; color: var(--ak-muted); }
665 +.ak-aside-broker b { color: var(--ak-text); }
666 +.ak-aside-broker a { display: inline-flex; align-items: center; gap: 6px; color: var(--ak-text-2); }
667 +/* jauge Vrai-Prix : libellés */
668 +.ak-gauge-lbls { margin-top: 4px; margin-bottom: 0; }
669 +.ak-kv .v a { color: var(--ak-text-2); text-decoration: underline; text-underline-offset: 2px; }
670 +.ak-kv .v { min-width: 0; overflow-wrap: anywhere; }
671 +
672 +
673 +/* ---- ajouts Auto-Ka : nuage de points, carte OSM, similaires ---- */
674 +.ak-fiche h3[id] { scroll-margin-top: calc(var(--ak-header-h) + var(--ak-nav-h) + 10px); }
675 +.ak-scatter { display: block; width: 100%; max-width: 560px; height: auto; margin: 10px 0 4px; }
676 +.ak-scatter .axis { stroke: var(--ak-border-2); stroke-width: 1; }
677 +.ak-scatter .median { stroke: var(--ak-text-2); stroke-width: 1.2; stroke-dasharray: 4 4; }
678 +.ak-scatter .dot { fill: #c9cac4; }
679 +.ak-scatter .me { fill: var(--ak-accent); stroke: #fff; stroke-width: 2.5; }
680 +.ak-scatter .lbl { font: 500 10px var(--font-body); fill: var(--ak-muted); }
681 +.ak-scatter .me-lbl { font-weight: 700; fill: var(--ak-text); }
682 +.ak-map { height: 300px; }
683 +@media (min-width: 768px) { .ak-map { height: 380px; } }
684 +.ak-map-embed { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
685 +.ak-similar.vgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; padding-bottom: 0; }
686 +@media (max-width: 480px) { .ak-similar.vgrid { grid-template-columns: 1fr; } }
687 +@media (min-width: 900px) { .ak-similar.vgrid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
688 +.ak-similar.vgrid .vcard:first-child { grid-column: auto; }
689 +.ak-similar.vgrid .vcard:first-child .photo { aspect-ratio: 4 / 3; }
690 +.ak-similar.vgrid .vcard:first-child h3 { font-size: 16px; }
691 +.ak-similar.vgrid .vcard:first-child .price { font-size: 20px; }
692 +.ak-gallery-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; aspect-ratio: 4 / 3; color: var(--ak-muted); font-size: 13px; }
693 +.ak-ka-ctx img { object-fit: cover; }
694 +
695 +/* ---- accessibilité mouvement réduit ---- */
696 +@media (prefers-reduced-motion: reduce) {
697 + .ak-sheet, .ak-sheet-backdrop, .ak-toast, .ak-kpi.anim .ak-kpi-v { animation: none; }
698 + .ak-score-ring .arc, .ak-acc-body, .ak-cta-bar { transition: none; }
699 +}
700 +
701 +/* ---- utilitaire a11y ---- */
702 +.visually-hidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
added frontend/src/fiche/synthese.ts +107 −0
@@ -0,0 +1,107 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/synthese.ts : synthèse DÉTERMINISTE de la fiche véhicule (aucun LLM,
5 +// aucune donnée inventée) :
6 +// · comparaisonPrix : position du prix face à la médiane des comparables
7 +// (analyse de marché calculée côté API : autoka/web.py _market_analysis) ;
8 +// · enBref : 4 à 6 constats priorisés (marché, kilométrage vs âge, baisse
9 +// de prix, même véhicule ailleurs, rappels, fraîcheur, Carfax) ;
10 +// · ligneResume : « 2021 · 45 000 km · Automatique · Essence · VUS ».
11 +// -----------------------------------------------------------------------------
12 +import { Recall, VehicleDetail, fmtKm, fmtPrice } from "../api";
13 +import { Tone, NBSP } from "./ui";
14 +
15 +export interface ComparaisonPrix {
16 + tone: "good" | "ok" | "high";
17 + pct: number; // écart signé en % vs médiane des comparables
18 + label: string;
19 + court: string;
20 + ref: number; // médiane ($)
21 + n: number;
22 + percentile: number; // % de comparables moins chers
23 +}
24 +
25 +export function comparaisonPrix(v: VehicleDetail): ComparaisonPrix | null {
26 + const m = v.market;
27 + if (!m || v.price == null || m.n < 3) return null;
28 + const pct = Math.round(m.delta_pct);
29 + const tone = m.badge === "excellent" || m.badge === "good" ? "good" : m.badge === "fair" ? "ok" : "high";
30 + return {
31 + tone, pct, ref: m.median, n: m.n, percentile: m.percentile,
32 + label: m.label || (tone === "good" ? "Bon prix" : tone === "ok" ? "Dans le marché" : "Au-dessus du marché"),
33 + court: `${pct < 0 ? "↓" : pct > 0 ? "↑" : "≈"} ${Math.abs(pct)}${NBSP}% vs médiane`,
34 + };
35 +}
36 +
37 +export interface Constat { tone: Tone; titre: string; detail: string; cle: string; }
38 +
39 +export function enBref(v: VehicleDetail, recalls: Recall[] | null): Constat[] {
40 + const out: Constat[] = [];
41 + const cmp = comparaisonPrix(v);
42 +
43 + // 1. marché
44 + if (cmp) {
45 + out.push({ cle: "prix", tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral",
46 + titre: cmp.tone === "good" ? `${cmp.label} — ${Math.abs(cmp.pct)}${NBSP}% sous la médiane` : cmp.tone === "high" ? `Prix au-dessus du marché (+${Math.abs(cmp.pct)}${NBSP}%)` : "Prix dans le marché",
47 + detail: `Médiane ${fmtPrice(cmp.ref)} sur ${cmp.n} ${v.make} ${v.model.split(" ")[0]} comparables · ${cmp.percentile}${NBSP}% sont moins chers` });
48 + } else if (v.price != null) {
49 + out.push({ cle: "prix", tone: "neutral", titre: "Prix non comparé", detail: "Pas assez de véhicules comparables en vente au Québec pour situer ce prix." });
50 + }
51 +
52 + // 2. kilométrage vs âge (composante du KA Score)
53 + const km = v.ka_score?.parts.find((p) => p.key === "km");
54 + if (km && v.mileage_km != null && v.year) {
55 + const age = Math.max(1, new Date().getFullYear() - v.year);
56 + const parAn = Math.round(v.mileage_km / age / 1000) * 1000;
57 + out.push({ cle: "km", tone: km.score >= 70 ? "good" : km.score >= 45 ? "neutral" : "warn",
58 + titre: km.score >= 70 ? "Kilométrage bas pour l'âge" : km.score >= 45 ? "Kilométrage dans la norme" : "Kilométrage élevé pour l'âge",
59 + detail: `${fmtKm(v.mileage_km)} en ${age} an${age > 1 ? "s" : ""} ≈ ${parAn.toLocaleString("fr-CA")} km/an (référence ≈ 20 000 km/an)` });
60 + }
61 +
62 + // 3. baisse de prix
63 + const hist = (v.price_history ?? []).filter((h) => h.price != null);
64 + if (hist.length >= 2 && hist[0].price! < hist[1].price!)
65 + out.push({ cle: "baisse", tone: "good", titre: "Prix en baisse", detail: `${fmtPrice(hist[1].price!)} → ${fmtPrice(hist[0].price!)} (−${(hist[1].price! - hist[0].price!).toLocaleString("fr-CA")} $) — observé par Auto-Ka` });
66 +
67 + // 4. même véhicule ailleurs
68 + const dups = v.dup_sources ?? [];
69 + if (dups.length > 0) {
70 + const moins = dups.filter((d) => d.price != null && v.price != null && d.price < v.price);
71 + out.push({ cle: "dups", tone: moins.length ? "warn" : "neutral",
72 + titre: moins.length ? `Le même véhicule est affiché moins cher ailleurs` : `Le même véhicule est affiché sur ${dups.length} autre${dups.length > 1 ? "s" : ""} site${dups.length > 1 ? "s" : ""}`,
73 + detail: moins.length ? `${fmtPrice(Math.min(...moins.map((d) => d.price!)))} chez ${moins[0].dealer_name || moins[0].source}` : "Même NIV repéré (dédoublonnage Auto-Ka) — voir le dossier" });
74 + }
75 +
76 + // 5. rappels
77 + if (recalls && recalls.length > 0)
78 + out.push({ cle: "rappels", tone: "warn", titre: `${recalls.length} rappel${recalls.length > 1 ? "s" : ""} Transports Canada pour ce modèle`,
79 + detail: `Le plus récent : ${recalls[0].date} · ${recalls[0].component} — vérifier auprès du concessionnaire s'ils ont été effectués` });
80 + else if (recalls && recalls.length === 0)
81 + out.push({ cle: "rappels", tone: "good", titre: "Aucun rappel Transports Canada répertorié", detail: `${v.make} ${v.model}${v.year ? ` ${v.year}` : ""} — base des rappels de sécurité` });
82 +
83 + // 6. fraîcheur / temps en ligne
84 + const jours = v.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - v.first_seen) / 86400)) : null;
85 + if (jours != null && jours >= 45)
86 + out.push({ cle: "age", tone: "neutral", titre: `En vente depuis ${jours}${NBSP}jours`, detail: "Observé par Auto-Ka depuis la première synchronisation — marge de négociation possible" });
87 + else if (jours != null && jours <= 3)
88 + out.push({ cle: "age", tone: "info", titre: jours === 0 ? "Arrivé aujourd'hui" : `Nouvel arrivage (${jours}${NBSP}j)`, detail: "Repéré récemment par Auto-Ka chez le concessionnaire" });
89 +
90 + // 7. Carfax
91 + if (v.carfax_url) out.push({ cle: "carfax", tone: "info", titre: "Rapport d'historique Carfax fourni", detail: "Lien du concessionnaire — accidents, réclamations, entretien" });
92 +
93 + return out.slice(0, 6);
94 +}
95 +
96 +export function ligneResume(v: VehicleDetail): string[] {
97 + const p: string[] = [];
98 + if (v.year) p.push(String(v.year));
99 + if (v.mileage_km != null) p.push(fmtKm(v.mileage_km));
100 + if (v.transmission) p.push(v.transmission);
101 + if (v.fuel) p.push(v.fuel);
102 + if (v.body_type) p.push(v.body_type);
103 + return p;
104 +}
105 +
106 +/** Jours depuis la première observation. */
107 +export const joursEnLigne = (v: VehicleDetail) => v.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - v.first_seen) / 86400)) : null;
added frontend/src/fiche/ui.tsx +177 −0
@@ -0,0 +1,177 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ui.tsx : primitives d'interface de la fiche véhicule (refonte
5 +// premium 2026-09-07, même socle que la fiche Lou-Ka v3) :
6 +// SectionCard · Accordion · StatTile · StatusBadge · Skeleton · EmptyState ·
7 +// ErrorState · MoreButton · SourceLine · useToast — sans dépendance externe,
8 +// ARIA correcte, cibles tactiles ≥ 44 px, animations légères.
9 +// -----------------------------------------------------------------------------
10 +import { ReactNode, useCallback, useEffect, useId, useState } from "react";
11 +import { createPortal } from "react-dom";
12 +import { Ico } from "../components/Icons";
13 +
14 +export type Tone = "good" | "warn" | "bad" | "neutral" | "info";
15 +
16 +/* --- carte de section ------------------------------------------------------ */
17 +export function SectionCard({ id, title, icon, sub, aside, children, className = "", label }: {
18 + id?: string; title?: ReactNode; icon?: ReactNode; sub?: ReactNode; aside?: ReactNode;
19 + children: ReactNode; className?: string; label?: string;
20 +}) {
21 + return (
22 + <section id={id} className={`ak-card ${className}`} aria-label={label}>
23 + {(title || aside) && (
24 + <div className="ak-card-head">
25 + <div>
26 + {title && <h2 className="ak-card-title">{icon}{title}</h2>}
27 + {sub && <p className="ak-card-sub">{sub}</p>}
28 + </div>
29 + {aside && <div className="ak-card-aside">{aside}</div>}
30 + </div>
31 + )}
32 + {children}
33 + </section>
34 + );
35 +}
36 +
37 +/* --- accordéon accessible (bouton + région, animation grid-rows) ----------- */
38 +export function Accordion({ title, meta, children, defaultOpen = false, small = false, onToggle }: {
39 + title: ReactNode; meta?: ReactNode; children: ReactNode; defaultOpen?: boolean;
40 + small?: boolean; onToggle?: (open: boolean) => void;
41 +}) {
42 + const [open, setOpen] = useState(defaultOpen);
43 + const id = useId();
44 + return (
45 + <div className={`ak-acc ${small ? "sm" : ""}`}>
46 + <button type="button" className="ak-acc-btn" aria-expanded={open}
47 + aria-controls={`${id}-body`} id={`${id}-btn`}
48 + onClick={() => { setOpen(!open); onToggle?.(!open); }}>
49 + <span>{title}</span>
50 + {meta && <span className="ak-acc-meta">{meta}</span>}
51 + <Ico name="chevdown" size={18} className="chev" />
52 + </button>
53 + <div className={`ak-acc-body ${open ? "open" : ""}`} id={`${id}-body`}
54 + role="region" aria-labelledby={`${id}-btn`}>
55 + <div><div className="ak-acc-inner">{children}</div></div>
56 + </div>
57 + </div>
58 + );
59 +}
60 +
61 +/* --- tuile KPI ------------------------------------------------------------- */
62 +export function StatTile({ value, unit, label, accent = false, anim = true }: {
63 + value: ReactNode; unit?: ReactNode; label: ReactNode; accent?: boolean; anim?: boolean;
64 +}) {
65 + return (
66 + <div className={`ak-kpi ${accent ? "accent" : ""} ${anim ? "anim" : ""}`}>
67 + <div className={`ak-kpi-v ${typeof value === "string" && value.length > 8 ? "wrap" : ""}`}>{value}{unit && <small>{unit}</small>}</div>
68 + <div className="ak-kpi-l">{label}</div>
69 + </div>
70 + );
71 +}
72 +
73 +/* --- pastille d'état ------------------------------------------------------- */
74 +export function StatusBadge({ tone = "neutral", children, lg = false }: {
75 + tone?: Tone; children: ReactNode; lg?: boolean;
76 +}) {
77 + return <span className={`ak-badge ${tone} ${lg ? "lg" : ""}`}>{children}</span>;
78 +}
79 +
80 +/* --- squelettes ------------------------------------------------------------ */
81 +export function Skeleton({ h = 14, w, r, className = "" }: { h?: number | string; w?: number | string; r?: number; className?: string }) {
82 + return <div className={`ak-skel ${className}`} style={{ height: h, width: w ?? "100%", borderRadius: r }} aria-hidden="true" />;
83 +}
84 +export function SkeletonLines({ n = 3 }: { n?: number }) {
85 + return (
86 + <div className="ak-skel-lines" aria-busy="true">
87 + {Array.from({ length: n }).map((_, i) => (
88 + <div key={i} className={`ak-skel ${i === n - 1 ? "short" : ""}`} />
89 + ))}
90 + </div>
91 + );
92 +}
93 +
94 +/* --- états vides / erreur -------------------------------------------------- */
95 +export function EmptyState({ children = "Aucune donnée disponible pour ce secteur." }: { children?: ReactNode }) {
96 + return <div className="ak-empty">{children}</div>;
97 +}
98 +export function ErrorState({ onRetry, children = "Données temporairement indisponibles." }: {
99 + onRetry?: () => void; children?: ReactNode;
100 +}) {
101 + return (
102 + <div className="ak-error" role="alert">
103 + <span>{children}</span>
104 + {onRetry && <button type="button" onClick={onRetry}>Réessayer</button>}
105 + </div>
106 + );
107 +}
108 +
109 +/* --- bouton « Voir plus » pleine largeur ----------------------------------- */
110 +export function MoreButton({ children, onClick, expanded }: {
111 + children: ReactNode; onClick: () => void; expanded?: boolean;
112 +}) {
113 + return (
114 + <button type="button" className="ak-more" onClick={onClick} aria-expanded={expanded}>
115 + {children}
116 + <Ico name="chevdown" size={16} className={expanded ? "flip" : ""} />
117 + </button>
118 + );
119 +}
120 +
121 +/* --- ligne « Source : … · Méthodologie » en pied de section ---------------- */
122 +export function SourceLine({ name, href, date, onMethod, methodLabel = "Méthodologie" }: {
123 + name: ReactNode; href?: string; date?: ReactNode; onMethod?: () => void; methodLabel?: string;
124 +}) {
125 + return (
126 + <div className="ak-source">
127 + <span>
128 + Source : <b>{href ? <a href={href} target="_blank" rel="noopener noreferrer">{name}</a> : name}</b>
129 + {date && <> · {date}</>}
130 + </span>
131 + {onMethod && <button type="button" onClick={onMethod}>{methodLabel}</button>}
132 + </div>
133 + );
134 +}
135 +
136 +/* --- toast minimal (retour d'action : favoris, lien copié) ------------------ */
137 +export function useToast(): [ReactNode, (msg: string) => void] {
138 + const [msg, setMsg] = useState<string | null>(null);
139 + useEffect(() => {
140 + if (!msg) return;
141 + const t = setTimeout(() => setMsg(null), 2200);
142 + return () => clearTimeout(t);
143 + }, [msg]);
144 + const show = useCallback((m: string) => setMsg(m), []);
145 + const node = msg
146 + ? createPortal(<div className="ak-toast" role="status" aria-live="polite">{msg}</div>, document.body)
147 + : null;
148 + return [node, show];
149 +}
150 +
151 +/* --- utilitaires de format -------------------------------------------------- */
152 +export const NBSP = " ";
153 +export const fmtN = (v: number, d = 0) =>
154 + v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: d });
155 +export const fmtPct = (v: number, signed = true) =>
156 + `${signed ? (v > 0 ? "+" : v < 0 ? "−" : "") : ""}${Math.abs(Math.round(v))}${NBSP}%`;
157 +/** ≈ minutes de marche (vol d'oiseau × 1,3 de détour, 4,8 km/h) */
158 +export const marcheMin = (m: number) => Math.max(1, Math.round((m * 1.3) / 80));
159 +export const fmtMarche = (m: number) => `${marcheMin(m)}${NBSP}min`;
160 +export const relTime = (ts: number | null | undefined): string | null => {
161 + if (!ts) return null;
162 + const s = Date.now() / 1000 - ts;
163 + if (s < 3600) return "à l'instant";
164 + if (s < 86400) return `il y a ${Math.round(s / 3600)}${NBSP}h`;
165 + const j = Math.round(s / 86400);
166 + if (j < 30) return `il y a ${j}${NBSP}jour${j > 1 ? "s" : ""}`;
167 + return new Date(ts * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" });
168 +};
169 +/** « 19 989 $ (2026) » → 19989 ; « 1 640 pi² » → 1640 ; sinon null */
170 +export const parseMontant = (v: unknown): number | null => {
171 + if (v == null) return null;
172 + const s = String(v).replace(/\(.*?\)/g, "").replace(/[^\d.,]/g, "").replace(/\s/g, "");
173 + if (!s) return null;
174 + // « 19 989 » et « 19989,50 » : la virgule est décimale, le point aussi
175 + const n = parseFloat(s.replace(/,(\d{1,2})$/, ".$1").replace(/,/g, ""));
176 + return Number.isFinite(n) && n > 0 ? n : null;
177 +};
added frontend/src/fiche/useFicheData.ts +31 −0
@@ -0,0 +1,31 @@
1 +// -----------------------------------------------------------------------------
2 +// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/useFicheData.ts : données annexes de la fiche véhicule — rappels
5 +// Transports Canada (/api/vehicles/{uid}/recalls), chargés dès que le
6 +// véhicule est connu. Le reste (marché, KA Score, fiche constructeur,
7 +// similaires, historique, autres offres) arrive avec /api/vehicles/{uid}.
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useState } from "react";
10 +import { Recall, Vehicle, fetchVehicleRecalls } from "../api";
11 +
12 +export type Res<T> =
13 + | { status: "idle" | "loading" }
14 + | { status: "ok"; data: T }
15 + | { status: "error"; error: string }
16 + | { status: "na" };
17 +
18 +export default function useFicheData(v: Vehicle | null) {
19 + const [recalls, setRecalls] = useState<Res<Recall[]>>({ status: "idle" });
20 + const [tick, setTick] = useState(0);
21 + useEffect(() => {
22 + if (!v) return;
23 + let alive = true;
24 + setRecalls({ status: "loading" });
25 + fetchVehicleRecalls(v.uid)
26 + .then((r) => { if (alive) setRecalls({ status: "ok", data: r.recalls }); })
27 + .catch((e) => { if (alive) setRecalls(/API 404/.test(String(e)) ? { status: "na" } : { status: "error", error: String(e) }); });
28 + return () => { alive = false; };
29 + }, [v, tick]);
30 + return { recalls, retry: () => setTick((t) => t + 1) };
31 +}
modified frontend/src/pages/Vehicle.tsx +107 −448
@@ -1,113 +1,43 @@
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 + lightbox, badge marché, analyse de
5 −// prix, autres offres (même VIN), fiche constructeur, rappels TC, KA Score,
6 −// carte, historique de prix, similaires
4 +// pages/Vehicle.tsx : fiche véhicule — refonte premium 2026-09-07 (même socle
5 +// que les fiches Lou-Ka v3 / Immo-Ka v3). Ordre DOM = ordre visuel, identique
6 +// mobile ET desktop (aucun `order`) : héro (concessionnaire · prix · capsule
7 +// marché · titre · galerie · actions) → KA Score → En bref → navigation
8 +// sticky → Prix et marché → Le véhicule (+ description, équipements, fiche
9 +// constructeur) → Rappels → Le même véhicule ailleurs → Où le voir →
10 +// Similaires → Dossier → Sources. Desktop ≥ 1024 px : colonne principale +
11 +// aside sticky. Aucun scrollIntoView à l'ouverture : la page s'ouvre en haut.
7 12 // -----------------------------------------------------------------------------
8 −import { useEffect, useMemo, useState } from "react";
13 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9 14 import { Link, useParams } from "react-router-dom";
10 −import {
11 − KaScore, MarketAnalysis, Recall, VehicleDetail,
12 − fetchVehicle, fetchVehicleRecalls, fmtDate, fmtKm, fmtPrice, sourceName,
13 −} from "../api";
14 −import VehicleCard from "../components/VehicleCard";
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(" ");
15 +import { VehicleDetail, fetchVehicle, fmtPrice, vehiclePath } from "../api";
16 +import { useAccount } from "../account";
17 +import { Ico } from "../components/Icons";
18 +import "../fiche/fiche.css";
19 +import useFicheData from "../fiche/useFicheData";
20 +import { setCurrentListing } from "../fiche/current";
21 +import { comparaisonPrix, enBref, ligneResume } from "../fiche/synthese";
22 +import VehicleHero from "../fiche/VehicleHero";
23 +import ScoreCard from "../fiche/ScoreCard";
24 +import SectionNav, { NavItem } from "../fiche/SectionNav";
25 +import MarketCard from "../fiche/MarketCard";
26 +import VehicleFacts from "../fiche/VehicleFacts";
27 +import { Dossier, LocationCard, OffersCard, RecallsCard, SimilarCard, Sources } from "../fiche/ContextCards";
28 +import { DesktopAside, KaAssistant, StickyCTA, Summary, usePastElement } from "../fiche/Closing";
29 +import { Skeleton, useToast } from "../fiche/ui";
30 +
31 +function FicheSkeleton() {
80 32 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>
33 + <div className="ak-fiche"><div className="ak-wrap" aria-busy="true" aria-label="Chargement de la fiche">
34 + <Skeleton h={14} w={180} /><div style={{ height: 10 }} />
35 + <Skeleton h={38} w={200} /><div style={{ height: 10 }} />
36 + <Skeleton h={16} w="70%" /><div style={{ height: 12 }} />
37 + <div className="ak-skel" style={{ aspectRatio: "4 / 3", borderRadius: 18 }} />
38 + <div style={{ height: 14 }} /><Skeleton h={46} /><div style={{ height: 14 }} />
39 + <div className="ak-card"><Skeleton h={88} w={88} r={44} /></div>
40 + </div></div>
111 41 );
112 42 }
113 43
@@ -115,365 +45,94 @@ export default function VehiclePage() {
115 45 const { uid } = useParams<{ uid: string }>();
116 46 const [v, setV] = useState<VehicleDetail | null>(null);
117 47 const [error, setError] = useState(false);
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);
48 + const { me, favs, toggleFav } = useAccount();
49 + const [toast, showToast] = useToast();
50 + const actionsRef = useRef<HTMLDivElement>(null);
51 + const data = useFicheData(v);
52 + const pastHero = usePastElement(actionsRef);
122 53
123 54 useEffect(() => {
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 − }
55 + setV(null); setError(false); setCurrentListing(null);
56 + if (uid) fetchVehicle(uid).then((x) => { setV(x); setCurrentListing(x); }).catch(() => setError(true));
130 57 window.scrollTo(0, 0);
58 + return () => setCurrentListing(null);
131 59 }, [uid]);
132 −
133 − useEffect(() => {
134 − if (v) document.title = `${v.title} — ${fmtPrice(v.price)} | Auto·Ka`;
135 − }, [v]);
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]);
60 + useEffect(() => { document.body.classList.add("ak-fiche-page"); return () => document.body.classList.remove("ak-fiche-page"); }, []);
61 + useEffect(() => { if (v) document.title = `${v.title} — ${fmtPrice(v.price)} | Auto·Ka`; }, [v]);
62 +
63 + const fav = !!(v && favs.has(v.uid));
64 + const onFav = useCallback(() => {
65 + if (!v) return;
66 + toggleFav(v);
67 + if (me) showToast(fav ? "Retiré des favoris" : "Ajouté à vos favoris");
68 + }, [v, me, fav, toggleFav, showToast]);
69 + const onShare = useCallback(async () => {
70 + if (!v) return;
71 + const url = `https://www.auto-ka.com${vehiclePath(v)}`;
72 + try {
73 + if (navigator.share) { await navigator.share({ title: `${v.title} — Auto·Ka`, url }); return; }
74 + await navigator.clipboard.writeText(url); showToast("Lien copié");
75 + } catch { /* annulé */ }
76 + }, [v, showToast]);
77 +
78 + const recalls = data.recalls.status === "ok" ? data.recalls.data : null;
79 + const cmp = useMemo(() => (v ? comparaisonPrix(v) : null), [v]);
80 + const brief = useMemo(() => (v ? enBref(v, recalls) : []), [v, recalls]);
143 81
144 82 if (error)
145 83 return (
146 − <div className="notice container">
147 − <div className="big">🚗</div>
84 + <div className="ak-fiche"><div className="ak-page-error">
85 + <Ico name="car" size={40} />
148 86 <h2>Véhicule introuvable</h2>
149 87 <p>Il a peut-être été vendu — l'inventaire évolue chaque jour.</p>
150 − <p><Link to="/" className="btn ghost">← Retour à la recherche</Link></p>
151 − </div>
152 − );
153 −
154 − if (!v)
155 − return (
156 − <div className="container vdetail">
157 − <div className="skeleton" style={{ height: 60, marginBottom: 20 }} />
158 − <div className="vd-cols">
159 − <div className="skeleton" style={{ height: 460 }} />
160 − <div className="skeleton" style={{ height: 460 }} />
161 − </div>
162 − </div>
88 + <Link className="ak-btn ak-btn-primary" to="/">Retour à la recherche</Link>
89 + </div></div>
163 90 );
164 −
165 − const prevPrice = v.price_history.find(
166 − (h) => h.price != null && v.price != null && h.price !== v.price
167 − )?.price;
168 −
169 − const specs: [string, string][] = [
170 − ["Année", v.year ? String(v.year) : "—"],
171 − ["Version", v.trim || "—"],
172 − ["Kilométrage", fmtKm(v.mileage_km)],
173 − ["Transmission", v.transmission || "—"],
174 − ["Carburant", v.fuel || "—"],
175 − ["Motricité", v.drivetrain || "—"],
176 − ["Carrosserie", v.body_type || "—"],
177 − ["Moteur", v.engine || "—"],
178 − ["Couleur ext.", v.exterior_color || "—"],
179 − ["Couleur int.", v.interior_color || "—"],
180 − ["Portes", v.doors ? String(v.doors) : "—"],
181 − ["Places", v.seats ? String(v.seats) : "—"],
182 − ["NIV (VIN)", v.vin || "—"],
183 − ["No de stock", v.stock_number || "—"],
184 − ].filter(([, val]) => val !== "—") as [string, string][];
185 −
186 − const m = v.market;
187 − const offers = v.dup_sources || [];
91 + if (!v) return <FicheSkeleton />;
92 +
93 + const geo = v.lat != null && v.lng != null;
94 + const nav: NavItem[] = [
95 + { id: "resume", label: "Résumé" },
96 + ...(v.price != null ? [{ id: "prix", label: "Prix" }] : []),
97 + { id: "vehicule", label: "Véhicule" },
98 + ...(v.features?.length ? [{ id: "equipements", label: "Équipements" }] : []),
99 + ...(data.recalls.status !== "na" ? [{ id: "rappels", label: "Rappels" }] : []),
100 + ...(geo ? [{ id: "carte", label: "Carte" }] : []),
101 + ...(v.similar?.length ? [{ id: "similaires", label: "Similaires" }] : []),
102 + { id: "dossier", label: "Dossier" }, { id: "sources", label: "Sources" },
103 + ];
104 + const listBase = v.kind === "moto" ? "/motos" : v.kind === "scooter" ? "/scooters" : "/";
105 + const listLabel = v.kind === "moto" ? "Motos" : v.kind === "scooter" ? "Scooters" : "Autos";
188 106
189 107 return (
190 − <div className="container vdetail">
191 − <p style={{ marginTop: 0 }}>
192 − <Link to="/" className="mono" style={{ fontSize: 12 }}>← Retour à la recherche</Link>
193 − </p>
194 − <div className="vd-head">
195 − <div>
196 − <span className="kicker">{v.dealer_name || v.source} — {v.city}{v.region ? ` · ${v.region}` : ""}</span>
197 − <h1>{v.title}</h1>
198 − </div>
199 − <div className="vd-price">
200 − <div className="p">{fmtPrice(v.price)}</div>
201 − {prevPrice != null && v.price != null && prevPrice > v.price && (
202 − <div className="was">{fmtPrice(prevPrice)}</div>
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 − )}
215 − </div>
216 − </div>
217 −
218 − <div className="vd-cols">
219 − <div>
220 − <div className="gallery">
221 − <div className="main">
222 − {v.images.length > 0 ? (
223 − <img
224 − src={v.images[img]}
225 − alt={v.title}
226 − onClick={() => setLightbox(true)}
227 − />
228 − ) : (
229 − <div className="nopic" style={{ display: "grid", placeItems: "center", height: "100%", fontSize: 60 }}>🚗</div>
230 − )}
231 − {v.images.length > 0 && (
232 − <button className="gal-count mono" onClick={() => setLightbox(true)}>
233 − 🖼 {img + 1} / {v.images.length}
234 − </button>
235 − )}
236 − <FavButton v={v} />
237 − </div>
238 − {v.images.length > 1 && (
239 − <div className="thumbs">
240 − {v.images.map((u, i) => (
241 − <img
242 − key={u}
243 − src={u}
244 − alt={`photo ${i + 1}`}
245 − loading="lazy"
246 − className={i === img ? "sel" : ""}
247 − onClick={() => setImg(i)}
248 − />
249 − ))}
250 − </div>
251 − )}
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 − )}
262 −
263 − {v.description && (
264 − <div className="panel" style={{ marginTop: 18 }}>
265 − <h3>📝 Description du concessionnaire</h3>
266 − <div className="desc">{v.description}</div>
267 − </div>
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 − )}
310 − </div>
311 −
312 − <div>
313 − <div className="panel">
314 − <h3>🔧 Caractéristiques</h3>
315 − <table className="spec-table">
316 − <tbody>
317 − {specs.map(([k, val]) => (
318 − <tr key={k}>
319 − <td>{k}</td>
320 − <td>{val}</td>
321 − </tr>
322 − ))}
323 − </tbody>
324 − </table>
325 − <a className="cta-source" href={v.url} target="_blank" rel="noreferrer">
326 − Voir chez {v.dealer_name || "le concessionnaire"} →
327 − </a>
328 − {v.carfax_url && (
329 − <a
330 − className="cta-source"
331 − style={{ background: "var(--surface)", color: "var(--ink)", marginTop: 10 }}
332 − href={v.carfax_url}
333 − target="_blank"
334 − rel="noreferrer"
335 − >
336 − 📋 Rapport Carfax
337 − </a>
338 − )}
339 − <div className="cta-note">
340 − annonce originale — prix et disponibilité confirmés à la source
341 − </div>
342 − </div>
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 −
372 − {v.features.length > 0 && (
373 − <div className="panel">
374 − <h3>✨ Équipements ({v.features.length})</h3>
375 − <div className="feat-list">
376 − {v.features.map((f) => (
377 − <span key={f} className="spec-chip">{f}</span>
378 − ))}
379 − </div>
380 − </div>
381 − )}
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 −
429 − {v.price_history.length > 1 && (
430 − <div className="panel">
431 − <h3>📉 Historique de prix</h3>
432 − <PriceSpark hist={v.price_history} />
433 − <div className="price-history">
434 − {v.price_history.map((h, i) => {
435 − const next = v.price_history[i + 1];
436 − const dir =
437 − next?.price != null && h.price != null
438 − ? h.price < next.price ? "down" : h.price > next.price ? "up" : ""
439 − : "";
440 − return (
441 − <div key={h.ts} className="row">
442 − <span>{fmtDate(h.ts)}</span>
443 − <span className={dir}>{fmtPrice(h.price)}</span>
444 − </div>
445 − );
446 − })}
447 − </div>
448 − </div>
449 − )}
450 −
451 − <div className="panel">
452 − <h3>ℹ️ Suivi Auto-Ka</h3>
453 − <table className="spec-table">
454 − <tbody>
455 − <tr><td>Repéré le</td><td>{fmtDate(v.first_seen)}</td></tr>
456 − <tr><td>Vérifié le</td><td>{fmtDate(v.updated_at)}</td></tr>
457 − <tr><td>Statut</td><td>{v.active ? "En vente" : "Retiré / vendu"}</td></tr>
458 − </tbody>
459 − </table>
108 + <div className="ak-fiche">
109 + <div className="ak-wrap">
110 + <nav className="ak-crumbs" aria-label="Fil d'Ariane">
111 + <Link to={listBase}>{listLabel}</Link><span aria-hidden="true">›</span>
112 + {v.make && <><Link to={`${listBase === "/" ? "" : listBase}?make=${encodeURIComponent(v.make)}`}>{v.make}</Link><span aria-hidden="true">›</span></>}
113 + <span>{v.title}</span>
114 + </nav>
115 + <div className="ak-grid">
116 + <div className="ak-main">
117 + <VehicleHero v={v} cmp={cmp} fav={fav} onFav={onFav} onShare={onShare} actionsRef={actionsRef} />
118 + {v.ka_score && <ScoreCard s={v.ka_score} resume={ligneResume(v)} />}
119 + <Summary items={brief} loading={data.recalls.status === "loading"} />
120 + <SectionNav items={nav} />
121 + <MarketCard v={v} cmp={cmp} />
122 + <VehicleFacts v={v} />
123 + <RecallsCard v={v} r={data.recalls} onRetry={data.retry} />
124 + <OffersCard v={v} />
125 + <LocationCard v={v} />
126 + <SimilarCard v={v} />
127 + <Dossier v={v} />
128 + <Sources v={v} onShare={onShare} />
460 129 </div>
130 + <DesktopAside v={v} cmp={cmp} brief={brief} fav={fav} onFav={onFav} onShare={onShare} />
461 131 </div>
462 132 </div>
463 −
464 − {v.similar.length > 0 && (
465 − <section style={{ marginTop: 40 }}>
466 − <span className="kicker">Comparer</span>
467 − <h2 style={{ margin: "8px 0 18px" }}>
468 − {v.make} {v.model} similaires au Québec
469 − </h2>
470 − <div className="vgrid">
471 − {v.similar.map((s) => (
472 − <VehicleCard key={s.uid} v={s} />
473 − ))}
474 − </div>
475 − </section>
476 − )}
133 + <StickyCTA v={v} cmp={cmp} show={pastHero} />
134 + <KaAssistant v={v} hidden={!pastHero} />
135 + {toast}
477 136 </div>
478 137 );
479 138 }
modified frontend/src/styles.css +7 −10
@@ -402,23 +402,20 @@ html.ka-scroll-lock .kaa-btn { display: none !important; }
402 402
403 403 /* ================= Grille véhicules — cartes sans cadre ==================== */
404 404 .vgrid {
405 − display: grid; gap: 36px 26px;
405 + display: grid; gap: 22px 20px;
406 406 grid-template-columns: repeat(auto-fill, minmax(285px, 1fr));
407 407 padding-bottom: 34px;
408 408 }
409 409 .vcard {
410 − background: transparent; border: 0; border-radius: 0; box-shadow: none;
411 − overflow: visible; position: relative;
410 + background: var(--surface); border: 1px solid rgba(20, 24, 20, 0.1); border-radius: 16px;
411 + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); overflow: hidden; position: relative;
412 412 display: flex; flex-direction: column;
413 + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
413 414 }
415 +.vcard:hover { transform: translateY(-2px); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08); border-color: rgba(20, 24, 20, 0.18); }
414 416 .vcard .photo {
415 417 aspect-ratio: 4 / 3; background: var(--surface-2); position: relative;
416 − overflow: hidden; border-radius: 10px;
417 −}
418 −.vcard .photo::after {
419 − /* liseré intérieur discret pour tenir les photos claires sur le papier */
420 − content: ""; position: absolute; inset: 0; border-radius: 10px;
421 − box-shadow: inset 0 0 0 1px rgba(20, 24, 20, 0.12); pointer-events: none;
418 + overflow: hidden; border-radius: 0; border-bottom: 1px solid rgba(20, 24, 20, 0.08);
422 419 }
423 420 .vcard .photo img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.45s cubic-bezier(0.2, 0.6, 0.2, 1); }
424 421 .vcard:hover .photo img { transform: scale(1.045); }
@@ -437,7 +434,7 @@ html.ka-scroll-lock .kaa-btn { display: none !important; }
437 434 background: var(--good); color: var(--paper); font-family: var(--font-mono);
438 435 font-size: 10.5px; font-weight: 600; padding: 3px 9px; border-radius: 999px;
439 436 }
440 −.vcard .body { padding: 13px 2px 0; display: flex; flex-direction: column; gap: 6px; flex: 1; }
437 +.vcard .body { padding: 13px 14px 14px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
441 438 .vcard h3 {
442 439 font-size: 17px; line-height: 1.22; letter-spacing: -0.02em;
443 440 text-decoration: underline transparent; text-decoration-thickness: 2px;
444 441