SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

feat(fiche): refonte premium mobile-first de la fiche logement (2026-09-04)

Frontend (frontend/src/fiche/, nouvelle arborescence) : héro prix → adresse →
résumé → galerie → actions ; Lou-Ka Score (60 % KA Score emplacement + 40 %
prix vs juste valeur, partiel si une composante manque) ; « En bref »
déterministe (prix, accessibilité, air, inondation, coûts, TAL, chaleur,
baisse de prix) ; navigation sticky par sections (scroll-spy) ; Prix et marché
(juste valeur + histogramme) ; Le logement (grille icône/valeur) ; description
tronquée + texte original ; inclusions dédoublonnées FR/EN ; grande carte 3D
Ka Maps avec filtres de lieux (transport, épiceries, pharmacies, commerces,
écoles, parcs, essence) ; À proximité / Transport en carrousels + bottom sheet ;
Quartier en KPI compacts + accessibilité en lignes ; KA Scores compacts ;
Registre des loyers orienté décision (jauge p10–p90, médianes par chambres,
3 comparables + sheet filtrable) ; coût réel + Hydro ; risque d'inondation
(statut clair, juridique en accordéon) ; qualité de l'air en tuiles ; essence
(3 stats, 3 stations, sheet) ; Dossier de l'immeuble en accordéons (historique,
passeport, gestionnaire, TAL, hiver) ; Sources et méthodologie + fin de fiche.
Primitives : SectionCard, Accordion, BottomSheet (modale ≥ 900 px), StatTile,
StatusBadge, Skeleton, états vides/erreur, toast. Données : useFicheData
(groupe critique au montage, groupe différé à l'approche de la carte).
Tokens --lk-* scopés (.lk-fiche) : fond #F7F7F5, cartes blanches bord 1 px,
ombres légères, rayons 10/14/18/22, orange réservé CTA/prix/score. Desktop
≥ 1024 : aside sticky (prix, CTA, score, constats, Demander à Ka). CTA sticky
compact affiché seulement après le héro ; bouton « ✦ Demander à Ka » discret
branché sur le widget KA Agent existant (contexte de la fiche transmis).
Header compact sur la fiche (56 px, ticker masqué, favoris + partage,
connexion en icône sur mobile). Aucun `order` CSS ; scrollIntoView banni de la
barre de sections (faisait ouvrir la page à 434 px). Anciennes classes
.fiche/.f-* intactes pour la fiche court terme. 16 anciens composants de fiche
supprimés (remplacés). check-order.mjs mis à jour + shots.mjs / interactions.mjs.

Backend (additif, rétrocompatible) : poi.py conserve lat/lng des POI (carte) ;
gaz.py expose lat/lng des stations ; /api/gaz et /api/rdl acceptent `limit`
(sheets « Voir les N »). vite.config : proxy /api paramétrable (LOUKA_API).

Validé : tsc + vite build, check-order OK (scrollY 0, ordre croissant, sans
overflow 375→1440), captures Playwright 6 largeurs, sheets/carte/Ka/404,
fiche sans KA Score. node_modules frontend réinstallés (typescript/esbuild
tronqués par ka2 le 2026-08-31 pour ENOSPC).
Simon-Pierre Boucher committed 20 days ago (Sep 5, 2026) parent 7a4e7b2

54 changed files +4,182 −2,266

modified frontend/scripts/check-order.mjs +28 −16
@@ -1,44 +1,56 @@
1 1 // Validation ordre des sections — fiche Lou-Ka (ordre DOM = ordre visuel)
2 +// Refonte 2026-09-04 : sections `.lk-*` / 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]
2 7 import { chromium, devices } from "playwright";
3 8
4 −const UID = process.argv[2] || "lespac:225560010";
5 −const URL = `http://localhost:8095/logement/${encodeURIComponent(UID)}`;
9 +const UID = process.argv[2] || "zumper:60608306";
10 +const BASE = process.argv[3] || "http://localhost:8095";
11 +const URL = `${BASE}/logement/${encodeURIComponent(UID)}`;
6 12
7 13 async function check(name, ctxOpts) {
8 14 const browser = await chromium.launch();
9 15 const ctx = await browser.newContext(ctxOpts);
10 16 const page = await ctx.newPage();
17 + const errors = [];
18 + page.on("pageerror", (e) => errors.push(String(e)));
19 + page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
11 20 await page.goto(URL, { waitUntil: "networkidle" });
12 − await page.waitForSelector(".f-galerie", { timeout: 15000 });
13 − await page.waitForTimeout(1500); // laisse PriceAnalysis/carte se charger
21 + await page.waitForSelector(".lk-hero", { timeout: 20000 });
22 + await page.waitForTimeout(2500);
14 23 const data = await page.evaluate(() => {
15 − const sel = [".f-galerie", ".f-hero", ".f-desc", ".f-incl", ".f-pratique",
16 − ".f-fairvalue", ".f-carte", ".f-kascores", ".f-quartier", ".f-poi"];
24 + const sel = [".lk-hero", "#score", "#resume", ".lk-nav", "#prix", "#logement", "#description", "#inclusions",
25 + "#carte", "#proximite", "#transport", "#quartier", "#ka-scores", "#loyers", "#cout", "#risques",
26 + "#air", "#essence", "#dossier", "#sources"];
17 27 const out = [];
18 28 for (const s of sel) {
19 29 const el = document.querySelector(s);
20 30 if (!el) { out.push({ s, missing: true }); continue; }
21 31 const r = el.getBoundingClientRect();
22 − const hidden = r.height === 0 && r.width === 0;
23 − out.push({ s, hidden, top: Math.round(r.top + window.scrollY), left: Math.round(r.left), order: getComputedStyle(el).order });
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 });
24 34 }
25 − return { out, scrollY: window.scrollY };
35 + return { out, scrollY: window.scrollY, overflow: document.documentElement.scrollWidth - window.innerWidth };
26 36 });
27 − console.log(`\n=== ${name} === scrollY initial: ${data.scrollY}`);
37 + console.log(`\n=== ${name} === scrollY initial: ${data.scrollY} · débordement horizontal: ${data.overflow}px`);
28 38 for (const b of data.out)
29 39 console.log(b.missing ? `${b.s.padEnd(14)} (non rendue)` : b.hidden ? `${b.s.padEnd(14)} (vide/masquée)` :
30 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));
31 42 await browser.close();
32 − return data;
43 + return { ...data, errors };
33 44 }
34 45
35 46 const mob = await check("iPhone 14 (mobile)", { ...devices["iPhone 14"] });
36 −await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
47 +const desk = await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
37 48
38 −const vis = mob.out.filter(b => !b.missing && !b.hidden);
49 +const vis = mob.out.filter((b) => !b.missing && !b.hidden);
39 50 const sorted = vis.every((b, i) => i === 0 || b.top >= vis[i - 1].top);
40 −const noOrder = vis.every(b => b.order === "0");
41 −const ok = sorted && mob.scrollY === 0 && vis[0].s === ".f-galerie" && vis[0].top < 300 && noOrder;
42 −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}`);
51 +const noOrder = vis.every((b) => b.order === "0");
52 +const ok = sorted && mob.scrollY === 0 && vis[0].s === ".lk-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}`);
43 55 console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC");
44 56 process.exit(ok ? 0 : 1);
added frontend/scripts/interactions.mjs +48 −0
@@ -0,0 +1,48 @@
1 +// QA interactions de la fiche : bottom sheets, assistant Ka, filtres carte, fiche sans coordonnées.
2 +// Usage : node frontend/scripts/interactions.mjs [baseUrl] [outDir] [uidRiche] [uidPauvre]
3 +import { chromium, devices } from "playwright";
4 +const BASE = process.argv[2] || "http://127.0.0.1:8095";
5 +const OUT = process.argv[3] || "/tmp";
6 +const RICHE = process.argv[4] || "zumper:60608306";
7 +const PAUVRE = process.argv[5] || "royal_lepage:21803288";
8 +const browser = await chromium.launch();
9 +const ctx = await browser.newContext({ ...devices["iPhone 14"] });
10 +const page = await ctx.newPage();
11 +const errs = []; page.on("pageerror", (e) => errs.push("PAGEERROR " + e.message));
12 +await page.goto(`${BASE}/logement/${encodeURIComponent(RICHE)}`, { waitUntil: "networkidle" });
13 +await page.waitForSelector(".lk-hero"); await page.waitForTimeout(1500);
14 +// accepter les témoins pour dégager l'écran
15 +const ok = page.locator(".cookie-actions .btn-primary, .cookie-actions button").last(); if (await ok.count()) await ok.click().catch(() => {});
16 +await page.waitForTimeout(400);
17 +// 1) sheet des loyers
18 +await page.locator("#loyers .lk-more").scrollIntoViewIfNeeded(); await page.locator("#loyers .lk-more").click();
19 +await page.waitForTimeout(600); await page.screenshot({ path: `${OUT}/ix-sheet-loyers.png` });
20 +await page.locator(".lk-sheet-x").click(); await page.waitForTimeout(300);
21 +// 2) sheet lieux
22 +await page.locator("#proximite .lk-more").scrollIntoViewIfNeeded(); await page.locator("#proximite .lk-more").click();
23 +await page.waitForTimeout(600); await page.screenshot({ path: `${OUT}/ix-sheet-lieux.png` });
24 +await page.locator(".lk-sheet-x").click(); await page.waitForTimeout(300);
25 +// 3) filtres carte
26 +await page.locator("#carte").scrollIntoViewIfNeeded(); await page.waitForTimeout(2500);
27 +await page.locator("#carte .lk-chip", { hasText: "Transport" }).click();
28 +await page.locator("#carte .lk-chip", { hasText: "Épiceries" }).click();
29 +await page.waitForTimeout(2500); await page.screenshot({ path: `${OUT}/ix-carte-filtres.png` });
30 +// 4) assistant Ka
31 +await page.locator(".lk-ka-btn").click(); await page.waitForTimeout(600); await page.screenshot({ path: `${OUT}/ix-ka-sheet.png` });
32 +await page.locator(".lk-ka-sug").first().click(); await page.waitForTimeout(1500); await page.screenshot({ path: `${OUT}/ix-ka-agent.png` });
33 +const kaOpen = await page.evaluate(() => !!document.querySelector(".kaa-panel.kaa-open"));
34 +console.log("KA Agent ouvert :", kaOpen);
35 +// 5) accordéon dossier
36 +await page.keyboard.press("Escape"); await page.waitForTimeout(400);
37 +await page.locator("#dossier .lk-acc-btn").first().scrollIntoViewIfNeeded(); await page.locator("#dossier .lk-acc-btn").first().click();
38 +await page.waitForTimeout(800); await page.screenshot({ path: `${OUT}/ix-dossier.png` });
39 +// 6) fiche pauvre (sans chambres/KA Score)
40 +await page.goto(`${BASE}/logement/${encodeURIComponent(PAUVRE)}`, { waitUntil: "networkidle" });
41 +await page.waitForSelector(".lk-hero"); await page.waitForTimeout(3000);
42 +await page.screenshot({ path: `${OUT}/ix-pauvre-top.png` });
43 +await page.screenshot({ path: `${OUT}/ix-pauvre-full.png`, fullPage: true });
44 +// 7) 404
45 +const r = await page.goto(`${BASE}/logement/inexistant:0`, { waitUntil: "networkidle" });
46 +await page.waitForTimeout(800); await page.screenshot({ path: `${OUT}/ix-404.png` });
47 +console.log("404 status", r?.status(), "errors", errs);
48 +await browser.close();
added frontend/scripts/shots.mjs +37 −0
@@ -0,0 +1,37 @@
1 +// Captures Playwright de la fiche logement à 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] || "zumper:60608306";
5 +const BASE = process.argv[3] || "http://127.0.0.1:8095";
6 +const OUT = process.argv[4] || "/tmp";
7 +const shots = [
8 + ["iphone14", { ...devices["iPhone 14"] }],
9 + ["w375", { viewport: { width: 375, height: 812 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }],
10 + ["w430", { viewport: { width: 430, height: 932 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }],
11 + ["w768", { viewport: { width: 768, height: 1024 }, deviceScaleFactor: 1 }],
12 + ["w1024", { viewport: { width: 1024, height: 800 }, deviceScaleFactor: 1 }],
13 + ["w1440", { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }],
14 +];
15 +const browser = await chromium.launch();
16 +for (const [name, opts] of shots) {
17 + const ctx = await browser.newContext(opts); const page = await ctx.newPage();
18 + const errs = [];
19 + page.on("pageerror", (e) => errs.push("PAGEERROR " + e.message));
20 + page.on("console", (m) => { if (m.type() === "error") errs.push(m.text().slice(0, 200)); });
21 + await page.goto(`${BASE}/logement/${encodeURIComponent(UID)}`, { waitUntil: "networkidle" });
22 + await page.waitForSelector(".lk-hero", { timeout: 20000 });
23 + // défilement complet (déclenche les chargements différés) puis retour en haut
24 + await page.evaluate(async () => {
25 + for (let y = 0; y < document.body.scrollHeight; y += 600) { window.scrollTo(0, y); await new Promise((r) => setTimeout(r, 120)); }
26 + window.scrollTo(0, 0);
27 + });
28 + await page.waitForTimeout(3500);
29 + const ov = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
30 + await page.screenshot({ path: `${OUT}/lk-${name}-full.png`, fullPage: true });
31 + await page.screenshot({ path: `${OUT}/lk-${name}-top.png` });
32 + await page.evaluate(() => window.scrollTo(0, 900)); await page.waitForTimeout(600);
33 + await page.screenshot({ path: `${OUT}/lk-${name}-scrolled.png` });
34 + console.log(name, "overflow", ov, "errors", errs.length, errs.slice(0, 3).join(" | "));
35 + await ctx.close();
36 +}
37 +await browser.close();
modified frontend/src/App.tsx +35 −6
@@ -14,7 +14,7 @@ import { AccountProvider, useAccount } from "./account";
14 14 import CookieConsent from "./components/CookieConsent";
15 15 import {
16 16 IcoChart, IcoCompass, IcoDoc, IcoFolder, IcoHeart, IcoHouse, IcoLock,
17 − IcoMap, IcoSearch, IcoUser,
17 + IcoMap, IcoSearch, IcoShare, IcoUser,
18 18 } from "./components/Icons";
19 19 import { LogoIcon } from "./components/Logo";
20 20 import GroupeKaBadge from "./ka/GroupeKaBadge";
@@ -110,7 +110,7 @@ function AccountMenu() {
110 110 if (!enabled) return null;
111 111 if (!me) {
112 112 return (
113 − <a className="login-btn" href="/api/auth/ka/login">
113 + <a className="login-btn" href="/api/auth/ka/login" aria-label="Connexion" title="Connexion">
114 114 <span
115 115 aria-hidden="true"
116 116 style={{
@@ -128,7 +128,7 @@ function AccountMenu() {
128 128 >
129 129 KA
130 130 </span>
131 − Connexion
131 + <span className="login-txt">Connexion</span>
132 132 </a>
133 133 );
134 134 }
@@ -209,9 +209,37 @@ function AccountMenu() {
209 209 );
210 210 }
211 211
212 +/** Actions du header sur une fiche logement : favoris + partage (compactes). */
213 +function FicheHeaderActions({ uid }: { uid: string }) {
214 + const { me, favs, toggleFav } = useAccount();
215 + const fav = favs.has(uid);
216 + const share = async () => {
217 + const url = `https://www.lou-ka.com/logement/${encodeURIComponent(uid)}`;
218 + try {
219 + if (navigator.share) await navigator.share({ title: document.title, url });
220 + else await navigator.clipboard.writeText(url);
221 + } catch { /* annulé */ }
222 + };
223 + return (
224 + <div className="hdr-actions">
225 + <button type="button" className={`hdr-btn ${fav ? "on" : ""}`} aria-pressed={fav}
226 + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"}
227 + onClick={() => { if (!me) { window.location.href = "/api/auth/ka/login"; return; } toggleFav(uid); }}>
228 + <IcoHeart size={18} filled={fav} />
229 + </button>
230 + <button type="button" className="hdr-btn" aria-label="Partager" onClick={share}>
231 + <IcoShare size={17} />
232 + </button>
233 + </div>
234 + );
235 +}
236 +
212 237 function Header() {
213 238 const [open, setOpen] = useState(false);
214 239 const location = useLocation();
240 + // fiche logement : header compact (56 px), sans ticker ni badge, favoris + partage
241 + const ficheUid = location.pathname.startsWith("/logement/")
242 + ? decodeURIComponent(location.pathname.slice("/logement/".length)) : null;
215 243
216 244 // fermer le menu à chaque navigation + verrouiller le défilement en dessous
217 245 useEffect(() => { setOpen(false); }, [location]);
@@ -222,14 +250,15 @@ function Header() {
222 250
223 251 return (
224 252 <>
225 − <header className="header">
253 + <header className={`header ${ficheUid ? "header--fiche" : ""}`}>
226 254 <div className="container header-inner">
227 255 <NavLink to="/" className="brand" aria-label="Lou-Ka — accueil">
228 256 <LogoIcon size={30} />
229 257 Lou-<span className="ka">Ka</span>
230 258 <span className="brand-tag">La porte d'entrée vers votre prochain chez-vous.</span>
231 259 </NavLink>
232 − <GroupeKaBadge />
260 + {!ficheUid && <GroupeKaBadge />}
261 + {ficheUid && <FicheHeaderActions uid={ficheUid} />}
233 262 <nav className="nav" aria-label="Navigation principale">
234 263 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
235 264 Logements
@@ -288,7 +317,7 @@ function Header() {
288 317 </div>
289 318 </header>
290 319 {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
291 − <Ticker />
320 + {!ficheUid && <Ticker />}
292 321 </>
293 322 );
294 323 }
modified frontend/src/api.ts +12 −6
@@ -25,6 +25,8 @@ export interface Poi {
25 25 cat: string; // epicerie, pharmacie, ecole, parc, bus…
26 26 name: string;
27 27 dist_m: number;
28 + lat?: number; // position (entrées de cache récentes seulement — carte)
29 + lng?: number;
28 30 }
29 31
30 32 export interface Digest {
@@ -243,9 +245,11 @@ export interface RdlNearby {
243 245 items: RdlItem[];
244 246 }
245 247
246 −/** Loyers déclarés au Registre des loyers autour d'un point (fiche). */
247 −export const fetchRdl = (lat: number, lng: number, radius = 600) =>
248 − get<RdlNearby>(`/api/rdl?lat=${lat}&lng=${lng}&radius=${radius}`);
248 +/** Loyers déclarés au Registre des loyers autour d'un point (fiche).
249 + * `limit` : nombre de déclarations dans `items` (défaut serveur 12, max 300). */
250 +export const fetchRdl = (lat: number, lng: number, radius = 600, limit?: number) =>
251 + get<RdlNearby>(`/api/rdl?lat=${lat}&lng=${lng}&radius=${radius}` +
252 + (limit ? `&limit=${limit}` : ""));
249 253
250 254 export interface TalDecision {
251 255 date: string | null;
@@ -785,6 +789,7 @@ export const fetchAir = (lat: number, lng: number) =>
785 789
786 790 export interface GazStation {
787 791 nom: string; adresse: string; dist_m: number;
792 + lat?: number; lng?: number; // position (carte de la fiche)
788 793 regulier: number | null; super: number | null; diesel: number | null;
789 794 moins_chere: boolean;
790 795 }
@@ -794,9 +799,10 @@ export interface GazNearby {
794 799 min_regulier: number | null; stations: GazStation[]; maj: string | null;
795 800 }
796 801
797 −/** Stations-service à proximité et prix courants (gazquebec.ca). */
798 −export const fetchGaz = (lat: number, lng: number) =>
799 − get<GazNearby>(`/api/gaz?lat=${lat}&lng=${lng}`);
802 +/** Stations-service à proximité et prix courants (gazquebec.ca).
803 + * `limit` : nombre de stations retournées (défaut serveur 5, max 60). */
804 +export const fetchGaz = (lat: number, lng: number, limit?: number) =>
805 + get<GazNearby>(`/api/gaz?lat=${lat}&lng=${lng}` + (limit ? `&limit=${limit}` : ""));
800 806
801 807 export interface CommerceItem {
802 808 id: string; commerce: string; nom: string; adresse: string;
deleted frontend/src/components/CommercesProches.tsx +0 −85
@@ -1,85 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/CommercesProches.tsx : bloc « Commerces et transport » (fiche)
5 −// Distance au point de vente le plus proche de chaque grande bannière
6 −// (Costco, Metro, IGA, Walmart… via l'API Mapbox Search Box) + station de
7 −// métro et arrêt de bus les plus proches. Pastilles SVG monogrammes aux
8 −// couleurs des bannières (pas de logos déposés).
9 −// -----------------------------------------------------------------------------
10 −import { useEffect, useState } from "react";
11 −import { CommercesNearby, fetchCommerces, fmtDist } from "../api";
12 −
13 −// id -> [couleur de fond, monogramme, couleur du texte]
14 −const ICONES: Record<string, [string, string, string?]> = {
15 − metro_station: ["#0083C9", "M"],
16 − rem_station: ["#84BD00", "R"],
17 − arret_bus: ["#4E5357", "B"],
18 − gare_train: ["#6E5B3F", "T"],
19 − costco: ["#005DAA", "C"],
20 − walmart: ["#0071CE", "W"],
21 − metro: ["#EF3E42", "M"],
22 − iga: ["#D50032", "IGA"],
23 − maxi: ["#0079C1", "Mx"],
24 − superc: ["#E4002B", "SC"],
25 − provigo: ["#DA291C", "P"],
26 − canadiantire: ["#D6001C", "CT"],
27 − dollarama: ["#00B140", "D", "#FFDD00"],
28 − saq: ["#892034", "SAQ"],
29 − pharmaprix: ["#E11B22", "Ph"],
30 − jeancoutu: ["#003DA5", "JC"],
31 − homedepot: ["#F96302", "HD"],
32 − rona: ["#1B4298", "R"],
33 −};
34 −
35 −function Pastille({ id }: { id: string }) {
36 − const [bg, mono, fg] = ICONES[id] ?? ["#777", "•"];
37 − const fs = mono.length >= 3 ? 9 : mono.length === 2 ? 11 : 14;
38 − return (
39 − <svg className="cm-ico" viewBox="0 0 28 28" width="28" height="28"
40 − aria-hidden="true">
41 − {["metro_station", "rem_station", "arret_bus", "gare_train"].includes(id)
42 − ? <circle cx="14" cy="14" r="13" fill={bg} />
43 − : <rect x="1" y="1" width="26" height="26" rx="7" fill={bg} />}
44 − <text x="14" y="14" textAnchor="middle" dominantBaseline="central"
45 − fontSize={fs} fontWeight="800" fontFamily="inherit"
46 − fill={fg ?? "#fff"}>{mono}</text>
47 − </svg>
48 − );
49 −}
50 −
51 −export default function CommercesProches({ lat, lng }:
52 − { lat: number | null; lng: number | null }) {
53 − const [d, setD] = useState<CommercesNearby | null>(null);
54 − useEffect(() => {
55 − setD(null);
56 − if (lat == null || lng == null) return;
57 − fetchCommerces(lat, lng).then(setD).catch(() => setD(null));
58 − }, [lat, lng]);
59 − if (lat == null || lng == null || !d) return null;
60 − const tous = [...(d.transit ?? []), ...(d.commerces ?? [])];
61 − if (tous.length === 0) return null;
62 −
63 − return (
64 − <section className="f-bloc f-commerces" id="commerces">
65 − <h2>Commerces et transport</h2>
66 − <ul className="cm-grille">
67 − {tous.map((c) => (
68 − <li key={c.id} className="cm-item"
69 − title={c.adresse || undefined}>
70 − <Pastille id={c.id} />
71 − <span className="cm-txt">
72 − <span className="cm-nom">{c.commerce}</span>
73 − <span className="cm-poi">{c.nom}</span>
74 − </span>
75 − <span className="cm-dist">{fmtDist(c.dist_m)}</span>
76 − </li>
77 − ))}
78 − </ul>
79 − <p className="fine">
80 − Point de vente le plus proche de chaque bannière — distances à vol
81 − d'oiseau (recherche Mapbox ; métro et bus : OpenStreetMap).
82 − </p>
83 − </section>
84 − );
85 −}
deleted frontend/src/components/CoutReel.tsx +0 −98
@@ -1,98 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/CoutReel.tsx : bloc « Coût réel mensuel » (fiche)
5 −// Loyer + frais non inclus, ligne par ligne, chaque poste étiqueté :
6 −// inclus (bail) / observé (source) / estimé (avec sa source) / inconnu
7 −// (dit tel quel — jamais chiffré arbitrairement). Prix au pi² avec
8 −// percentile réellement calculé sur les comparables.
9 −// -----------------------------------------------------------------------------
10 −import { useEffect, useState } from "react";
11 −import { CoutReel as CoutReelT, fetchCoutReel, fmtPrice } from "../api";
12 −
13 −const NBSP = " ";
14 −
15 −const STATUT_META: Record<string, { label: string; cls: string }> = {
16 − included: { label: "inclus", cls: "st-included" },
17 − observed: { label: "observé", cls: "st-observed" },
18 − estimated: { label: "estimé", cls: "st-estimated" },
19 − unknown: { label: "inconnu", cls: "st-unknown" },
20 −};
21 −
22 −export default function CoutReel({ uid }: { uid: string }) {
23 − const [d, setD] = useState<CoutReelT | null>(null);
24 − useEffect(() => {
25 − setD(null);
26 − fetchCoutReel(uid).then(setD).catch(() => setD(null));
27 − }, [uid]);
28 − if (!d || d.loyer == null) return null;
29 −
30 − const pi2 = d.pi2;
31 − return (
32 − <section className="f-bloc f-coutreel" id="cout-reel">
33 − <h2>Coût réel mensuel</h2>
34 − <table className="cr-table">
35 − <tbody>
36 − {d.lignes.map((li) => {
37 − const m = STATUT_META[li.statut] ?? STATUT_META.unknown;
38 − return (
39 − <tr key={li.poste}>
40 − <td className="cr-poste">
41 − {li.poste}
42 − <span className={`st-pill ${m.cls}`}>{m.label}</span>
43 − </td>
44 − <td className="cr-montant">
45 − {li.montant != null && li.montant > 0 && fmtPrice(li.montant)}
46 − {li.montant === 0 && li.statut === "included" && `0${NBSP}$`}
47 − {li.montant == null && "—"}
48 − </td>
49 − </tr>
50 − );
51 − })}
52 − </tbody>
53 − {d.total_estime != null && (
54 − <tfoot>
55 − <tr>
56 − <td className="cr-poste"><b>Total estimé</b></td>
57 − <td className="cr-montant"><b>≈{NBSP}{fmtPrice(d.total_estime)}{NBSP}/mois</b></td>
58 − </tr>
59 − {d.annuel_estime != null && (
60 − <tr className="cr-annuel">
61 − <td className="cr-poste">soit sur 12 mois</td>
62 − <td className="cr-montant">≈{NBSP}{fmtPrice(d.annuel_estime)}</td>
63 − </tr>
64 − )}
65 − </tfoot>
66 − )}
67 − </table>
68 − {d.postes_inconnus.length > 0 && (
69 − <p className="cr-inconnus">
70 − Postes non chiffrables avec les données publiées :{" "}
71 − {d.postes_inconnus.join(", ").toLowerCase()} — le total réel peut
72 − être plus élevé.
73 − </p>
74 − )}
75 − {pi2 && (
76 − <div className="cr-pi2">
77 − <span className="zi-badge zi-nc">
78 − {pi2.valeur.toFixed(2).replace(".", ",")}{NBSP}$/pi²
79 − </span>
80 − {pi2.percentile_secteur != null && (
81 − <span>
82 − {" "}moins cher que <b>{100 - pi2.percentile_secteur}{NBSP}%</b> des{" "}
83 − {pi2.n_secteur} logements comparables du secteur (~2{NBSP}km)
84 − </span>
85 − )}
86 − {pi2.percentile_secteur == null && pi2.percentile_ville != null && (
87 − <span>
88 − {" "}moins cher que <b>{100 - pi2.percentile_ville}{NBSP}%</b> des{" "}
89 − {pi2.n_ville} comparables ({pi2.portee_ville})
90 − </span>
91 − )}
92 − {pi2.percentile_note && <span> {pi2.percentile_note}</span>}
93 − </div>
94 − )}
95 − <p className="fine">{d.methode}</p>
96 − </section>
97 − );
98 −}
deleted frontend/src/components/EssenceProche.tsx +0 −74
@@ -1,74 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/EssenceProche.tsx : bloc « Essence à proximité » (fiche)
5 −// Stations-service les plus proches avec les prix courants (gazquebec.ca) :
6 −// médiane du secteur, station la moins chère mise en évidence, prix
7 −// Régulier / Super / Diesel par station.
8 −// -----------------------------------------------------------------------------
9 −import { useEffect, useState } from "react";
10 −import { fetchGaz, fmtDist, GazNearby } from "../api";
11 −
12 −const cents = (v: number | null | undefined) =>
13 − v == null ? "—" : `${v.toLocaleString("fr-CA", { minimumFractionDigits: 1 })} ¢`;
14 −
15 −export default function EssenceProche({ lat, lng }:
16 − { lat: number | null; lng: number | null }) {
17 − const [d, setD] = useState<GazNearby | null>(null);
18 − useEffect(() => {
19 − setD(null);
20 − if (lat == null || lng == null) return;
21 − fetchGaz(lat, lng).then(setD).catch(() => setD(null));
22 − }, [lat, lng]);
23 − if (lat == null || lng == null || !d || d.stations.length === 0) return null;
24 −
25 − return (
26 − <section className="f-bloc f-gaz" id="essence">
27 − <h2>Essence à proximité</h2>
28 − <div className="rdl-kpis">
29 − <div className="rdl-kpi">
30 − <span className="rdl-kpi-v">{d.n}</span>
31 − <span className="rdl-kpi-l">stations<br />à moins de {fmtDist(d.rayon_m)}</span>
32 − </div>
33 − {d.mediane_regulier != null && (
34 − <div className="rdl-kpi">
35 − <span className="rdl-kpi-v">{cents(d.mediane_regulier)}<small>/L</small></span>
36 − <span className="rdl-kpi-l">médiane du secteur<br />essence régulière</span>
37 − </div>
38 − )}
39 − {d.min_regulier != null && (
40 − <div className="rdl-kpi">
41 − <span className="rdl-kpi-v">{cents(d.min_regulier)}<small>/L</small></span>
42 − <span className="rdl-kpi-l">meilleur prix<br />du secteur</span>
43 − </div>
44 − )}
45 − </div>
46 − <table className="rdl-table">
47 − <caption className="rdl-cap">Stations les plus proches</caption>
48 − <thead className="gaz-head">
49 − <tr><th>Station</th><th>Régulier</th><th>Super</th><th>Diesel</th><th></th></tr>
50 − </thead>
51 − <tbody>
52 − {d.stations.map((s, i) => (
53 − <tr key={i}>
54 − <td className="rdl-addr">
55 − <b>{s.nom}</b>
56 − {s.moins_chere && <span className="gaz-best"> la moins chère</span>}
57 − <span className="gaz-adr">{s.adresse}</span>
58 − </td>
59 − <td className="rdl-prix">{cents(s.regulier)}</td>
60 − <td className="rdl-date">{cents(s.super)}</td>
61 − <td className="rdl-date">{cents(s.diesel)}</td>
62 − <td className="rdl-dist">{fmtDist(s.dist_m)}</td>
63 − </tr>
64 − ))}
65 − </tbody>
66 − </table>
67 − <p className="fine">
68 − Prix courants en ¢/litre —{" "}
69 − <a href="https://gazquebec.ca" target="_blank"
70 − rel="noopener noreferrer">gazquebec.ca</a>, mis à jour {d.maj}.
71 − </p>
72 − </section>
73 − );
74 −}
deleted frontend/src/components/GestionnaireBloc.tsx +0 −129
@@ -1,129 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/GestionnaireBloc.tsx : bloc « Qui gère ce logement? » (fiche)
5 −// Fiche du gestionnaire (source directe) + réputation Google : distribution
6 −// réelle des notes, moyenne récente, tendance et thèmes récurrents calculés
7 −// sur les avis synchronisés dans NOTRE base (louka/managers.py) — pas
8 −// seulement la moyenne affichée par Google, et zéro appel API au chargement.
9 −// La fiche Google n'est montrée que si l'association est confiante.
10 −// -----------------------------------------------------------------------------
11 −import { useEffect, useState } from "react";
12 −import { Gestionnaire, fetchGestionnaire } from "../api";
13 −
14 −const NBSP = " ";
15 −
16 −const SENT_CLS: Record<string, string> = {
17 − "négatif": "zi-eleve", "neutre": "zi-nc", "positif": "zi-ok",
18 −};
19 −
20 −export default function GestionnaireBloc({ source }: { source: string }) {
21 − const [d, setD] = useState<Gestionnaire | null>(null);
22 − useEffect(() => {
23 − setD(null);
24 − fetchGestionnaire(source).then(setD).catch(() => setD(null));
25 − }, [source]);
26 − // sans fiche Google associée, le bloc « Détails pratiques » suffit
27 − if (!d || !d.google_maps || d.google_maps.statut === "non_associe") return null;
28 −
29 − const g = d.google_maps;
30 − const avis = d.avis;
31 − const dist = avis?.distribution;
32 − const total = dist ? Object.values(dist).reduce((a, b) => a + b, 0) : 0;
33 − const recents = (d.avis_recents ?? []).filter((a) => a.texte).slice(0, 3);
34 −
35 − return (
36 − <section className="f-bloc f-gest" id="gestionnaire">
37 − <h2>Qui gère ce logement?</h2>
38 − <div className="gest-head">
39 − <div className="gest-nom">{d.nom}</div>
40 − <div className="gest-meta">
41 − {d.annonces_actives}{NBSP}annonce{d.annonces_actives > 1 ? "s" : ""} active{d.annonces_actives > 1 ? "s" : ""} sur Lou-Ka
42 − {d.site_web && (
43 − <>
44 − {" · "}
45 − <a href={d.site_web} target="_blank" rel="noopener noreferrer">site web ↗</a>
46 − </>
47 − )}
48 − </div>
49 − </div>
50 −
51 − {g.note != null && (
52 − <div className="gest-google">
53 − <span className="gest-note">★ {g.note.toFixed(1).replace(".", ",")}</span>
54 − <span className="gest-navis">
55 − {g.nombre_avis}{NBSP}avis Google — fiche «{NBSP}{g.nom}{NBSP}»
56 − </span>
57 − </div>
58 − )}
59 −
60 − {avis && avis.n > 0 && (
61 − <>
62 − {dist && total > 0 && (
63 − <div className="gest-bars" aria-label="Distribution des notes (avis analysés)">
64 − {[5, 4, 3, 2, 1].map((n) => {
65 − const c = dist[String(n)] ?? 0;
66 − return (
67 − <div className="gest-bar" key={n}>
68 − <span className="gb-n">{n}★</span>
69 − <span className="gb-track">
70 − <span className={`gb-fill ${n <= 2 ? "neg" : n >= 4 ? "pos" : ""}`}
71 − style={{ width: `${Math.round((100 * c) / total)}%` }} />
72 − </span>
73 − <span className="gb-c">{c}</span>
74 − </div>
75 − );
76 − })}
77 − </div>
78 − )}
79 − <div className="gest-stats">
80 − {avis.moyenne_12m != null && (
81 − <span>Moyenne des 12 derniers mois : <b>{avis.moyenne_12m.toFixed(1).replace(".", ",")}</b> ({avis.n_12m} avis)</span>
82 − )}
83 − {avis.tendance && (
84 − <span className={`zi-badge ${avis.tendance === "en amélioration" ? "zi-ok" : avis.tendance === "en dégradation" ? "zi-modere" : "zi-nc"}`}>
85 − {avis.tendance}
86 − </span>
87 − )}
88 − </div>
89 − {(avis.plaintes_frequentes?.length ?? 0) > 0 && (
90 − <div className="gest-themes">
91 − <span className="k">Plaintes récurrentes dans les avis :</span>{" "}
92 − {avis.plaintes_frequentes!.map((t) => (
93 − <span className="zi-badge zi-modere" key={t}>{t}</span>
94 − ))}
95 − </div>
96 − )}
97 − {recents.length > 0 && (
98 − <details className="gest-avis">
99 − <summary>Extraits d'avis récents ({recents.length})</summary>
100 − {recents.map((a, i) => (
101 − <blockquote className="gest-citation" key={i}>
102 − <span className={`zi-badge ${SENT_CLS[a.analyse.sentiment ?? ""] ?? "zi-nc"}`}>
103 − {a.note != null ? `${a.note}★` : "—"}
104 − </span>{" "}
105 − {a.texte}
106 − <footer>
107 − {a.date ? new Date(a.date).toLocaleDateString("fr-CA", { month: "long", year: "numeric" }) : ""}
108 − {a.reponse_proprietaire && " · le gestionnaire a répondu"}
109 − </footer>
110 − </blockquote>
111 − ))}
112 − </details>
113 − )}
114 − </>
115 − )}
116 −
117 − <p className="fine">
118 − Fiche Google Maps associée automatiquement (confiance{" "}
119 − {Math.round((g.confiance_association ?? 0) * 100)}{NBSP}% —{" "}
120 − {g.methode}). Statistiques calculées sur les {avis?.n ?? 0} avis
121 − synchronisés dans la base Lou-Ka
122 − {g.nombre_avis && avis && avis.n < g.nombre_avis
123 − ? ` (échantillon des plus récents ; Google en annonce ${g.nombre_avis})`
124 − : ""}. Thèmes détectés par lexique — inférence indicative, pas une
125 − lecture humaine.
126 − </p>
127 − </section>
128 − );
129 −}
deleted frontend/src/components/HistoriqueLouka.tsx +0 −126
@@ -1,126 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/HistoriqueLouka.tsx : bloc « Historique Lou-Ka » (fiche)
5 −// Timeline des observations RÉELLES des synchronisations : changements de
6 −// prix, description, superficie, disponibilité, inclusions, photos,
7 −// retraits/retours. + « Vies antérieures » : annonces recyclées probables
8 −// du même logement (score multi-signaux, inférence explicable).
9 −// -----------------------------------------------------------------------------
10 −import { useEffect, useState } from "react";
11 −import {
12 − HistoriqueLouka as HistoT, Recyclees,
13 − fetchHistorique, fetchRecyclees, fmtPrice,
14 −} from "../api";
15 −
16 −const NBSP = " ";
17 −
18 −const fmtTs = (ts: number | null | undefined): string =>
19 − ts
20 − ? new Date(ts * 1000).toLocaleDateString("fr-CA",
21 − { day: "numeric", month: "short", year: "numeric" })
22 − : "—";
23 −
24 −const EVENT_LABEL: Record<string, string> = {
25 − prix: "Prix",
26 − description: "Description modifiée",
27 − superficie: "Superficie modifiée",
28 − dispo: "Disponibilité modifiée",
29 − inclusions: "Inclusions modifiées",
30 − photos: "Photos modifiées",
31 − disparition: "Annonce retirée",
32 − reapparition: "Annonce republiée",
33 −};
34 −
35 −export default function HistoriqueLouka({ uid }: { uid: string }) {
36 − const [d, setD] = useState<HistoT | null>(null);
37 − const [rec, setRec] = useState<Recyclees | null>(null);
38 − useEffect(() => {
39 − setD(null); setRec(null);
40 − fetchHistorique(uid).then(setD).catch(() => setD(null));
41 − fetchRecyclees(uid).then(setRec).catch(() => setRec(null));
42 − }, [uid]);
43 − if (!d) return null;
44 −
45 − const variationPct = d.variation != null
46 − ? `${d.variation > 0 ? "+" : "−"}${Math.abs(Math.round(d.variation * 100))}${NBSP}%`
47 − : null;
48 − const items = d.timeline.slice(0, 12);
49 − const matches = rec?.matches ?? [];
50 −
51 − return (
52 − <section className="f-bloc f-histolk" id="historique-louka">
53 − <h2>Historique Lou-Ka</h2>
54 − <div className="kv">
55 − <div className="cell">
56 − <div className="k">Suivie depuis</div>
57 − <div className="v">{fmtTs(d.premiere_observation)}</div>
58 − </div>
59 − <div className="cell">
60 − <div className="k">En ligne</div>
61 − <div className="v">{d.jours_en_ligne}{NBSP}jour{d.jours_en_ligne > 1 ? "s" : ""}</div>
62 − </div>
63 − {d.prix_initial != null && d.prix_actuel != null && d.prix_initial !== d.prix_actuel && (
64 − <div className="cell">
65 − <div className="k">Prix initial → actuel</div>
66 − <div className="v">
67 − {fmtPrice(d.prix_initial)} → {fmtPrice(d.prix_actuel)}
68 − {variationPct && ` (${variationPct})`}
69 − </div>
70 − </div>
71 − )}
72 − <div className="cell">
73 − <div className="k">Modifications observées</div>
74 − <div className="v">{d.modifications}</div>
75 − </div>
76 − </div>
77 −
78 − {items.length > 0 && (
79 − <ul className="hl-timeline">
80 − {items.map((it, i) => (
81 − <li key={`${it.ts}-${it.type}-${i}`}
82 − className={`hl-item hl-${it.type}`}>
83 − <span className="hl-date">{fmtTs(it.ts)}</span>
84 − <span className="hl-texte">
85 − {it.type === "prix" ? (
86 − it.prix_avant != null
87 − ? <>Prix {it.prix_avant! > (it.prix ?? 0) ? "baissé" : "monté"} de {fmtPrice(it.prix_avant ?? null)} à <b>{fmtPrice(it.prix ?? null)}</b></>
88 − : <>Premier prix observé : <b>{fmtPrice(it.prix ?? null)}</b></>
89 − ) : (
90 − EVENT_LABEL[it.type] ?? it.type
91 − )}
92 − </span>
93 − </li>
94 − ))}
95 − </ul>
96 − )}
97 − {items.length === 0 && (
98 − <p className="fine">
99 − Aucune modification observée depuis la première synchronisation de
100 − cette annonce.
101 − </p>
102 − )}
103 −
104 − {matches.length > 0 && (
105 − <div className="hl-recyclees">
106 − <h3>Vies antérieures probables de ce logement</h3>
107 − {matches.slice(0, 3).map((m) => (
108 − <div className="hl-match" key={m.uid}>
109 − <div>
110 − <span className="zi-badge zi-modere">
111 − republication probable ({m.confiance}{NBSP}% de confiance)
112 − </span>{" "}
113 − {m.prix != null && <>affiché {fmtPrice(m.prix)}</>}
114 − {m.derniere_observation != null && <> jusqu'en {fmtTs(m.derniere_observation)}</>}
115 − </div>
116 − <div className="hl-signaux">{m.signaux.join(" · ")}</div>
117 − </div>
118 − ))}
119 − <p className="fine">{rec?.methode} — inférence, sans fusion automatique.</p>
120 − </div>
121 − )}
122 −
123 − <p className="fine">{d.methode}</p>
124 − </section>
125 − );
126 −}
deleted frontend/src/components/HistoriqueTAL.tsx +0 −105
@@ -1,105 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/HistoriqueTAL.tsx : bloc « Historique TAL » (fiche)
5 −// Décisions du Tribunal administratif du logement (diffusées par SOQUIJ)
6 −// rendues à l'adresse de l'annonce : le locataire potentiel voit si
7 −// l'immeuble a un historique de litiges (non-paiement, résiliation,
8 −// expulsion, reprise/éviction, insalubrité…) et qui était à l'initiative
9 −// du recours (propriétaire ou locataire). Par respect de la vie privée,
10 −// aucun nom de partie n'est affiché — seulement la référence neutre
11 −// (ex. « 2026 QCTAL 21509 ») avec lien vers le texte intégral sur SOQUIJ.
12 −// -----------------------------------------------------------------------------
13 −import { useEffect, useState } from "react";
14 −import { fetchTal, TalHistory } from "../api";
15 −
16 −const fmtDate = (d: string | null): string => {
17 − if (!d) return "—";
18 − const dt = new Date(d + "T00:00:00");
19 − return Number.isNaN(dt.getTime())
20 − ? d
21 − : dt.toLocaleDateString("fr-CA", { month: "long", year: "numeric" });
22 −};
23 −
24 −export default function HistoriqueTAL({ address, city }:
25 − { address: string | null; city: string | null }) {
26 − const [d, setD] = useState<TalHistory | null>(null);
27 − useEffect(() => {
28 − setD(null);
29 − if (!address) return;
30 − fetchTal(address, city).then(setD).catch(() => setD(null));
31 − }, [address, city]);
32 − if (!address || !d || d.status === "na" || d.status === "error") return null;
33 −
34 − const decisions = d.decisions ?? [];
35 − const alerte = (d.eviction ?? 0) > 0 || (d.contre_locataire ?? 0) > 0;
36 − return (
37 − <section className="f-bloc f-tal" id="historique-tal">
38 − <h2>Historique au TAL</h2>
39 − {d.status === "pending" && (
40 − <p className="tal-pending">
41 − <span className="zi-badge zi-nc">Vérification en cours</span>{" "}
42 − Cette adresse est en file de vérification auprès des décisions
43 − publiées du Tribunal administratif du logement.
44 − </p>
45 − )}
46 − {d.status === "ok" && decisions.length === 0 && (
47 − <p className="tal-ok">
48 − <span className="zi-badge zi-ok">Aucune décision trouvée</span>{" "}
49 − Aucune décision publiée du Tribunal administratif du logement n'a
50 − été repérée à cette adresse.
51 − </p>
52 − )}
53 − {d.status === "ok" && decisions.length > 0 && (
54 − <>
55 − <p className="tal-resume">
56 − <span className={`zi-badge ${alerte ? "zi-modere" : "zi-nc"}`}>
57 − {d.n} décision{(d.n ?? 0) > 1 ? "s" : ""} au TAL
58 − </span>{" "}
59 − {d.contre_locataire
60 − ? `dont ${d.contre_locataire} à l'initiative du propriétaire`
61 − : "aucune à l'initiative du propriétaire"}
62 − {d.last_date ? ` — la plus récente : ${fmtDate(d.last_date)}` : ""}.
63 − </p>
64 − <ul className="tal-liste">
65 − {decisions.slice(0, 8).map((dec) => (
66 − <li key={dec.url}>
67 − <div className="tal-ligne">
68 − <span className="tal-date">{fmtDate(dec.date)}</span>
69 − {dec.demandeur && (
70 − <span className={`tal-part tal-part-${dec.demandeur}`}>
71 − demande du {dec.demandeur}
72 − </span>
73 − )}
74 − {dec.verdict && (
75 − <span className="tal-verdict">demande {dec.verdict}</span>
76 − )}
77 − </div>
78 − {dec.tags.length > 0 && (
79 − <div className="tal-tags">
80 − {dec.tags.map((t) => (
81 − <span className="tal-tag" key={t}>{t}</span>
82 − ))}
83 − </div>
84 − )}
85 − <a href={dec.url} target="_blank" rel="noopener noreferrer"
86 − className="tal-ref">
87 − {dec.citation || "Texte intégral"} — SOQUIJ
88 − </a>
89 − </li>
90 − ))}
91 − </ul>
92 − </>
93 − )}
94 − <p className="fine">
95 − Décisions publiques du{" "}
96 − <a href="https://www.tal.gouv.qc.ca/" target="_blank"
97 − rel="noopener noreferrer">Tribunal administratif du logement</a>,
98 − diffusées par SOQUIJ (citoyens.soquij.qc.ca) et repérées par
99 − correspondance d'adresse dans le texte des décisions — vérification
100 − automatisée fournie à titre indicatif, sans valeur juridique. Une
101 − décision peut concerner un autre logement du même immeuble.
102 − </p>
103 − </section>
104 − );
105 −}
deleted frontend/src/components/HiverScore.tsx +0 −43
@@ -1,43 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/HiverScore.tsx : bloc « Vie quotidienne en hiver » (fiche)
5 −// Peut-on vivre son quotidien à pied à -20 °C? Score 0-100 déterministe
6 −// (louka/hiver.py) : épicerie, pharmacie, bus/métro, dépanneur à distance
7 −// de marche. La méthodologie assume ce qui n'est PAS pris en compte.
8 −// -----------------------------------------------------------------------------
9 −import { Hiver } from "../api";
10 −
11 −const NBSP = " ";
12 −
13 −const CLS: Record<string, string> = {
14 − "très pratique": "zi-ok", "pratique": "zi-ok",
15 − "exigeant": "zi-modere", "difficile": "zi-eleve",
16 −};
17 −
18 −export default function HiverScore({ h }: { h: Hiver }) {
19 − return (
20 − <section className="f-bloc f-hiver" id="hiver">
21 − <h2>Vie quotidienne en hiver</h2>
22 − <div className="hiver-head">
23 − <span className="hiver-score">{h.score}</span>
24 − <span className={`zi-badge ${CLS[h.classe] ?? "zi-nc"}`}>{h.classe}</span>
25 − <span className="hiver-sur">quotidien à pied, même à −20{NBSP}°C</span>
26 − </div>
27 − <ul className="hiver-detail">
28 − {h.detail.map((c) => (
29 − <li key={c.critere}>
30 − <span className="hd-crit">{c.critere}</span>
31 − <span className="hd-val">
32 − {c.distance_m != null
33 − ? `≈${NBSP}${c.minutes}${NBSP}min à pied${c.nom ? ` (${c.nom})` : ""}`
34 − : c.note}
35 − </span>
36 − <span className="hd-score">{c.score}</span>
37 − </li>
38 − ))}
39 − </ul>
40 − <p className="fine">{h.methode}</p>
41 − </section>
42 − );
43 −}
deleted frontend/src/components/HydroEstimation.tsx +0 −81
@@ -1,81 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/HydroEstimation.tsx : bloc « Coût d'électricité » (fiche)
5 −// Estimation Hydro-Québec du coût annuel d'électricité à l'adresse
6 −// (louka/hydro.py). Les annonces récentes sont pré-calculées et mises en
7 −// cache : le bloc s'affiche alors automatiquement. Sinon un bouton permet
8 −// de lancer le calcul à la demande (une résolution de captcha par appel).
9 −// -----------------------------------------------------------------------------
10 −import { useEffect, useState } from "react";
11 −import { fetchHydro, fmtPrice, HydroEstimate } from "../api";
12 −
13 −export default function HydroEstimation({ adresse, uid, lat, lng }:
14 − { adresse: string | null; uid?: string;
15 − lat?: number | null; lng?: number | null }) {
16 − const [d, setD] = useState<HydroEstimate | null>(null);
17 − const [loading, setLoading] = useState(false);
18 − const [masque, setMasque] = useState(false);
19 −
20 − // au montage : lecture du cache uniquement (aucun captcha, aucun coût)
21 − useEffect(() => {
22 − setD(null);
23 − if (!adresse) return;
24 − fetchHydro(adresse, { uid, lat, lng }, false)
25 − .then((r) => {
26 − if (r.disponible || r.en_attente) setD(r);
27 − else if (/captcha|configuré|incomplète/.test(r.raison || ""))
28 − setMasque(true); // service off ou adresse inexploitable
29 − else setD(r);
30 − })
31 − .catch(() => setMasque(true));
32 − }, [adresse, uid, lat, lng]);
33 −
34 − if (masque || !adresse || !d) return null;
35 −
36 − const lancer = () => {
37 − setLoading(true);
38 − fetchHydro(adresse, { uid, lat, lng }, true)
39 − .then((r) => {
40 − if (!r.disponible && /captcha|configuré/.test(r.raison || ""))
41 − setMasque(true);
42 − else setD(r);
43 − })
44 − .catch(() => setMasque(true))
45 − .finally(() => setLoading(false));
46 − };
47 −
48 − return (
49 − <section className="f-bloc f-hydro" id="hydro">
50 − <h2>Coût d'électricité</h2>
51 − {d.disponible ? (
52 − <>
53 − <div className="hydro-montant">
54 − {fmtPrice(d.cout_mensuel!)} <small>/ mois</small>
55 − <span className="hydro-an">soit ~{fmtPrice(d.cout_annuel!)} / an</span>
56 − </div>
57 − <p className="fine">
58 − Estimation Hydro-Québec pour {d.adresse}
59 − {d.kwh_annuel ? ` — ${d.kwh_annuel.toLocaleString("fr-CA")} kWh/an` : ""},
60 − fondée sur la consommation réelle du logement. Le montant réel
61 − varie selon l'occupation et les habitudes.
62 − </p>
63 − </>
64 − ) : d.en_attente ? (
65 − <>
66 − <p className="hydro-intro">
67 − Obtenez une estimation du coût annuel d'électricité pour ce
68 − logement, calculée par Hydro-Québec d'après sa consommation réelle.
69 − </p>
70 − <button className="hydro-btn" onClick={lancer} disabled={loading}>
71 − {loading ? "Estimation en cours…" : "Estimer le coût d'électricité"}
72 − </button>
73 − </>
74 − ) : (
75 − <p className="hydro-vide">
76 − Hydro-Québec n'a pas d'estimation pour cette adresse.
77 − </p>
78 − )}
79 − </section>
80 − );
81 −}
modified frontend/src/components/Icons.tsx +223 −0
@@ -223,3 +223,226 @@ export const IcoChevronRight = (p: P) => (
223 223 <path d="m9.5 5 7 7-7 7" />
224 224 </Base>
225 225 );
226 +
227 +/* ---------------------------------------------------------------------------
228 + Icônes de la fiche logement (refonte 2026-09-04) — même style linéaire,
229 + trait 2 px, 24×24. Nommage aligné sur Lucide pour la lisibilité du code.
230 +--------------------------------------------------------------------------- */
231 +export const IcoMapPin = (p: P) => (
232 + <Base {...p}>
233 + <path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 0 1 16 0z" />
234 + <circle cx="12" cy="10" r="3" />
235 + </Base>
236 +);
237 +export const IcoTrain = (p: P) => (
238 + <Base {...p}>
239 + <rect x="4" y="3" width="16" height="14" rx="3" />
240 + <path d="M4 11h16M8 21l2-4M16 21l-2-4M9 7h6" />
241 + <path d="M8.5 14h.01M15.5 14h.01" />
242 + </Base>
243 +);
244 +export const IcoBus = (p: P) => (
245 + <Base {...p}>
246 + <path d="M5 4h14a2 2 0 0 1 2 2v11H3V6a2 2 0 0 1 2-2z" />
247 + <path d="M3 11h18M7 17v3M17 17v3M7.5 14h.01M16.5 14h.01" />
248 + </Base>
249 +);
250 +export const IcoCart = (p: P) => (
251 + <Base {...p}>
252 + <path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h8.4a2 2 0 0 0 2-1.6L21 8H6" />
253 + <circle cx="10" cy="20" r="1.2" /><circle cx="17" cy="20" r="1.2" />
254 + </Base>
255 +);
256 +export const IcoPill = (p: P) => (
257 + <Base {...p}>
258 + <path d="M10.5 20.5 3.5 13.5a5 5 0 0 1 7-7l7 7a5 5 0 0 1-7 7z" />
259 + <path d="M7 10l7 7" />
260 + </Base>
261 +);
262 +export const IcoSchool = (p: P) => (
263 + <Base {...p}>
264 + <path d="M3 10 12 5l9 5" />
265 + <path d="M5 11v8h14v-8" />
266 + <path d="M10 19v-4h4v4M12 5V3" />
267 + </Base>
268 +);
269 +export const IcoTrees = (p: P) => (
270 + <Base {...p}>
271 + <path d="M9 3 4.5 10h2L4 15h10l-2.5-5h2L9 3zM9 15v6" />
272 + <path d="M16 8l-2.5 4h1.5l-1.5 3h5l-1.5-3h1.5L16 8zM16 15v5" />
273 + </Base>
274 +);
275 +export const IcoFuel = (p: P) => (
276 + <Base {...p}>
277 + <path d="M4 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16" />
278 + <path d="M3 21h12M6 8h6" />
279 + <path d="M14 10h2a2 2 0 0 1 2 2v5a1.5 1.5 0 0 0 3 0V9l-2.5-2.5" />
280 + </Base>
281 +);
282 +export const IcoWind = (p: P) => (
283 + <Base {...p}>
284 + <path d="M3 8h10a3 3 0 1 0-3-3" />
285 + <path d="M3 12h15a3 3 0 1 1-3 3" />
286 + <path d="M3 16h7" />
287 + </Base>
288 +);
289 +export const IcoDroplets = (p: P) => (
290 + <Base {...p}>
291 + <path d="M12 3s-6 6.5-6 10.5a6 6 0 0 0 12 0C18 9.5 12 3 12 3z" />
292 + </Base>
293 +);
294 +export const IcoWallet = (p: P) => (
295 + <Base {...p}>
296 + <path d="M3 7a2 2 0 0 1 2-2h13v4" />
297 + <rect x="3" y="7" width="18" height="12" rx="2" />
298 + <path d="M16 13h.01" />
299 + </Base>
300 +);
301 +export const IcoGraduation = (p: P) => (
302 + <Base {...p}>
303 + <path d="M2 9 12 4l10 5-10 5L2 9z" />
304 + <path d="M6 11.5V16c0 1.5 3 3 6 3s6-1.5 6-3v-4.5M22 9v5" />
305 + </Base>
306 +);
307 +export const IcoUsers = (p: P) => (
308 + <Base {...p}>
309 + <circle cx="9" cy="8" r="3.2" />
310 + <path d="M3.5 20c0-3.2 2.5-5.3 5.5-5.3s5.5 2.1 5.5 5.3" />
311 + <path d="M15.5 5.2a3.2 3.2 0 0 1 0 5.6M20.5 20c0-2.7-1.8-4.7-4.3-5.2" />
312 + </Base>
313 +);
314 +export const IcoShare = (p: P) => (
315 + <Base {...p}>
316 + <circle cx="18" cy="5" r="2.5" /><circle cx="6" cy="12" r="2.5" /><circle cx="18" cy="19" r="2.5" />
317 + <path d="M8.2 10.8 15.8 6.3M8.2 13.2l7.6 4.5" />
318 + </Base>
319 +);
320 +export const IcoSparkles = (p: P) => (
321 + <Base {...p}>
322 + <path d="M12 3l1.9 5.6L19.5 10.5l-5.6 1.9L12 18l-1.9-5.6L4.5 10.5l5.6-1.9L12 3z" />
323 + <path d="M19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8L19 16z" />
324 + </Base>
325 +);
326 +export const IcoBed = (p: P) => (
327 + <Base {...p}>
328 + <path d="M3 18V7M3 13h18v5M3 10h7v3M21 13v-1a3 3 0 0 0-3-3h-8" />
329 + </Base>
330 +);
331 +export const IcoBath = (p: P) => (
332 + <Base {...p}>
333 + <path d="M4 12h16v2a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5v-2z" />
334 + <path d="M6 12V6a2.5 2.5 0 0 1 4.6-1.3M7 19l-1 2M17 19l1 2" />
335 + </Base>
336 +);
337 +export const IcoRuler = (p: P) => (
338 + <Base {...p}>
339 + <path d="M3.5 16 16 3.5l4.5 4.5L8 20.5 3.5 16z" />
340 + <path d="M7.5 16l1.5 1.5M10.5 13l1.5 1.5M13.5 10l1.5 1.5M16.5 7 18 8.5" />
341 + </Base>
342 +);
343 +export const IcoCalendar = (p: P) => (
344 + <Base {...p}>
345 + <rect x="4" y="5" width="16" height="16" rx="2" />
346 + <path d="M4 10h16M8 3v4M16 3v4" />
347 + </Base>
348 +);
349 +export const IcoChevronDown = (p: P) => (
350 + <Base {...p}><path d="m6 9 6 6 6-6" /></Base>
351 +);
352 +export const IcoClose = (p: P) => (
353 + <Base {...p}><path d="M18 6 6 18M6 6l12 12" /></Base>
354 +);
355 +export const IcoCheck = (p: P) => (
356 + <Base {...p}><path d="m5 12.5 4.5 4.5L19 7.5" /></Base>
357 +);
358 +export const IcoInfo = (p: P) => (
359 + <Base {...p}>
360 + <circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" />
361 + </Base>
362 +);
363 +export const IcoExternal = (p: P) => (
364 + <Base {...p}>
365 + <path d="M14 4h6v6M20 4l-9 9" />
366 + <path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" />
367 + </Base>
368 +);
369 +export const IcoExpand = (p: P) => (
370 + <Base {...p}>
371 + <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
372 + </Base>
373 +);
374 +export const IcoWaves = (p: P) => (
375 + <Base {...p}>
376 + <path d="M3 8c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0" />
377 + <path d="M3 13c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0" />
378 + <path d="M3 18c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0" />
379 + </Base>
380 +);
381 +export const IcoShield = (p: P) => (
382 + <Base {...p}>
383 + <path d="M12 3 5 6v5.5c0 4.4 3 7.6 7 9.5 4-1.9 7-5.1 7-9.5V6l-7-3z" />
384 + </Base>
385 +);
386 +export const IcoThermo = (p: P) => (
387 + <Base {...p}>
388 + <path d="M10 14.5V5a2 2 0 1 1 4 0v9.5a3.5 3.5 0 1 1-4 0z" />
389 + </Base>
390 +);
391 +export const IcoSnow = (p: P) => (
392 + <Base {...p}>
393 + <path d="M12 3v18M4 7.5l16 9M20 7.5l-16 9" />
394 + </Base>
395 +);
396 +export const IcoScale = (p: P) => (
397 + <Base {...p}>
398 + <path d="M12 3v18M5 21h14M12 6l7 2-3 7h-4M12 6 5 8l3 7h4" />
399 + </Base>
400 +);
401 +export const IcoTrendDown = (p: P) => (
402 + <Base {...p}><path d="m3 7 7 7 4-4 7 7M15 17h6v-6" /></Base>
403 +);
404 +export const IcoTrendUp = (p: P) => (
405 + <Base {...p}><path d="m3 17 7-7 4 4 7-7M15 7h6v6" /></Base>
406 +);
407 +export const IcoCoffee = (p: P) => (
408 + <Base {...p}>
409 + <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" />
410 + </Base>
411 +);
412 +export const IcoHospital = (p: P) => (
413 + <Base {...p}>
414 + <rect x="4" y="4" width="16" height="16" rx="2" />
415 + <path d="M12 8v8M8 12h8" />
416 + </Base>
417 +);
418 +export const IcoBook = (p: P) => (
419 + <Base {...p}>
420 + <path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2V5z" />
421 + <path d="M4 19a2 2 0 0 0 2 2h13" />
422 + </Base>
423 +);
424 +export const IcoDumbbell = (p: P) => (
425 + <Base {...p}>
426 + <path d="M6.7 6.7v10.6M17.3 6.7v10.6M3.5 9.2v5.6M20.5 9.2v5.6M6.7 12h10.6" />
427 + </Base>
428 +);
429 +export const IcoBaby = (p: P) => (
430 + <Base {...p}>
431 + <circle cx="12" cy="9" r="5" />
432 + <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" />
433 + </Base>
434 +);
435 +export const IcoStore = (p: P) => (
436 + <Base {...p}>
437 + <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" />
438 + <path d="M5 14v6h14v-6M10 20v-4h4v4" />
439 + </Base>
440 +);
441 +export const IcoBolt2 = (p: P) => (
442 + <Base {...p}><path d="M13 2 4.5 13.5H11l-1.5 8.5 8.5-11.5H12.5L13 2z" /></Base>
443 +);
444 +export const IcoLayers = (p: P) => (
445 + <Base {...p}>
446 + <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" />
447 + </Base>
448 +);
deleted frontend/src/components/ImmeubleBloc.tsx +0 −113
@@ -1,113 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/ImmeubleBloc.tsx : bloc « Passeport de l'immeuble » (fiche)
5 −// Tout est calculé sur ce que Lou-Ka observe réellement (annonces actives
6 −// ET historiques du même bâtiment) : unités, loyers médians, rotation,
7 −// pression sur les loyers. Un indicateur sans échantillon suffisant est
8 −// affiché « données insuffisantes » — jamais inventé.
9 −// -----------------------------------------------------------------------------
10 −import { Immeuble, fmtPrice } from "../api";
11 −
12 −const NBSP = " ";
13 −
14 −const fmtTs = (ts: number | null | undefined): string =>
15 − ts
16 − ? new Date(ts * 1000).toLocaleDateString("fr-CA", { month: "long", year: "numeric" })
17 − : "—";
18 −
19 −const ROTATION_CLS: Record<string, string> = {
20 − "faible": "zi-ok",
21 − "normale": "zi-nc",
22 − "élevée": "zi-modere",
23 − "très élevée": "zi-eleve",
24 −};
25 −
26 −export default function ImmeubleBloc({ im }: { im: Immeuble }) {
27 − // au moins 2 annonces regroupées, sinon le « passeport » n'apporte rien
28 − if (!im || im.annonces_total < 2) return null;
29 − const rot = im.rotation;
30 − const pres = im.pression_loyers;
31 − const parCc = im.loyer_median_par_cc;
32 −
33 − return (
34 − <section className="f-bloc f-immeuble" id="immeuble">
35 − <h2>Passeport de l'immeuble</h2>
36 − <div className="kv">
37 − <div className="cell">
38 − <div className="k">Annonces observées</div>
39 − <div className="v">{im.annonces_total} <small>dont {im.annonces_actives} active{im.annonces_actives > 1 ? "s" : ""}</small></div>
40 − </div>
41 − <div className="cell">
42 − <div className="k">Unités estimées</div>
43 − <div className="v">≥{NBSP}{im.unites_estimees}</div>
44 − </div>
45 − {im.loyer_median != null && (
46 − <div className="cell">
47 − <div className="k">Loyer médian (actives)</div>
48 − <div className="v">{fmtPrice(im.loyer_median)}</div>
49 − </div>
50 − )}
51 − {im.pi2_median != null && (
52 − <div className="cell">
53 − <div className="k">Médiane $/pi²</div>
54 − <div className="v">{im.pi2_median.toFixed(2).replace(".", ",")}{NBSP}$</div>
55 − </div>
56 − )}
57 − </div>
58 −
59 − {parCc && Object.keys(parCc).length > 0 && (
60 − <p className="im-parcc">
61 − Par nombre de chambres :{" "}
62 − {Object.entries(parCc)
63 − .map(([cc, v]) => `${cc}${NBSP}ch. ${fmtPrice(v)}`)
64 − .join(" · ")}
65 − </p>
66 − )}
67 −
68 − <div className="im-indicateurs">
69 − <div className="im-indic">
70 − <span className="k">Rotation des logements</span>{" "}
71 − {rot.statut === "calculated" ? (
72 − <>
73 − <span className={`zi-badge ${ROTATION_CLS[rot.classe ?? ""] ?? "zi-nc"}`}>
74 − {rot.classe}
75 − </span>{" "}
76 − <span className="im-detail">
77 − {rot.annonces_12m} annonce{(rot.annonces_12m ?? 0) > 1 ? "s" : ""} sur
78 − 12 mois pour ≥{NBSP}{rot.unites_estimees} unités
79 − </span>
80 − </>
81 − ) : (
82 − <span className="zi-badge zi-nc">données insuffisantes</span>
83 − )}
84 − </div>
85 − <div className="im-indic">
86 − <span className="k">Pression sur les loyers</span>{" "}
87 − {pres ? (
88 − <>
89 − <span className={`zi-badge ${pres.variation_12m > 0.05 ? "zi-modere" : pres.variation_12m < -0.02 ? "zi-ok" : "zi-nc"}`}>
90 − {pres.variation_12m > 0 ? "+" : "−"}
91 − {Math.abs(Math.round(pres.variation_12m * 100))}{NBSP}% sur 12 mois
92 − </span>{" "}
93 − <span className="im-detail">
94 − médiane des prix d'entrée : {fmtPrice(pres.mediane_12_24m)} →{" "}
95 − {fmtPrice(pres.mediane_12m)} ({pres.n_12_24m} vs {pres.n_12m} annonces)
96 − </span>
97 − </>
98 − ) : (
99 − <span className="zi-badge zi-nc">échantillon insuffisant</span>
100 − )}
101 − </div>
102 − </div>
103 −
104 − <p className="fine">
105 − Immeuble suivi par Lou-Ka depuis {fmtTs(im.premiere_observation)}
106 − {rot.statut === "calculated" && rot.methode ? ` — ${rot.methode}` : (
107 − " — indicateurs calculés uniquement sur les annonces observées par " +
108 − "les synchronisations Lou-Ka (pas un recensement du bâtiment)."
109 − )}
110 − </p>
111 − </section>
112 − );
113 −}
deleted frontend/src/components/KaScoresBlock.tsx +0 −125
@@ -1,125 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/KaScoresBlock.tsx : section « KA Scores » de la fiche d'annonce.
5 −// Pastille globale + 5 jauges (Walk/Transit/Bike/Calme/Services), détail
6 −// honnête par score, score personnalisé selon les pondérations de
7 −// l'utilisateur (/ka-scores), lien vers la méthodologie.
8 −// -----------------------------------------------------------------------------
9 −import { Link } from "react-router-dom";
10 −import { KaScores, KA_DEFAULT_WEIGHTS, fmtDist, kaGlobal, kaWeights } from "../api";
11 −import KaScoreBadge, { KaScoreCircle } from "./KaScoreBadge";
12 −
13 −const CAT_LABELS: Record<string, string> = {
14 − epicerie: "Épicerie", pharmacie: "Pharmacie", parc: "Parc", cafe: "Café",
15 − ecole: "École", clinique: "Clinique", garderie: "Garderie",
16 − depanneur: "Dépanneur", gym: "Gym", bibliotheque: "Bibliothèque",
17 −};
18 −const FAM_LABELS: Record<string, string> = {
19 − commerces: "commerces", sante: "santé", education: "éducation",
20 − loisirs: "loisirs",
21 −};
22 −
23 −export default function KaScoresBlock({ ks }: { ks: KaScores }) {
24 − const weights = kaWeights();
25 − const custom = JSON.stringify(weights) !== JSON.stringify(KA_DEFAULT_WEIGHTS);
26 − const perso = custom ? kaGlobal(ks, weights) : null;
27 − const d = ks.details ?? {};
28 − const walkCats = (d.walk?.cats ?? []).filter((c) => c.dist_m != null).slice(0, 5);
29 −
30 − return (
31 − <section className="f-bloc f-kascores" id="ka-scores">
32 − <h2>
33 − KA Scores
34 − {ks.global != null && <KaScoreBadge score={ks.global} />}
35 − {perso != null && (
36 − <span className="ka-perso" title="Score global recalculé selon vos priorités (réglées sur la page KA Scores)">
37 − vous : <b>{Math.round(perso)}</b>
38 − </span>
39 − )}
40 − </h2>
41 −
42 − <div className="ka-circles">
43 − <KaScoreCircle score={ks.walk} nom="Marche" />
44 − <KaScoreCircle score={ks.transit} nom="Transport"
45 − note={ks.transit == null ? "Non desservi" : undefined} />
46 − <KaScoreCircle score={ks.bike} nom="Vélo" />
47 − <KaScoreCircle score={ks.calme} nom="Calme" />
48 − <KaScoreCircle score={ks.services} nom="Services" />
49 − </div>
50 −
51 − <details className="ka-detail">
52 − <summary>Le détail des scores de ce secteur</summary>
53 − <div className="ka-detail-grille">
54 − {walkCats.length > 0 && (
55 − <div>
56 − <h4>Marche</h4>
57 − <ul>
58 − {walkCats.map((c) => (
59 − <li key={c.cat}>
60 − {CAT_LABELS[c.cat] ?? c.cat} : {fmtDist(c.dist_m as number)}
61 − {" "}{c.pts >= 99 ? "✓" : c.pts >= 50 ? "~" : "·"}
62 − </li>
63 − ))}
64 − </ul>
65 − </div>
66 − )}
67 − <div>
68 − <h4>Transport</h4>
69 − <ul>
70 − {d.transit?.arret_bus_m != null && (
71 − <li>Arrêt de bus à {fmtDist(d.transit.arret_bus_m)}</li>
72 − )}
73 − {d.transit?.station_metro_m != null && (
74 − <li>Station de métro à {fmtDist(d.transit.station_metro_m)}</li>
75 − )}
76 − {d.transit?.pmd_percentile != null && (
77 − <li>Desserte du secteur : {d.transit.pmd_percentile}ᵉ percentile canadien (StatCan)</li>
78 − )}
79 − {d.transit?.raison && <li>{d.transit.raison}</li>}
80 − </ul>
81 − <h4>Vélo</h4>
82 − <ul>
83 − {d.bike?.km_cyclables_1km != null && (
84 − <li>{d.bike.km_cyclables_1km.toLocaleString("fr-CA")} km de voies cyclables à moins de 1 km</li>
85 − )}
86 − {d.bike?.raison && <li>{d.bike.raison}</li>}
87 − </ul>
88 − </div>
89 − <div>
90 − <h4>Calme</h4>
91 − <ul>
92 − {(d.calme?.sources_bruit ?? []).length === 0 && (
93 − <li>Aucune source de bruit majeure détectée à proximité</li>
94 − )}
95 − {(d.calme?.sources_bruit ?? []).map((s) => (
96 − <li key={s.source}>
97 − {s.source}{s.dist_m != null ? ` à ${fmtDist(s.dist_m)}` : ""}
98 − </li>
99 − ))}
100 − {(d.calme?.bonus_parc ?? 0) > 0 && <li>Parc à proximité ✓</li>}
101 − </ul>
102 − {d.services?.familles && (
103 − <>
104 − <h4>Services</h4>
105 − <ul>
106 − {Object.entries(d.services.familles).map(([f, n]) => (
107 − <li key={f}>{n} {FAM_LABELS[f] ?? f} dans le secteur</li>
108 − ))}
109 − </ul>
110 − </>
111 − )}
112 − </div>
113 − </div>
114 − </details>
115 −
116 − <p className="fine">
117 − Scores 0-100 calculés depuis OpenStreetMap et les mesures de proximité
118 − de Statistique Canada — le Calme est une estimation d'environnement,
119 − pas une mesure sonore. Calculé le{" "}
120 − {new Date(ks.computed_at * 1000).toLocaleDateString("fr-CA")} (barème {ks.version}).{" "}
121 − <Link to="/ka-scores">Comment sont calculés les KA Scores ? — et régler vos priorités</Link>
122 − </p>
123 − </section>
124 − );
125 −}
deleted frontend/src/components/ListingMap3D.tsx +0 −60
@@ -1,60 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Author: Simon-Pierre Boucher
3 −// Contact: contact@spboucher.ai
4 −// Project: Groupe Ka / Ka Maps (Lou-Ka integration)
5 −// components/ListingMap3D.tsx : mini-carte 3D de la fiche — caméra serrée
6 −// sur l'adresse (zoom 17, inclinaison 62°), fond Mapbox Standard réaliste,
7 −// et l'immeuble de l'annonce surligné EN ROUGE (featureset « buildings »
8 −// via KaSpotlightMap). Chargée paresseusement depuis Listing.tsx.
9 −// -----------------------------------------------------------------------------
10 −import MetroLignes from "./MetroLignes";
11 −import { useMemo } from "react";
12 −import "mapbox-gl/dist/mapbox-gl.css";
13 −import "@groupe-ka/ka-maps/styles.css";
14 −import type { MapProperty } from "@groupe-ka/ka-maps";
15 −import { KaBrandBadge, KaSpotlightMap } from "@groupe-ka/ka-maps/react";
16 −import type { Listing } from "../api";
17 −import { louKaMapTheme } from "../kamaps/theme";
18 −import { MAPBOX_TOKEN } from "../kamaps/config";
19 −
20 −/** Rouge signal de la fiche — même langage que les badges d'alerte. */
21 −export const BUILDING_RED = "#ff6a00"; // orange officiel Lou-Ka
22 −
23 −export default function ListingMap3D({ l }: { l: Listing }) {
24 − const property = useMemo<MapProperty | null>(() => {
25 − if (l.lat == null || l.lng == null) return null;
26 − return {
27 − id: l.uid,
28 − appSource: "lou-ka",
29 − latitude: l.lat,
30 − longitude: l.lng,
31 − kind: "listing",
32 − listingType: "rent",
33 − price: l.price ?? undefined,
34 − propertyType: l.unit_type || undefined,
35 − address: l.address || l.title || undefined,
36 − city: l.city || undefined,
37 − thumbnailUrl: l.images?.[0],
38 − };
39 − }, [l.uid, l.lat, l.lng, l.price, l.unit_type, l.address, l.title, l.city, l.images]);
40 −
41 − if (!property) return null;
42 −
43 − return (
44 − <div className="lmap3d" role="img"
45 − aria-label={`Carte 3D — ${l.address || l.title}, bâtiment de l'annonce en orange`}>
46 − <KaSpotlightMap
47 − theme={louKaMapTheme}
48 − mapboxToken={MAPBOX_TOKEN}
49 − property={property}
50 − buildingColor={BUILDING_RED}
51 − >
52 − <KaBrandBadge />
53 − <MetroLignes />
54 − </KaSpotlightMap>
55 − <span className="lmap3d-legende" aria-hidden="true">
56 − <i /> Immeuble de l'annonce
57 − </span>
58 − </div>
59 − );
60 −}
deleted frontend/src/components/PriceAnalysis.tsx +0 −110
@@ -1,110 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/PriceAnalysis.tsx : bloc « Analyse de prix Lou-Ka » (fiche)
5 −// Juste valeur estimée + fourchette + écart + confiance + mini-histogramme
6 −// de la position du loyer dans la distribution du segment de marché.
7 −// Estimation indicative calculée sur les annonces comparables (fairvalue.py).
8 −// -----------------------------------------------------------------------------
9 −import { useEffect, useState } from "react";
10 −import { Link } from "react-router-dom";
11 −import { FairValueDetail, fetchFairValue, fmtPrice } from "../api";
12 −import FairValueBadge, { fmtDeviation } from "./FairValueBadge";
13 −
14 −const CONF_LABEL = { fort: "Confiance forte", moyen: "Confiance moyenne",
15 − faible: "Estimation indicative" } as const;
16 −
17 −/** Mini-histogramme : distribution des loyers du segment, marqueurs texte
18 − * pour « ce loyer » et la juste valeur (jamais la couleur seule). */
19 −function MiniHisto({ d, price }: { d: FairValueDetail; price: number }) {
20 − const bins = d.histogram;
21 − if (!bins || bins.length < 4) return null;
22 − const W = 320, H = 96, top = 18, bottom = 16;
23 − const lo = bins[0].x0, hi = bins[bins.length - 1].x1;
24 − if (hi <= lo) return null;
25 − const max = Math.max(...bins.map((b) => b.n), 1);
26 − const x = (v: number) => ((Math.min(Math.max(v, lo), hi) - lo) / (hi - lo)) * W;
27 − const bw = W / bins.length;
28 − const plotH = H - top - bottom;
29 − const priceX = x(price), fvX = x(d.fv);
30 − // étiquettes écartées si les deux marqueurs sont proches
31 − const close = Math.abs(priceX - fvX) < 64;
32 − const lblAnchor = (px: number) => (px < 56 ? "start" : px > W - 56 ? "end" : "middle");
33 − return (
34 − <svg className="fv-histo" viewBox={`0 0 ${W} ${H}`} role="img"
35 − aria-label={`Position du loyer (${fmtPrice(price)}) parmi ${d.segment_n} annonces comparables`}>
36 − {bins.map((b, i) => {
37 − const h = Math.max(1.5, (b.n / max) * plotH);
38 − const inBin = price >= b.x0 && price < b.x1;
39 − return (
40 − <rect key={i} x={i * bw + 1} y={H - bottom - h} rx="2"
41 − width={Math.max(1, bw - 2)} height={h}
42 − fill={inBin ? "var(--accent, #ff6a00)" : "rgba(204, 85, 0, 0.28)"}>
43 − <title>{`${b.x0} $ – ${b.x1} $ : ${b.n} annonce${b.n > 1 ? "s" : ""}`}</title>
44 − </rect>
45 − );
46 − })}
47 − {/* fourchette de juste valeur (bande) */}
48 − <rect x={x(d.fv_low)} y={H - bottom} width={Math.max(2, x(d.fv_high) - x(d.fv_low))}
49 − height="3.5" rx="1.5" fill="rgba(204, 85, 0, 0.45)" />
50 − {/* marqueur juste valeur */}
51 − <line x1={fvX} x2={fvX} y1={top - 2} y2={H - bottom} stroke="var(--accent-deep, #cc5500)"
52 − strokeWidth="1.6" strokeDasharray="3 3" />
53 − {!close && (
54 − <text x={fvX} y={top - 7} textAnchor={lblAnchor(fvX)}
55 − className="fv-histo-lbl fv-histo-lbl-fv">Juste valeur</text>
56 − )}
57 − {/* marqueur du loyer demandé */}
58 − <line x1={priceX} x2={priceX} y1={top - 2} y2={H - bottom}
59 − stroke="var(--ink, #141814)" strokeWidth="2" />
60 − <text x={priceX} y={close ? top - 7 : H - 4} textAnchor={lblAnchor(priceX)}
61 − className="fv-histo-lbl">Ce loyer{close ? " / juste valeur" : ""}</text>
62 − {/* bornes de l'axe */}
63 − <text x="1" y={H - 4} className="fv-histo-axis" textAnchor="start">{lo} $</text>
64 − <text x={W - 1} y={H - 4} className="fv-histo-axis" textAnchor="end">{hi} $</text>
65 − </svg>
66 − );
67 −}
68 −
69 −export default function PriceAnalysis({ uid, price }: { uid: string; price: number | null }) {
70 − const [d, setD] = useState<FairValueDetail | null>(null);
71 − useEffect(() => {
72 − setD(null);
73 − fetchFairValue(uid).then(setD).catch(() => setD(null));
74 − }, [uid]);
75 − if (!d || price == null) return null;
76 −
77 − const pct = fmtDeviation(d.deviation);
78 − return (
79 − <section className="f-bloc f-fairvalue" id="analyse-prix">
80 − <h2>Analyse de prix Lou-Ka</h2>
81 − <div className="fv-head">
82 − <div>
83 − <div className="fv-value">{fmtPrice(d.fv)} <small>/ mois</small></div>
84 − <div className="fv-range">
85 − Juste valeur estimée · fourchette {fmtPrice(d.fv_low)} – {fmtPrice(d.fv_high)}
86 − </div>
87 − </div>
88 − <FairValueBadge verdict={d.verdict} deviation={d.deviation} />
89 − </div>
90 − {d.verdict == null && (
91 − <p className="fine">
92 − {CONF_LABEL[d.confidence]} — pas assez de comparables fiables pour
93 − classer ce loyer ; l'estimation est fournie à titre indicatif.
94 − </p>
95 − )}
96 − <MiniHisto d={d} price={price} />
97 − <div className="fv-meta">
98 − {pct && <span>Écart : <b>{pct}</b> vs juste valeur</span>}
99 − <span>{CONF_LABEL[d.confidence]}</span>
100 − <span>{d.segment_n.toLocaleString("fr-CA")} annonces comparables</span>
101 − {d.comps > 0 && <span>{d.comps} voisines retenues</span>}
102 − </div>
103 − <p className="fine">
104 − Estimation indicative calculée en continu à partir des annonces
105 − comparables du marché — pas une évaluation officielle.{" "}
106 − <Link to="/juste-valeur">Comment est calculée la juste valeur ?</Link>
107 − </p>
108 − </section>
109 − );
110 −}
deleted frontend/src/components/QualiteAir.tsx +0 −86
@@ -1,86 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/QualiteAir.tsx : bloc « Qualité de l'air » (fiche)
5 −// Moyennes annuelles de la station RSQAQ (MELCCFP) la plus proche :
6 −// PM2.5, PST (particules en suspension totales), PM10, NO2, O3, SO2 —
7 −// chaque mesure située par rapport à son repère annuel (OMS 2021, ou la
8 −// norme québécoise RAA pour les PST) avec une barre de progression.
9 −// -----------------------------------------------------------------------------
10 −import { useEffect, useState } from "react";
11 −import { AirNearby, fetchAir } from "../api";
12 −
13 −const ORDRE = ["PM2.5", "PST", "PM10", "NO2", "O3", "SO2", "CO"];
14 −const NOMS: Record<string, string> = {
15 − "PM2.5": "Particules fines (PM2,5)",
16 − PST: "Particules totales (PST)",
17 − PM10: "Particules (PM10)",
18 − NO2: "Dioxyde d'azote (NO₂)",
19 − O3: "Ozone (O₃)", SO2: "Dioxyde de soufre (SO₂)", CO: "Monoxyde (CO)",
20 −};
21 −
22 −function badge(d: AirNearby): [string, string] {
23 − const pm = d.mesures?.["PM2.5"];
24 − if (pm?.ref) {
25 − if (pm.moyenne <= pm.ref) return ["zi-ok", "Air de très bonne qualité"];
26 − if (pm.moyenne <= 2 * pm.ref) return ["zi-ok", "Air de bonne qualité"];
27 − if (pm.moyenne <= 3 * pm.ref) return ["zi-modere", "Qualité passable"];
28 − return ["zi-eleve", "Particules élevées"];
29 − }
30 − const pst = d.mesures?.["PST"];
31 − if (pst?.ref)
32 − return pst.moyenne <= pst.ref
33 − ? ["zi-ok", "Particules sous la norme"]
34 − : ["zi-eleve", "Particules au-dessus de la norme"];
35 − return ["zi-nc", "Mesures disponibles"];
36 −}
37 −
38 −export default function QualiteAir({ lat, lng }:
39 − { lat: number | null; lng: number | null }) {
40 − const [d, setD] = useState<AirNearby | null>(null);
41 − useEffect(() => {
42 − setD(null);
43 − if (lat == null || lng == null) return;
44 − fetchAir(lat, lng).then(setD).catch(() => setD(null));
45 − }, [lat, lng]);
46 − if (lat == null || lng == null || !d || !d.station) return null;
47 −
48 − const pols = ORDRE.filter((p) => d.mesures[p]);
49 − if (pols.length === 0) return null;
50 − const [cls, label] = badge(d);
51 − return (
52 − <section className="f-bloc f-air" id="qualite-air">
53 − <h2>Qualité de l'air</h2>
54 − <div className="zi-head"><span className={`zi-badge ${cls}`}>{label}</span></div>
55 − <ul className="air-liste">
56 − {pols.map((p) => {
57 − const m = d.mesures[p];
58 − const pct = m.ref ? Math.min(150, (m.moyenne / m.ref) * 100) : null;
59 − return (
60 − <li key={p}>
61 − <span className="air-nom">{NOMS[p] ?? p}</span>
62 − <span className="air-barre" aria-hidden="true">
63 − {pct != null && (
64 − <i className={pct > 100 ? "air-sur" : ""}
65 − style={{ width: `${Math.max(4, Math.min(100, pct * 2 / 3))}%` }} />
66 − )}
67 − </span>
68 − <span className="air-val">
69 − {m.moyenne.toLocaleString("fr-CA")} <small>{m.unite}</small>
70 − {m.ref != null && (
71 − <small className="air-ref"> · repère {m.ref_nom} : {m.ref}</small>
72 − )}
73 − </span>
74 − </li>
75 − );
76 − })}
77 − </ul>
78 − <p className="fine">
79 − Moyennes annuelles {Object.values(d.mesures)[0]?.annee} mesurées à la
80 − station <b>{d.station}</b> ({d.ville}, à {d.distance_km} km) — Réseau de
81 − surveillance de la qualité de l'air du Québec (MELCCFP, données
82 − ouvertes). L'air à l'adresse peut différer localement.
83 − </p>
84 − </section>
85 − );
86 −}
deleted frontend/src/components/QuartierBlock.tsx +0 −163
@@ -1,163 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/QuartierBlock.tsx : section « Le quartier » de la fiche —
5 −// démographie du recensement 2021 (aire de diffusion ~500 hab.), scores de
6 −// proximité StatCan, îlot de chaleur/fraîcheur INSPQ, criminalité.
7 −// -----------------------------------------------------------------------------
8 −import { Quartier } from "../api";
9 −
10 −const fmtMoney = (v: number | null | undefined) =>
11 − v == null ? null : v.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $";
12 −const fmtPct = (v: number | null | undefined) =>
13 − v == null ? null : Math.round(v) + " %";
14 −
15 −// scores PMD affichés (clé backend -> libellé)
16 −const PROX_LABELS: [string, string][] = [
17 − ["prox_epicerie", "Épiceries"],
18 − ["prox_transport", "Transport en commun"],
19 − ["prox_parc", "Parcs"],
20 − ["prox_ecole_prim", "Écoles primaires"],
21 − ["prox_sante", "Soins de santé"],
22 − ["prox_pharmacie", "Pharmacies"],
23 −];
24 −
25 −function chaleurBadge(classe: number, ecart: number | null) {
26 − if (classe <= 3)
27 − return { txt: "Îlot de fraîcheur", cls: "q-badge cool", ico: "🌿" };
28 − if (classe >= 7)
29 − return {
30 − txt: `Îlot de chaleur${ecart != null ? ` (+${ecart.toFixed(1)} °C)` : ""}`,
31 − cls: "q-badge hot", ico: "🌡",
32 − };
33 − return { txt: "Température de quartier moyenne", cls: "q-badge neutral", ico: "🌤" };
34 −}
35 −
36 −export default function QuartierBlock({ q }: { q: Quartier }) {
37 − const d = q.demographie;
38 − const stats: [string, string | null][] = d
39 − ? [
40 − ["Revenu médian des ménages", fmtMoney(d.revenu_median)],
41 − ["Ménages locataires", fmtPct(d.pct_locataires)],
42 − ["Loyer moyen du secteur", fmtMoney(d.loyer_moyen)],
43 − ["Âge médian", d.age_median != null ? `${Math.round(d.age_median)} ans` : null],
44 − ["Français à la maison", fmtPct(d.pct_francais)],
45 − ["Diplôme universitaire", fmtPct(d.pct_univ)],
46 − ]
47 − : [];
48 − const statsOk = stats.filter(([, v]) => v != null) as [string, string][];
49 − const prox = q.proximite ?? {};
50 − const proxOk = PROX_LABELS.filter(([k]) => typeof prox[k] === "number");
51 −
52 − if (statsOk.length === 0 && proxOk.length === 0 && !q.chaleur && !q.crime)
53 − return null;
54 −
55 − return (
56 − <section className="quartier">
57 − <h2>Le quartier</h2>
58 − <p className="q-sub">
59 − Secteur immédiat de l'immeuble (aire de diffusion du recensement, ± 500 habitants).
60 − </p>
61 −
62 − {statsOk.length > 0 && (
63 − <div className="q-grid">
64 − {statsOk.map(([label, val]) => (
65 − <div className="q-cell" key={label}>
66 − <div className="q-val">{val}</div>
67 − <div className="q-label">{label}</div>
68 − </div>
69 − ))}
70 − </div>
71 − )}
72 −
73 − {proxOk.length > 0 && (
74 − <div className="q-prox">
75 − {proxOk.map(([k, label]) => {
76 − const v = Math.max(0, Math.min(1, prox[k]));
77 − return (
78 − <div className="q-bar" key={k}>
79 − <span className="q-bar-label">{label}</span>
80 − <span className="q-bar-track">
81 − <span className="q-bar-fill" style={{ width: `${Math.round(v * 100)}%` }} />
82 − </span>
83 − <span className="q-bar-num">{Math.round(v * 100)}</span>
84 − </div>
85 − );
86 − })}
87 − <div className="fine">Accessibilité 0–100 — mesures de proximité de Statistique Canada.</div>
88 − </div>
89 − )}
90 −
91 − <div className="q-badges">
92 − {q.chaleur && (() => {
93 − const b = chaleurBadge(q.chaleur.classe, q.chaleur.ecart);
94 − return <span className={b.cls}>{b.ico} {b.txt}</span>;
95 − })()}
96 − {q.crime?.type === "points" && (
97 − <div className="q-crime">
98 − <span className="q-badge neutral">
99 − 🛡 {q.crime.douze_mois} acte{q.crime.douze_mois > 1 ? "s" : ""} criminel{q.crime.douze_mois > 1 ? "s" : ""} à
100 − moins de 500 m (12 mois)
101 − {q.crime.douze_mois_precedents > 0 && (
102 − q.crime.douze_mois <= q.crime.douze_mois_precedents
103 − ? ` · en baisse (${q.crime.douze_mois_precedents} l'année d'avant)`
104 − : ` · en hausse (${q.crime.douze_mois_precedents} l'année d'avant)`
105 − )}
106 − </span>
107 − {(q.crime.categories?.length ?? 0) > 0 && (
108 − <ul className="q-crime-cats">
109 − {q.crime.categories!.filter((c) => c.n + c.n_prec > 0).map((c) => {
110 − const max = Math.max(...q.crime!.type === "points"
111 − ? q.crime!.categories!.map((x) => x.n) : [1], 1);
112 − const delta = c.n - c.n_prec;
113 − return (
114 − <li key={c.nom}>
115 − <span className="q-crime-nom">{c.nom}</span>
116 − <span className="q-crime-barre" aria-hidden="true">
117 − <i style={{ width: `${Math.max(3, (c.n / max) * 100)}%` }} />
118 − </span>
119 − <span className="q-crime-n">{c.n}
120 − <small>{delta === 0 ? " =" : delta > 0
121 − ? ` ▲${delta}` : ` ▼${-delta}`}</small>
122 − </span>
123 − </li>
124 − );
125 − })}
126 − </ul>
127 − )}
128 − <p className="fine q-crime-src">
129 − Actes criminels enregistrés par le SPVM (données ouvertes,
130 − position approximée à l'intersection) — 12 derniers mois,
131 − variation vs les 12 précédents.
132 − </p>
133 − </div>
134 − )}
135 − {q.crime?.type === "igc" && (() => {
136 − const c = q.crime;
137 − if (c.indice_canada != null && c.indice_canada > 0) {
138 − const delta = Math.round(100 * (c.indice - c.indice_canada) / c.indice_canada);
139 − const sous = delta <= 0;
140 − return (
141 − <span className={`q-badge ${sous ? "cool" : "neutral"}`}>
142 − 🛡 Criminalité {Math.abs(delta)} % {sous ? "sous" : "au-dessus de"} la
143 − moyenne canadienne{sous ? " ✅" : ""}
144 − <small className="q-badge-sub">({c.ville} {c.annee} : {c.indice} · Canada : {c.indice_canada})</small>
145 − </span>
146 − );
147 − }
148 − return (
149 − <span className="q-badge neutral">
150 − 🛡 Gravité de la criminalité ({c.ville}, {c.annee}) : <b>{c.indice}</b>
151 − </span>
152 − );
153 − })()}
154 − </div>
155 −
156 − <div className="fine">
157 − Sources : Statistique Canada (Recensement 2021, licence ouverte), INSPQ
158 − (CC-BY 4.0){q.crime?.type === "points" ? ", Ville de Montréal (CC-BY 4.0)" : ""}.
159 − Statistiques du secteur, pas de l'immeuble.
160 − </div>
161 − </section>
162 − );
163 −}
deleted frontend/src/components/RegistreLoyers.tsx +0 −206
@@ -1,206 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/RegistreLoyers.tsx : bloc « Registre des loyers » (fiche)
5 −// Loyers réellement payés, déclarés volontairement par des locataires au
6 −// Registre des loyers (registre-des-loyers.ca, initiative de Vivre en
7 −// ville) — autour de l'adresse de l'annonce :
8 −// · bande de position : le loyer demandé vs la distribution déclarée
9 −// (p10–p90, boîte p25–p75, médiane) ;
10 −// · barres des médianes par nombre de chambres (celle de l'annonce
11 −// surlignée et étiquetée — jamais la couleur seule) ;
12 −// · déclarations les plus proches avec la date de la valeur.
13 −// -----------------------------------------------------------------------------
14 −import { useEffect, useState } from "react";
15 −import { fetchRdl, fmtDist, fmtPrice, RdlNearby } from "../api";
16 −
17 −/** Date de la valeur : l'année du loyer déclaré ; précision au mois quand le
18 − * début de bail tombe dans cette même année (sinon le bail peut être bien
19 − * antérieur au loyer déclaré et la date induirait en erreur). */
20 −function fmtDate(date: string | null | undefined,
21 − year: number | null | undefined): string {
22 − if (date && year != null && date.startsWith(String(year))) {
23 − const d = new Date(date + "T00:00:00");
24 − if (!Number.isNaN(d.getTime()))
25 − return d.toLocaleDateString("fr-CA", { month: "short", year: "numeric" });
26 − }
27 − return year != null ? String(year) : (date ? date.slice(0, 4) : "—");
28 −}
29 −
30 −/** -8 % -> « 8 % sous », +12 % -> « 12 % au-dessus » */
31 −function ecart(price: number, ref: number): string | null {
32 − if (!ref) return null;
33 − const pct = Math.round(((price - ref) / ref) * 100);
34 − if (Math.abs(pct) < 3) return "dans la moyenne des loyers déclarés";
35 − return pct > 0
36 − ? `${pct} % au-dessus des loyers déclarés du secteur`
37 − : `${-pct} % sous les loyers déclarés du secteur`;
38 −}
39 −
40 −/** Variante avec le nombre de chambres explicité (comparaison plus juste). */
41 −function ecartRooms(price: number | null, ref: number, rooms: string,
42 − n: number): string | null {
43 − if (price == null || !ref) return null;
44 − const pct = Math.round(((price - ref) / ref) * 100);
45 − const base = `${n} loyers déclarés de ${rooms} ch. du secteur`;
46 − if (Math.abs(pct) < 3) return `dans la moyenne des ${base}`;
47 − return pct > 0 ? `${pct} % au-dessus des ${base}`
48 − : `${-pct} % sous les ${base}`;
49 −}
50 −
51 −/** Bande de position : p10–p90 en piste, boîte p25–p75, médiane, ce loyer. */
52 −function PriceStrip({ d, price }: { d: RdlNearby; price: number }) {
53 − const q = d.quartiles;
54 − const med = d.median_recent ?? d.median;
55 − if (!q || med == null) return null;
56 − const W = 320, H = 74, top = 24, bandY = 34, bandH = 12, lblY = 68;
57 − const lo = q.p10, hi = q.p90;
58 − if (hi <= lo) return null;
59 − const x = (v: number) =>
60 − ((Math.min(Math.max(v, lo), hi) - lo) / (hi - lo)) * (W - 2) + 1;
61 − const priceX = x(price), medX = x(med);
62 − const anchor = (px: number) =>
63 − px < 60 ? "start" : px > W - 60 ? "end" : "middle";
64 − return (
65 − <svg className="rdl-strip" viewBox={`0 0 ${W} ${H}`} role="img"
66 − aria-label={`Ce loyer (${fmtPrice(price)}) parmi les loyers déclarés :
67 − p25 ${fmtPrice(q.p25)}, médiane ${fmtPrice(med)}, p75 ${fmtPrice(q.p75)}`}>
68 − {/* piste p10–p90 puis boîte p25–p75 */}
69 − <rect x="1" y={bandY} width={W - 2} height={bandH} rx="6"
70 − className="rdl-strip-track" />
71 − <rect x={x(q.p25)} y={bandY} width={Math.max(4, x(q.p75) - x(q.p25))}
72 − height={bandH} rx="6" className="rdl-strip-box" />
73 − {/* médiane : tick + étiquette texte (jamais la couleur seule) */}
74 − <line x1={medX} x2={medX} y1={bandY - 4} y2={bandY + bandH + 4}
75 − className="rdl-strip-med" />
76 − <text x={medX} y={lblY} textAnchor={anchor(medX)}
77 − className="rdl-strip-lbl">médiane {fmtPrice(med)}</text>
78 − {/* ce loyer : marqueur + étiquette au-dessus */}
79 − <line x1={priceX} x2={priceX} y1={top - 6} y2={bandY + bandH}
80 − className="rdl-strip-me" />
81 − <circle cx={priceX} cy={bandY + bandH / 2} r="4.5"
82 − className="rdl-strip-me-dot" />
83 − <text x={priceX} y={top - 10} textAnchor={anchor(priceX)}
84 − className="rdl-strip-me-lbl">ce loyer {fmtPrice(price)}</text>
85 − {/* bornes de la piste */}
86 − <text x="1" y={lblY} textAnchor="start" className="rdl-strip-axis"
87 − style={{ display: anchor(medX) === "start" ? "none" : undefined }}>
88 − {fmtPrice(lo)}</text>
89 − <text x={W - 1} y={lblY} textAnchor="end" className="rdl-strip-axis"
90 − style={{ display: anchor(medX) === "end" ? "none" : undefined }}>
91 − {fmtPrice(hi)}</text>
92 − </svg>
93 − );
94 −}
95 −
96 −/** Barres des médianes par nombre de chambres — série unique, barre de
97 − * l'annonce en accent foncé + étiquette « ce logement ». */
98 −function RoomBars({ d, rKey }: { d: RdlNearby; rKey: string | null }) {
99 − const entries = Object.entries(d.by_rooms ?? {})
100 − .filter(([, v]) => v.n >= 2)
101 − .sort(([a], [b]) => Number(a) - Number(b));
102 − if (entries.length < 2) return null;
103 − const W = 320, H = 128, top = 22, bottom = 34;
104 − const plotH = H - top - bottom;
105 − const max = Math.max(...entries.map(([, v]) => v.median), 1);
106 − const slot = W / entries.length;
107 − const bw = Math.min(34, slot * 0.55);
108 − return (
109 − <svg className="rdl-bars" viewBox={`0 0 ${W} ${H}`} role="img"
110 − aria-label="Loyer médian déclaré selon le nombre de chambres">
111 − {entries.map(([rooms, v], i) => {
112 − const h = Math.max(3, (v.median / max) * plotH);
113 − const bx = i * slot + (slot - bw) / 2;
114 − const cx = i * slot + slot / 2;
115 − const on = rooms === rKey;
116 − return (
117 − <g key={rooms}>
118 − <title>{`${rooms} ch. : médiane ${fmtPrice(v.median)} (${v.n} déclarations)`}</title>
119 − <rect x={bx} y={H - bottom - h} width={bw} height={h} rx="4"
120 − className={on ? "rdl-bar rdl-bar-on" : "rdl-bar"} />
121 − <text x={cx} y={H - bottom - h - 5} textAnchor="middle"
122 − className="rdl-bar-val">{fmtPrice(v.median)}</text>
123 − <text x={cx} y={H - bottom + 14} textAnchor="middle"
124 − className={on ? "rdl-bar-cat rdl-bar-cat-on" : "rdl-bar-cat"}>
125 − {rooms} ch.</text>
126 − <text x={cx} y={H - bottom + 27} textAnchor="middle"
127 − className="rdl-bar-n">
128 − {on ? "ce logement" : `${v.n} décl.`}</text>
129 − </g>
130 − );
131 − })}
132 − <line x1="0" x2={W} y1={H - bottom} y2={H - bottom}
133 − className="rdl-bars-axe" />
134 − </svg>
135 − );
136 −}
137 −
138 −export default function RegistreLoyers({ lat, lng, price, bedrooms }:
139 − { lat: number | null; lng: number | null; price: number | null;
140 − bedrooms?: number | null }) {
141 − const [d, setD] = useState<RdlNearby | null>(null);
142 − useEffect(() => {
143 − setD(null);
144 − if (lat == null || lng == null) return;
145 − fetchRdl(lat, lng).then(setD).catch(() => setD(null));
146 − }, [lat, lng]);
147 − if (lat == null || lng == null || !d || d.n === 0) return null;
148 −
149 − // Référence de comparaison : la médiane du même nombre de chambres quand
150 − // le secteur en compte assez (≥ 5 déclarations), sinon la médiane globale.
151 − const rKey = bedrooms != null ? String(Math.round(bedrooms)) : null;
152 − const sameRooms = rKey && d.by_rooms?.[rKey] && d.by_rooms[rKey].n >= 5
153 − ? d.by_rooms[rKey] : null;
154 − const globalRef = d.median_recent ?? d.median ?? 0;
155 − const cmp = sameRooms
156 − ? ecartRooms(price, sameRooms.median, rKey!, sameRooms.n)
157 − : price != null ? ecart(price, globalRef) : null;
158 − return (
159 − <section className="f-bloc f-rdl" id="registre-loyers">
160 − <h2>Registre des loyers</h2>
161 − <div className="rdl-kpis">
162 − <div className="rdl-kpi">
163 − <span className="rdl-kpi-v">{d.n.toLocaleString("fr-CA")}</span>
164 − <span className="rdl-kpi-l">loyers déclarés<br />à moins de {fmtDist(d.radius_m)}</span>
165 − </div>
166 − <div className="rdl-kpi">
167 − <span className="rdl-kpi-v">{fmtPrice(globalRef)}</span>
168 − <span className="rdl-kpi-l">médiane du secteur<br />
169 − {d.n_recent ? `${d.n_recent.toLocaleString("fr-CA")} décl. depuis 2023` : "toutes années"}</span>
170 − </div>
171 − {sameRooms && (
172 − <div className="rdl-kpi">
173 − <span className="rdl-kpi-v">{fmtPrice(sameRooms.median)}</span>
174 − <span className="rdl-kpi-l">médiane {rKey} ch.<br />{sameRooms.n} déclarations</span>
175 − </div>
176 − )}
177 − </div>
178 − {price != null && <PriceStrip d={d} price={price} />}
179 − {cmp && <p className="rdl-ecart">Ce loyer est <b>{cmp}</b>.</p>}
180 − <RoomBars d={d} rKey={rKey} />
181 − {d.items.length > 0 && (
182 − <table className="rdl-table">
183 − <caption className="rdl-cap">Déclarations les plus proches</caption>
184 − <tbody>
185 − {d.items.slice(0, 8).map((it, i) => (
186 − <tr key={i}>
187 − <td className="rdl-addr">{it.address}</td>
188 − <td>{it.rooms != null ? `${it.rooms} ch.` : "—"}</td>
189 − <td className="rdl-date">{fmtDate(it.date, it.year)}</td>
190 − <td className="rdl-prix">{fmtPrice(it.price)}</td>
191 − <td className="rdl-dist">{fmtDist(it.dist_m)}</td>
192 − </tr>
193 − ))}
194 − </tbody>
195 − </table>
196 − )}
197 − <p className="fine">
198 − Loyers réellement payés, déclarés volontairement par des locataires au{" "}
199 − <a href="https://registre-des-loyers.ca/fr/qc/carte" target="_blank"
200 − rel="noopener noreferrer">Registre des loyers</a>{" "}
201 − (initiative de Vivre en ville) — données citoyennes non vérifiées,
202 − fournies à titre indicatif.
203 − </p>
204 − </section>
205 − );
206 −}
deleted frontend/src/components/RisqueInondation.tsx +0 −76
@@ -1,76 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/RisqueInondation.tsx : bloc « Risque d'inondation » (fiche)
5 −// Position de l'adresse vis-à-vis des zones inondables officielles (BDZI,
6 −// gouvernement du Québec) : dans une zone, à proximité (≤ 100 m), hors
7 −// zone d'un secteur cartographié, ou secteur non couvert par la
8 −// cartographie. Indicatif seulement — la carte officielle fait foi.
9 −// -----------------------------------------------------------------------------
10 −import { useEffect, useState } from "react";
11 −import { fetchInondation, Inondation } from "../api";
12 −
13 −const BADGE: Record<string, [string, string]> = {
14 − eleve: ["zi-eleve", "Risque élevé"],
15 − modere: ["zi-modere", "Risque modéré"],
16 − present: ["zi-present", "Zone inondable"],
17 − hors_zone: ["zi-ok", "Hors zone inondable"],
18 − non_cartographie: ["zi-nc", "Secteur non cartographié"],
19 −};
20 −
21 −function libelle(d: Inondation): string {
22 − const z = d.zones[0];
23 − if (d.statut === "en_zone" && z)
24 − return `L'adresse se trouve dans une ${z.type.toLowerCase()}` +
25 − (z.recurrence ? ` (${z.recurrence})` : "") + ".";
26 − if (d.statut === "a_proximite" && z)
27 − return `Une ${z.type.toLowerCase()} se trouve à environ ${z.distance_m} m` +
28 − (z.recurrence ? ` (${z.recurrence})` : "") + ".";
29 − if (d.statut === "hors_zone")
30 − return "L'adresse est à l'extérieur des zones inondables cartographiées " +
31 − "de ce secteur.";
32 − return "Ce secteur n'est pas couvert par la cartographie officielle des " +
33 − "zones inondables — l'absence de zone ne signifie pas une absence " +
34 − "de risque.";
35 −}
36 −
37 −export default function RisqueInondation({ lat, lng }:
38 − { lat: number | null; lng: number | null }) {
39 − const [d, setD] = useState<Inondation | null>(null);
40 − useEffect(() => {
41 − setD(null);
42 − if (lat == null || lng == null) return;
43 − fetchInondation(lat, lng).then(setD).catch(() => setD(null));
44 − }, [lat, lng]);
45 − if (lat == null || lng == null || !d) return null;
46 −
47 − const key = d.statut === "en_zone" || d.statut === "a_proximite"
48 − ? (d.severite ?? "present") : d.statut;
49 − const [cls, label] = BADGE[key] ?? BADGE.non_cartographie;
50 − return (
51 − <section className="f-bloc f-zi" id="inondation">
52 − <h2>Risque d'inondation</h2>
53 − <div className="zi-head">
54 − <span className={`zi-badge ${cls}`}>{label}</span>
55 − </div>
56 − <p className="zi-texte">{libelle(d)}</p>
57 − {d.zones.length > 1 && (
58 − <ul className="zi-liste">
59 − {d.zones.slice(1, 3).map((z, i) => (
60 − <li key={i}>
61 − {z.type}{z.recurrence ? ` (${z.recurrence})` : ""} —{" "}
62 − {z.distance_m === 0 ? "à l'adresse" : `à ~${z.distance_m} m`}
63 − </li>
64 − ))}
65 − </ul>
66 − )}
67 − <p className="fine">
68 − Base de données des zones à risque d'inondation (BDZI), gouvernement
69 − du Québec — indicatif seulement, selon la position géocodée ;{" "}
70 − <a href="https://www.quebec.ca/agriculture-environnement-et-ressources-naturelles/eau/zones-inondables-mobilite-rives-littoral/cartographies"
71 − target="_blank" rel="noopener noreferrer">
72 − la cartographie officielle fait foi</a>.
73 − </p>
74 − </section>
75 − );
76 −}
added frontend/src/fiche/BottomSheet.tsx +72 −0
@@ -0,0 +1,72 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (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 comparables, lieux, stations,
9 +// assistant Ka, méthodologies.
10 +// -----------------------------------------------------------------------------
11 +import { ReactNode, useEffect, useRef, useState } from "react";
12 +import { createPortal } from "react-dom";
13 +import { IcoClose } from "../components/Icons";
14 +
15 +export default function BottomSheet({ open, onClose, title, sub, children, footer, tall = false }: {
16 + open: boolean; onClose: () => void; title: ReactNode; sub?: ReactNode;
17 + children: ReactNode; footer?: ReactNode; tall?: boolean;
18 +}) {
19 + const [full, setFull] = useState(tall);
20 + const panel = useRef<HTMLDivElement>(null);
21 + const drag = useRef<{ y0: number; t0: number } | null>(null);
22 +
23 + useEffect(() => {
24 + if (!open) return;
25 + setFull(tall);
26 + document.documentElement.classList.add("ka-scroll-lock");
27 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
28 + window.addEventListener("keydown", onKey);
29 + // focus initial sur le panneau (lecteurs d'écran + clavier)
30 + const t = setTimeout(() => panel.current?.focus(), 30);
31 + return () => {
32 + document.documentElement.classList.remove("ka-scroll-lock");
33 + window.removeEventListener("keydown", onKey);
34 + clearTimeout(t);
35 + };
36 + }, [open, onClose, tall]);
37 +
38 + if (!open) return null;
39 +
40 + const onDown = (e: React.PointerEvent) => {
41 + drag.current = { y0: e.clientY, t0: Date.now() };
42 + };
43 + const onUp = (e: React.PointerEvent) => {
44 + if (!drag.current) return;
45 + const dy = e.clientY - drag.current.y0;
46 + drag.current = null;
47 + if (dy > 90) onClose();
48 + else if (dy < -60) setFull(true);
49 + };
50 +
51 + return createPortal(
52 + <>
53 + <div className="lk-sheet-backdrop" onClick={onClose} aria-hidden="true" />
54 + <div className={`lk-sheet ${full ? "full" : ""}`} role="dialog" aria-modal="true"
55 + aria-label={typeof title === "string" ? title : undefined} ref={panel} tabIndex={-1}>
56 + <div className="lk-sheet-handle" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp} />
57 + <div className="lk-sheet-head" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp}>
58 + <div style={{ minWidth: 0 }}>
59 + <h3 className="lk-sheet-title">{title}</h3>
60 + {sub && <p className="lk-sheet-sub">{sub}</p>}
61 + </div>
62 + <button type="button" className="lk-sheet-x" onClick={onClose} aria-label="Fermer">
63 + <IcoClose size={18} />
64 + </button>
65 + </div>
66 + <div className="lk-sheet-body">{children}</div>
67 + {footer && <div className="lk-sheet-foot">{footer}</div>}
68 + </div>
69 + </>,
70 + document.body,
71 + );
72 +}
added frontend/src/fiche/BuildingDossier.tsx +260 −0
@@ -0,0 +1,260 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/BuildingDossier.tsx : « Dossier de l'immeuble » — toutes les données
5 +// d'intelligence locative en accordéons compacts (aucune n'est supprimée) :
6 +// historique Lou-Ka + vies antérieures, passeport de l'immeuble, gestionnaire
7 +// (réputation Google analysée), historique au TAL (SOQUIJ), vie en hiver.
8 +// Chaque volet affiche un état vide propre quand la donnée manque.
9 +// -----------------------------------------------------------------------------
10 +import {
11 + Gestionnaire, Hiver, HistoriqueLouka, Immeuble, Recyclees, TalHistory, fmtPrice, sourceName,
12 +} from "../api";
13 +import { IcoBuilding, IcoShield, IcoSnow, IcoUser } from "../components/Icons";
14 +import { Accordion, SectionCard, SkeletonLines, StatTile, StatusBadge, Tone, NBSP } from "./ui";
15 +import { Res } from "./useFicheData";
16 +
17 +const NBSP_ = NBSP;
18 +const fmtTs = (ts: number | null | undefined, m: "short" | "long" = "short") =>
19 + ts ? new Date(ts * 1000).toLocaleDateString("fr-CA", m === "short" ? { day: "numeric", month: "short", year: "numeric" } : { month: "long", year: "numeric" }) : "—";
20 +const EVENT: Record<string, string> = {
21 + description: "Description modifiée", superficie: "Superficie modifiée", dispo: "Disponibilité modifiée",
22 + inclusions: "Inclusions modifiées", photos: "Photos modifiées", disparition: "Annonce retirée", reapparition: "Annonce republiée",
23 +};
24 +
25 +function Vide({ children }: { children: React.ReactNode }) { return <p style={{ color: "var(--lk-muted)", margin: 0 }}>{children}</p>; }
26 +
27 +function Historique({ r, rec }: { r: Res<HistoriqueLouka>; rec: Res<Recyclees> }) {
28 + if (r.status === "loading" || r.status === "idle") return <SkeletonLines n={3} />;
29 + const d = r.status === "ok" ? r.data : null;
30 + if (!d) return <Vide>Historique indisponible pour cette annonce.</Vide>;
31 + const items = d.timeline.slice(0, 12);
32 + const matches = rec.status === "ok" ? rec.data.matches : [];
33 + const var_ = d.variation != null ? `${d.variation > 0 ? "+" : "−"}${Math.abs(Math.round(d.variation * 100))}${NBSP_}%` : null;
34 + return (
35 + <>
36 + <div className="lk-kpis cols-3" style={{ marginBottom: 10 }}>
37 + <StatTile value={fmtTs(d.premiere_observation)} label="Suivie depuis" anim={false} />
38 + <StatTile value={`${d.jours_en_ligne}${NBSP_}j`} label="En ligne" anim={false} />
39 + <StatTile value={d.modifications} label="Modifications observées" anim={false} />
40 + </div>
41 + {d.prix_initial != null && d.prix_actuel != null && d.prix_initial !== d.prix_actuel && (
42 + <p style={{ margin: "0 0 8px" }}>Prix initial {fmtPrice(d.prix_initial)} → <b>{fmtPrice(d.prix_actuel)}</b>{var_ ? ` (${var_})` : ""}</p>
43 + )}
44 + {items.length > 0 ? (
45 + <ul className="lk-tl">
46 + {items.map((it, i) => (
47 + <li key={`${it.ts}-${it.type}-${i}`} className={it.type}>
48 + <span className="lk-tl-date">{fmtTs(it.ts)}</span>
49 + {it.type === "prix"
50 + ? (it.prix_avant != null
51 + ? <>Prix {it.prix_avant > (it.prix ?? 0) ? "baissé" : "monté"} de {fmtPrice(it.prix_avant)} à <b>{fmtPrice(it.prix ?? null)}</b></>
52 + : <>Premier prix observé : <b>{fmtPrice(it.prix ?? null)}</b></>)
53 + : (EVENT[it.type] ?? it.type)}
54 + </li>
55 + ))}
56 + </ul>
57 + ) : <Vide>Aucune modification observée depuis la première synchronisation.</Vide>}
58 + {matches.length > 0 && (
59 + <div style={{ marginTop: 12 }}>
60 + <b style={{ fontSize: 13 }}>Vies antérieures probables de ce logement</b>
61 + {matches.slice(0, 3).map((m) => (
62 + <div key={m.uid} className="lk-quote" style={{ borderLeftColor: "var(--lk-orange)" }}>
63 + <StatusBadge tone="warn">republication probable · {m.confiance}{NBSP_}%</StatusBadge>{" "}
64 + {m.prix != null && <>affiché {fmtPrice(m.prix)}</>}{m.derniere_observation != null && <> jusqu'en {fmtTs(m.derniere_observation, "long")}</>}
65 + <footer>{m.signaux.join(" · ")}</footer>
66 + </div>
67 + ))}
68 + <p className="lk-fine">{rec.status === "ok" ? rec.data.methode : ""} — inférence, sans fusion automatique.</p>
69 + </div>
70 + )}
71 + <p className="lk-fine">{d.methode}</p>
72 + </>
73 + );
74 +}
75 +
76 +function Passeport({ im }: { im: Immeuble | null }) {
77 + if (!im || im.annonces_total < 2)
78 + return <Vide>Une seule annonce observée pour cet immeuble : pas encore de passeport (unités, rotation, pression sur les loyers).</Vide>;
79 + const rot = im.rotation, pres = im.pression_loyers;
80 + const ROT: Record<string, Tone> = { faible: "good", normale: "neutral", "élevée": "warn", "très élevée": "bad" };
81 + return (
82 + <>
83 + <div className="lk-kpis" style={{ marginBottom: 10 }}>
84 + <StatTile value={im.annonces_total} label={`Annonces observées · ${im.annonces_actives} active${im.annonces_actives > 1 ? "s" : ""}`} anim={false} />
85 + <StatTile value={`≥${NBSP_}${im.unites_estimees}`} label="Unités estimées" anim={false} />
86 + {im.loyer_median != null && <StatTile value={fmtPrice(im.loyer_median)} label="Loyer médian (actives)" anim={false} />}
87 + {im.pi2_median != null && <StatTile value={`${im.pi2_median.toFixed(2).replace(".", ",")}${NBSP_}$`} label="Médiane $/pi²" anim={false} />}
88 + </div>
89 + {im.loyer_median_par_cc && Object.keys(im.loyer_median_par_cc).length > 0 && (
90 + <p style={{ margin: "0 0 8px" }}>Par nombre de chambres : {Object.entries(im.loyer_median_par_cc).map(([cc, v]) => `${cc}${NBSP_}ch. ${fmtPrice(v)}`).join(" · ")}</p>
91 + )}
92 + <div className="lk-status" style={{ marginBottom: 6 }}>
93 + <span>Rotation</span>
94 + {rot.statut === "calculated"
95 + ? <><StatusBadge tone={ROT[rot.classe ?? ""] ?? "neutral"}>{rot.classe}</StatusBadge><small style={{ color: "var(--lk-muted)" }}>{rot.annonces_12m} annonce{(rot.annonces_12m ?? 0) > 1 ? "s" : ""} / 12 mois pour ≥{NBSP_}{rot.unites_estimees} unités</small></>
96 + : <StatusBadge tone="neutral">données insuffisantes</StatusBadge>}
97 + </div>
98 + <div className="lk-status">
99 + <span>Pression sur les loyers</span>
100 + {pres
101 + ? <><StatusBadge tone={pres.variation_12m > 0.05 ? "warn" : pres.variation_12m < -0.02 ? "good" : "neutral"}>{pres.variation_12m > 0 ? "+" : "−"}{Math.abs(Math.round(pres.variation_12m * 100))}{NBSP_}% sur 12 mois</StatusBadge>
102 + <small style={{ color: "var(--lk-muted)" }}>médiane d'entrée {fmtPrice(pres.mediane_12_24m)} → {fmtPrice(pres.mediane_12m)}</small></>
103 + : <StatusBadge tone="neutral">échantillon insuffisant</StatusBadge>}
104 + </div>
105 + <p className="lk-fine">Immeuble suivi par Lou-Ka depuis {fmtTs(im.premiere_observation, "long")} — {rot.statut === "calculated" && rot.methode ? rot.methode : "indicateurs calculés uniquement sur les annonces observées par les synchronisations Lou-Ka (pas un recensement du bâtiment)."}</p>
106 + </>
107 + );
108 +}
109 +
110 +function GestionnaireVue({ r, source }: { r: Res<Gestionnaire>; source: string }) {
111 + if (r.status === "loading" || r.status === "idle") return <SkeletonLines n={3} />;
112 + const d = r.status === "ok" ? r.data : null;
113 + if (!d) return <Vide>Gestionnaire : <b>{sourceName(source)}</b>. Aucune fiche de réputation associée.</Vide>;
114 + const g = d.google_maps, avis = d.avis, dist = avis?.distribution;
115 + const total = dist ? Object.values(dist).reduce((a, b) => a + b, 0) : 0;
116 + const recents = (d.avis_recents ?? []).filter((a) => a.texte).slice(0, 3);
117 + const SENT: Record<string, Tone> = { "négatif": "bad", neutre: "neutral", positif: "good" };
118 + return (
119 + <>
120 + <p style={{ margin: "0 0 8px" }}>
121 + <b style={{ fontSize: 15 }}>{d.nom ?? sourceName(source)}</b> — {d.annonces_actives}{NBSP_}annonce{d.annonces_actives > 1 ? "s" : ""} active{d.annonces_actives > 1 ? "s" : ""} sur Lou-Ka
122 + {d.site_web && <> · <a href={d.site_web} target="_blank" rel="noopener noreferrer">site web ↗</a></>}
123 + </p>
124 + {g && g.statut !== "non_associe" && g.note != null ? (
125 + <>
126 + <div className="lk-status" style={{ marginBottom: 8 }}>
127 + <span className="lk-star">★ {g.note.toFixed(1).replace(".", ",")}</span>
128 + <small style={{ color: "var(--lk-muted)" }}>{g.nombre_avis}{NBSP_}avis Google — fiche «{NBSP_}{g.nom}{NBSP_}»</small>
129 + {avis?.tendance && <StatusBadge tone={avis.tendance === "en amélioration" ? "good" : avis.tendance === "en dégradation" ? "warn" : "neutral"}>{avis.tendance}</StatusBadge>}
130 + </div>
131 + {dist && total > 0 && (
132 + <div className="lk-rows" aria-label="Distribution des notes analysées">
133 + {[5, 4, 3, 2, 1].map((n) => {
134 + const c = dist[String(n)] ?? 0;
135 + return (
136 + <div className="lk-row" key={n}>
137 + <span className="lk-row-name">{n}★</span>
138 + <span className="lk-row-bar" aria-hidden="true"><i className={n <= 2 ? "bad" : n >= 4 ? "good" : ""} style={{ width: `${Math.round((100 * c) / total)}%` }} /></span>
139 + <span className="lk-row-val">{c}</span>
140 + </div>
141 + );
142 + })}
143 + </div>
144 + )}
145 + {avis?.moyenne_12m != null && <p style={{ margin: "8px 0 0" }}>Moyenne des 12 derniers mois : <b>{avis.moyenne_12m.toFixed(1).replace(".", ",")}</b> ({avis.n_12m} avis)</p>}
146 + {(avis?.plaintes_frequentes?.length ?? 0) > 0 && (
147 + <div className="lk-tags" style={{ marginTop: 8 }}>
148 + <span style={{ fontSize: 12.5, color: "var(--lk-text-2)" }}>Plaintes récurrentes :</span>
149 + {avis!.plaintes_frequentes!.map((t) => <span className="lk-tag" key={t}>{t}</span>)}
150 + </div>
151 + )}
152 + {recents.map((a, i) => (
153 + <blockquote className="lk-quote" key={i}>
154 + <StatusBadge tone={SENT[a.analyse.sentiment ?? ""] ?? "neutral"}>{a.note != null ? `${a.note}★` : "—"}</StatusBadge>{" "}{a.texte}
155 + <footer>{a.date ? new Date(a.date).toLocaleDateString("fr-CA", { month: "long", year: "numeric" }) : ""}{a.reponse_proprietaire && " · le gestionnaire a répondu"}</footer>
156 + </blockquote>
157 + ))}
158 + <p className="lk-fine">
159 + Fiche Google Maps associée automatiquement (confiance {Math.round((g.confiance_association ?? 0) * 100)}{NBSP_}% — {g.methode}). Statistiques calculées sur les {avis?.n ?? 0} avis synchronisés dans la base Lou-Ka
160 + {g.nombre_avis && avis && avis.n < g.nombre_avis ? ` (échantillon des plus récents ; Google en annonce ${g.nombre_avis})` : ""}. Thèmes détectés par lexique — inférence indicative.
161 + </p>
162 + </>
163 + ) : <Vide>Aucune fiche Google associée avec confiance à ce gestionnaire.</Vide>}
164 + </>
165 + );
166 +}
167 +
168 +function Tal({ r }: { r: Res<TalHistory> }) {
169 + if (r.status === "loading" || r.status === "idle") return <SkeletonLines n={2} />;
170 + const d = r.status === "ok" ? r.data : null;
171 + if (!d || d.status === "na" || d.status === "error") return <Vide>Vérification au Tribunal administratif du logement non disponible pour cette adresse.</Vide>;
172 + const fmt = (s: string | null) => { if (!s) return "—"; const dt = new Date(s + "T00:00:00"); return Number.isNaN(dt.getTime()) ? s : dt.toLocaleDateString("fr-CA", { month: "long", year: "numeric" }); };
173 + const decisions = d.decisions ?? [];
174 + return (
175 + <>
176 + {d.status === "pending" && <p style={{ margin: 0 }}><StatusBadge tone="neutral">Vérification en cours</StatusBadge> Adresse en file de vérification auprès des décisions publiées du TAL.</p>}
177 + {d.status === "ok" && decisions.length === 0 && <p style={{ margin: 0 }}><StatusBadge tone="good">Aucune décision trouvée</StatusBadge> Aucune décision publiée du TAL repérée à cette adresse.</p>}
178 + {d.status === "ok" && decisions.length > 0 && (
179 + <>
180 + <p style={{ margin: "0 0 8px" }}>
181 + <StatusBadge tone={(d.eviction ?? 0) > 0 || (d.contre_locataire ?? 0) > 0 ? "warn" : "neutral"}>{d.n} décision{(d.n ?? 0) > 1 ? "s" : ""}</StatusBadge>{" "}
182 + {d.contre_locataire ? `dont ${d.contre_locataire} à l'initiative du propriétaire` : "aucune à l'initiative du propriétaire"}{d.last_date ? ` — la plus récente : ${fmt(d.last_date)}` : ""}.
183 + </p>
184 + <ul className="lk-list">
185 + {decisions.slice(0, 8).map((dec) => (
186 + <li className="lk-item" key={dec.url} style={{ alignItems: "flex-start" }}>
187 + <div className="lk-item-main">
188 + <div className="lk-item-t">{fmt(dec.date)}{dec.demandeur ? ` · demande du ${dec.demandeur}` : ""}{dec.verdict ? ` · ${dec.verdict}` : ""}</div>
189 + {dec.tags.length > 0 && <div className="lk-tags">{dec.tags.map((t) => <span className="lk-tag" key={t}>{t}</span>)}</div>}
190 + <a href={dec.url} target="_blank" rel="noopener noreferrer" className="lk-item-s" style={{ display: "inline-block", marginTop: 3 }}>{dec.citation || "Texte intégral"} — SOQUIJ</a>
191 + </div>
192 + </li>
193 + ))}
194 + </ul>
195 + </>
196 + )}
197 + <p className="lk-fine">
198 + Décisions publiques du <a href="https://www.tal.gouv.qc.ca/" target="_blank" rel="noopener noreferrer">Tribunal administratif du logement</a>, diffusées par SOQUIJ et repérées par
199 + correspondance d'adresse — vérification automatisée, indicative, sans valeur juridique ; une décision peut concerner un autre logement du même immeuble. Aucun nom de partie n'est affiché.
200 + </p>
201 + </>
202 + );
203 +}
204 +
205 +function HiverVue({ h }: { h: Hiver | null }) {
206 + if (!h) return <Vide>Score hiver non calculable (commodités de proximité inconnues pour cette adresse).</Vide>;
207 + const CLS: Record<string, Tone> = { "très pratique": "good", pratique: "good", exigeant: "warn", difficile: "bad" };
208 + return (
209 + <>
210 + <div className="lk-status" style={{ marginBottom: 8 }}>
211 + <span className="lk-market-big" style={{ fontSize: 26 }}>{h.score}<small>/ 100</small></span>
212 + <StatusBadge tone={CLS[h.classe] ?? "neutral"} lg>{h.classe}</StatusBadge>
213 + <small style={{ color: "var(--lk-muted)" }}>quotidien à pied, même à −20{NBSP_}°C</small>
214 + </div>
215 + <div className="lk-rows">
216 + {h.detail.map((c) => (
217 + <div className="lk-row" key={c.critere}>
218 + <span className="lk-row-name">{c.critere}<small>{c.distance_m != null ? ` ≈ ${c.minutes} min à pied${c.nom ? ` (${c.nom})` : ""}` : c.note ? ` ${c.note}` : ""}</small></span>
219 + <span className="lk-row-bar" aria-hidden="true"><i style={{ width: `${c.score}%` }} /></span>
220 + <span className="lk-row-val">{c.score}</span>
221 + </div>
222 + ))}
223 + </div>
224 + <p className="lk-fine">{h.methode}</p>
225 + </>
226 + );
227 +}
228 +
229 +export default function BuildingDossier({ source, immeuble, hiver, historique, recyclees, tal, gestionnaire }: {
230 + source: string; immeuble: Immeuble | null; hiver: Hiver | null;
231 + historique: Res<HistoriqueLouka>; recyclees: Res<Recyclees>; tal: Res<TalHistory>; gestionnaire: Res<Gestionnaire>;
232 +}) {
233 + const talOk = tal.status === "ok" ? tal.data : null;
234 + const talMeta = talOk?.status === "ok" ? (talOk.n ? `${talOk.n} décision${talOk.n > 1 ? "s" : ""}` : "aucune") : talOk?.status === "pending" ? "en cours" : undefined;
235 + const histo = historique.status === "ok" ? historique.data : null;
236 + return (
237 + <SectionCard id="dossier" title="Dossier de l'immeuble" icon={<IcoBuilding size={18} />}
238 + sub="Ce que Lou-Ka observe réellement : historique, immeuble, gestionnaire, litiges, hiver">
239 + <Accordion title={<><IcoBuilding size={15} style={{ verticalAlign: -2, marginRight: 8, color: "var(--lk-muted)" }} />Historique Lou-Ka</>}
240 + meta={histo ? `${histo.jours_en_ligne}${NBSP_}j en ligne` : undefined}>
241 + <Historique r={historique} rec={recyclees} />
242 + </Accordion>
243 + <Accordion title={<><IcoBuilding size={15} style={{ verticalAlign: -2, marginRight: 8, color: "var(--lk-muted)" }} />Passeport de l'immeuble</>}
244 + meta={immeuble && immeuble.annonces_total >= 2 ? `${immeuble.annonces_total} annonces` : undefined}>
245 + <Passeport im={immeuble} />
246 + </Accordion>
247 + <Accordion title={<><IcoUser size={15} style={{ verticalAlign: -2, marginRight: 8, color: "var(--lk-muted)" }} />Qui gère ce logement ?</>}
248 + meta={gestionnaire.status === "ok" && gestionnaire.data.google_maps?.note != null ? `★ ${gestionnaire.data.google_maps.note.toFixed(1).replace(".", ",")}` : sourceName(source)}>
249 + <GestionnaireVue r={gestionnaire} source={source} />
250 + </Accordion>
251 + <Accordion title={<><IcoShield size={15} style={{ verticalAlign: -2, marginRight: 8, color: "var(--lk-muted)" }} />Historique au TAL</>} meta={talMeta}>
252 + <Tal r={tal} />
253 + </Accordion>
254 + <Accordion title={<><IcoSnow size={15} style={{ verticalAlign: -2, marginRight: 8, color: "var(--lk-muted)" }} />Vie quotidienne en hiver</>}
255 + meta={hiver ? `${hiver.score}/100 · ${hiver.classe}` : undefined}>
256 + <HiverVue h={hiver} />
257 + </Accordion>
258 + </SectionCard>
259 + );
260 +}
added frontend/src/fiche/DesktopAside.tsx +57 −0
@@ -0,0 +1,57 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/DesktopAside.tsx : colonne droite sticky (≥ 1024 px) — prix, capsule
5 +// marché, adresse, CTA, PDF, Lou-Ka Score en miniature, constats clés,
6 +// favoris / partage. Masquée sur mobile (display:none, pas de réordonnancement).
7 +// -----------------------------------------------------------------------------
8 +import { Listing, fmtPrice, sourceName } from "../api";
9 +import { IcoDoc, IcoExternal, IcoHeart, IcoShare, IcoSparkles } from "../components/Icons";
10 +import { ScoreRing } from "./LouKaScore";
11 +import { PriceCapsule } from "./PropertyHero";
12 +import { BriefList } from "./PropertySummary";
13 +import { ComparaisonPrix, Constat, LouKaScore } from "./synthese";
14 +import { NBSP, relTime } from "./ui";
15 +
16 +export default function DesktopAside({ l, cmp, score, brief, fav, onFav, onShare }: {
17 + l: Listing; cmp: ComparaisonPrix | null; score: LouKaScore; brief: Constat[];
18 + fav: boolean; onFav: () => void; onShare: () => void;
19 +}) {
20 + const maj = relTime(l.updated_at);
21 + return (
22 + <aside className="lk-aside" aria-label="Résumé et actions">
23 + <div className="lk-card lk-aside-card">
24 + <div>
25 + <div className="lk-price">{fmtPrice(l.price, l.price_label)}{l.price != null && <small>/{NBSP}mois</small>}</div>
26 + <div className="lk-price-row" style={{ marginTop: 8 }}><PriceCapsule cmp={cmp} /></div>
27 + </div>
28 + <div className="lk-aside-addr">{l.address || l.title}{l.city ? <><br />{[l.sector, l.city].filter(Boolean).join(", ")}</> : null}</div>
29 + <a className="lk-btn lk-btn-primary" href={`/passerelle/${encodeURIComponent(l.uid)}`} target="_blank" rel="noopener noreferrer">
30 + Voir l'annonce chez {sourceName(l.source)} <IcoExternal size={16} />
31 + </a>
32 + <div className="lk-actions">
33 + <button type="button" className={`lk-btn lk-btn-ghost ${fav ? "on" : ""}`} style={{ flex: 1 }} aria-pressed={fav} onClick={onFav}>
34 + <IcoHeart size={17} filled={fav} /> {fav ? "Favori" : "Favoris"}
35 + </button>
36 + <button type="button" className="lk-btn lk-btn-ghost" style={{ flex: 1 }} onClick={onShare}><IcoShare size={16} /> Partager</button>
37 + <a className="lk-btn lk-btn-ghost lk-btn-icon" aria-label="Fiche PDF" title="Fiche PDF" href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download><IcoDoc size={17} /></a>
38 + </div>
39 + <button type="button" className="lk-btn lk-btn-ghost lk-ka-inline" onClick={() => window.dispatchEvent(new Event("lk:askka"))}>
40 + <IcoSparkles size={16} /> Demander à Ka
41 + </button>
42 + {(score.value != null || brief.length > 0) && <hr className="lk-aside-sep" />}
43 + {score.value != null && (
44 + <div className="lk-aside-score">
45 + <ScoreRing value={score.value} partial={score.partial} size={56} />
46 + <div className="lk-aside-score-txt">
47 + <b>Lou-Ka Score · {score.label}</b>
48 + {score.partial ? "Score partiel" : `Emplacement ${score.emplacement} · Prix ${score.prix}`}
49 + </div>
50 + </div>
51 + )}
52 + {brief.length > 0 && <BriefList items={brief.slice(0, 4)} compact />}
53 + {maj && <div className="lk-aside-meta">Synchronisé {maj} · {sourceName(l.source)}</div>}
54 + </div>
55 + </aside>
56 + );
57 +}
added frontend/src/fiche/EnvironmentCards.tsx +201 −0
@@ -0,0 +1,201 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/EnvironmentCards.tsx : cartes « Risques » et « Environnement » —
5 +// · FloodRiskCard : statut clair (BDZI), explication courte, carte officielle,
6 +// méthodologie et portée juridique intactes dans « En savoir plus » ;
7 +// · AirQualityCard : verdict + 3 tuiles polluants (couleur sémantique
8 +// discrète vs repère OMS / RAA), station et année, méthodologie repliée ;
9 +// · GasNearbyCard : 3 statistiques compactes, 3 stations, « Voir les N ».
10 +// -----------------------------------------------------------------------------
11 +import { useState } from "react";
12 +import { AirNearby, GazNearby, Inondation, fmtDist } from "../api";
13 +import { IcoDroplets, IcoFuel, IcoWind } from "../components/Icons";
14 +import BottomSheet from "./BottomSheet";
15 +import { Accordion, ErrorState, MoreButton, SectionCard, SkeletonLines, SourceLine, StatTile, StatusBadge, Tone, NBSP } from "./ui";
16 +import { Res } from "./useFicheData";
17 +
18 +/* --- Risque d'inondation ------------------------------------------------------ */
19 +const ZI_URL = "https://www.quebec.ca/agriculture-environnement-et-ressources-naturelles/eau/zones-inondables-mobilite-rives-littoral/cartographies";
20 +
21 +export function FloodRiskCard({ r, onRetry }: { r: Res<Inondation>; onRetry: () => void }) {
22 + if (r.status === "na") return null;
23 + const d = r.status === "ok" ? r.data : null;
24 + let tone: Tone = "neutral", titre = "Données insuffisantes", texte = "";
25 + if (d) {
26 + const z = d.zones[0];
27 + if (d.statut === "en_zone") {
28 + tone = d.severite === "eleve" ? "bad" : "warn";
29 + titre = d.severite === "eleve" ? "Risque élevé" : d.severite === "modere" ? "Risque modéré" : "Zone inondable";
30 + texte = `L'adresse se trouve dans une ${z?.type.toLowerCase() ?? "zone inondable"}${z?.recurrence ? ` (${z.recurrence})` : ""}.`;
31 + } else if (d.statut === "a_proximite") {
32 + tone = "warn"; titre = "Zone inondable à proximité";
33 + texte = `Une ${z?.type.toLowerCase() ?? "zone inondable"} se trouve à environ ${z?.distance_m ?? "—"} m${z?.recurrence ? ` (${z.recurrence})` : ""}.`;
34 + } else if (d.statut === "hors_zone") {
35 + tone = "good"; titre = "Hors zone inondable";
36 + texte = "L'adresse est à l'extérieur des zones inondables cartographiées de ce secteur.";
37 + } else {
38 + tone = "neutral"; titre = "Données insuffisantes";
39 + texte = "Cette adresse n'est pas couverte par la cartographie officielle consultée.";
40 + }
41 + }
42 + return (
43 + <SectionCard id="risques" title="Risque d'inondation" icon={<IcoDroplets size={18} />}>
44 + {r.status === "loading" && <SkeletonLines n={2} />}
45 + {r.status === "error" && <ErrorState onRetry={onRetry}>Base des zones inondables temporairement indisponible.</ErrorState>}
46 + {d && (
47 + <>
48 + <div className="lk-status">
49 + <StatusBadge tone={tone} lg>{titre}</StatusBadge>
50 + </div>
51 + <p className="lk-status-d">{texte}</p>
52 + {d.zones.length > 1 && (
53 + <ul className="lk-list" style={{ marginTop: 6 }}>
54 + {d.zones.slice(1, 4).map((z, i) => (
55 + <li className="lk-item" key={i} style={{ padding: "6px 0" }}>
56 + <div className="lk-item-main"><div className="lk-item-s" style={{ color: "var(--lk-text-2)" }}>{z.type}{z.recurrence ? ` (${z.recurrence})` : ""}</div></div>
57 + <div className="lk-item-r"><div className="lk-item-m">{z.distance_m === 0 ? "à l'adresse" : `~${z.distance_m} m`}</div></div>
58 + </li>
59 + ))}
60 + </ul>
61 + )}
62 + <a className="lk-btn lk-btn-ghost" href={ZI_URL} target="_blank" rel="noopener noreferrer" style={{ marginTop: 12, width: "100%" }}>
63 + Voir la carte officielle
64 + </a>
65 + <SourceLine name="Gouvernement du Québec (BDZI)" />
66 + <Accordion title="En savoir plus" small>
67 + <p>
68 + Base de données des zones à risque d'inondation (BDZI), gouvernement du Québec — indicatif seulement,
69 + selon la position géocodée de l'adresse. L'absence de zone dans un secteur non cartographié ne signifie
70 + pas une absence de risque.{" "}
71 + <a href={ZI_URL} target="_blank" rel="noopener noreferrer">La cartographie officielle fait foi</a>.
72 + </p>
73 + </Accordion>
74 + </>
75 + )}
76 + </SectionCard>
77 + );
78 +}
79 +
80 +/* --- Qualité de l'air ------------------------------------------------------- */
81 +const ORDRE = ["PM2.5", "NO2", "O3", "PST", "PM10", "SO2", "CO"];
82 +const NOMS: Record<string, string> = { "PM2.5": "PM2,5", PST: "PST", PM10: "PM10", NO2: "NO₂", O3: "O₃", SO2: "SO₂", CO: "CO" };
83 +const LONG: Record<string, string> = {
84 + "PM2.5": "Particules fines", PST: "Particules totales", PM10: "Particules PM10", NO2: "Dioxyde d'azote",
85 + O3: "Ozone", SO2: "Dioxyde de soufre", CO: "Monoxyde de carbone",
86 +};
87 +
88 +export function AirQualityCard({ r, onRetry }: { r: Res<AirNearby>; onRetry: () => void }) {
89 + if (r.status === "na") return null;
90 + const d = r.status === "ok" ? r.data : null;
91 + const pols = d ? ORDRE.filter((p) => d.mesures[p]) : [];
92 + if (d && (!d.station || pols.length === 0)) return null;
93 + let tone: Tone = "neutral", verdict = "Mesures disponibles";
94 + if (d) {
95 + const pm = d.mesures["PM2.5"];
96 + if (pm?.ref) {
97 + const rr = pm.moyenne / pm.ref;
98 + [tone, verdict] = rr <= 1 ? ["good", "Très bonne"] : rr <= 2 ? ["good", "Bonne"] : rr <= 3 ? ["warn", "Passable"] : ["bad", "Particules élevées"];
99 + } else if (d.mesures.PST?.ref) {
100 + [tone, verdict] = d.mesures.PST.moyenne <= d.mesures.PST.ref ? ["good", "Sous la norme"] : ["bad", "Au-dessus de la norme"];
101 + }
102 + }
103 + const annee = d ? Object.values(d.mesures)[0]?.annee : null;
104 + return (
105 + <SectionCard id="air" title="Qualité de l'air" icon={<IcoWind size={18} />}
106 + aside={d && <StatusBadge tone={tone} lg>{verdict}</StatusBadge>}>
107 + {r.status === "loading" && <SkeletonLines n={3} />}
108 + {r.status === "error" && <ErrorState onRetry={onRetry}>Données de qualité de l'air temporairement indisponibles.</ErrorState>}
109 + {d && (
110 + <>
111 + <div className="lk-air">
112 + {pols.slice(0, 3).map((p) => {
113 + const m = d.mesures[p];
114 + const ratio = m.ref ? m.moyenne / m.ref : null;
115 + const t: Tone = ratio == null ? "neutral" : ratio <= 1 ? "good" : ratio <= 2 ? "warn" : "bad";
116 + const s = ratio == null ? "sans repère annuel" : ratio <= 1 ? "sous le repère" : ratio <= 1.5 ? "légèrement au-dessus du repère" : `${ratio.toFixed(1).replace(".", ",")}× le repère`;
117 + return (
118 + <div className="lk-air-c" key={p} title={LONG[p]}>
119 + <div className="lk-air-p">{NOMS[p] ?? p}</div>
120 + <div className="lk-air-v">{m.moyenne.toLocaleString("fr-CA")}<small>{m.unite}</small></div>
121 + <div className={`lk-air-s ${t}`}>{s}{m.ref_nom && ratio != null ? ` ${m.ref_nom}` : ""}</div>
122 + </div>
123 + );
124 + })}
125 + </div>
126 + {pols.length > 3 && (
127 + <Accordion title={`Autres mesures (${pols.length - 3})`} small>
128 + <div className="lk-rows">
129 + {pols.slice(3).map((p) => {
130 + const m = d.mesures[p];
131 + return (
132 + <div className="lk-row" key={p}>
133 + <span className="lk-row-name">{LONG[p] ?? p}</span>
134 + <span className="lk-row-val">{m.moyenne.toLocaleString("fr-CA")} <small style={{ fontWeight: 500, color: "var(--lk-muted)" }}>{m.unite}</small></span>
135 + <span className="lk-row-lbl">{m.ref != null ? `repère ${m.ref}` : ""}</span>
136 + </div>
137 + );
138 + })}
139 + </div>
140 + </Accordion>
141 + )}
142 + <SourceLine name="MELCCFP (RSQAQ)" date={<>station {d.station}{d.distance_km != null ? ` · ${d.distance_km.toLocaleString("fr-CA")} km` : ""}{annee ? ` · données ${annee}` : ""}</>} />
143 + <Accordion title="Méthodologie" small>
144 + <p>
145 + Moyennes annuelles {annee} mesurées à la station <b>{d.station}</b> ({d.ville}, à {d.distance_km} km) du Réseau de
146 + surveillance de la qualité de l'air du Québec (MELCCFP, données ouvertes). Chaque mesure est située par
147 + rapport à son repère annuel (lignes directrices OMS 2021, ou norme québécoise RAA pour les PST).
148 + L'air à l'adresse peut différer localement.
149 + </p>
150 + </Accordion>
151 + </>
152 + )}
153 + </SectionCard>
154 + );
155 +}
156 +
157 +/* --- Essence ------------------------------------------------------------------ */
158 +const cents = (v: number | null | undefined) => (v == null ? "—" : `${v.toLocaleString("fr-CA", { minimumFractionDigits: 1 })}${NBSP}¢`);
159 +
160 +export function GasNearbyCard({ r, onRetry }: { r: Res<GazNearby>; onRetry: () => void }) {
161 + const [sheet, setSheet] = useState(false);
162 + if (r.status === "na" || r.status === "idle") return null;
163 + const d = r.status === "ok" ? r.data : null;
164 + if (d && d.stations.length === 0) return null;
165 + const Row = ({ s, full = false }: { s: GazNearby["stations"][number]; full?: boolean }) => (
166 + <li className="lk-item">
167 + <span className="lk-item-ico" aria-hidden="true"><IcoFuel size={17} /></span>
168 + <div className="lk-item-main">
169 + <div className="lk-item-t">{s.nom}{s.moins_chere && <span className="lk-item-best">la moins chère</span>}</div>
170 + <div className="lk-item-s">{fmtDist(s.dist_m)}{full && s.adresse ? ` · ${s.adresse}` : ""}{full ? ` · super ${cents(s.super)} · diesel ${cents(s.diesel)}` : ""}</div>
171 + </div>
172 + <div className="lk-item-r">
173 + <div className="lk-item-v">{cents(s.regulier)}<small style={{ fontWeight: 500, color: "var(--lk-muted)" }}>/L</small></div>
174 + <div className="lk-item-m">régulier</div>
175 + </div>
176 + </li>
177 + );
178 + return (
179 + <SectionCard id="essence" title="Essence" icon={<IcoFuel size={18} />}>
180 + {r.status === "loading" && <SkeletonLines n={3} />}
181 + {r.status === "error" && <ErrorState onRetry={onRetry}>Prix de l'essence temporairement indisponibles.</ErrorState>}
182 + {d && (
183 + <>
184 + <div className="lk-kpis cols-3">
185 + <StatTile accent value={d.min_regulier != null ? d.min_regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 }) : "—"} unit="¢/L" label="Meilleur prix" />
186 + <StatTile value={d.mediane_regulier != null ? d.mediane_regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 }) : "—"} unit="¢/L" label="Médiane du secteur" />
187 + <StatTile value={d.n} label={`Stations à moins de ${fmtDist(d.rayon_m)}`} />
188 + </div>
189 + <ul className="lk-list" style={{ marginTop: 10 }}>
190 + {d.stations.slice(0, 3).map((s, i) => <Row s={s} key={i} />)}
191 + </ul>
192 + {d.stations.length > 3 && <MoreButton onClick={() => setSheet(true)}>Voir les {d.n} stations</MoreButton>}
193 + <SourceLine name="gazquebec.ca" href="https://gazquebec.ca" date={d.maj ? `mis à jour ${d.maj}` : undefined} />
194 + <BottomSheet open={sheet} onClose={() => setSheet(false)} title="Stations-service" sub={`${d.n} stations à moins de ${fmtDist(d.rayon_m)} · prix en ¢/L`} tall>
195 + <ul className="lk-list">{d.stations.map((s, i) => <Row s={s} full key={i} />)}</ul>
196 + </BottomSheet>
197 + </>
198 + )}
199 + </SectionCard>
200 + );
201 +}
added frontend/src/fiche/InteractiveMap.tsx +124 −0
@@ -0,0 +1,124 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/InteractiveMap.tsx : grande carte 3D de la fiche (Ka Maps / Mapbox) —
5 +// immeuble surligné en orange, lignes de métro, et FILTRES de lieux
6 +// (Transport · Épiceries · Pharmacies · Commerces · Écoles · Parcs · Essence)
7 +// dessinés comme couche GeoJSON (cercle coloré + étiquette). Les lieux
8 +// viennent des données déjà chargées (commerces/transit Mapbox-OSM, POI OSM
9 +// avec coordonnées, stations gazquebec) ; un filtre sans coordonnée connue
10 +// est désactivé (jamais de point inventé). La caméra recule pour englober
11 +// les lieux affichés puis revient sur l'immeuble quand aucun filtre n'est actif.
12 +// -----------------------------------------------------------------------------
13 +import { lazy, Suspense, useEffect, useMemo, useState } from "react";
14 +import type { CommercesNearby, GazNearby, Listing, Poi } from "../api";
15 +import { fmtDist } from "../api";
16 +import { IcoMapPin } from "../components/Icons";
17 +import { SectionCard, Skeleton } from "./ui";
18 +
19 +const MapInner = lazy(() => import("./MapInner"));
20 +
21 +export interface Lieu { id: string; cat: string; name: string; sub?: string; lat: number; lng: number; dist_m: number; }
22 +export interface Categorie { key: string; label: string; color: string; }
23 +
24 +export const CATEGORIES: Categorie[] = [
25 + { key: "transport", label: "Transport", color: "#0083c9" },
26 + { key: "epicerie", label: "Épiceries", color: "#1e7b4a" },
27 + { key: "pharmacie", label: "Pharmacies", color: "#c2185b" },
28 + { key: "commerce", label: "Commerces", color: "#5c4bb5" },
29 + { key: "ecole", label: "Écoles", color: "#b8770b" },
30 + { key: "parc", label: "Parcs", color: "#3d8f3d" },
31 + { key: "essence", label: "Essence", color: "#4e5357" },
32 +];
33 +
34 +const COMMERCE_CAT: Record<string, string> = {
35 + metro_station: "transport", rem_station: "transport", arret_bus: "transport", gare_train: "transport",
36 + costco: "epicerie", walmart: "epicerie", metro: "epicerie", iga: "epicerie", maxi: "epicerie",
37 + superc: "epicerie", provigo: "epicerie", pharmaprix: "pharmacie", jeancoutu: "pharmacie",
38 +};
39 +const POI_CAT: Record<string, string> = {
40 + epicerie: "epicerie", depanneur: "commerce", pharmacie: "pharmacie", ecole: "ecole", garderie: "ecole",
41 + bibliotheque: "ecole", parc: "parc", bus: "transport", metro: "transport", gym: "commerce", cafe: "commerce",
42 + clinique: "commerce", hopital: "commerce",
43 +};
44 +
45 +/** Fusionne toutes les sources de lieux géolocalisés (dédoublonnage grossier). */
46 +export function lieuxDepuis(pois: Poi[], cm: CommercesNearby | null, gaz: GazNearby | null): Lieu[] {
47 + const out: Lieu[] = [];
48 + const seen = new Set<string>();
49 + const push = (x: Lieu) => {
50 + const k = `${x.cat}|${x.name.toLowerCase()}|${x.lat.toFixed(4)}|${x.lng.toFixed(4)}`;
51 + if (seen.has(k)) return;
52 + seen.add(k); out.push(x);
53 + };
54 + for (const c of [...(cm?.transit ?? []), ...(cm?.commerces ?? [])])
55 + if (c.lat != null && c.lng != null)
56 + push({ id: `cm-${c.id}-${c.dist_m}`, cat: COMMERCE_CAT[c.id] ?? "commerce", name: c.commerce || c.nom,
57 + sub: c.nom !== c.commerce ? c.nom : c.adresse, lat: c.lat, lng: c.lng, dist_m: c.dist_m });
58 + for (const p of pois)
59 + if (p.lat != null && p.lng != null)
60 + push({ id: `poi-${p.cat}`, cat: POI_CAT[p.cat] ?? "commerce", name: p.name, lat: p.lat, lng: p.lng, dist_m: p.dist_m });
61 + for (const s of gaz?.stations ?? [])
62 + if (s.lat != null && s.lng != null)
63 + push({ id: `gaz-${s.lat}-${s.lng}`, cat: "essence", name: s.nom,
64 + sub: s.regulier != null ? `${s.regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 })} ¢/L` : s.adresse,
65 + lat: s.lat, lng: s.lng, dist_m: s.dist_m });
66 + return out;
67 +}
68 +
69 +export default function InteractiveMap({ l, lieux, loadingLieux }: { l: Listing; lieux: Lieu[]; loadingLieux: boolean }) {
70 + const [on, setOn] = useState<Set<string>>(new Set());
71 + const [visible, setVisible] = useState(false);
72 + const counts = useMemo(() => {
73 + const c: Record<string, number> = {};
74 + for (const x of lieux) c[x.cat] = (c[x.cat] ?? 0) + 1;
75 + return c;
76 + }, [lieux]);
77 + const actifs = useMemo(() => lieux.filter((x) => on.has(x.cat)), [lieux, on]);
78 +
79 + // la carte (Mapbox) ne se charge qu'à l'approche de la section
80 + useEffect(() => {
81 + const el = document.getElementById("carte");
82 + if (!el) return;
83 + if (!("IntersectionObserver" in window)) { setVisible(true); return; }
84 + const io = new IntersectionObserver((e) => { if (e.some((x) => x.isIntersecting)) { setVisible(true); io.disconnect(); } },
85 + { rootMargin: "400px 0px" });
86 + io.observe(el);
87 + return () => io.disconnect();
88 + }, []);
89 +
90 + if (l.lat == null || l.lng == null) return null;
91 + const toggle = (k: string) => setOn((s) => { const n = new Set(s); if (n.has(k)) n.delete(k); else n.add(k); return n; });
92 +
93 + return (
94 + <SectionCard id="carte" title="Carte" icon={<IcoMapPin size={18} />}
95 + sub="Immeuble de l'annonce en orange · position selon l'adresse géocodée (Adresses Québec)">
96 + <div className="lk-chips" role="group" aria-label="Lieux à afficher sur la carte">
97 + {CATEGORIES.map((c) => {
98 + const n = counts[c.key] ?? 0;
99 + return (
100 + <button type="button" key={c.key} className={`lk-chip ${on.has(c.key) ? "on" : ""}`}
101 + style={{ "--c": c.color } as React.CSSProperties} disabled={n === 0}
102 + aria-pressed={on.has(c.key)} onClick={() => toggle(c.key)}
103 + title={n === 0 ? (loadingLieux ? "Chargement…" : "Aucune position connue pour ce secteur") : undefined}>
104 + <i className="dot" aria-hidden="true" />{c.label}{n > 0 && <small>{n}</small>}
105 + </button>
106 + );
107 + })}
108 + </div>
109 + <div className="lk-map" role="img" aria-label={`Carte 3D — ${l.address || l.title}`}>
110 + {visible ? (
111 + <Suspense fallback={<Skeleton className="lk-map-skel" h="100%" r={0} />}>
112 + <MapInner l={l} lieux={actifs} categories={CATEGORIES} />
113 + </Suspense>
114 + ) : <Skeleton className="lk-map-skel" h="100%" r={0} />}
115 + <span className="lk-map-legend" aria-hidden="true"><i /> Immeuble de l'annonce</span>
116 + </div>
117 + {actifs.length > 0 && (
118 + <p className="lk-map-hint">
119 + {actifs.length} lieu{actifs.length > 1 ? "x" : ""} affiché{actifs.length > 1 ? "s" : ""} · le plus proche à {fmtDist(Math.min(...actifs.map((x) => x.dist_m)))}
120 + </p>
121 + )}
122 + </SectionCard>
123 + );
124 +}
added frontend/src/fiche/KaAssistant.tsx +92 −0
@@ -0,0 +1,92 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/KaAssistant.tsx : « ✦ Demander à Ka » — bouton discret (bas droite,
5 +// au-dessus du CTA) qui remplace la grosse bulle KA Agent sur la fiche, et
6 +// bottom sheet de démarrage : contexte du logement, suggestions prédéfinies,
7 +// champ libre. L'envoi passe par le widget KA Agent existant (ka-agent.js,
8 +// backend api-ka inchangé) : on ouvre son panneau et on lui transmet la
9 +// question, enrichie du contexte de la fiche (titre, ville, prix, lien).
10 +// -----------------------------------------------------------------------------
11 +import { useEffect, useState } from "react";
12 +import { Listing, fmtPrice } from "../api";
13 +import { IcoChevronRight, IcoSparkles } from "../components/Icons";
14 +import BottomSheet from "./BottomSheet";
15 +
16 +const SUGGESTIONS = [
17 + "Est-ce une bonne affaire ?",
18 + "Compare ce loyer au quartier",
19 + "Le secteur est-il bon sans voiture ?",
20 + "Quels sont les points négatifs ?",
21 + "Explique le registre des loyers",
22 +];
23 +
24 +/** Ouvre le panneau KA Agent (widget partagé) et envoie la question. */
25 +function envoyerAKa(question: string): boolean {
26 + const btn = document.querySelector<HTMLButtonElement>(".kaa-btn");
27 + const ta = document.querySelector<HTMLTextAreaElement>(".kaa-panel textarea");
28 + const send = document.querySelector<HTMLButtonElement>(".kaa-in button");
29 + if (!btn || !ta || !send) return false;
30 + btn.click(); // ouvre le panneau (plein écran)
31 + ta.value = question;
32 + setTimeout(() => send.click(), 60);
33 + return true;
34 +}
35 +
36 +export default function KaAssistant({ l, hidden }: { l: Listing; hidden?: boolean }) {
37 + const [open, setOpen] = useState(false);
38 + const [q, setQ] = useState("");
39 + const [err, setErr] = useState(false);
40 + // ouverture depuis un autre composant (bouton de l'aside desktop)
41 + useEffect(() => {
42 + const on = () => setOpen(true);
43 + window.addEventListener("lk:askka", on);
44 + return () => window.removeEventListener("lk:askka", on);
45 + }, []);
46 +
47 + const contexte = () =>
48 + `(Logement consulté sur Lou-Ka : ${l.title || l.address}${l.city ? `, ${l.city}` : ""}` +
49 + `${l.price != null ? ` — ${fmtPrice(l.price)}/mois` : ""}${l.unit_type ? ` — ${l.unit_type}` : ""}` +
50 + ` — https://www.lou-ka.com/logement/${encodeURIComponent(l.uid)})`;
51 +
52 + const poser = (question: string) => {
53 + const ok = envoyerAKa(`${question}\n\n${contexte()}`);
54 + if (ok) { setOpen(false); setQ(""); setErr(false); } else setErr(true);
55 + };
56 +
57 + return (
58 + <>
59 + <button type="button" className={`lk-ka-btn ${hidden ? "hide" : ""}`} onClick={() => setOpen(true)}
60 + aria-label="Demander à Ka, l'assistant Groupe KA">
61 + <IcoSparkles size={16} /> Demander à Ka
62 + </button>
63 + <BottomSheet open={open} onClose={() => setOpen(false)} title="Demander à Ka" sub="Assistant Groupe KA · connaît cette fiche"
64 + footer={
65 + <form className="lk-ka-in" onSubmit={(e) => { e.preventDefault(); if (q.trim()) poser(q.trim()); }}>
66 + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Posez votre question sur ce logement…" aria-label="Votre question" />
67 + <button type="submit" aria-label="Envoyer" disabled={!q.trim()}><IcoChevronRight size={20} /></button>
68 + </form>
69 + }>
70 + <div className="lk-ka-intro">
71 + <span className="lk-ka-avatar" aria-hidden="true"><IcoSparkles size={18} /></span>
72 + <p>Je peux comparer ce loyer au quartier, expliquer les données de la fiche et chercher dans les autres plateformes Groupe KA.</p>
73 + </div>
74 + <div className="lk-ka-ctx">
75 + {l.images?.[0] && <img src={`/api/img?u=${encodeURIComponent(l.images[0])}&w=160`} alt="" />}
76 + <span style={{ minWidth: 0 }}>
77 + <b>{l.title || l.address}</b>
78 + {[l.city, l.price != null ? `${fmtPrice(l.price)}/mois` : null, l.unit_type].filter(Boolean).join(" · ")}
79 + </span>
80 + </div>
81 + <div className="lk-ka-sugs">
82 + {SUGGESTIONS.map((s) => (
83 + <button type="button" className="lk-ka-sug" key={s} onClick={() => poser(s)}>
84 + {s} <IcoChevronRight size={16} />
85 + </button>
86 + ))}
87 + </div>
88 + {err && <p className="lk-note warn">L'assistant n'est pas encore chargé — réessayez dans un instant.</p>}
89 + </BottomSheet>
90 + </>
91 + );
92 +}
added frontend/src/fiche/KaScoresCard.tsx +105 −0
@@ -0,0 +1,105 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/KaScoresCard.tsx : « KA Scores » — 5 cercles compacts (Marche,
5 +// Transport, Vélo, Calme, Services), score personnalisé selon les
6 +// pondérations de l'utilisateur, détail honnête en accordéon.
7 +// -----------------------------------------------------------------------------
8 +import { Link } from "react-router-dom";
9 +import { KaScores, KA_DEFAULT_WEIGHTS, fmtDist, kaGlobal, kaLabel, kaWeights } from "../api";
10 +import { kaTint } from "../components/KaScoreBadge";
11 +import { IcoLayers } from "../components/Icons";
12 +import { Accordion, SectionCard, SourceLine } from "./ui";
13 +
14 +const CAT: Record<string, string> = {
15 + epicerie: "Épicerie", pharmacie: "Pharmacie", parc: "Parc", cafe: "Café", ecole: "École",
16 + clinique: "Clinique", garderie: "Garderie", depanneur: "Dépanneur", gym: "Gym", bibliotheque: "Bibliothèque",
17 +};
18 +const FAM: Record<string, string> = { commerces: "commerces", sante: "santé", education: "éducation", loisirs: "loisirs" };
19 +
20 +function Cercle({ score, nom, note }: { score: number | null; nom: string; note?: string }) {
21 + const r = 22, c = 2 * Math.PI * r;
22 + const part = score == null ? 0 : Math.max(0, Math.min(1, score / 100));
23 + return (
24 + <div className={`lk-ks-c ${kaTint(score)}`} role="img"
25 + aria-label={`${nom} : ${score == null ? note ?? "données insuffisantes" : `${Math.round(score)} sur 100`}`}>
26 + <div className="lk-ks-wrap">
27 + <svg viewBox="0 0 52 52" aria-hidden="true">
28 + <circle className="bg" cx="26" cy="26" r={r} />
29 + <circle className="arc" cx="26" cy="26" r={r} strokeDasharray={`${c * part} ${c}`} />
30 + </svg>
31 + <div className="lk-ks-v">{score == null ? "—" : Math.round(score)}</div>
32 + </div>
33 + <div className="lk-ks-n">{nom}</div>
34 + <div className="lk-ks-l">{score == null ? (note ?? "n/d") : kaLabel(score)}</div>
35 + </div>
36 + );
37 +}
38 +
39 +export default function KaScoresCard({ ks }: { ks: KaScores }) {
40 + const weights = kaWeights();
41 + const custom = JSON.stringify(weights) !== JSON.stringify(KA_DEFAULT_WEIGHTS);
42 + const perso = custom ? kaGlobal(ks, weights) : null;
43 + const d = ks.details ?? {};
44 + const walkCats = (d.walk?.cats ?? []).filter((c) => c.dist_m != null).slice(0, 6);
45 +
46 + return (
47 + <SectionCard id="ka-scores" title="KA Scores" icon={<IcoLayers size={18} />}
48 + sub={ks.global != null ? `Global ${Math.round(ks.global)} · ${kaLabel(ks.global)}${perso != null ? ` · selon vos priorités : ${Math.round(perso)}` : ""}` : undefined}>
49 + <div className="lk-ks">
50 + <Cercle score={ks.walk} nom="Marche" />
51 + <Cercle score={ks.transit} nom="Transport" note={ks.transit == null ? "Non desservi" : undefined} />
52 + <Cercle score={ks.bike} nom="Vélo" />
53 + <Cercle score={ks.calme} nom="Calme" />
54 + <Cercle score={ks.services} nom="Services" />
55 + </div>
56 + <Accordion title="Le détail des scores de ce secteur" small>
57 + <div className="lk-ks-detail">
58 + {walkCats.length > 0 && (
59 + <div>
60 + <h4>Marche</h4>
61 + <ul>{walkCats.map((c) => <li key={c.cat}>{CAT[c.cat] ?? c.cat} : {fmtDist(c.dist_m as number)}</li>)}</ul>
62 + </div>
63 + )}
64 + <div>
65 + <h4>Transport</h4>
66 + <ul>
67 + {d.transit?.arret_bus_m != null && <li>Arrêt de bus à {fmtDist(d.transit.arret_bus_m)}</li>}
68 + {d.transit?.station_metro_m != null && <li>Station de métro à {fmtDist(d.transit.station_metro_m)}</li>}
69 + {d.transit?.pmd_percentile != null && <li>Desserte : {d.transit.pmd_percentile}ᵉ percentile canadien (StatCan)</li>}
70 + {d.transit?.raison && <li>{d.transit.raison}</li>}
71 + </ul>
72 + <h4>Vélo</h4>
73 + <ul>
74 + {d.bike?.km_cyclables_1km != null && <li>{d.bike.km_cyclables_1km.toLocaleString("fr-CA")} km de voies cyclables à moins de 1 km</li>}
75 + {d.bike?.raison && <li>{d.bike.raison}</li>}
76 + {d.bike?.note && <li>{d.bike.note}</li>}
77 + </ul>
78 + </div>
79 + <div>
80 + <h4>Calme</h4>
81 + <ul>
82 + {(d.calme?.sources_bruit ?? []).length === 0 && <li>Aucune source de bruit majeure détectée à proximité</li>}
83 + {(d.calme?.sources_bruit ?? []).map((s) => <li key={s.source}>{s.source}{s.dist_m != null ? ` à ${fmtDist(s.dist_m)}` : ""}</li>)}
84 + {(d.calme?.bonus_parc ?? 0) > 0 && <li>Parc à proximité</li>}
85 + {d.calme?.note && <li>{d.calme.note}</li>}
86 + </ul>
87 + {d.services?.familles && (
88 + <>
89 + <h4>Services</h4>
90 + <ul>{Object.entries(d.services.familles).map(([f, n]) => <li key={f}>{n} {FAM[f] ?? f} dans le secteur</li>)}</ul>
91 + </>
92 + )}
93 + </div>
94 + </div>
95 + <p>
96 + Scores 0-100 calculés depuis OpenStreetMap et les mesures de proximité de Statistique Canada — le Calme
97 + est une estimation d'environnement, pas une mesure sonore. Barème {ks.version}, calculé le{" "}
98 + {new Date(ks.computed_at * 1000).toLocaleDateString("fr-CA")}.{" "}
99 + <Link to="/ka-scores">Méthodologie et réglage de vos priorités</Link>
100 + </p>
101 + </Accordion>
102 + <SourceLine name="OpenStreetMap · Statistique Canada" date={`barème ${ks.version}`} />
103 + </SectionCard>
104 + );
105 +}
added frontend/src/fiche/LouKaScore.tsx +66 −0
@@ -0,0 +1,66 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/LouKaScore.tsx : carte « Lou-Ka Score » — anneau 0-100 animé, libellé,
5 +// composantes (emplacement / prix) et méthodologie en accordéon. Quand une
6 +// composante manque, le score est présenté comme partiel ; quand aucune
7 +// n'est fiable, la carte affiche la synthèse sans chiffre (jamais inventé).
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useState } from "react";
10 +import { Link } from "react-router-dom";
11 +import { IcoSparkles } from "../components/Icons";
12 +import { LouKaScore as Score } from "./synthese";
13 +import { Accordion, SectionCard } from "./ui";
14 +
15 +export function ScoreRing({ value, partial, size = 88 }: { value: number | null; partial?: boolean; size?: number }) {
16 + const [v, setV] = useState(0);
17 + useEffect(() => {
18 + const t = requestAnimationFrame(() => setV(value ?? 0));
19 + return () => cancelAnimationFrame(t);
20 + }, [value]);
21 + const r = 40, c = 2 * Math.PI * r;
22 + return (
23 + <div className="lk-score-ring" style={{ width: size, height: size }} role="img"
24 + aria-label={value != null ? `Lou-Ka Score ${value} sur 100${partial ? ", partiel" : ""}` : "Score non calculable"}>
25 + <svg viewBox="0 0 100 100" style={{ width: size, height: size }} aria-hidden="true">
26 + <circle className="bg" cx="50" cy="50" r={r} />
27 + <circle className={`arc ${partial ? "partial" : ""}`} cx="50" cy="50" r={r}
28 + strokeDasharray={`${(c * Math.max(0, Math.min(100, v))) / 100} ${c}`} />
29 + </svg>
30 + <div className="lk-score-val">
31 + <div>{value != null ? value : "—"}<small>/ 100</small></div>
32 + </div>
33 + </div>
34 + );
35 +}
36 +
37 +export default function LouKaScore({ s, resume }: { s: Score; resume: string[] }) {
38 + return (
39 + <SectionCard id="score" title="Lou-Ka Score" icon={<IcoSparkles size={18} />}
40 + sub={s.partial ? "Score partiel — une composante manque" : undefined}>
41 + <div className="lk-score">
42 + <ScoreRing value={s.value} partial={s.partial} />
43 + <div>
44 + <div className="lk-score-lbl">
45 + {s.value != null ? s.label : "Pas assez de données pour un score"}
46 + </div>
47 + {resume.length > 0 && <div className="lk-score-sub">{resume.join(" · ")}</div>}
48 + <div className="lk-score-parts">
49 + <span className={`lk-score-part ${s.emplacement == null ? "na" : ""}`}>
50 + Emplacement {s.emplacement != null ? <b>{s.emplacement}</b> : "non évalué"}
51 + </span>
52 + <span className={`lk-score-part ${s.prix == null ? "na" : ""}`}>
53 + Prix {s.prix != null ? <b>{s.prix}</b> : "non évalué"}
54 + </span>
55 + </div>
56 + </div>
57 + </div>
58 + <Accordion title="Comment est calculé ce score ?" small>
59 + <p>{s.explication}</p>
60 + <p>
61 + <Link to="/ka-scores">Méthodologie des KA Scores</Link> · <Link to="/juste-valeur">Méthodologie de la juste valeur</Link>
62 + </p>
63 + </Accordion>
64 + </SectionCard>
65 + );
66 +}
added frontend/src/fiche/MapInner.tsx +108 −0
@@ -0,0 +1,108 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/MapInner.tsx : partie Mapbox de la carte de la fiche (chargée
5 +// paresseusement) — KaSpotlightMap (Ka Maps) + lignes de métro + couche des
6 +// lieux filtrés (cercles colorés par catégorie + étiquettes) + caméra qui
7 +// englobe les lieux affichés.
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useMemo } from "react";
10 +import "mapbox-gl/dist/mapbox-gl.css";
11 +import "@groupe-ka/ka-maps/styles.css";
12 +import type { MapProperty } from "@groupe-ka/ka-maps";
13 +import { KaBrandBadge, KaSpotlightMap, useKaMap } from "@groupe-ka/ka-maps/react";
14 +import type mapboxgl from "mapbox-gl";
15 +import type { Listing } from "../api";
16 +import MetroLignes from "../components/MetroLignes";
17 +import { louKaMapTheme } from "../kamaps/theme";
18 +import { MAPBOX_TOKEN } from "../kamaps/config";
19 +import type { Categorie, Lieu } from "./InteractiveMap";
20 +
21 +const SRC = "lk-lieux";
22 +const ORANGE = "#ff6a00";
23 +
24 +function LieuxLayer({ lieux, categories, center }: { lieux: Lieu[]; categories: Categorie[]; center: [number, number] }) {
25 + const ka = useKaMap();
26 + const couleurs = useMemo(() => {
27 + const m: unknown[] = ["match", ["get", "cat"]];
28 + for (const c of categories) m.push(c.key, c.color);
29 + m.push("#666");
30 + return m;
31 + }, [categories]);
32 +
33 + useEffect(() => {
34 + if (!ka) return;
35 + const map = (ka as unknown as { map: mapboxgl.Map }).map;
36 + if (!map) return;
37 + const data = {
38 + type: "FeatureCollection" as const,
39 + features: lieux.map((x) => ({
40 + type: "Feature" as const, properties: { cat: x.cat, name: x.name },
41 + geometry: { type: "Point" as const, coordinates: [x.lng, x.lat] },
42 + })),
43 + };
44 + const ensure = () => {
45 + const src = map.getSource(SRC) as mapboxgl.GeoJSONSource | undefined;
46 + if (src) { src.setData(data); return; }
47 + map.addSource(SRC, { type: "geojson", data });
48 + map.addLayer({
49 + id: `${SRC}-halo`, type: "circle", source: SRC,
50 + paint: { "circle-radius": 9, "circle-color": "#ffffff", "circle-opacity": 0.95 },
51 + });
52 + map.addLayer({
53 + id: `${SRC}-dot`, type: "circle", source: SRC,
54 + paint: { "circle-radius": 6, "circle-color": couleurs as mapboxgl.ExpressionSpecification },
55 + });
56 + map.addLayer({
57 + id: `${SRC}-lbl`, type: "symbol", source: SRC,
58 + layout: {
59 + "text-field": ["get", "name"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top",
60 + "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"], "text-optional": true,
61 + },
62 + paint: { "text-color": "#141814", "text-halo-color": "#ffffff", "text-halo-width": 1.4 },
63 + });
64 + };
65 + const apply = () => { try { ensure(); } catch { /* style pas prêt */ } };
66 + if (map.isStyleLoaded()) apply();
67 + map.on("style.load", apply);
68 + map.on("load", apply);
69 + // caméra : englober les lieux + l'immeuble ; sans lieu → retour sur l'immeuble
70 + if (lieux.length > 0) {
71 + let w = center[0], e = center[0], s = center[1], n = center[1];
72 + const proches = lieux.filter((x) => x.dist_m <= 2000);
73 + for (const x of proches.length ? proches : lieux) { w = Math.min(w, x.lng); e = Math.max(e, x.lng); s = Math.min(s, x.lat); n = Math.max(n, x.lat); }
74 + map.fitBounds([[w, s], [e, n]], { padding: { top: 60, bottom: 40, left: 40, right: 40 }, pitch: 30, maxZoom: 16, duration: 700 });
75 + } else {
76 + map.easeTo({ center, zoom: 17, pitch: 62, duration: 700 });
77 + }
78 + return () => { map.off("style.load", apply); map.off("load", apply); };
79 + }, [ka, lieux, couleurs, center]);
80 +
81 + useEffect(() => () => {
82 + if (!ka) return;
83 + const map = (ka as unknown as { map: mapboxgl.Map }).map;
84 + try {
85 + for (const id of [`${SRC}-lbl`, `${SRC}-dot`, `${SRC}-halo`]) if (map.getLayer(id)) map.removeLayer(id);
86 + if (map.getSource(SRC)) map.removeSource(SRC);
87 + } catch { /* carte détruite */ }
88 + }, [ka]);
89 + return null;
90 +}
91 +
92 +export default function MapInner({ l, lieux, categories }: { l: Listing; lieux: Lieu[]; categories: Categorie[] }) {
93 + const property = useMemo<MapProperty>(() => ({
94 + id: l.uid, appSource: "lou-ka", latitude: l.lat as number, longitude: l.lng as number,
95 + kind: "listing", listingType: "rent", price: l.price ?? undefined,
96 + propertyType: l.unit_type || undefined, address: l.address || l.title || undefined,
97 + city: l.city || undefined, thumbnailUrl: l.images?.[0],
98 + }), [l.uid, l.lat, l.lng, l.price, l.unit_type, l.address, l.title, l.city, l.images]);
99 + const center = useMemo<[number, number]>(() => [l.lng as number, l.lat as number], [l.lat, l.lng]);
100 +
101 + return (
102 + <KaSpotlightMap theme={louKaMapTheme} mapboxToken={MAPBOX_TOKEN} property={property} buildingColor={ORANGE}>
103 + <KaBrandBadge />
104 + <MetroLignes />
105 + <LieuxLayer lieux={lieux} categories={categories} center={center} />
106 + </KaSpotlightMap>
107 + );
108 +}
added frontend/src/fiche/MarketPriceCard.tsx +99 −0
@@ -0,0 +1,99 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/MarketPriceCard.tsx : « Prix et marché » — juste valeur estimée
5 +// (fairvalue.py) : valeur, fourchette, écart, confiance, histogramme du
6 +// segment (ce loyer en orange, juste valeur en tiret), méthodologie en
7 +// accordéon. Données : Res<FairValueDetail> (squelette / vide / erreur).
8 +// -----------------------------------------------------------------------------
9 +import { Link } from "react-router-dom";
10 +import { FairValueDetail, Listing, fmtPrice } from "../api";
11 +import { IcoScale } from "../components/Icons";
12 +import { PriceCapsule } from "./PropertyHero";
13 +import { ComparaisonPrix } from "./synthese";
14 +import { Accordion, ErrorState, SectionCard, SkeletonLines, StatTile, NBSP } from "./ui";
15 +import { Res } from "./useFicheData";
16 +
17 +const CONF = { fort: "Confiance forte", moyen: "Confiance moyenne", faible: "Estimation indicative" } as const;
18 +
19 +function Histo({ d, price }: { d: FairValueDetail; price: number }) {
20 + const bins = d.histogram;
21 + if (!bins || bins.length < 4) return null;
22 + const W = 320, H = 92, top = 18, bottom = 16;
23 + const lo = bins[0].x0, hi = bins[bins.length - 1].x1;
24 + if (hi <= lo) return null;
25 + const max = Math.max(...bins.map((b) => b.n), 1);
26 + const x = (v: number) => ((Math.min(Math.max(v, lo), hi) - lo) / (hi - lo)) * W;
27 + const bw = W / bins.length;
28 + const plotH = H - top - bottom;
29 + const priceX = x(price), fvX = x(d.fv);
30 + const close = Math.abs(priceX - fvX) < 64;
31 + const anchor = (px: number) => (px < 56 ? "start" : px > W - 56 ? "end" : "middle");
32 + return (
33 + <svg className="lk-histo" viewBox={`0 0 ${W} ${H}`} role="img"
34 + aria-label={`Position du loyer (${fmtPrice(price)}) parmi ${d.segment_n} annonces comparables`}>
35 + {bins.map((b, i) => {
36 + const h = Math.max(1.5, (b.n / max) * plotH);
37 + const on = price >= b.x0 && price < b.x1;
38 + return (
39 + <rect key={i} className={`lk-histo-bar ${on ? "on" : ""}`} x={i * bw + 1} y={H - bottom - h}
40 + rx="2" width={Math.max(1, bw - 2)} height={h}>
41 + <title>{`${b.x0} $ – ${b.x1} $ : ${b.n} annonce${b.n > 1 ? "s" : ""}`}</title>
42 + </rect>
43 + );
44 + })}
45 + <rect x={x(d.fv_low)} y={H - bottom} width={Math.max(2, x(d.fv_high) - x(d.fv_low))} height="3" rx="1.5" fill="#c9cac4" />
46 + <line x1={fvX} x2={fvX} y1={top - 2} y2={H - bottom} stroke="#4d5551" strokeWidth="1.5" strokeDasharray="3 3" />
47 + {!close && <text x={fvX} y={top - 7} textAnchor={anchor(fvX)} className="lk-histo-lbl fv">Juste valeur</text>}
48 + <line x1={priceX} x2={priceX} y1={top - 2} y2={H - bottom} stroke="#141814" strokeWidth="2" />
49 + <text x={priceX} y={close ? top - 7 : H - 4} textAnchor={anchor(priceX)} className="lk-histo-lbl">Ce loyer{close ? " / juste valeur" : ""}</text>
50 + <text x="1" y={H - 4} className="lk-histo-axis" textAnchor="start">{lo} $</text>
51 + <text x={W - 1} y={H - 4} className="lk-histo-axis" textAnchor="end">{hi} $</text>
52 + </svg>
53 + );
54 +}
55 +
56 +export default function MarketPriceCard({ l, fv, cmp, onRetry }: {
57 + l: Listing; fv: Res<FairValueDetail>; cmp: ComparaisonPrix | null; onRetry: () => void;
58 +}) {
59 + if (l.price == null) return null;
60 + const d = fv.status === "ok" ? fv.data : null;
61 + return (
62 + <SectionCard id="prix" title="Prix et marché" icon={<IcoScale size={18} />}
63 + aside={cmp && <PriceCapsule cmp={cmp} short />}>
64 + {fv.status === "loading" && <SkeletonLines n={4} />}
65 + {fv.status === "error" && <ErrorState onRetry={onRetry}>Analyse de prix temporairement indisponible.</ErrorState>}
66 + {(fv.status === "na" || (d && d.verdict == null)) && !d && (
67 + <p className="lk-fine meth">Pas d'analyse de prix pour cette annonce (loyer absent ou segment sans comparables).</p>
68 + )}
69 + {d && (
70 + <>
71 + <div className="lk-kpis cols-3">
72 + <StatTile accent value={fmtPrice(l.price)} label="Loyer demandé" />
73 + <StatTile value={fmtPrice(d.fv)} label="Juste valeur estimée" />
74 + <StatTile value={`${fmtPrice(d.fv_low)} – ${fmtPrice(d.fv_high)}`} label="Fourchette estimée" />
75 + </div>
76 + {d.verdict == null && (
77 + <p className="lk-note info">
78 + {CONF[d.confidence]} — pas assez de comparables fiables pour classer ce loyer.
79 + </p>
80 + )}
81 + <Histo d={d} price={l.price} />
82 + <div className="lk-meta">
83 + <span>{CONF[d.confidence]}</span>
84 + <span><b>{d.segment_n.toLocaleString("fr-CA")}</b> annonces comparables</span>
85 + {d.comps > 0 && <span><b>{d.comps}</b> voisines retenues</span>}
86 + </div>
87 + <Accordion title="Méthodologie de la juste valeur" small>
88 + <p>
89 + Estimation indicative recalculée en continu à partir des annonces comparables du marché
90 + (même segment{NBSP}: nombre de chambres, ville, période), méthode {d.method}, modèle {d.model_version}.
91 + Ce n'est pas une évaluation officielle.{" "}
92 + <Link to="/juste-valeur">Comment est calculée la juste valeur ?</Link>
93 + </p>
94 + </Accordion>
95 + </>
96 + )}
97 + </SectionCard>
98 + );
99 +}
added frontend/src/fiche/NearbyPlaces.tsx +187 −0
@@ -0,0 +1,187 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/NearbyPlaces.tsx : « À proximité » et « Transport » — carrousels de
5 +// cartes horizontales (transport · courses · services) construits à partir
6 +// des POI OpenStreetMap (louka/poi.py) et des grandes bannières (Mapbox),
7 +// les 4 lieux les plus pertinents en liste, puis « Voir les N lieux »
8 +// → bottom sheet groupé par catégorie.
9 +// -----------------------------------------------------------------------------
10 +import { ReactNode, useState } from "react";
11 +import { CommerceItem, CommercesNearby, Poi, fmtDist } from "../api";
12 +import {
13 + IcoBaby, IcoBook, IcoBus, IcoCart, IcoCoffee, IcoDumbbell, IcoHospital, IcoMapPin, IcoPill,
14 + IcoSchool, IcoStore, IcoTrain, IcoTrees,
15 +} from "../components/Icons";
16 +import BottomSheet from "./BottomSheet";
17 +import { ErrorState, MoreButton, SectionCard, SkeletonLines, SourceLine, fmtMarche, NBSP } from "./ui";
18 +import { Res } from "./useFicheData";
19 +
20 +const POI_META: Record<string, { ico: ReactNode; label: string }> = {
21 + epicerie: { ico: <IcoCart size={17} />, label: "Épicerie" },
22 + depanneur: { ico: <IcoStore size={17} />, label: "Dépanneur" },
23 + pharmacie: { ico: <IcoPill size={17} />, label: "Pharmacie" },
24 + ecole: { ico: <IcoSchool size={17} />, label: "École" },
25 + garderie: { ico: <IcoBaby size={17} />, label: "Garderie" },
26 + parc: { ico: <IcoTrees size={17} />, label: "Parc" },
27 + bus: { ico: <IcoBus size={17} />, label: "Arrêt de bus" },
28 + metro: { ico: <IcoTrain size={17} />, label: "Métro" },
29 + gym: { ico: <IcoDumbbell size={17} />, label: "Gym" },
30 + cafe: { ico: <IcoCoffee size={17} />, label: "Café" },
31 + clinique: { ico: <IcoHospital size={17} />, label: "Clinique" },
32 + hopital: { ico: <IcoHospital size={17} />, label: "Hôpital" },
33 + bibliotheque: { ico: <IcoBook size={17} />, label: "Bibliothèque" },
34 +};
35 +const GROUPES: { titre: string; cats: string[] }[] = [
36 + { titre: "Transport", cats: ["metro", "bus"] },
37 + { titre: "Courses", cats: ["epicerie", "depanneur", "pharmacie"] },
38 + { titre: "Études et famille", cats: ["ecole", "garderie", "bibliotheque"] },
39 + { titre: "Santé", cats: ["clinique", "hopital"] },
40 + { titre: "Vie de quartier", cats: ["cafe", "parc", "gym"] },
41 +];
42 +// bannières : [couleur, monogramme, texte]
43 +const BANNIERE_CAT: Record<string, string> = {
44 + metro_station: "Station de métro", rem_station: "Station REM", arret_bus: "Arrêt de bus", gare_train: "Gare de train",
45 + costco: "Épicerie · entrepôt", walmart: "Grande surface", metro: "Épicerie", iga: "Épicerie", maxi: "Épicerie",
46 + superc: "Épicerie", provigo: "Épicerie", canadiantire: "Quincaillerie", dollarama: "Magasin à 1 $", saq: "Alcools",
47 + pharmaprix: "Pharmacie", jeancoutu: "Pharmacie", homedepot: "Rénovation", rona: "Rénovation",
48 +};
49 +const catDe = (c: CommerceItem) => BANNIERE_CAT[c.id] ?? c.commerce;
50 +const BANNIERES: Record<string, [string, string, string?]> = {
51 + metro_station: ["#0083C9", "M"], rem_station: ["#84BD00", "R"], arret_bus: ["#4E5357", "B"], gare_train: ["#6E5B3F", "T"],
52 + costco: ["#005DAA", "C"], walmart: ["#0071CE", "W"], metro: ["#EF3E42", "M"], iga: ["#D50032", "IGA"],
53 + maxi: ["#0079C1", "Mx"], superc: ["#E4002B", "SC"], provigo: ["#DA291C", "P"], canadiantire: ["#D6001C", "CT"],
54 + dollarama: ["#00B140", "D", "#FFDD00"], saq: ["#892034", "SAQ"], pharmaprix: ["#E11B22", "Ph"],
55 + jeancoutu: ["#003DA5", "JC"], homedepot: ["#F96302", "HD"], rona: ["#1B4298", "R"],
56 +};
57 +
58 +export function Pastille({ id }: { id: string }) {
59 + const [bg, mono, fg] = BANNIERES[id] ?? ["#777", "•"];
60 + const fs = mono.length >= 3 ? 9 : mono.length === 2 ? 11 : 14;
61 + const rond = ["metro_station", "rem_station", "arret_bus", "gare_train"].includes(id);
62 + return (
63 + <svg className="cm-ico" viewBox="0 0 28 28" width="26" height="26" aria-hidden="true">
64 + {rond ? <circle cx="14" cy="14" r="13" fill={bg} /> : <rect x="1" y="1" width="26" height="26" rx="7" fill={bg} />}
65 + <text x="14" y="14" textAnchor="middle" dominantBaseline="central" fontSize={fs} fontWeight="800" fontFamily="inherit" fill={fg ?? "#fff"}>{mono}</text>
66 + </svg>
67 + );
68 +}
69 +
70 +interface Carte { key: string; cat: string; nom: string; sous?: string; dist: number; ico: ReactNode; }
71 +
72 +function CCard({ c }: { c: Carte }) {
73 + return (
74 + <div className="lk-ccard" role="listitem">
75 + <div className="lk-ccard-top">
76 + <span className="lk-ccard-c">{c.cat}</span>
77 + <span aria-hidden="true">{c.ico}</span>
78 + </div>
79 + <div className="lk-ccard-d">{fmtDist(c.dist)}</div>
80 + <div className="lk-ccard-n" title={c.nom}>{c.nom}</div>
81 + <div className="lk-ccard-m">≈{NBSP}{fmtMarche(c.dist)} à pied{c.sous ? ` · ${c.sous}` : ""}</div>
82 + </div>
83 + );
84 +}
85 +
86 +export default function NearbyPlaces({ pois, commerces, onRetry }: { pois: Poi[]; commerces: Res<CommercesNearby>; onRetry: () => void }) {
87 + const [sheet, setSheet] = useState(false);
88 + const cm = commerces.status === "ok" ? commerces.data : null;
89 + const transit: CommerceItem[] = cm?.transit ?? [];
90 + const bannieres: CommerceItem[] = cm?.commerces ?? [];
91 +
92 + // cartes « Transport » : métro/bus/REM/train (bannières transit) + POI bus/métro
93 + const transport: Carte[] = [
94 + ...transit.map((t) => ({ key: `t-${t.id}`, cat: catDe(t), nom: t.nom, dist: t.dist_m, ico: <Pastille id={t.id} /> })),
95 + ...pois.filter((p) => (p.cat === "metro" || p.cat === "bus") && !transit.some((t) => t.dist_m === p.dist_m))
96 + .map((p) => ({ key: `p-${p.cat}`, cat: POI_META[p.cat].label, nom: p.name, dist: p.dist_m, ico: POI_META[p.cat].ico })),
97 + ].sort((a, b) => a.dist - b.dist);
98 + // cartes « Courses et services » : bannières + POI hors transport
99 + const services: Carte[] = [
100 + ...bannieres.map((b) => ({ key: `b-${b.id}`, cat: catDe(b), nom: b.nom, dist: b.dist_m, ico: <Pastille id={b.id} /> })),
101 + ...pois.filter((p) => p.cat !== "metro" && p.cat !== "bus")
102 + .map((p) => ({ key: `p-${p.cat}`, cat: POI_META[p.cat]?.label ?? p.cat, nom: p.name, dist: p.dist_m, ico: POI_META[p.cat]?.ico ?? <IcoMapPin size={17} /> })),
103 + ].sort((a, b) => a.dist - b.dist);
104 + const total = transport.length + services.length;
105 + const loading = commerces.status === "loading" || commerces.status === "idle";
106 +
107 + if (total === 0 && !loading && commerces.status !== "error") return null;
108 +
109 + // « les plus pertinents » : métro/épicerie/pharmacie/parc/école les plus proches
110 + const prio = ["Station de métro", "Métro", "Épicerie", "Pharmacie", "Parc", "École", "Arrêt de bus"];
111 + const top = [...transport, ...services]
112 + .sort((a, b) => (prio.findIndex((p) => a.cat.startsWith(p)) + 1 || 99) - (prio.findIndex((p) => b.cat.startsWith(p)) + 1 || 99) || a.dist - b.dist)
113 + .filter((c, i, arr) => arr.findIndex((x) => x.cat === c.cat) === i)
114 + .slice(0, 4);
115 +
116 + return (
117 + <>
118 + <SectionCard id="proximite" title="À proximité" icon={<IcoMapPin size={18} />}
119 + sub={total ? `${total} lieux repérés · temps de marche estimés` : undefined}>
120 + {loading && total === 0 && <SkeletonLines n={3} />}
121 + {commerces.status === "error" && <ErrorState onRetry={onRetry}>Commerces et transport temporairement indisponibles.</ErrorState>}
122 + {top.length > 0 && (
123 + <ul className="lk-list">
124 + {top.map((c) => (
125 + <li className="lk-item" key={c.key}>
126 + <span className="lk-item-ico" aria-hidden="true">{c.ico}</span>
127 + <div className="lk-item-main">
128 + <div className="lk-item-t">{c.nom}</div>
129 + <div className="lk-item-s">{c.cat}</div>
130 + </div>
131 + <div className="lk-item-r">
132 + <div className="lk-item-v">{fmtDist(c.dist)}</div>
133 + <div className="lk-item-m">≈{NBSP}{fmtMarche(c.dist)}</div>
134 + </div>
135 + </li>
136 + ))}
137 + </ul>
138 + )}
139 + {services.length > 0 && (
140 + <>
141 + <h3 className="lk-card-sub" style={{ margin: "14px 0 8px", fontWeight: 600, color: "var(--lk-text-2)" }}>Courses et services</h3>
142 + <div className="lk-carousel" role="list" aria-label="Courses et services à proximité">
143 + {services.slice(0, 12).map((c) => <CCard c={c} key={c.key} />)}
144 + </div>
145 + </>
146 + )}
147 + {total > 4 && <MoreButton onClick={() => setSheet(true)}>Voir les {total} lieux à proximité</MoreButton>}
148 + <SourceLine name="OpenStreetMap · Mapbox Search"
149 + date={`distances à vol d'oiseau, marche ≈ distance × 1,3 à 4,8${NBSP}km/h`} />
150 + </SectionCard>
151 +
152 + {transport.length > 0 && (
153 + <SectionCard id="transport" title="Transport" icon={<IcoTrain size={18} />}
154 + sub="Stations et arrêts les plus proches">
155 + <div className="lk-carousel" role="list" aria-label="Transport en commun à proximité">
156 + {transport.slice(0, 10).map((c) => <CCard c={c} key={c.key} />)}
157 + </div>
158 + </SectionCard>
159 + )}
160 +
161 + <BottomSheet open={sheet} onClose={() => setSheet(false)} title="Lieux à proximité" sub={`${total} lieux · distances à vol d'oiseau`} tall>
162 + {GROUPES.map((g) => {
163 + const items = [
164 + ...pois.filter((p) => g.cats.includes(p.cat)).map((p) => ({ key: `p-${p.cat}`, cat: POI_META[p.cat]?.label ?? p.cat, nom: p.name, dist: p.dist_m, ico: POI_META[p.cat]?.ico })),
165 + ...(g.titre === "Transport" ? transit.map((t) => ({ key: `t-${t.id}`, cat: catDe(t), nom: t.nom, dist: t.dist_m, ico: <Pastille id={t.id} /> })) : []),
166 + ...(g.titre === "Courses" ? bannieres.map((b) => ({ key: `b-${b.id}`, cat: catDe(b), nom: b.nom, dist: b.dist_m, ico: <Pastille id={b.id} /> })) : []),
167 + ].sort((a, b) => a.dist - b.dist);
168 + if (items.length === 0) return null;
169 + return (
170 + <div key={g.titre} style={{ marginBottom: 14 }}>
171 + <h4 className="lk-card-sub" style={{ fontWeight: 700, color: "var(--lk-text)", margin: "0 0 4px" }}>{g.titre} <small style={{ fontWeight: 500 }}>· {items.length}</small></h4>
172 + <ul className="lk-list">
173 + {items.map((c) => (
174 + <li className="lk-item" key={c.key + c.dist}>
175 + <span className="lk-item-ico" aria-hidden="true">{c.ico}</span>
176 + <div className="lk-item-main"><div className="lk-item-t">{c.nom}</div><div className="lk-item-s">{c.cat}</div></div>
177 + <div className="lk-item-r"><div className="lk-item-v">{fmtDist(c.dist)}</div><div className="lk-item-m">≈{NBSP}{fmtMarche(c.dist)}</div></div>
178 + </li>
179 + ))}
180 + </ul>
181 + </div>
182 + );
183 + })}
184 + </BottomSheet>
185 + </>
186 + );
187 +}
added frontend/src/fiche/NeighborhoodStats.tsx +119 −0
@@ -0,0 +1,119 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/NeighborhoodStats.tsx : « Quartier » — KPI compacts du recensement
5 +// (aire de diffusion), accessibilité StatCan en lignes compactes (4 par
6 +// défaut + « Voir tous les indicateurs »), îlot de chaleur, criminalité
7 +// (résumé + détail par catégorie en accordéon), sources.
8 +// -----------------------------------------------------------------------------
9 +import { useState } from "react";
10 +import { Quartier } from "../api";
11 +import { IcoUsers } from "../components/Icons";
12 +import { Accordion, MoreButton, SectionCard, SourceLine, StatTile, StatusBadge, NBSP } from "./ui";
13 +
14 +const fmtMoney = (v: number | null | undefined) => (v == null ? null : `${Math.round(v / 1000)}${NBSP}k$`);
15 +const fmtPct = (v: number | null | undefined) => (v == null ? null : `${Math.round(v)}${NBSP}%`);
16 +
17 +const PROX: [string, string][] = [
18 + ["prox_epicerie", "Épiceries"], ["prox_transport", "Transport"], ["prox_pharmacie", "Pharmacies"],
19 + ["prox_parc", "Parcs"], ["prox_ecole_prim", "Écoles primaires"], ["prox_ecole_sec", "Écoles secondaires"],
20 + ["prox_sante", "Soins de santé"], ["prox_garderie", "Garderies"], ["prox_bibliotheque", "Bibliothèques"],
21 + ["prox_emploi", "Emplois"],
22 +];
23 +const niveau = (v: number) => (v >= 90 ? ["Excellent", "good"] : v >= 70 ? ["Très bon", "good"] : v >= 45 ? ["Moyen", "warn"] : ["Faible", "bad"]) as [string, string];
24 +
25 +export default function NeighborhoodStats({ q }: { q: Quartier }) {
26 + const [all, setAll] = useState(false);
27 + const d = q.demographie;
28 + const kpis: [string, string | null][] = d ? [
29 + ["Revenu médian", fmtMoney(d.revenu_median)],
30 + ["Locataires", fmtPct(d.pct_locataires)],
31 + ["Loyer moyen 2021", d.loyer_moyen != null ? `${Math.round(d.loyer_moyen).toLocaleString("fr-CA")}${NBSP}$` : null],
32 + ["Âge médian", d.age_median != null ? `${Math.round(d.age_median)} ans` : null],
33 + ["Français à la maison", fmtPct(d.pct_francais)],
34 + ["Universitaires", fmtPct(d.pct_univ)],
35 + ] : [];
36 + const kpisOk = kpis.filter(([, v]) => v != null) as [string, string][];
37 + const prox = q.proximite ?? {};
38 + const proxOk = PROX.filter(([k]) => typeof prox[k] === "number")
39 + .map(([k, l]) => [l, Math.round(Math.max(0, Math.min(1, prox[k])) * 100)] as [string, number]);
40 + const shown = all ? proxOk : proxOk.slice(0, 4);
41 + const crime = q.crime;
42 +
43 + if (kpisOk.length === 0 && proxOk.length === 0 && !q.chaleur && !crime) return null;
44 +
45 + return (
46 + <SectionCard id="quartier" title="Quartier" icon={<IcoUsers size={18} />}
47 + sub="Secteur immédiat de l'immeuble (aire de diffusion du recensement, ±500 habitants)">
48 + {kpisOk.length > 0 && (
49 + <div className="lk-kpis">
50 + {kpisOk.map(([l, v]) => <StatTile key={l} value={v} label={l} />)}
51 + </div>
52 + )}
53 + {proxOk.length > 0 && (
54 + <>
55 + <h3 className="lk-card-sub" style={{ margin: "14px 0 8px", fontWeight: 600, color: "var(--lk-text-2)" }}>Accessibilité du quartier <small>· indice 0–100 StatCan</small></h3>
56 + <div className="lk-rows">
57 + {shown.map(([l, v]) => {
58 + const [lbl, tone] = niveau(v);
59 + return (
60 + <div className="lk-row" key={l}>
61 + <span className="lk-row-name">{l}</span>
62 + <span className="lk-row-bar" aria-hidden="true"><i className={tone} style={{ width: `${v}%` }} /></span>
63 + <span className="lk-row-val">{v} <span className={`lk-row-lbl ${tone}`} style={{ minWidth: 0 }}>{lbl}</span></span>
64 + </div>
65 + );
66 + })}
67 + </div>
68 + {proxOk.length > 4 && <MoreButton onClick={() => setAll(!all)} expanded={all}>{all ? "Réduire" : "Voir tous les indicateurs"}</MoreButton>}
69 + </>
70 + )}
71 + {(q.chaleur || crime) && (
72 + <div className="lk-status" style={{ marginTop: 14 }}>
73 + {q.chaleur && (
74 + q.chaleur.classe <= 3 ? <StatusBadge tone="good">Îlot de fraîcheur</StatusBadge>
75 + : q.chaleur.classe >= 7 ? <StatusBadge tone="warn">Îlot de chaleur{q.chaleur.ecart != null ? ` (+${q.chaleur.ecart.toFixed(1)}${NBSP}°C)` : ""}</StatusBadge>
76 + : <StatusBadge tone="neutral">Température de quartier moyenne</StatusBadge>
77 + )}
78 + {crime?.type === "points" && (
79 + <StatusBadge tone={crime.douze_mois <= crime.douze_mois_precedents ? "neutral" : "warn"}>
80 + {crime.douze_mois} acte{crime.douze_mois > 1 ? "s" : ""} criminel{crime.douze_mois > 1 ? "s" : ""} à moins de {crime.rayon_m} m (12 mois)
81 + {crime.douze_mois_precedents > 0 && (crime.douze_mois <= crime.douze_mois_precedents ? " · en baisse" : " · en hausse")}
82 + </StatusBadge>
83 + )}
84 + {crime?.type === "igc" && (() => {
85 + const c = crime;
86 + if (c.indice_canada != null && c.indice_canada > 0) {
87 + const delta = Math.round(100 * (c.indice - c.indice_canada) / c.indice_canada);
88 + return <StatusBadge tone={delta <= 0 ? "good" : "neutral"}>Criminalité {Math.abs(delta)}{NBSP}% {delta <= 0 ? "sous" : "au-dessus de"} la moyenne canadienne</StatusBadge>;
89 + }
90 + return <StatusBadge tone="neutral">Indice de gravité de la criminalité : {c.indice}</StatusBadge>;
91 + })()}
92 + </div>
93 + )}
94 + {crime?.type === "points" && (crime.categories?.length ?? 0) > 0 && (
95 + <Accordion title="Détail des actes criminels (SPVM)" small meta={`${crime.douze_mois} vs ${crime.douze_mois_precedents}`}>
96 + <div className="lk-rows">
97 + {crime.categories!.filter((c) => c.n + c.n_prec > 0).map((c) => {
98 + const max = Math.max(...crime.categories!.map((x) => x.n), 1);
99 + const delta = c.n - c.n_prec;
100 + return (
101 + <div className="lk-row" key={c.nom}>
102 + <span className="lk-row-name">{c.nom}</span>
103 + <span className="lk-row-bar" aria-hidden="true"><i style={{ width: `${Math.max(3, (c.n / max) * 100)}%` }} /></span>
104 + <span className="lk-row-val">{c.n}<small style={{ color: "var(--lk-muted)", fontWeight: 500 }}> {delta === 0 ? "=" : delta > 0 ? `▲${delta}` : `▼${-delta}`}</small></span>
105 + </div>
106 + );
107 + })}
108 + </div>
109 + <p className="lk-fine">Actes criminels enregistrés par le SPVM (données ouvertes, position approximée à l'intersection) — 12 derniers mois vs les 12 précédents.</p>
110 + </Accordion>
111 + )}
112 + {crime?.type === "igc" && (
113 + <p className="lk-fine">Indice de gravité de la criminalité (Statistique Canada) — {crime.ville}, {crime.annee} : {crime.indice}{crime.indice_canada != null ? ` · Canada : ${crime.indice_canada}` : ""}.</p>
114 + )}
115 + <SourceLine name={`Statistique Canada (Recensement 2021)${q.chaleur ? ", INSPQ" : ""}${crime?.type === "points" ? ", Ville de Montréal" : ""}`}
116 + date="statistiques du secteur, pas de l'immeuble" />
117 + </SectionCard>
118 + );
119 +}
added frontend/src/fiche/PropertyAmenities.tsx +94 −0
@@ -0,0 +1,94 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertyAmenities.tsx : inclusions et commodités — grille compacte
5 +// d'items avec icône contextuelle (AmenityIco), confirmés (✓ données
6 +// structurées) puis mentionnés ; 8 visibles, « Voir les N commodités ».
7 +// -----------------------------------------------------------------------------
8 +import { useState } from "react";
9 +import { Listing } from "../api";
10 +import AmenityIco from "../components/AmenityIco";
11 +import { IcoCheck } from "../components/Icons";
12 +import { MoreButton, SectionCard, NBSP } from "./ui";
13 +
14 +/** Badges dérivés des détails structurés (inclusions confirmées ✓). */
15 +export function badgesConfirmes(l: Listing): string[] {
16 + const d = l.details ?? {};
17 + const out: string[] = [];
18 + const inc = d.inclusions ?? {};
19 + if (inc.heating) out.push("Chauffage inclus");
20 + if (inc.electricity) out.push("Électricité incluse");
21 + if (inc.hot_water) out.push("Eau chaude incluse");
22 + if (inc.internet) out.push("Internet inclus");
23 + if (inc.cable) out.push("Câble inclus");
24 + const app = d.appliances ?? {};
25 + if (app.dishwasher) out.push("Lave-vaisselle");
26 + if (app.washer_dryer) out.push("Laveuse-sécheuse");
27 + if (app.fridge && app.stove) out.push("Électroménagers");
28 + if (d.ac) out.push("Climatisation");
29 + if (d.elevator) out.push("Ascenseur");
30 + if (d.balcony) out.push("Balcon");
31 + if (d.pool) out.push("Piscine");
32 + if (d.gym) out.push("Gym");
33 + if (d.laundry) out.push("Buanderie");
34 + if (d.storage) out.push("Rangement");
35 + if (d.parking?.available)
36 + out.push(`Stationnement${d.parking.type ? ` ${d.parking.type}` : ""}${d.parking.included ? " inclus" : ""}`);
37 + if (l.furnished) out.push("Meublé");
38 + if (d.smoking === false) out.push("Non-fumeur");
39 + return out;
40 +}
41 +
42 +/** Libellés anglais fréquents des sources → français ; sert aussi au dédoublonnage
43 + * avec les inclusions confirmées (« Balcony » ≈ « Balcon »). */
44 +const FR: [RegExp, string][] = [
45 + [/^balcony$/i, "Balcon"], [/^storage$/i, "Rangement"], [/^(in-?unit|onsite|on-site) laundry$/i, "Buanderie"],
46 + [/^laundry$/i, "Buanderie"], [/^dishwasher$/i, "Lave-vaisselle"], [/^hardwood floors?$/i, "Planchers de bois franc"],
47 + [/^high ceilings?$/i, "Plafonds hauts"], [/^parking$/i, "Stationnement"], [/^gym|fitness/i, "Gym"],
48 + [/^pool$/i, "Piscine"], [/^elevator$/i, "Ascenseur"], [/^furnished$/i, "Meublé"], [/^heating$/i, "Chauffage"],
49 + [/^air conditioning|a\/c$/i, "Climatisation"], [/^pets? (allowed|friendly)$/i, "Animaux acceptés"],
50 + [/^wheelchair access(ible)?$/i, "Accès fauteuil roulant"], [/^doorman$/i, "Portier"], [/^garage$/i, "Garage"],
51 +];
52 +const fr = (a: string) => { for (const [re, t] of FR) if (re.test(a.trim())) return t; return a; };
53 +const norm = (s: string) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/s\b/g, "");
54 +
55 +export default function PropertyAmenities({ l }: { l: Listing }) {
56 + const [all, setAll] = useState(false);
57 + const confirmes = badgesConfirmes(l);
58 + const autres = Array.from(new Set((l.amenities ?? []).map(fr))).filter(
59 + (a) => !confirmes.some((b) => norm(b).includes(norm(a)) || norm(a).includes(norm(b))));
60 + const items = [
61 + ...confirmes.map((t) => ({ t, ok: true })),
62 + ...autres.map((t) => ({ t, ok: false })),
63 + ];
64 + const inc = l.details?.inclusions ?? {};
65 + const zeroFrais = inc.heating && inc.electricity && inc.hot_water;
66 + const LIMIT = 8;
67 + const shown = all ? items : items.slice(0, LIMIT);
68 +
69 + return (
70 + <SectionCard id="inclusions" title="Inclusions et commodités"
71 + sub={items.length ? `${confirmes.length} confirmée${confirmes.length > 1 ? "s" : ""} par la source${autres.length ? ` · ${autres.length} mentionnée${autres.length > 1 ? "s" : ""}` : ""}` : undefined}>
72 + {zeroFrais && <p className="lk-note good" style={{ marginTop: 0, marginBottom: 12 }}>Chauffage, électricité et eau chaude inclus — 0{NBSP}$ de frais énergétiques cachés.</p>}
73 + {items.length === 0 ? (
74 + <p className="lk-fine">La source ne précise pas les inclusions.</p>
75 + ) : (
76 + <div className="lk-amen" role="list">
77 + {shown.map((it) => (
78 + <div className={`lk-amen-it ${it.ok ? "confirmed" : "unconfirmed"}`} role="listitem" key={it.t}
79 + title={it.ok ? "Confirmé par les données structurées de la source" : "Mentionné par la source, sans confirmation structurée"}>
80 + <span className="lk-amen-ico" aria-hidden="true"><AmenityIco label={it.t} size={15} fallback={it.ok ? "check" : "spark"} /></span>
81 + <span className="lk-amen-txt">{it.t}</span>
82 + {it.ok && <IcoCheck size={14} className="lk-amen-conf" aria-label="confirmé" />}
83 + </div>
84 + ))}
85 + </div>
86 + )}
87 + {items.length > LIMIT && (
88 + <MoreButton onClick={() => setAll(!all)} expanded={all}>
89 + {all ? "Réduire" : `Voir les ${items.length} commodités`}
90 + </MoreButton>
91 + )}
92 + </SectionCard>
93 + );
94 +}
added frontend/src/fiche/PropertyDescription.tsx +53 −0
@@ -0,0 +1,53 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertyDescription.tsx : description — accroche « en bref » du digest,
5 +// sections structurées, texte tronqué à ~200 px avec « Lire la suite »,
6 +// texte original de la source en accordéon.
7 +// -----------------------------------------------------------------------------
8 +import { useState } from "react";
9 +import { Listing } from "../api";
10 +import { Accordion, MoreButton, SectionCard } from "./ui";
11 +
12 +export default function PropertyDescription({ l }: { l: Listing }) {
13 + const dg = l.digest;
14 + const [open, setOpen] = useState(false);
15 + const texte = dg?.texte_nettoye || l.description || "";
16 + if (!dg && !texte) {
17 + return (
18 + <SectionCard id="description" title="Description">
19 + <p className="lk-fine">La source ne fournit pas de description pour cette annonce.</p>
20 + </SectionCard>
21 + );
22 + }
23 + const long = (dg ? dg.sections.reduce((n, s) => n + s.texte.length, 0) + (dg.en_bref?.length ?? 0) : texte.length) > 420;
24 + return (
25 + <SectionCard id="description" title="Description">
26 + <div className={`lk-desc ${long && !open ? "clamped" : ""}`}>
27 + <div className="lk-desc-body">
28 + {dg ? (
29 + <>
30 + {dg.en_bref && <p className="lk-desc-lead">{dg.en_bref}</p>}
31 + {dg.sections.map((s) => (
32 + <div key={s.titre}>
33 + <h4>{s.titre}</h4>
34 + <p>{s.texte}</p>
35 + </div>
36 + ))}
37 + </>
38 + ) : (
39 + <p>{texte}</p>
40 + )}
41 + </div>
42 + </div>
43 + {long && <MoreButton onClick={() => setOpen(!open)} expanded={open}>{open ? "Réduire" : "Lire la suite"}</MoreButton>}
44 + {dg && l.description && (
45 + <div className="lk-desc-orig">
46 + <Accordion title="Texte original de la source" small>
47 + <p style={{ whiteSpace: "pre-line" }}>{l.description}</p>
48 + </Accordion>
49 + </div>
50 + )}
51 + </SectionCard>
52 + );
53 +}
added frontend/src/fiche/PropertyGallery.tsx +176 −0
@@ -0,0 +1,176 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertyGallery.tsx : galerie premium — grande image, balayage natif
5 +// (scroll-snap), compteur « 1 / 12 », bouton plein écran, flèches au survol
6 +// (desktop), vignettes ≥ 768 px, préchargement de la photo suivante, lazy
7 +// loading des autres. Lightbox : balayage + pincement pour zoomer + double
8 +// tape (logique conservée de la fiche v2). URLs d'images inchangées
9 +// (miniatures WebP via SmartImg → repli original → visuel de secours).
10 +// -----------------------------------------------------------------------------
11 +import { useEffect, useRef, useState } from "react";
12 +import SmartImg from "../components/SmartImg";
13 +import { IcoChevronLeft, IcoChevronRight, IcoClose, IcoExpand } from "../components/Icons";
14 +import { thumb } from "../api";
15 +
16 +function Lightbox({ images, start, titre, onClose }:
17 + { images: string[]; start: number; titre: string; onClose: () => void }) {
18 + const [idx, setIdx] = useState(start);
19 + const [scale, setScale] = useState(1);
20 + const [tx, setTx] = useState(0);
21 + const [ty, setTy] = useState(0);
22 + const track = useRef<HTMLDivElement>(null);
23 + const pointers = useRef(new Map<number, { x: number; y: number }>());
24 + const pinch = useRef<{ d: number; scale: number } | null>(null);
25 + const lastTap = useRef(0);
26 +
27 + useEffect(() => {
28 + track.current?.scrollTo({ left: start * track.current.clientWidth });
29 + document.documentElement.classList.add("ka-scroll-lock");
30 + const onKey = (e: KeyboardEvent) => {
31 + if (e.key === "Escape") onClose();
32 + if (e.key === "ArrowRight") go(1);
33 + if (e.key === "ArrowLeft") go(-1);
34 + };
35 + window.addEventListener("keydown", onKey);
36 + return () => {
37 + document.documentElement.classList.remove("ka-scroll-lock");
38 + window.removeEventListener("keydown", onKey);
39 + };
40 + // eslint-disable-next-line react-hooks/exhaustive-deps
41 + }, []);
42 +
43 + const resetZoom = () => { setScale(1); setTx(0); setTy(0); };
44 + const go = (d: number) => {
45 + const el = track.current; if (!el) return;
46 + const i = Math.max(0, Math.min(images.length - 1, Math.round(el.scrollLeft / el.clientWidth) + d));
47 + el.scrollTo({ left: i * el.clientWidth, behavior: "smooth" });
48 + };
49 + const onScroll = () => {
50 + const el = track.current;
51 + if (el && scale === 1) {
52 + const i = Math.round(el.scrollLeft / el.clientWidth);
53 + if (i !== idx) { setIdx(i); resetZoom(); }
54 + }
55 + };
56 + const dist = () => {
57 + const [a, b] = [...pointers.current.values()];
58 + return Math.hypot(a.x - b.x, a.y - b.y);
59 + };
60 + const onPointerDown = (e: React.PointerEvent) => {
61 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
62 + if (pointers.current.size === 2) pinch.current = { d: dist(), scale };
63 + if (pointers.current.size === 1) {
64 + const now = Date.now();
65 + if (now - lastTap.current < 300) { if (scale > 1) resetZoom(); else setScale(2.5); }
66 + lastTap.current = now;
67 + }
68 + };
69 + const onPointerMove = (e: React.PointerEvent) => {
70 + const prev = pointers.current.get(e.pointerId);
71 + if (!prev) return;
72 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
73 + if (pointers.current.size === 2 && pinch.current) {
74 + const s = Math.min(4, Math.max(1, pinch.current.scale * (dist() / pinch.current.d)));
75 + setScale(s);
76 + if (s === 1) { setTx(0); setTy(0); }
77 + } else if (pointers.current.size === 1 && scale > 1) {
78 + setTx((v) => v + (e.clientX - prev.x));
79 + setTy((v) => v + (e.clientY - prev.y));
80 + }
81 + };
82 + const onPointerUp = (e: React.PointerEvent) => {
83 + pointers.current.delete(e.pointerId);
84 + if (pointers.current.size < 2) pinch.current = null;
85 + };
86 +
87 + return (
88 + <div className="lk-lightbox" role="dialog" aria-modal="true" aria-label={`Photos — ${titre}`}>
89 + <button type="button" className="lk-lightbox-close" aria-label="Fermer" onClick={onClose}><IcoClose size={20} /></button>
90 + <span className="lk-lightbox-count" aria-live="polite">{idx + 1} / {images.length}</span>
91 + <div className="lk-lightbox-track" ref={track} onScroll={onScroll}
92 + style={scale > 1 ? { overflow: "hidden", touchAction: "none" } : undefined}
93 + onPointerDown={onPointerDown} onPointerMove={onPointerMove}
94 + onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>
95 + {images.map((u, i) => (
96 + <div className="lk-lightbox-cell" key={u}>
97 + <SmartImg src={u} original alt={`${titre} — photo ${i + 1} de ${images.length}`}
98 + draggable={false} loading={Math.abs(i - idx) <= 1 ? "eager" : "lazy"}
99 + style={i === idx && scale > 1 ? { transform: `translate(${tx}px, ${ty}px) scale(${scale})` } : undefined} />
100 + </div>
101 + ))}
102 + </div>
103 + {scale === 1 && idx > 0 && (
104 + <button type="button" className="lk-gallery-nav prev" aria-label="Photo précédente" onClick={() => go(-1)}><IcoChevronLeft size={20} /></button>
105 + )}
106 + {scale === 1 && idx < images.length - 1 && (
107 + <button type="button" className="lk-gallery-nav next" aria-label="Photo suivante" onClick={() => go(1)}><IcoChevronRight size={20} /></button>
108 + )}
109 + </div>
110 + );
111 +}
112 +
113 +export default function PropertyGallery({ images, titre, unitType }:
114 + { images: string[]; titre: string; unitType?: string }) {
115 + const [idx, setIdx] = useState(0);
116 + const [zoom, setZoom] = useState(false);
117 + const track = useRef<HTMLDivElement>(null);
118 +
119 + // préchargement discret de la photo suivante (miniature 800)
120 + useEffect(() => {
121 + const next = images[idx + 1];
122 + if (!next) return;
123 + const img = new Image();
124 + img.src = thumb(next, 800);
125 + }, [idx, images]);
126 +
127 + const onScroll = () => {
128 + const el = track.current;
129 + if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
130 + };
131 + const goto = (i: number) =>
132 + track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
133 +
134 + if (images.length === 0)
135 + return (
136 + <div className="lk-gallery" aria-label="Photos">
137 + <SmartImg src={null} fallbackLabel={unitType} alt="Aucune photo fournie par la source" />
138 + </div>
139 + );
140 +
141 + return (
142 + <>
143 + <div className="lk-gallery" aria-roledescription="carrousel" aria-label="Photos du logement">
144 + <div className="lk-gallery-track" ref={track} onScroll={onScroll}>
145 + {images.map((u, i) => (
146 + <SmartImg key={u} src={u} width_={i === 0 ? 1280 : 800} fallbackLabel={unitType}
147 + loading={i === 0 ? "eager" : "lazy"} decoding="async"
148 + alt={`${titre} — photo ${i + 1} de ${images.length}`}
149 + onClick={() => setZoom(true)} />
150 + ))}
151 + </div>
152 + <span className="lk-gallery-count" aria-live="polite">{idx + 1} / {images.length}</span>
153 + <button type="button" className="lk-gallery-full" aria-label="Voir en plein écran" onClick={() => setZoom(true)}>
154 + <IcoExpand size={17} />
155 + </button>
156 + {idx > 0 && (
157 + <button type="button" className="lk-gallery-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}><IcoChevronLeft size={20} /></button>
158 + )}
159 + {idx < images.length - 1 && (
160 + <button type="button" className="lk-gallery-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}><IcoChevronRight size={20} /></button>
161 + )}
162 + </div>
163 + {images.length > 1 && (
164 + <div className="lk-thumbs" role="list">
165 + {images.slice(0, 12).map((u, i) => (
166 + <button type="button" key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}
167 + aria-label={`Photo ${i + 1}`} aria-current={i === idx} role="listitem">
168 + <SmartImg src={u} width_={160} alt="" loading="lazy" decoding="async" />
169 + </button>
170 + ))}
171 + </div>
172 + )}
173 + {zoom && <Lightbox images={images} start={idx} titre={titre} onClose={() => setZoom(false)} />}
174 + </>
175 + );
176 +}
added frontend/src/fiche/PropertyHero.tsx +84 −0
@@ -0,0 +1,84 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertyHero.tsx : héro de la fiche — prix + capsule « vs marché »,
5 +// adresse (H1) + ville, ligne résumé, puis galerie, puis actions
6 +// (favoris · partager · Voir l'annonce). Ordre DOM = ordre visuel.
7 +// -----------------------------------------------------------------------------
8 +import { Listing, fmtAvailability, fmtPrice, sourceName } from "../api";
9 +import { IcoDoc, IcoExternal, IcoHeart, IcoShare } from "../components/Icons";
10 +import PropertyGallery from "./PropertyGallery";
11 +import { ComparaisonPrix, ligneResume } from "./synthese";
12 +import { NBSP } from "./ui";
13 +
14 +export function PriceCapsule({ cmp, short = false }: { cmp: ComparaisonPrix | null; short?: boolean }) {
15 + if (!cmp) return null;
16 + return (
17 + <span className={`lk-capsule ${cmp.tone}`}>
18 + {short ? <b>{cmp.court}</b> : <><b>{cmp.label}</b><span aria-hidden="true">·</span>{cmp.court}</>}
19 + </span>
20 + );
21 +}
22 +
23 +export default function PropertyHero({ l, cmp, fav, onFav, onShare, actionsRef }: {
24 + l: Listing; cmp: ComparaisonPrix | null; fav: boolean;
25 + onFav: () => void; onShare: () => void;
26 + actionsRef: React.RefObject<HTMLDivElement>;
27 +}) {
28 + const dispo = fmtAvailability(l.availability_date);
29 + const resume = ligneResume(l, dispo);
30 + const titre = l.title || l.address;
31 + const adresse = l.address && l.address !== l.title ? l.address : l.title;
32 + const where = [l.sector, l.city].filter(Boolean).join(", ");
33 + const hist = (l.price_history ?? []).filter((h) => h.price != null);
34 + const baisse = hist.length >= 2 && hist[0].price! < hist[1].price! ? hist[1].price! : null;
35 +
36 + return (
37 + <header className="lk-hero" aria-label="Résumé du logement">
38 + <div className="lk-hero-top">
39 + <div>
40 + <div className="lk-price">
41 + {fmtPrice(l.price, l.price_label)}
42 + {l.price != null && <small>/{NBSP}mois</small>}
43 + {baisse != null && <small style={{ textDecoration: "line-through", opacity: 0.7 }}>{fmtPrice(baisse)}</small>}
44 + </div>
45 + <div className="lk-price-row" style={{ marginTop: 8 }}>
46 + <PriceCapsule cmp={cmp} />
47 + {l.details?.price_from && <span className="lk-capsule neutral">à partir de</span>}
48 + {l.ka_reco && <span className="lk-capsule brand">Recommandé pour vous</span>}
49 + </div>
50 + </div>
51 + </div>
52 + <h1 className="lk-h1">
53 + {adresse || titre}
54 + {where && <span className="lk-city">{where}</span>}
55 + </h1>
56 + {resume.length > 0 && (
57 + <p className="lk-summary" aria-label="Résumé">
58 + {resume.map((r, i) => (
59 + <span key={r}>{i > 0 && <span className="sep" aria-hidden="true">· </span>}{r}</span>
60 + ))}
61 + </p>
62 + )}
63 + <PropertyGallery images={l.images ?? []} titre={titre} unitType={l.unit_type || undefined} />
64 + <div className="lk-actions" ref={actionsRef}>
65 + <button type="button" className={`lk-btn lk-btn-ghost lk-btn-icon ${fav ? "on" : ""}`}
66 + aria-pressed={fav} aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={onFav}>
67 + <IcoHeart size={19} filled={fav} />
68 + </button>
69 + <button type="button" className="lk-btn lk-btn-ghost lk-btn-icon" aria-label="Partager" onClick={onShare}>
70 + <IcoShare size={18} />
71 + </button>
72 + <a className="lk-btn lk-btn-ghost lk-btn-icon" aria-label="Télécharger la fiche PDF" title="Fiche PDF"
73 + href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download>
74 + <IcoDoc size={18} />
75 + </a>
76 + <a className="lk-btn lk-btn-primary" href={`/passerelle/${encodeURIComponent(l.uid)}`}
77 + target="_blank" rel="noopener noreferrer">
78 + Voir l'annonce <IcoExternal size={16} />
79 + <span className="visually-hidden"> chez {sourceName(l.source)}</span>
80 + </a>
81 + </div>
82 + </header>
83 + );
84 +}
added frontend/src/fiche/PropertyQuickFacts.tsx +86 −0
@@ -0,0 +1,86 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertyQuickFacts.tsx : « Le logement » — grille compacte icône +
5 +// valeur + libellé (chambres, salle de bain, type, superficie, étage,
6 +// meublé, animaux, disponibilité, bail…) puis rangées pratiques
7 +// (gestionnaire, prix affiché, en ligne depuis, synchronisé, historique).
8 +// -----------------------------------------------------------------------------
9 +import { ReactNode } from "react";
10 +import { Listing, fmtAvailability, fmtPrice, sourceName } from "../api";
11 +import {
12 + IcoBath, IcoBed, IcoBuilding, IcoCalendar, IcoDoc, IcoHouse, IcoPaw, IcoRuler, IcoSofa, IcoUsers,
13 +} from "../components/Icons";
14 +import { SectionCard, NBSP, relTime } from "./ui";
15 +
16 +const PETS: Record<string, string> = { oui: "Acceptés", non: "Refusés", conditions: "Sous conditions" };
17 +
18 +export default function PropertyQuickFacts({ l }: { l: Listing }) {
19 + const f = l.digest?.faits;
20 + const conf = l.digest?.confiance ?? {};
21 + const d = l.details ?? {};
22 + const dispo = fmtAvailability(l.availability_date);
23 + const facts: { ico: ReactNode; v: string; l: string }[] = [];
24 +
25 + if (l.bedrooms != null)
26 + facts.push({ ico: <IcoBed size={17} />, v: l.bedrooms === 0 ? "Studio" : `${Math.round(l.bedrooms)}`, l: l.bedrooms === 0 ? "Aire ouverte" : `Chambre${l.bedrooms > 1 ? "s" : ""}` });
27 + const sdb = (l as unknown as { bathrooms?: number | null }).bathrooms;
28 + if (sdb != null)
29 + facts.push({ ico: <IcoBath size={17} />, v: `${sdb}`, l: `Salle${sdb > 1 ? "s" : ""} de bain` });
30 + else if (f?.salle_de_bain && conf.salle_de_bain !== "faible")
31 + facts.push({ ico: <IcoBath size={17} />, v: f.salle_de_bain === "commune" ? "Partagée" : "Privée", l: "Salle de bain" });
32 + if (l.unit_type) facts.push({ ico: <IcoHouse size={17} />, v: l.unit_type, l: "Type" });
33 + if (l.area_sqft) facts.push({ ico: <IcoRuler size={17} />, v: `${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`, l: "Superficie" });
34 + if (d.floor != null) facts.push({ ico: <IcoBuilding size={17} />, v: `${d.floor}ᵉ`, l: "Étage" });
35 + if (l.furnished != null) facts.push({ ico: <IcoSofa size={17} />, v: l.furnished ? "Meublé" : "Non meublé", l: "Ameublement" });
36 + if (l.pets) facts.push({ ico: <IcoPaw size={17} />, v: PETS[l.pets] ?? l.pets, l: "Animaux" });
37 + if (dispo) {
38 + const iso = l.availability_date;
39 + const court = iso && iso !== "now"
40 + ? new Date(iso + "T00:00:00").toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" }).replace(/^1 /, "1ᵉʳ ")
41 + : "Maintenant";
42 + facts.push({ ico: <IcoCalendar size={17} />, v: court, l: "Disponible" });
43 + }
44 + if (f?.duree_bail_minimale_mois) facts.push({ ico: <IcoDoc size={17} />, v: `${f.duree_bail_minimale_mois} mois`, l: "Bail minimum" });
45 + if (f?.nb_occupants_total && conf.nb_occupants_total !== "faible")
46 + facts.push({ ico: <IcoUsers size={17} />, v: `${f.nb_occupants_total}`, l: "Occupants" });
47 +
48 + const enLigne = l.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null;
49 + const rows: [string, string][] = [["Gestionnaire", sourceName(l.source)]];
50 + if (l.price_label && l.price != null && l.price_label.replace(/\s/g, "") !== `${fmtPrice(l.price)}/mois`.replace(/\s/g, ""))
51 + rows.push(["Prix affiché par la source", l.price_label]);
52 + if (enLigne != null) rows.push(["En ligne sur Lou-Ka", enLigne === 0 ? "depuis aujourd'hui" : `depuis ${enLigne}${NBSP}jour${enLigne > 1 ? "s" : ""}`]);
53 + const maj = relTime(l.updated_at);
54 + if (maj) rows.push(["Synchronisé", maj]);
55 + if (f?.depot_mentionne && conf.depot_mentionne !== "faible") rows.push(["Dépôt mentionné", f.depot_mentionne]);
56 +
57 + if (facts.length === 0 && rows.length <= 1) return null;
58 +
59 + return (
60 + <SectionCard id="logement" title="Le logement">
61 + {facts.length > 0 && (
62 + <div className="lk-facts" role="list">
63 + {facts.map((x) => (
64 + <div className="lk-fact" role="listitem" key={x.l + x.v}>
65 + <span className="lk-fact-ico" aria-hidden="true">{x.ico}</span>
66 + <span className="lk-fact-txt">
67 + <div className="lk-fact-v">{x.v}</div>
68 + <div className="lk-fact-l">{x.l}</div>
69 + </span>
70 + </div>
71 + ))}
72 + </div>
73 + )}
74 + <div className="lk-facts-rows">
75 + {rows.map(([k, v]) => (
76 + <div className="lk-kv" key={k}><span className="k">{k}</span><span className="v">{v}</span></div>
77 + ))}
78 + </div>
79 + {(l.digest?.incoherences?.length ?? 0) > 0 && (
80 + <p className="lk-note warn">
81 + Incohérence relevée dans l'annonce : {l.digest!.incoherences.join(" ; ")}
82 + </p>
83 + )}
84 + </SectionCard>
85 + );
86 +}
added frontend/src/fiche/PropertySummary.tsx +35 −0
@@ -0,0 +1,35 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertySummary.tsx : carte « En bref » — 4 à 6 constats déterministes
5 +// (voir synthese.ts) avec ton (✓ / ! / ○) et preuve chiffrée.
6 +// -----------------------------------------------------------------------------
7 +import { Constat } from "./synthese";
8 +import { SectionCard, SkeletonLines } from "./ui";
9 +
10 +export function BriefList({ items, compact = false }: { items: Constat[]; compact?: boolean }) {
11 + const glyph = (t: Constat["tone"]) => (t === "good" ? "✓" : t === "bad" ? "✕" : t === "warn" ? "!" : "○");
12 + return (
13 + <ul className="lk-brief">
14 + {items.map((c) => (
15 + <li key={c.cle}>
16 + <span className={`lk-brief-ico ${c.tone === "info" ? "neutral" : c.tone}`} aria-hidden="true">{glyph(c.tone)}</span>
17 + <div>
18 + <div className="lk-brief-t">{c.titre}</div>
19 + {!compact && c.detail && <div className="lk-brief-d">{c.detail}</div>}
20 + </div>
21 + </li>
22 + ))}
23 + </ul>
24 + );
25 +}
26 +
27 +export default function PropertySummary({ items, loading }: { items: Constat[]; loading: boolean }) {
28 + return (
29 + <SectionCard id="resume" title="En bref">
30 + {items.length > 0 ? <BriefList items={items} /> : loading ? <SkeletonLines n={4} /> : (
31 + <p className="lk-fine">Pas encore de synthèse : les données de marché et d'environnement manquent pour ce secteur.</p>
32 + )}
33 + </SectionCard>
34 + );
35 +}
added frontend/src/fiche/RentRegistryCard.tsx +204 −0
@@ -0,0 +1,204 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/RentRegistryCard.tsx : « Registre des loyers » orienté décision —
5 +// en tête le loyer et son écart à la médiane du même nombre de chambres,
6 +// jauge dynamique (p10 → p90, boîte p25–p75, médiane, ce loyer), médianes
7 +// par nombre de chambres (type du logement en avant), 3 déclarations les
8 +// plus proches puis « Voir les N loyers comparables » → bottom sheet avec
9 +// filtres (même nb de chambres · tri distance / date / prix).
10 +// -----------------------------------------------------------------------------
11 +import { useMemo, useState } from "react";
12 +import { Listing, RdlItem, RdlNearby, fmtDist, fmtPrice } from "../api";
13 +import { IcoWallet } from "../components/Icons";
14 +import BottomSheet from "./BottomSheet";
15 +import { Accordion, ErrorState, MoreButton, SectionCard, SkeletonLines, SourceLine, StatTile, NBSP } from "./ui";
16 +import { Res } from "./useFicheData";
17 +
18 +function fmtDate(date: string | null | undefined, year: number | null | undefined): string {
19 + if (date && year != null && date.startsWith(String(year))) {
20 + const d = new Date(date + "T00:00:00");
21 + if (!Number.isNaN(d.getTime()))
22 + return d.toLocaleDateString("fr-CA", { month: "short", year: "numeric" });
23 + }
24 + return year != null ? String(year) : (date ? date.slice(0, 4) : "—");
25 +}
26 +
27 +/** Jauge : piste p10–p90, boîte p25–p75, médiane (tick) et ce loyer (point). */
28 +function Gauge({ d, price, med }: { d: RdlNearby; price: number; med: number }) {
29 + const q = d.quartiles;
30 + if (!q || q.p90 <= q.p10) return null;
31 + const W = 320, H = 46, y = 18, h = 10;
32 + const lo = Math.min(q.p10, price), hi = Math.max(q.p90, price);
33 + const x = (v: number) => ((Math.min(Math.max(v, lo), hi) - lo) / (hi - lo)) * (W - 12) + 6;
34 + const px = x(price), mx = x(med);
35 + const anchor = (v: number) => (v < 50 ? "start" : v > W - 50 ? "end" : "middle");
36 + return (
37 + <div className="lk-gauge">
38 + <div className="lk-gauge-lbls" aria-hidden="true">
39 + <span className="good">Très bon prix</span><span>Médiane</span><span className="bad">Cher</span>
40 + </div>
41 + <svg viewBox={`0 0 ${W} ${H}`} role="img"
42 + aria-label={`Ce loyer ${fmtPrice(price)} ; loyers déclarés du secteur de ${fmtPrice(q.p10)} à ${fmtPrice(q.p90)}, médiane ${fmtPrice(med)}`}>
43 + <rect className="lk-gauge-track" x="6" y={y} width={W - 12} height={h} rx="5" />
44 + <rect className="lk-gauge-box" x={x(q.p25)} y={y} width={Math.max(4, x(q.p75) - x(q.p25))} height={h} rx="5" />
45 + <line className="lk-gauge-med" x1={mx} x2={mx} y1={y - 5} y2={y + h + 5} />
46 + <text className="lk-gauge-txt" x={mx} y={H - 2} textAnchor={anchor(mx)}>{fmtPrice(med)}</text>
47 + <circle className="lk-gauge-me" cx={px} cy={y + h / 2} r="7" />
48 + <text className="lk-gauge-txt me" x={px} y={y - 8} textAnchor={anchor(px)}>ce loyer {fmtPrice(price)}</text>
49 + </svg>
50 + </div>
51 + );
52 +}
53 +
54 +/** Barres des médianes par nombre de chambres — type du logement en orange. */
55 +function RoomBars({ d, rKey }: { d: RdlNearby; rKey: string | null }) {
56 + let entries = Object.entries(d.by_rooms ?? {}).filter(([, v]) => v.n >= 2)
57 + .sort(([a], [b]) => Number(a) - Number(b));
58 + // au plus 5 colonnes : 0–4+ (regroupement des grands logements)
59 + if (entries.length > 5) {
60 + const keep = entries.filter(([k]) => Number(k) <= 3);
61 + const big = entries.filter(([k]) => Number(k) >= 4);
62 + if (big.length) {
63 + const n = big.reduce((s, [, v]) => s + v.n, 0);
64 + const med = Math.round(big.reduce((s, [, v]) => s + v.median * v.n, 0) / n);
65 + keep.push(["4+", { n, median: med }]);
66 + }
67 + entries = keep;
68 + }
69 + if (entries.length < 2) return null;
70 + const on = (k: string) => k === rKey || (k === "4+" && rKey != null && Number(rKey) >= 4);
71 + const W = 320, H = 118, top = 20, bottom = 32;
72 + const plotH = H - top - bottom;
73 + const max = Math.max(...entries.map(([, v]) => v.median), 1);
74 + const slot = W / entries.length, bw = Math.min(40, slot * 0.6);
75 + return (
76 + <svg className="lk-bars" viewBox={`0 0 ${W} ${H}`} role="img" aria-label="Loyer médian déclaré selon le nombre de chambres">
77 + {entries.map(([rooms, v], i) => {
78 + const h = Math.max(3, (v.median / max) * plotH);
79 + const bx = i * slot + (slot - bw) / 2, cx = i * slot + slot / 2;
80 + const lbl = rooms === "0" ? "Studio" : `${rooms} ch.`;
81 + return (
82 + <g key={rooms}>
83 + <title>{`${lbl} : médiane ${fmtPrice(v.median)} (${v.n} déclarations)`}</title>
84 + <rect className={`lk-bar ${on(rooms) ? "on" : ""}`} x={bx} y={H - bottom - h} width={bw} height={h} rx="5" />
85 + <text className="lk-bar-val" x={cx} y={H - bottom - h - 5} textAnchor="middle">{fmtPrice(v.median)}</text>
86 + <text className={`lk-bar-cat ${on(rooms) ? "on" : ""}`} x={cx} y={H - bottom + 13} textAnchor="middle">{lbl}</text>
87 + <text className="lk-bar-n" x={cx} y={H - bottom + 25} textAnchor="middle">{on(rooms) ? "ce logement" : `${v.n} décl.`}</text>
88 + </g>
89 + );
90 + })}
91 + <line className="lk-bars-axe" x1="0" x2={W} y1={H - bottom} y2={H - bottom} />
92 + </svg>
93 + );
94 +}
95 +
96 +function Item({ it }: { it: RdlItem }) {
97 + return (
98 + <li className="lk-item">
99 + <div className="lk-item-main">
100 + <div className="lk-item-t">{it.address}</div>
101 + <div className="lk-item-s">
102 + {it.rooms != null ? (it.rooms === 0 ? "Studio" : `${it.rooms} ch.`) : "—"} · {fmtDate(it.date, it.year)}
103 + {it.heating ? " · chauffé" : ""}{it.furnished ? " · meublé" : ""}
104 + </div>
105 + </div>
106 + <div className="lk-item-r">
107 + <div className="lk-item-v">{fmtPrice(it.price)}</div>
108 + <div className="lk-item-m">{fmtDist(it.dist_m)}</div>
109 + </div>
110 + </li>
111 + );
112 +}
113 +
114 +export default function RentRegistryCard({ l, rdl, onRetry }: { l: Listing; rdl: Res<RdlNearby>; onRetry: () => void }) {
115 + const [sheet, setSheet] = useState(false);
116 + const [meme, setMeme] = useState(true);
117 + const [tri, setTri] = useState<"dist" | "date" | "prix">("dist");
118 + const d = rdl.status === "ok" ? rdl.data : null;
119 + const rKey = l.bedrooms != null ? String(Math.round(l.bedrooms)) : null;
120 + const same = d && rKey && d.by_rooms?.[rKey] && d.by_rooms[rKey].n >= 5 ? d.by_rooms[rKey] : null;
121 + const med = d ? (same ? same.median : (d.median_recent ?? d.median ?? 0)) : 0;
122 + const pct = d && l.price != null && med ? Math.round(((l.price - med) / med) * 100) : null;
123 +
124 + const items = useMemo(() => {
125 + if (!d) return [];
126 + let arr = d.items;
127 + if (meme && rKey && arr.some((i) => i.rooms != null && String(i.rooms) === rKey))
128 + arr = arr.filter((i) => i.rooms != null && String(i.rooms) === rKey);
129 + arr = [...arr];
130 + if (tri === "dist") arr.sort((a, b) => a.dist_m - b.dist_m);
131 + if (tri === "date") arr.sort((a, b) => (b.year ?? 0) - (a.year ?? 0) || a.dist_m - b.dist_m);
132 + if (tri === "prix") arr.sort((a, b) => a.price - b.price);
133 + return arr;
134 + }, [d, meme, rKey, tri]);
135 +
136 + if (rdl.status === "na" || (d && d.n === 0)) return null;
137 +
138 + return (
139 + <SectionCard id="loyers" title="Registre des loyers" icon={<IcoWallet size={18} />}
140 + sub={d ? `${d.n.toLocaleString("fr-CA")} loyers réellement payés, déclarés à moins de ${fmtDist(d.radius_m)}` : undefined}>
141 + {rdl.status === "loading" && <SkeletonLines n={5} />}
142 + {rdl.status === "error" && <ErrorState onRetry={onRetry}>Registre des loyers temporairement indisponible.</ErrorState>}
143 + {d && (
144 + <>
145 + <div className="lk-market-head">
146 + <div>
147 + <div className="lk-market-big">{l.price != null ? fmtPrice(l.price) : "—"}<small>/ mois</small></div>
148 + <div className="lk-market-sub">
149 + {pct != null
150 + ? <>{Math.abs(pct) < 3 ? "Dans la moyenne" : <><b>{Math.abs(pct)}{NBSP}%</b> {pct < 0 ? "sous" : "au-dessus"}</>} des loyers déclarés
151 + {same ? ` (${rKey === "0" ? "studios" : `${rKey} ch.`}, ${same.n} déclarations)` : ""}</>
152 + : "Loyer non comparable"}
153 + </div>
154 + </div>
155 + <StatTile value={fmtPrice(med)} label={same ? `Médiane ${rKey === "0" ? "studio" : `${rKey} ch.`}` : "Médiane du secteur"} anim={false} />
156 + </div>
157 + {l.price != null && med > 0 && <Gauge d={d} price={l.price} med={med} />}
158 + <RoomBars d={d} rKey={rKey} />
159 + {d.items.length > 0 && (
160 + <>
161 + <ul className="lk-list" style={{ marginTop: 8 }}>
162 + {[...d.items].sort((a, b) => a.dist_m - b.dist_m).slice(0, 3).map((it, i) => <Item it={it} key={i} />)}
163 + </ul>
164 + {d.items.length > 3 && (
165 + <MoreButton onClick={() => setSheet(true)}>
166 + {d.items.length < d.n
167 + ? `Voir les ${d.items.length.toLocaleString("fr-CA")} loyers les plus proches (sur ${d.n.toLocaleString("fr-CA")})`
168 + : `Voir les ${d.items.length.toLocaleString("fr-CA")} loyers comparables`}
169 + </MoreButton>
170 + )}
171 + </>
172 + )}
173 + <SourceLine name="Registre des loyers" href="https://registre-des-loyers.ca/fr/qc/carte"
174 + date={d.n_recent ? `${d.n_recent.toLocaleString("fr-CA")} déclarations depuis 2023` : undefined} />
175 + <Accordion title="À propos de ces données" small>
176 + <p>Loyers réellement payés, déclarés volontairement par des locataires au Registre des loyers
177 + (initiative de Vivre en ville) — données citoyennes non vérifiées, fournies à titre indicatif.
178 + La comparaison privilégie la médiane du même nombre de chambres quand le secteur compte au
179 + moins 5 déclarations ; la jauge va du 10ᵉ au 90ᵉ centile, la boîte grise couvre le quart
180 + central (p25–p75).</p>
181 + </Accordion>
182 + <BottomSheet open={sheet} onClose={() => setSheet(false)} title="Loyers comparables"
183 + sub={`${d.n.toLocaleString("fr-CA")} déclarations à moins de ${fmtDist(d.radius_m)} · ${items.length} affichées`} tall>
184 + <div className="lk-sheet-filters">
185 + {rKey && (
186 + <button type="button" className={`lk-chip ${meme ? "on" : ""}`} onClick={() => setMeme(!meme)} aria-pressed={meme}>
187 + Même nombre de chambres
188 + </button>
189 + )}
190 + <div className="lk-seg" role="group" aria-label="Tri">
191 + {(["dist", "date", "prix"] as const).map((t) => (
192 + <button type="button" key={t} className={tri === t ? "on" : ""} onClick={() => setTri(t)} aria-pressed={tri === t}>
193 + {t === "dist" ? "Distance" : t === "date" ? "Date" : "Prix"}
194 + </button>
195 + ))}
196 + </div>
197 + </div>
198 + <ul className="lk-list">{items.map((it, i) => <Item it={it} key={i} />)}</ul>
199 + </BottomSheet>
200 + </>
201 + )}
202 + </SectionCard>
203 + );
204 +}
added frontend/src/fiche/SectionNav.tsx +60 −0
@@ -0,0 +1,60 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (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="lk-nav" aria-label="Sections de la fiche">
50 + <div className="lk-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/SourceDisclosure.tsx +56 −0
@@ -0,0 +1,56 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/SourceDisclosure.tsx : fin de fiche — « Sources et méthodologie »
5 +// (source · rôle · date · lien) et rangée d'actions de clôture (signaler,
6 +// partager, PDF, dernière mise à jour).
7 +// -----------------------------------------------------------------------------
8 +import { Listing, sourceName } from "../api";
9 +import { IcoAlert, IcoDoc, IcoFolder, IcoShare } from "../components/Icons";
10 +import { SectionCard, relTime } from "./ui";
11 +
12 +interface Src { n: string; r: string; d?: string; href?: string; }
13 +
14 +export default function SourceDisclosure({ l, onShare, dates }: {
15 + l: Listing; onShare: () => void; dates: { air?: number | null; gaz?: string | null; ks?: number | null };
16 +}) {
17 + const maj = relTime(l.updated_at);
18 + const sources: Src[] = [
19 + { n: sourceName(l.source), r: "Annonce, prix, photos et disponibilité (source originale)", d: maj ?? undefined, href: `/passerelle/${encodeURIComponent(l.uid)}` },
20 + { n: "Adresses Québec", r: "Géocodage de l'adresse (position sur la carte)" },
21 + { n: "Statistique Canada", r: "Recensement 2021 (aire de diffusion) et mesures de proximité", d: "2021" },
22 + { n: "Registre des loyers (Vivre en ville)", r: "Loyers réellement payés, déclarés par des locataires", href: "https://registre-des-loyers.ca/fr/qc/carte" },
23 + { n: "Gouvernement du Québec (BDZI)", r: "Zones à risque d'inondation", href: "https://www.quebec.ca/agriculture-environnement-et-ressources-naturelles/eau/zones-inondables-mobilite-rives-littoral/cartographies" },
24 + { n: "MELCCFP (RSQAQ)", r: "Qualité de l'air — station la plus proche", d: dates.air ? String(dates.air) : undefined },
25 + { n: "INSPQ", r: "Îlots de chaleur et de fraîcheur urbains" },
26 + { n: "gazquebec.ca", r: "Prix de l'essence par station", d: dates.gaz ?? undefined, href: "https://gazquebec.ca" },
27 + { n: "OpenStreetMap · Mapbox", r: "Lieux à proximité, transport, fond de carte", d: dates.ks ? new Date(dates.ks * 1000).toLocaleDateString("fr-CA") : undefined },
28 + { n: "TAL / SOQUIJ", r: "Décisions publiques du Tribunal administratif du logement", href: "https://www.tal.gouv.qc.ca/" },
29 + { n: "Hydro-Québec", r: "Estimation du coût d'électricité à l'adresse" },
30 + { n: "Lou-Ka", r: "Juste valeur, KA Scores, Lou-Ka Score, coût réel, historique — calculs maison, indicatifs" },
31 + ];
32 + return (
33 + <SectionCard id="sources" title="Sources et méthodologie" icon={<IcoFolder size={18} />}
34 + sub="Chaque donnée de cette fiche renvoie à sa source ; les calculs Lou-Ka sont indicatifs et documentés">
35 + <ul className="lk-sources">
36 + {sources.map((s) => (
37 + <li key={s.n}>
38 + <span className="n">{s.href ? <a href={s.href} target="_blank" rel="noopener noreferrer">{s.n}</a> : s.n}</span>
39 + {s.d && <span className="d">{s.d}</span>}
40 + <span className="r">{s.r}</span>
41 + </li>
42 + ))}
43 + </ul>
44 + <div className="lk-end" style={{ marginTop: 14 }}>
45 + <a href="/sources"><IcoFolder size={18} />Données et méthodologie<small>Toutes les sources Lou-Ka</small></a>
46 + <a href={`/contact?sujet=${encodeURIComponent(`Erreur sur la fiche ${l.uid}`)}`}><IcoAlert size={18} />Signaler une erreur<small>Prix, photos, adresse…</small></a>
47 + <button type="button" onClick={onShare}><IcoShare size={18} />Partager cette annonce<small>Lien de la fiche</small></button>
48 + <a href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download><IcoDoc size={18} />Fiche PDF<small>{maj ? `Mise à jour ${maj}` : "Télécharger"}</small></a>
49 + </div>
50 + <p className="lk-fine" style={{ marginTop: 12 }}>
51 + Les prix et disponibilités sont ceux affichés par la source — chaque fiche renvoie à l'annonce originale.
52 + Lou-Ka est un agrégateur indépendant.
53 + </p>
54 + </SectionCard>
55 + );
56 +}
added frontend/src/fiche/StickyListingCTA.tsx +48 −0
@@ -0,0 +1,48 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/StickyListingCTA.tsx : barre CTA compacte du bas (mobile/tablette) —
5 +// n'apparaît qu'une fois la rangée d'actions du héro sortie de l'écran,
6 +// respecte la safe-area iOS, ne masque aucun contenu (padding de page).
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useState } from "react";
9 +import { Listing, fmtPrice, sourceName } from "../api";
10 +import { IcoExternal } from "../components/Icons";
11 +import { ComparaisonPrix } from "./synthese";
12 +import { NBSP } from "./ui";
13 +
14 +/** true dès que l'élément suivi est passé au-dessus du viewport (défilement). */
15 +export function usePastElement(watch: React.RefObject<HTMLElement>): boolean {
16 + const [past, setPast] = useState(false);
17 + useEffect(() => {
18 + // visible dès que la rangée d'actions du héro est passée au-dessus du viewport
19 + const check = () => {
20 + const el = watch.current;
21 + if (!el) return;
22 + setPast(el.getBoundingClientRect().bottom < 0);
23 + };
24 + check();
25 + window.addEventListener("scroll", check, { passive: true });
26 + window.addEventListener("resize", check);
27 + return () => { window.removeEventListener("scroll", check); window.removeEventListener("resize", check); };
28 + }, [watch]);
29 + return past;
30 +}
31 +
32 +export default function StickyListingCTA({ l, cmp, show }: {
33 + l: Listing; cmp: ComparaisonPrix | null; show: boolean;
34 +}) {
35 + return (
36 + <div className={`lk-cta-bar ${show ? "show" : ""}`} aria-hidden={!show}>
37 + <div className="lk-cta-txt">
38 + <div className="lk-cta-price">{fmtPrice(l.price, l.price_label)}{l.price != null && <small>{NBSP}/ mois</small>}</div>
39 + {cmp && <div className={`lk-cta-sub ${cmp.tone === "good" ? "good" : ""}`}>{cmp.court}</div>}
40 + </div>
41 + <a className="lk-btn lk-btn-primary" href={`/passerelle/${encodeURIComponent(l.uid)}`}
42 + target="_blank" rel="noopener noreferrer" tabIndex={show ? 0 : -1}>
43 + Voir l'annonce <IcoExternal size={15} />
44 + <span className="visually-hidden"> chez {sourceName(l.source)}</span>
45 + </a>
46 + </div>
47 + );
48 +}
added frontend/src/fiche/TrueCostCard.tsx +91 −0
@@ -0,0 +1,91 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/TrueCostCard.tsx : « Coût réel mensuel » — loyer + frais non inclus,
5 +// chaque poste étiqueté (inclus / observé / estimé / inconnu), total et
6 +// annuel, prix au pi² avec percentile réellement calculé, estimation
7 +// Hydro-Québec (cache ; bouton pour lancer le calcul à la demande).
8 +// -----------------------------------------------------------------------------
9 +import { useState } from "react";
10 +import { CoutReel, HydroEstimate, fetchHydro, fmtPrice } from "../api";
11 +import { IcoBolt2, IcoWallet } from "../components/Icons";
12 +import { Accordion, ErrorState, SectionCard, SkeletonLines, NBSP } from "./ui";
13 +import { Res } from "./useFicheData";
14 +
15 +const ST: Record<string, string> = { included: "inclus", observed: "observé", estimated: "estimé", unknown: "inconnu", calculated: "calculé", inferred: "estimé" };
16 +
17 +export default function TrueCostCard({ cout, hydro, adresse, uid, lat, lng, onRetry }: {
18 + cout: Res<CoutReel>; hydro: Res<HydroEstimate>; adresse: string; uid: string;
19 + lat: number | null; lng: number | null; onRetry: () => void;
20 +}) {
21 + const [h, setH] = useState<HydroEstimate | null>(null);
22 + const [busy, setBusy] = useState(false);
23 + const d = cout.status === "ok" ? cout.data : null;
24 + const hy = h ?? (hydro.status === "ok" ? hydro.data : null);
25 + const hydroVisible = hy && (hy.disponible || hy.en_attente || !/captcha|configuré|incomplète/.test(hy.raison || ""));
26 + if (cout.status === "na" || (d && d.loyer == null)) return null;
27 +
28 + const lancer = () => {
29 + setBusy(true);
30 + fetchHydro(adresse, { uid, lat, lng }, true).then(setH).catch(() => {}).finally(() => setBusy(false));
31 + };
32 +
33 + return (
34 + <SectionCard id="cout" title="Coût réel mensuel" icon={<IcoWallet size={18} />}
35 + sub="Loyer + frais non inclus, chaque poste avec son statut">
36 + {cout.status === "loading" && <SkeletonLines n={4} />}
37 + {cout.status === "error" && <ErrorState onRetry={onRetry}>Calcul du coût réel temporairement indisponible.</ErrorState>}
38 + {d && (
39 + <>
40 + <ul className="lk-cost">
41 + {d.lignes.map((li) => (
42 + <li key={li.poste}>
43 + <span className="n">{li.poste}<span className={`lk-pill ${li.statut in ST ? (li.statut === "inferred" || li.statut === "calculated" ? "estimated" : li.statut) : "unknown"}`}>{ST[li.statut] ?? li.statut}</span></span>
44 + <span className={`v ${li.montant == null ? "na" : ""}`}>
45 + {li.montant != null && li.montant > 0 && fmtPrice(li.montant)}
46 + {li.montant === 0 && li.statut === "included" && `0${NBSP}$`}
47 + {li.montant == null && "—"}
48 + </span>
49 + </li>
50 + ))}
51 + {d.total_estime != null && (
52 + <li className="total"><span className="n">Total estimé</span><span className="v">≈{NBSP}{fmtPrice(d.total_estime)}{NBSP}/mois</span></li>
53 + )}
54 + {d.annuel_estime != null && (
55 + <li className="annuel"><span className="n">soit sur 12 mois</span><span className="v">≈{NBSP}{fmtPrice(d.annuel_estime)}</span></li>
56 + )}
57 + </ul>
58 + {d.postes_inconnus.length > 0 && (
59 + <p className="lk-cost-note">Non chiffrables avec les données publiées : {d.postes_inconnus.join(", ").toLowerCase()} — le total réel peut être plus élevé.</p>
60 + )}
61 + {d.pi2 && (
62 + <p className="lk-cost-note">
63 + <b>{d.pi2.valeur.toFixed(2).replace(".", ",")}{NBSP}$/pi²</b>
64 + {d.pi2.percentile_secteur != null && <> — moins cher que <b>{100 - d.pi2.percentile_secteur}{NBSP}%</b> des {d.pi2.n_secteur} logements comparables du secteur (~2{NBSP}km)</>}
65 + {d.pi2.percentile_secteur == null && d.pi2.percentile_ville != null && <> — moins cher que <b>{100 - d.pi2.percentile_ville}{NBSP}%</b> des {d.pi2.n_ville} comparables ({d.pi2.portee_ville})</>}
66 + {d.pi2.percentile_note && <> {d.pi2.percentile_note}</>}
67 + </p>
68 + )}
69 + {hydroVisible && hy && (
70 + <div className="lk-note info" style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
71 + <IcoBolt2 size={16} />
72 + {hy.disponible ? (
73 + <span><b>Électricité : {fmtPrice(hy.cout_mensuel!)}{NBSP}/mois</b> (≈{NBSP}{fmtPrice(hy.cout_annuel!)}{NBSP}/an{hy.kwh_annuel ? `, ${hy.kwh_annuel.toLocaleString("fr-CA")} kWh` : ""}) — estimation Hydro-Québec fondée sur la consommation réelle du logement.</span>
74 + ) : hy.en_attente ? (
75 + <>
76 + <span style={{ flex: 1 }}>Estimation Hydro-Québec du coût d'électricité disponible à la demande.</span>
77 + <button type="button" className="lk-btn lk-btn-ghost" style={{ minHeight: 38 }} onClick={lancer} disabled={busy}>
78 + {busy ? "Estimation en cours…" : "Estimer"}
79 + </button>
80 + </>
81 + ) : (
82 + <span>Hydro-Québec n'a pas d'estimation pour cette adresse.</span>
83 + )}
84 + </div>
85 + )}
86 + <Accordion title="Méthodologie" small><p>{d.methode}</p></Accordion>
87 + </>
88 + )}
89 + </SectionCard>
90 + );
91 +}
added frontend/src/fiche/fiche.css +631 −0
@@ -0,0 +1,631 @@
1 +/* -----------------------------------------------------------------------------
2 + Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + fiche/fiche.css : fiche logement premium (refonte 2026-09-04)
5 + · Tokens `--lk-*` posés sur :root (les panneaux en portail y accèdent),
6 + styles SCOPÉS sous `.lk-fiche` / `.lk-*` — la fiche court terme
7 + (CourtTermeFiche) conserve les anciennes classes `.fiche/.f-*` intactes.
8 + · Fond cassé #F7F7F5, cartes blanches à bord 1 px #E5E5E1, ombres légères,
9 + rayons 10/14/18/22, orange réservé aux CTA / prix / score / états actifs.
10 + · Mobile-first ; desktop ≥ 1024 px : colonne principale + aside sticky.
11 + · Aucune propriété `order` : ordre DOM = ordre visuel (standard Groupe Ka).
12 +----------------------------------------------------------------------------- */
13 +:root {
14 + --lk-bg: #f7f7f5;
15 + --lk-surface: #ffffff;
16 + --lk-surface-2: #fafaf8;
17 + --lk-border: #e5e5e1;
18 + --lk-border-2: #d3d3cd;
19 + --lk-text: #141814;
20 + --lk-text-2: #4d5551;
21 + --lk-muted: #7c837e;
22 + --lk-orange: #ff6a00;
23 + --lk-orange-deep: #c85300;
24 + --lk-orange-soft: #fff1e6;
25 + --lk-success: #1e7b4a;
26 + --lk-success-soft: #e7f4ec;
27 + --lk-warning: #a8690a;
28 + --lk-warning-soft: #fcf3e1;
29 + --lk-danger: #b3423a;
30 + --lk-danger-soft: #fbe9e7;
31 + --lk-info: #3b5bdb;
32 + --lk-info-soft: #e9edfb;
33 + --lk-r-sm: 10px;
34 + --lk-r-md: 14px;
35 + --lk-r-lg: 18px;
36 + --lk-r-xl: 22px;
37 + --lk-shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.04);
38 + --lk-shadow-md: 0 8px 28px rgba(0, 0, 0, 0.07);
39 + --lk-header-h: 56px;
40 + --lk-nav-h: 52px;
41 +}
42 +
43 +/* ---- page : fond cassé, header compact, chrome global effacé ---- */
44 +body.lk-fiche-page { background: var(--lk-bg); }
45 +body.lk-fiche-page .tabbar { display: none !important; }
46 +body.lk-fiche-page .kaa-btn { display: none !important; } /* remplacé par « Demander à Ka » */
47 +body.lk-fiche-page .prefooter { margin-top: 8px; }
48 +.header--fiche { background: rgba(247, 247, 245, 0.92); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom-color: var(--lk-border); }
49 +.header--fiche .header-inner { height: var(--lk-header-h); gap: 10px; }
50 +.header--fiche .brand { font-size: 22px; }
51 +.header--fiche .brand .logo-ico { width: 26px; height: 26px; }
52 +.header--fiche .brand-tag { display: none; }
53 +.header--fiche .menu-btn { margin-left: 0; border-color: var(--lk-border); box-shadow: none; }
54 +.hdr-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
55 +.hdr-btn {
56 + display: inline-grid; place-items: center; width: 40px; height: 40px;
57 + border-radius: 999px; border: 1px solid var(--lk-border); background: var(--lk-surface);
58 + color: var(--lk-text); cursor: pointer; padding: 0; transition: background 0.15s, transform 0.15s;
59 +}
60 +.hdr-btn:active { transform: scale(0.95); }
61 +.hdr-btn.on { color: var(--lk-orange); border-color: var(--lk-orange); background: var(--lk-orange-soft); }
62 +@media (hover: hover) { .hdr-btn:hover { background: var(--lk-surface-2); } }
63 +.header--fiche .nav { margin-left: 0; }
64 +.header--fiche .login-btn { margin-left: 0; }
65 +/* connexion : capsule légère ; sur téléphone, icône seule */
66 +.login-btn .login-txt { margin-left: 2px; }
67 +@media (max-width: 760px) {
68 + .login-btn { padding: 0; width: 40px; height: 40px; justify-content: center; border-radius: 999px; margin-left: 0; }
69 + .login-btn .login-txt { display: none; }
70 +}
71 +
72 +/* ---- gabarit ---- */
73 +.lk-fiche { padding: 10px 0 110px; color: var(--lk-text); }
74 +.lk-wrap { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
75 +.lk-grid { display: block; }
76 +.lk-main { min-width: 0; display: flex; flex-direction: column; gap: 14px; }
77 +.lk-aside { display: none; }
78 +@media (min-width: 768px) {
79 + .lk-wrap { padding: 0 24px; }
80 + .lk-fiche { padding-top: 18px; }
81 + .lk-main { gap: 16px; }
82 +}
83 +@media (min-width: 1024px) {
84 + .lk-grid { display: grid; grid-template-columns: minmax(0, 1fr) 356px; gap: 32px; align-items: start; }
85 + .lk-aside { display: block; position: sticky; top: calc(var(--lk-header-h) + 16px); }
86 + .lk-fiche { padding-bottom: 80px; }
87 +}
88 +.lk-crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: var(--lk-muted); margin: 0 0 10px; }
89 +.lk-crumbs a { color: var(--lk-text-2); }
90 +.lk-crumbs span:last-child { color: var(--lk-text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60vw; }
91 +.lk-fiche section[id] { scroll-margin-top: calc(var(--lk-header-h) + var(--lk-nav-h) + 10px); }
92 +
93 +/* ---- carte de section ---- */
94 +.lk-card {
95 + background: var(--lk-surface); border: 1px solid var(--lk-border);
96 + border-radius: var(--lk-r-lg); box-shadow: var(--lk-shadow-sm);
97 + padding: 16px; min-width: 0;
98 +}
99 +@media (min-width: 768px) { .lk-card { padding: 20px 22px; } }
100 +.lk-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
101 +.lk-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(--lk-text); }
102 +.lk-card-title svg { color: var(--lk-orange-deep); flex: none; }
103 +.lk-card-sub { font-size: 12.5px; color: var(--lk-muted); margin: 3px 0 0; }
104 +.lk-card-aside { flex: none; display: flex; align-items: center; gap: 8px; }
105 +.lk-link { background: none; border: 0; padding: 0; color: var(--lk-text-2); font: 600 13px var(--font-body); cursor: pointer; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--lk-border-2); }
106 +.lk-link:hover { color: var(--lk-orange-deep); }
107 +.lk-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(--lk-border); border-radius: var(--lk-r-sm); background: var(--lk-surface);
111 + color: var(--lk-text); font: 600 13.5px var(--font-body); cursor: pointer;
112 + transition: background 0.15s, border-color 0.15s;
113 +}
114 +.lk-more:hover { background: var(--lk-surface-2); border-color: var(--lk-border-2); }
115 +.lk-more svg { color: var(--lk-muted); }
116 +
117 +/* ---- boutons ---- */
118 +.lk-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 +.lk-btn:active { transform: translateY(1px); }
125 +.lk-btn-primary { background: var(--lk-orange); color: #fff; box-shadow: 0 6px 18px rgba(255, 106, 0, 0.22); }
126 +.lk-btn-primary:hover { background: var(--lk-orange-deep); }
127 +.lk-btn-ghost { background: var(--lk-surface); border-color: var(--lk-border); color: var(--lk-text); }
128 +.lk-btn-ghost:hover { background: var(--lk-surface-2); border-color: var(--lk-border-2); }
129 +.lk-btn-icon { width: 46px; padding: 0; flex: none; }
130 +.lk-btn-icon.on { color: var(--lk-orange); border-color: var(--lk-orange); background: var(--lk-orange-soft); }
131 +
132 +/* ---- héro ---- */
133 +.lk-hero { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 0; }
134 +.lk-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
135 +.lk-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(--lk-text); display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; }
136 +.lk-price small { font: 500 14px var(--font-body); color: var(--lk-muted); letter-spacing: 0; }
137 +.lk-price-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
138 +.lk-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 +.lk-capsule b { font-weight: 700; }
143 +.lk-capsule.good { background: var(--lk-success-soft); color: var(--lk-success); }
144 +.lk-capsule.ok { background: var(--lk-surface); color: var(--lk-text-2); border-color: var(--lk-border); }
145 +.lk-capsule.high { background: var(--lk-warning-soft); color: var(--lk-warning); }
146 +.lk-capsule.neutral { background: var(--lk-surface-2); color: var(--lk-muted); border-color: var(--lk-border); }
147 +.lk-capsule.brand { background: var(--lk-orange-soft); color: var(--lk-orange-deep); }
148 +.lk-h1 { margin: 0; font-family: var(--font-body); font-weight: 600; font-size: 16px; line-height: 1.35; letter-spacing: 0; color: var(--lk-text); }
149 +.lk-h1 .lk-city { display: block; font-weight: 500; font-size: 14px; color: var(--lk-text-2); }
150 +.lk-summary { margin: 0; font-size: 14px; color: var(--lk-text-2); display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
151 +.lk-summary .sep { color: var(--lk-border-2); }
152 +.lk-actions { display: flex; gap: 8px; align-items: center; }
153 +.lk-actions .lk-btn-primary { flex: 1; }
154 +.lk-hero .lk-actions { margin-top: 2px; }
155 +@media (min-width: 1024px) {
156 + .lk-hero .lk-actions { display: none; } /* actions dans l'aside sticky */
157 +}
158 +
159 +/* ---- galerie ---- */
160 +.lk-gallery { position: relative; border-radius: var(--lk-r-lg); overflow: hidden; background: #ecece8; }
161 +.lk-gallery-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; aspect-ratio: 4 / 3; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
162 +.lk-gallery-track::-webkit-scrollbar { display: none; }
163 +.lk-gallery-track img, .lk-gallery > img { flex: 0 0 100%; width: 100%; height: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; display: block; }
164 +.lk-gallery > img { aspect-ratio: 4 / 3; }
165 +@media (min-width: 768px) { .lk-gallery-track, .lk-gallery > img { aspect-ratio: 16 / 9; } }
166 +.lk-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 +.lk-gallery-full, .lk-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(--lk-text); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
176 +}
177 +.lk-gallery-full { right: 12px; bottom: 12px; }
178 +.lk-gallery-nav { top: 50%; transform: translateY(-50%); display: none; }
179 +.lk-gallery-nav.prev { left: 12px; } .lk-gallery-nav.next { right: 12px; }
180 +@media (hover: hover) and (min-width: 768px) { .lk-gallery-nav { display: grid; } }
181 +.lk-thumbs { display: none; }
182 +@media (min-width: 768px) {
183 + .lk-thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 6px; margin-top: 8px; }
184 + .lk-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 + .lk-thumbs button.on, .lk-thumbs button:hover { opacity: 1; }
186 + .lk-thumbs button.on { outline: 2px solid var(--lk-orange); }
187 + .lk-thumbs img { width: 100%; height: 100%; object-fit: cover; }
188 +}
189 +/* lightbox (plein écran) */
190 +.lk-lightbox { position: fixed; inset: 0; z-index: var(--z-modal, 900); background: rgba(12, 14, 12, 0.96); }
191 +.lk-lightbox-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; height: 100%; scrollbar-width: none; touch-action: pan-x pinch-zoom; }
192 +.lk-lightbox-track::-webkit-scrollbar { display: none; }
193 +.lk-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 +.lk-lightbox-cell img { max-width: 100%; max-height: 100%; border-radius: 10px; user-select: none; -webkit-user-drag: none; will-change: transform; }
195 +.lk-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 +.lk-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 +.lk-lightbox .lk-gallery-nav { display: none; }
198 +@media (hover: hover) { .lk-lightbox .lk-gallery-nav { display: grid; position: fixed; } }
199 +
200 +/* ---- Lou-Ka Score ---- */
201 +.lk-score { display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: center; }
202 +.lk-score-ring { position: relative; width: 88px; height: 88px; flex: none; }
203 +.lk-score-ring svg { width: 88px; height: 88px; transform: rotate(-90deg); }
204 +.lk-score-ring .bg { fill: none; stroke: var(--lk-surface-2); stroke-width: 7; }
205 +.lk-score-ring .arc { fill: none; stroke: var(--lk-orange); stroke-width: 7; stroke-linecap: round; transition: stroke-dasharray 0.9s cubic-bezier(0.2, 0.8, 0.2, 1); }
206 +.lk-score-ring .arc.partial { stroke: var(--lk-text-2); }
207 +.lk-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 +.lk-score-val small { display: block; text-align: center; font: 600 10px var(--font-body); color: var(--lk-muted); margin-top: 2px; letter-spacing: 0.04em; }
209 +.lk-score-lbl { font-family: var(--font-display); font-weight: 700; font-size: 17px; letter-spacing: -0.02em; }
210 +.lk-score-sub { font-size: 13px; color: var(--lk-text-2); margin-top: 2px; line-height: 1.4; }
211 +.lk-score-parts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
212 +.lk-score-part { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; padding: 4px 9px; border-radius: 8px; background: var(--lk-surface-2); border: 1px solid var(--lk-border); color: var(--lk-text-2); }
213 +.lk-score-part b { color: var(--lk-text); font-variant-numeric: tabular-nums; }
214 +.lk-score-part.na { color: var(--lk-muted); border-style: dashed; }
215 +
216 +/* ---- En bref ---- */
217 +.lk-brief { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
218 +.lk-brief li { display: grid; grid-template-columns: 24px 1fr; gap: 10px; align-items: start; }
219 +.lk-brief-ico { width: 24px; height: 24px; border-radius: 8px; display: grid; place-items: center; font-size: 12px; font-weight: 700; margin-top: 1px; }
220 +.lk-brief-ico.good { background: var(--lk-success-soft); color: var(--lk-success); }
221 +.lk-brief-ico.warn { background: var(--lk-warning-soft); color: var(--lk-warning); }
222 +.lk-brief-ico.bad { background: var(--lk-danger-soft); color: var(--lk-danger); }
223 +.lk-brief-ico.neutral { background: var(--lk-surface-2); color: var(--lk-muted); border: 1px solid var(--lk-border); }
224 +.lk-brief-t { font-weight: 600; font-size: 14px; line-height: 1.35; }
225 +.lk-brief-d { font-size: 13px; color: var(--lk-text-2); line-height: 1.4; margin-top: 1px; }
226 +
227 +/* ---- navigation par sections (sticky) ---- */
228 +.lk-nav {
229 + position: sticky; top: var(--lk-header-h); z-index: var(--z-sticky, 300);
230 + margin: 0 -16px; padding: 6px 16px;
231 + background: rgba(247, 247, 245, 0.9); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
232 + border-bottom: 1px solid var(--lk-border);
233 +}
234 +@media (min-width: 768px) { .lk-nav { margin: 0 -24px; padding: 6px 24px; } }
235 +@media (min-width: 1024px) { .lk-nav { margin: 0; padding: 6px 0; border-radius: 0; } }
236 +.lk-nav-track { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
237 +.lk-nav-track::-webkit-scrollbar { display: none; }
238 +.lk-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(--lk-text-2);
241 + border: 1px solid transparent; transition: background 0.15s, color 0.15s; scroll-snap-align: start;
242 +}
243 +.lk-nav a:hover { background: var(--lk-surface); border-color: var(--lk-border); }
244 +.lk-nav a.on { background: var(--lk-text); color: #fff; border-color: var(--lk-text); }
245 +
246 +/* ---- caractéristiques ---- */
247 +.lk-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
248 +@media (min-width: 560px) { .lk-facts { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
249 +@media (min-width: 900px) { .lk-facts { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
250 +.lk-fact { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--lk-border); border-radius: 12px; background: var(--lk-surface-2); min-width: 0; }
251 +.lk-fact-ico { width: 32px; height: 32px; border-radius: 9px; background: var(--lk-surface); border: 1px solid var(--lk-border); display: grid; place-items: center; color: var(--lk-orange-deep); flex: none; }
252 +.lk-fact-v { font-weight: 700; font-size: 14px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
253 +.lk-fact-l { font-size: 11.5px; color: var(--lk-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
254 +.lk-fact-txt { min-width: 0; }
255 +.lk-facts-rows { margin-top: 12px; }
256 +.lk-kv { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--lk-border); font-size: 13.5px; }
257 +.lk-kv .k { color: var(--lk-muted); } .lk-kv .v { font-weight: 600; text-align: right; }
258 +.lk-note { margin: 10px 0 0; padding: 9px 12px; border-radius: 10px; font-size: 13px; line-height: 1.4; }
259 +.lk-note.good { background: var(--lk-success-soft); color: var(--lk-success); }
260 +.lk-note.info { background: var(--lk-info-soft); color: var(--lk-info); }
261 +.lk-note.warn { background: var(--lk-warning-soft); color: var(--lk-warning); }
262 +
263 +/* ---- description ---- */
264 +.lk-desc { position: relative; }
265 +.lk-desc-body p { font-size: 15px; line-height: 1.6; color: var(--lk-text-2); margin: 0 0 10px; white-space: pre-line; }
266 +.lk-desc-body h4 { margin: 12px 0 3px; font: 700 14px var(--font-body); color: var(--lk-text); }
267 +.lk-desc-lead { font-size: 15.5px !important; color: var(--lk-text) !important; font-weight: 500; }
268 +.lk-desc.clamped .lk-desc-body { max-height: 200px; overflow: hidden; -webkit-mask-image: linear-gradient(#000 62%, transparent); mask-image: linear-gradient(#000 62%, transparent); }
269 +.lk-desc-orig { margin-top: 8px; }
270 +.lk-desc-orig p { font-size: 13.5px; color: var(--lk-muted); }
271 +
272 +/* ---- inclusions ---- */
273 +.lk-amen { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
274 +@media (min-width: 640px) { .lk-amen { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
275 +.lk-amen-it { display: flex; align-items: center; gap: 9px; min-height: 42px; padding: 5px 0; border-bottom: 1px solid var(--lk-border); font-size: 13.5px; min-width: 0; }
276 +.lk-amen-ico { width: 28px; height: 28px; border-radius: 8px; background: var(--lk-orange-soft); color: var(--lk-orange-deep); display: grid; place-items: center; flex: none; }
277 +.lk-amen-it.unconfirmed { color: var(--lk-text-2); }
278 +.lk-amen-it.unconfirmed .lk-amen-ico { background: var(--lk-surface-2); color: var(--lk-muted); border: 1px dashed var(--lk-border-2); }
279 +.lk-amen-txt { min-width: 0; line-height: 1.25; }
280 +.lk-amen-conf { margin-left: auto; color: var(--lk-success); flex: none; }
281 +
282 +/* ---- KPI / tuiles ---- */
283 +.lk-kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
284 +@media (min-width: 600px) { .lk-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } .lk-kpi-v { font-size: 20px; } }
285 +.lk-kpis.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
286 +.lk-kpi { padding: 10px 12px; border: 1px solid var(--lk-border); border-radius: 12px; background: var(--lk-surface); min-width: 0; }
287 +.lk-kpi.accent { background: var(--lk-orange-soft); border-color: #ffd9bf; }
288 +.lk-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(--lk-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
289 +.lk-kpi-v.wrap { white-space: normal; font-size: 16px; line-height: 1.2; }
290 +.lk-kpi-v small { font: 600 11px var(--font-body); color: var(--lk-muted); margin-left: 3px; }
291 +.lk-kpi-l { font-size: 11.5px; color: var(--lk-muted); margin-top: 3px; line-height: 1.3; }
292 +.lk-kpi.anim .lk-kpi-v { animation: lk-rise 0.5s ease both; }
293 +@keyframes lk-rise { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
294 +
295 +/* ---- rangées compactes (accessibilité, mesures) ---- */
296 +.lk-rows { display: flex; flex-direction: column; gap: 4px; }
297 +.lk-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; min-height: 30px; font-size: 13.5px; }
298 +.lk-row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--lk-text); }
299 +.lk-row-name small { color: var(--lk-muted); font-size: 12px; margin-left: 4px; }
300 +.lk-row-bar { width: 78px; height: 6px; border-radius: 3px; background: var(--lk-surface-2); border: 1px solid var(--lk-border); overflow: hidden; }
301 +.lk-row-bar i { display: block; height: 100%; background: var(--lk-text); border-radius: 3px; }
302 +.lk-row-bar i.good { background: var(--lk-success); }
303 +.lk-row-bar i.warn { background: var(--lk-warning); }
304 +.lk-row-bar i.bad { background: var(--lk-danger); }
305 +.lk-row-val { font-weight: 700; font-size: 13px; font-variant-numeric: tabular-nums; min-width: 28px; text-align: right; }
306 +.lk-row-lbl { font-size: 12px; color: var(--lk-muted); min-width: 68px; text-align: right; }
307 +.lk-row-lbl.good { color: var(--lk-success); } .lk-row-lbl.warn { color: var(--lk-warning); } .lk-row-lbl.bad { color: var(--lk-danger); }
308 +
309 +/* ---- pastilles d'état ---- */
310 +.lk-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 +.lk-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
312 +.lk-badge.good { background: var(--lk-success-soft); color: var(--lk-success); }
313 +.lk-badge.warn { background: var(--lk-warning-soft); color: var(--lk-warning); }
314 +.lk-badge.bad { background: var(--lk-danger-soft); color: var(--lk-danger); }
315 +.lk-badge.neutral { background: var(--lk-surface-2); color: var(--lk-text-2); border-color: var(--lk-border); }
316 +.lk-badge.info { background: var(--lk-info-soft); color: var(--lk-info); }
317 +.lk-badge.lg { font-size: 13.5px; padding: 6px 12px; }
318 +.lk-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 +.lk-pill.included { background: var(--lk-success-soft); color: var(--lk-success); }
320 +.lk-pill.observed { background: var(--lk-surface-2); color: var(--lk-text-2); border: 1px solid var(--lk-border); }
321 +.lk-pill.estimated { background: var(--lk-orange-soft); color: var(--lk-orange-deep); }
322 +.lk-pill.unknown { background: var(--lk-surface-2); color: var(--lk-muted); border: 1px dashed var(--lk-border-2); }
323 +
324 +/* ---- listes d'items (lieux, comparables, stations) ---- */
325 +.lk-list { list-style: none; margin: 0; padding: 0; }
326 +.lk-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--lk-border); min-width: 0; }
327 +.lk-list > .lk-item:first-child { border-top: 0; padding-top: 2px; }
328 +.lk-item-ico { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; flex: none; background: var(--lk-surface-2); border: 1px solid var(--lk-border); color: var(--lk-text-2); }
329 +.lk-item-ico svg.cm-ico { width: 26px; height: 26px; }
330 +.lk-item-main { flex: 1; min-width: 0; }
331 +.lk-item-t { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
332 +.lk-item-s { font-size: 12.5px; color: var(--lk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
333 +.lk-item-r { text-align: right; flex: none; }
334 +.lk-item-v { font-weight: 700; font-size: 14px; font-variant-numeric: tabular-nums; }
335 +.lk-item-m { font-size: 12px; color: var(--lk-muted); }
336 +.lk-item-best { color: var(--lk-success); font-weight: 700; font-size: 11.5px; margin-left: 6px; }
337 +
338 +/* ---- carrousel horizontal ---- */
339 +.lk-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 +.lk-carousel::-webkit-scrollbar { display: none; }
341 +@media (min-width: 768px) { .lk-carousel { margin: 0; padding: 2px 0 6px; } }
342 +.lk-ccard { flex: 0 0 44%; min-width: 148px; max-width: 210px; scroll-snap-align: start; border: 1px solid var(--lk-border); border-radius: var(--lk-r-md); padding: 12px; background: var(--lk-surface); display: flex; flex-direction: column; gap: 6px; min-height: 104px; }
343 +@media (min-width: 768px) { .lk-ccard { flex-basis: 176px; } }
344 +.lk-ccard-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
345 +.lk-ccard-c { font: 600 10.5px var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; color: var(--lk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
346 +.lk-ccard-d { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
347 +.lk-ccard-n { font-size: 12.5px; color: var(--lk-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
348 +.lk-ccard-m { font-size: 11.5px; color: var(--lk-muted); }
349 +
350 +/* ---- filtres (chips) ---- */
351 +.lk-chips { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; padding-bottom: 10px; }
352 +.lk-chips::-webkit-scrollbar { display: none; }
353 +.lk-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(--lk-border); background: var(--lk-surface); color: var(--lk-text-2); font: 600 12.5px var(--font-body); cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; }
354 +.lk-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c, var(--lk-text)); flex: none; }
355 +.lk-chip small { font-weight: 600; opacity: 0.65; }
356 +.lk-chip.on { background: var(--lk-text); color: #fff; border-color: var(--lk-text); }
357 +.lk-chip:disabled { opacity: 0.45; cursor: default; }
358 +
359 +/* ---- carte ---- */
360 +.lk-map { position: relative; height: 340px; border-radius: var(--lk-r-lg); overflow: hidden; border: 1px solid var(--lk-border); background: #ecece8; }
361 +@media (min-width: 768px) { .lk-map { height: 440px; } }
362 +@media (min-width: 1024px) { .lk-map { height: 520px; } }
363 +.lk-map .ka-map {
364 + position: absolute; inset: 0;
365 + --ka-accent: var(--lk-orange); --ka-on-accent: #fff; --ka-surface: var(--lk-surface);
366 + --ka-ink: var(--lk-text); --ka-line: var(--lk-border-2); --ka-radius: 10px;
367 + --ka-shadow: 0 8px 24px rgba(20, 24, 20, 0.14); --ka-font: var(--font-body);
368 +}
369 +.lk-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(--lk-border); font: 500 11px var(--font-body); color: var(--lk-text-2); pointer-events: none; }
370 +.lk-map-legend i { width: 10px; height: 10px; border-radius: 3px; background: var(--lk-orange); }
371 +.lk-map-skel { position: absolute; inset: 0; }
372 +.lk-map-hint { font-size: 12px; color: var(--lk-muted); margin: 8px 0 0; }
373 +
374 +/* ---- accordéon ---- */
375 +.lk-acc { border-top: 1px solid var(--lk-border); }
376 +.lk-acc:first-child { border-top: 0; }
377 +.lk-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(--lk-text); cursor: pointer; text-align: left; }
378 +.lk-acc-btn .lk-acc-meta { font-weight: 500; font-size: 12.5px; color: var(--lk-muted); margin-left: auto; white-space: nowrap; }
379 +.lk-acc-btn .chev { color: var(--lk-muted); flex: none; transition: transform 0.2s ease; }
380 +.lk-acc-btn[aria-expanded="true"] .chev { transform: rotate(180deg); }
381 +.lk-acc-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.22s ease; }
382 +.lk-acc-body.open { grid-template-rows: 1fr; }
383 +.lk-acc-body > div { min-height: 0; overflow: hidden; }
384 +.lk-acc-inner { padding: 0 0 14px; font-size: 13.5px; color: var(--lk-text-2); line-height: 1.5; }
385 +.lk-acc-inner p { margin: 0 0 8px; }
386 +.lk-acc-inner a { color: var(--lk-text-2); text-decoration: underline; text-underline-offset: 2px; }
387 +.lk-acc.sm .lk-acc-btn { min-height: 40px; font-size: 13px; color: var(--lk-text-2); }
388 +
389 +/* ---- bottom sheet (mobile) / modale (desktop) ---- */
390 +.lk-sheet-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay, 800); background: rgba(20, 24, 20, 0.42); animation: lk-fade 0.18s ease; }
391 +.lk-sheet {
392 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
393 + height: var(--lk-sheet-h, 68dvh); max-height: 94dvh;
394 + background: var(--lk-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: lk-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 +.lk-sheet.full { height: 94dvh; }
401 +.lk-sheet-handle { width: 40px; height: 4px; border-radius: 2px; background: var(--lk-border-2); margin: 8px auto 0; flex: none; }
402 +.lk-sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px 10px; border-bottom: 1px solid var(--lk-border); flex: none; }
403 +.lk-sheet-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; letter-spacing: -0.02em; margin: 0; }
404 +.lk-sheet-sub { font-size: 12.5px; color: var(--lk-muted); margin: 2px 0 0; }
405 +.lk-sheet-x { width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--lk-border); background: var(--lk-surface-2); display: grid; place-items: center; cursor: pointer; color: var(--lk-text); flex: none; }
406 +.lk-sheet-body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: 12px 16px 18px; }
407 +.lk-sheet-foot { flex: none; padding: 10px 16px; border-top: 1px solid var(--lk-border); background: var(--lk-surface); }
408 +@keyframes lk-up { from { transform: translateY(40px); opacity: 0.6; } to { transform: none; opacity: 1; } }
409 +@keyframes lk-fade { from { opacity: 0; } to { opacity: 1; } }
410 +@keyframes lk-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 + .lk-sheet, .lk-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(--lk-r-lg); animation: lk-pop 0.18s ease; padding-bottom: 0;
416 + }
417 + .lk-sheet-handle { display: none; }
418 + .lk-sheet-head { padding: 16px 20px 12px; }
419 + .lk-sheet-body { padding: 14px 20px 20px; }
420 +}
421 +.lk-sheet-filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
422 +.lk-seg { display: inline-flex; border: 1px solid var(--lk-border); border-radius: 999px; overflow: hidden; background: var(--lk-surface); }
423 +.lk-seg button { border: 0; background: transparent; padding: 7px 12px; min-height: 36px; font: 600 12.5px var(--font-body); color: var(--lk-text-2); cursor: pointer; }
424 +.lk-seg button.on { background: var(--lk-text); color: #fff; }
425 +.lk-seg button + button { border-left: 1px solid var(--lk-border); }
426 +
427 +/* ---- CTA sticky ---- */
428 +.lk-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(--lk-border);
434 + transform: translateY(110%); transition: transform 0.25s ease; will-change: transform;
435 +}
436 +.lk-cta-bar.show { transform: none; }
437 +.lk-cta-txt { min-width: 0; flex: 0 1 auto; }
438 +.lk-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 +.lk-cta-price small { font: 500 11px var(--font-body); color: var(--lk-muted); letter-spacing: 0; }
440 +.lk-cta-sub { font-size: 12px; color: var(--lk-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
441 +.lk-cta-sub.good { color: var(--lk-success); font-weight: 600; }
442 +.lk-cta-bar .lk-btn-primary { flex: 1; min-height: 44px; margin-left: auto; max-width: 260px; }
443 +@media (min-width: 1024px) { .lk-cta-bar { display: none; } }
444 +
445 +/* ---- Demander à Ka ---- */
446 +.lk-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(--lk-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 +.lk-ka-btn svg { color: var(--lk-orange); }
454 +.lk-ka-btn:active { transform: scale(0.97); }
455 +.lk-ka-btn.hide { opacity: 0; pointer-events: none; transform: translateY(8px); }
456 +@media (min-width: 1024px) { .lk-ka-btn { display: none; } } /* desktop : bouton dans l'aside */
457 +.lk-ka-intro { display: flex; gap: 12px; align-items: flex-start; padding: 4px 0 12px; }
458 +.lk-ka-avatar { width: 38px; height: 38px; border-radius: 12px; background: var(--lk-text); color: var(--lk-orange); display: grid; place-items: center; flex: none; }
459 +.lk-ka-intro p { margin: 0; font-size: 13.5px; color: var(--lk-text-2); line-height: 1.45; }
460 +.lk-ka-ctx { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--lk-surface-2); border: 1px solid var(--lk-border); font-size: 12.5px; color: var(--lk-text-2); margin-bottom: 12px; }
461 +.lk-ka-ctx img { width: 44px; height: 34px; object-fit: cover; border-radius: 6px; flex: none; }
462 +.lk-ka-ctx b { color: var(--lk-text); display: block; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
463 +.lk-ka-sugs { display: flex; flex-direction: column; gap: 6px; }
464 +.lk-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(--lk-border); background: var(--lk-surface); font: 500 14px var(--font-body); color: var(--lk-text); cursor: pointer; text-align: left; transition: background 0.15s, border-color 0.15s; }
465 +.lk-ka-sug:hover { background: var(--lk-orange-soft); border-color: #ffd9bf; }
466 +.lk-ka-sug svg { color: var(--lk-muted); flex: none; }
467 +.lk-ka-in { display: flex; gap: 8px; align-items: center; }
468 +.lk-ka-in input { flex: 1; min-height: 46px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--lk-border); background: var(--lk-surface); font: 400 16px var(--font-body); color: var(--lk-text); }
469 +.lk-ka-in input:focus { outline: 2px solid var(--lk-orange); outline-offset: 1px; }
470 +.lk-ka-in button { width: 46px; height: 46px; border-radius: 12px; border: 0; background: var(--lk-orange); color: #fff; display: grid; place-items: center; cursor: pointer; flex: none; }
471 +.lk-ka-in button:disabled { opacity: 0.4; cursor: default; }
472 +
473 +/* ---- aside desktop ---- */
474 +.lk-aside-card { display: flex; flex-direction: column; gap: 12px; }
475 +.lk-aside .lk-btn-primary { white-space: normal; text-align: center; line-height: 1.25; }
476 +.lk-aside .lk-ka-inline { justify-content: flex-start; }
477 +.lk-aside .lk-ka-inline svg { color: var(--lk-orange); }
478 +.lk-aside .lk-price { font-size: 32px; }
479 +.lk-aside-addr { font-size: 14px; color: var(--lk-text-2); line-height: 1.4; }
480 +.lk-aside-sep { border: 0; border-top: 1px solid var(--lk-border); margin: 4px 0; }
481 +.lk-aside-score { display: flex; align-items: center; gap: 12px; }
482 +.lk-aside-score .lk-score-ring { width: 56px; height: 56px; }
483 +.lk-aside-score .lk-score-ring svg { width: 56px; height: 56px; }
484 +.lk-aside-score .lk-score-val { font-size: 18px; }
485 +.lk-aside-score .lk-score-val small { display: none; }
486 +.lk-aside-score-txt { font-size: 13px; color: var(--lk-text-2); line-height: 1.4; }
487 +.lk-aside-score-txt b { display: block; color: var(--lk-text); font-size: 14px; }
488 +.lk-aside .lk-brief { gap: 8px; }
489 +.lk-aside .lk-brief-t { font-size: 13.5px; }
490 +.lk-aside .lk-brief-d { display: none; }
491 +.lk-aside-meta { font-size: 12px; color: var(--lk-muted); text-align: center; }
492 +
493 +/* ---- source & méthodologie (pied de section) ---- */
494 +.lk-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(--lk-border); font-size: 12px; color: var(--lk-muted); }
495 +.lk-source b { font-weight: 600; color: var(--lk-text-2); }
496 +.lk-source a, .lk-source button { color: var(--lk-text-2); background: none; border: 0; padding: 0; font: inherit; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: var(--lk-border-2); cursor: pointer; }
497 +.lk-source a:hover, .lk-source button:hover { color: var(--lk-orange-deep); }
498 +.lk-fine { font-size: 12.5px; color: var(--lk-muted); line-height: 1.5; margin: 8px 0 0; }
499 +.lk-fine a { color: var(--lk-text-2); text-decoration: underline; text-underline-offset: 2px; }
500 +.lk-fine.meth { margin-top: 0; }
501 +
502 +/* ---- états : squelettes, vides, erreurs ---- */
503 +.lk-skel { border-radius: 10px; background: linear-gradient(90deg, #ecece8 25%, #f4f4f1 50%, #ecece8 75%); background-size: 400% 100%; animation: lk-shimmer 1.3s infinite linear; }
504 +@keyframes lk-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
505 +.lk-skel-lines { display: flex; flex-direction: column; gap: 8px; }
506 +.lk-skel-lines .lk-skel { height: 12px; }
507 +.lk-skel-lines .lk-skel.short { width: 55%; }
508 +.lk-empty { padding: 14px; border-radius: 12px; background: var(--lk-surface-2); border: 1px dashed var(--lk-border-2); color: var(--lk-muted); font-size: 13.5px; text-align: center; line-height: 1.45; }
509 +.lk-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; border-radius: 12px; background: var(--lk-warning-soft); color: var(--lk-warning); font-size: 13.5px; }
510 +.lk-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 +.lk-page-error { max-width: 520px; margin: 60px auto; text-align: center; padding: 0 16px; }
512 +.lk-page-error h2 { font-family: var(--font-display); letter-spacing: -0.02em; }
513 +.lk-page-error p { color: var(--lk-text-2); }
514 +.lk-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(--lk-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: lk-up 0.25s ease; max-width: calc(100vw - 32px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
515 +
516 +/* ---- prix / marché ---- */
517 +.lk-market-head { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: start; }
518 +.lk-market-big { font-family: var(--font-display); font-weight: 700; font-size: 28px; letter-spacing: -0.03em; line-height: 1; }
519 +.lk-market-big small { font: 500 13px var(--font-body); color: var(--lk-muted); margin-left: 4px; letter-spacing: 0; }
520 +.lk-market-sub { font-size: 13px; color: var(--lk-text-2); margin-top: 4px; }
521 +.lk-histo { display: block; width: 100%; max-width: 520px; height: auto; margin: 12px 0 4px; }
522 +.lk-histo-bar { fill: #dedfd9; }
523 +.lk-histo-bar.on { fill: var(--lk-orange); }
524 +.lk-histo-lbl { font: 700 10px var(--font-body); fill: var(--lk-text); }
525 +.lk-histo-lbl.fv { fill: var(--lk-text-2); }
526 +.lk-histo-axis { font: 500 9.5px var(--font-body); fill: var(--lk-muted); }
527 +.lk-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12.5px; color: var(--lk-text-2); margin: 6px 0 0; }
528 +.lk-meta b { color: var(--lk-text); }
529 +
530 +/* jauge du registre des loyers */
531 +.lk-gauge { margin: 14px 0 6px; }
532 +.lk-gauge-lbls { display: flex; justify-content: space-between; font: 600 11px var(--font-body); color: var(--lk-muted); margin-bottom: 6px; }
533 +.lk-gauge-lbls .good { color: var(--lk-success); } .lk-gauge-lbls .bad { color: var(--lk-danger); }
534 +.lk-gauge svg { display: block; width: 100%; max-width: 520px; height: auto; overflow: visible; }
535 +.lk-gauge, .lk-gauge-lbls { max-width: 520px; }
536 +.lk-gauge-track { fill: var(--lk-surface-2); stroke: var(--lk-border); }
537 +.lk-gauge-box { fill: #ecece8; }
538 +.lk-gauge-med { stroke: var(--lk-text-2); stroke-width: 1.5; }
539 +.lk-gauge-me { fill: var(--lk-orange); stroke: #fff; stroke-width: 2.5; }
540 +.lk-gauge-txt { font: 600 10.5px var(--font-body); fill: var(--lk-muted); }
541 +.lk-gauge-txt.me { fill: var(--lk-text); font-weight: 700; }
542 +.lk-bars { display: block; width: 100%; max-width: 520px; height: auto; margin: 6px 0 0; }
543 +.lk-bar { fill: #dedfd9; }
544 +.lk-bar.on { fill: var(--lk-orange); }
545 +.lk-bar-val { font: 600 10px var(--font-body); fill: var(--lk-text-2); }
546 +.lk-bar-cat { font: 600 11px var(--font-body); fill: var(--lk-text-2); }
547 +.lk-bar-cat.on { fill: var(--lk-text); font-weight: 700; }
548 +.lk-bar-n { font: 500 9.5px var(--font-body); fill: var(--lk-muted); }
549 +.lk-bars-axe { stroke: var(--lk-border); }
550 +
551 +/* ---- risques / environnement ---- */
552 +.lk-status { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
553 +.lk-status-t { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; }
554 +.lk-status-d { font-size: 13.5px; color: var(--lk-text-2); margin: 8px 0 0; line-height: 1.5; }
555 +.lk-air { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
556 +@media (max-width: 400px) { .lk-air { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
557 +.lk-air-c { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--lk-border); background: var(--lk-surface-2); min-width: 0; }
558 +.lk-air-p { font: 600 11.5px var(--font-body); color: var(--lk-muted); text-transform: uppercase; letter-spacing: 0.05em; }
559 +.lk-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 +.lk-air-v small { font: 500 11px var(--font-body); color: var(--lk-muted); margin-left: 3px; }
561 +.lk-air-s { font-size: 11.5px; margin-top: 3px; line-height: 1.3; }
562 +.lk-air-s.good { color: var(--lk-success); } .lk-air-s.warn { color: var(--lk-warning); } .lk-air-s.bad { color: var(--lk-danger); } .lk-air-s.neutral { color: var(--lk-muted); }
563 +
564 +/* ---- coût réel ---- */
565 +.lk-cost { list-style: none; margin: 0; padding: 0; }
566 +.lk-cost li { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 8px 0; border-top: 1px solid var(--lk-border); font-size: 13.5px; }
567 +.lk-cost li:first-child { border-top: 0; }
568 +.lk-cost .n { color: var(--lk-text-2); min-width: 0; }
569 +.lk-cost .v { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
570 +.lk-cost .v.na { color: var(--lk-muted); font-weight: 500; }
571 +.lk-cost li.total { border-top: 1px solid var(--lk-border-2); margin-top: 4px; padding-top: 10px; font-size: 15px; }
572 +.lk-cost li.total .n, .lk-cost li.total .v { color: var(--lk-text); font-weight: 700; }
573 +.lk-cost li.annuel { font-size: 12.5px; color: var(--lk-muted); border-top: 0; padding-top: 0; }
574 +.lk-cost-note { font-size: 12.5px; color: var(--lk-text-2); margin: 8px 0 0; }
575 +
576 +/* ---- dossier de l'immeuble (accordéons) ---- */
577 +.lk-tl { list-style: none; margin: 4px 0 0; padding: 0 0 0 12px; border-left: 2px solid var(--lk-border); display: flex; flex-direction: column; gap: 8px; }
578 +.lk-tl li { position: relative; font-size: 13px; color: var(--lk-text-2); }
579 +.lk-tl li::before { content: ""; position: absolute; left: -17.5px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--lk-surface); border: 2px solid var(--lk-border-2); }
580 +.lk-tl li.prix::before { border-color: var(--lk-orange); }
581 +.lk-tl li.disparition::before { border-color: var(--lk-danger); }
582 +.lk-tl li.reapparition::before { border-color: var(--lk-success); }
583 +.lk-tl-date { display: inline-block; min-width: 84px; font: 600 11.5px var(--font-body); color: var(--lk-muted); }
584 +.lk-star { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: var(--lk-text); display: inline-flex; align-items: center; gap: 5px; }
585 +.lk-star svg { color: #e0a800; }
586 +.lk-quote { margin: 10px 0 0; padding: 8px 12px; font-size: 13px; color: var(--lk-text-2); border-left: 3px solid var(--lk-border-2); background: var(--lk-surface-2); border-radius: 8px; }
587 +.lk-quote footer { margin-top: 4px; font-size: 11.5px; color: var(--lk-muted); }
588 +.lk-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 4px; }
589 +.lk-tag { font: 600 11.5px var(--font-body); padding: 2px 8px; border-radius: 999px; background: var(--lk-danger-soft); color: var(--lk-danger); }
590 +.lk-tag.n { background: var(--lk-surface-2); color: var(--lk-text-2); border: 1px solid var(--lk-border); }
591 +
592 +/* ---- KA Scores (cercles compacts) ---- */
593 +.lk-ks { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
594 +@media (max-width: 400px) { .lk-ks { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
595 +.lk-ks-c { text-align: center; padding: 8px 4px; border-radius: 12px; border: 1px solid var(--lk-border); background: var(--lk-surface-2); min-width: 0; }
596 +.lk-ks-c svg { width: 52px; height: 52px; transform: rotate(-90deg); }
597 +.lk-ks-c .bg { fill: none; stroke: #e6e6e1; stroke-width: 5; }
598 +.lk-ks-c .arc { fill: none; stroke: var(--lk-text); stroke-width: 5; stroke-linecap: round; }
599 +.lk-ks-c.haut .arc { stroke: var(--lk-success); } .lk-ks-c.bon .arc { stroke: var(--lk-text); } .lk-ks-c.moyen .arc { stroke: var(--lk-warning); } .lk-ks-c.bas .arc { stroke: var(--lk-danger); }
600 +.lk-ks-wrap { position: relative; width: 52px; height: 52px; margin: 0 auto; }
601 +.lk-ks-v { position: absolute; inset: 0; display: grid; place-items: center; font: 700 15px var(--font-display); letter-spacing: -0.02em; }
602 +.lk-ks-n { font: 600 11.5px var(--font-body); margin-top: 6px; color: var(--lk-text); }
603 +.lk-ks-l { font-size: 10.5px; color: var(--lk-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
604 +.lk-ks-detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px 18px; }
605 +.lk-ks-detail h4 { margin: 8px 0 4px; font: 700 12.5px var(--font-body); color: var(--lk-text); }
606 +.lk-ks-detail ul { margin: 0; padding-left: 16px; font-size: 13px; color: var(--lk-text-2); }
607 +.lk-ks-detail li { margin: 2px 0; }
608 +
609 +/* ---- fin de fiche ---- */
610 +.lk-end { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
611 +@media (min-width: 640px) { .lk-end { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
612 +.lk-end a, .lk-end button { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; padding: 12px; border-radius: 12px; border: 1px solid var(--lk-border); background: var(--lk-surface); color: var(--lk-text); font: 600 13px var(--font-body); cursor: pointer; text-align: left; min-height: 64px; }
613 +.lk-end a:hover, .lk-end button:hover { background: var(--lk-surface-2); }
614 +.lk-end svg { color: var(--lk-orange-deep); }
615 +.lk-end small { font-weight: 500; color: var(--lk-muted); font-size: 12px; }
616 +.lk-sources { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
617 +.lk-sources li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; padding: 9px 0; border-top: 1px solid var(--lk-border); font-size: 13px; align-items: center; }
618 +.lk-sources li:first-child { border-top: 0; }
619 +.lk-sources .n { font-weight: 600; }
620 +.lk-sources .r { font-size: 12.5px; color: var(--lk-text-2); grid-column: 1; }
621 +.lk-sources .d { font-size: 12px; color: var(--lk-muted); grid-row: 1 / span 2; text-align: right; white-space: nowrap; }
622 +.lk-sources a { color: var(--lk-text-2); text-decoration: underline; text-underline-offset: 2px; }
623 +
624 +/* ---- accessibilité mouvement réduit ---- */
625 +@media (prefers-reduced-motion: reduce) {
626 + .lk-sheet, .lk-sheet-backdrop, .lk-toast, .lk-kpi.anim .lk-kpi-v { animation: none; }
627 + .lk-score-ring .arc, .lk-acc-body, .lk-cta-bar { transition: none; }
628 +}
629 +
630 +/* ---- utilitaire a11y ---- */
631 +.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 +212 −0
@@ -0,0 +1,212 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/synthese.ts : synthèse DÉTERMINISTE de la fiche (aucun LLM, aucune
5 +// donnée inventée) — trois briques réutilisées par le héro, le score,
6 +// « En bref », l'aside desktop et le CTA sticky :
7 +// · comparaisonPrix : position du loyer vs juste valeur (fairvalue.py),
8 +// sinon vs médiane du Registre des loyers du même nombre de chambres ;
9 +// · louKaScore : 0-100 = 60 % emplacement (KA Score global) + 40 % prix
10 +// (écart à la juste valeur, 0 % → 65, −30 % → 100, +30 % → 20). Score
11 +// PARTIEL et dit tel quel quand une composante manque ;
12 +// · enBref : 4 à 6 constats priorisés (prix, accessibilité, air, inondation,
13 +// coûts, TAL, chaleur, baisse de prix), chacun avec son ton et sa preuve.
14 +// -----------------------------------------------------------------------------
15 +import {
16 + AirNearby, CoutReel, FairValueDetail, Inondation, Listing, RdlNearby, TalHistory,
17 + fmtDist, fmtPrice, kaLabel,
18 +} from "../api";
19 +import { Tone, NBSP, fmtPct } from "./ui";
20 +
21 +export interface ComparaisonPrix {
22 + tone: "good" | "ok" | "high";
23 + pct: number; // écart signé en % (négatif = moins cher)
24 + label: string; // « Très bon prix » / « Dans le marché » / « Au-dessus du marché »
25 + court: string; // « ↓ 13 % vs juste valeur »
26 + base: "fv" | "rdl";
27 + ref: number; // valeur de référence ($)
28 + refLabel: string; // « juste valeur estimée » / « médiane 2 ch. du secteur »
29 + n?: number;
30 +}
31 +
32 +export function comparaisonPrix(l: Listing, rdl: RdlNearby | null): ComparaisonPrix | null {
33 + if (l.price == null) return null;
34 + if (l.fv != null && l.fv > 0 && l.fv_verdict) {
35 + const pct = Math.round(((l.price - l.fv) / l.fv) * 100);
36 + return {
37 + tone: l.fv_verdict === "sous" ? "good" : l.fv_verdict === "sur" ? "high" : "ok",
38 + pct, base: "fv", ref: l.fv, refLabel: "juste valeur estimée",
39 + label: l.fv_verdict === "sous" ? "Très bon prix" : l.fv_verdict === "sur" ? "Au-dessus du marché" : "Dans le marché",
40 + court: `${pct < 0 ? "↓" : pct > 0 ? "↑" : "≈"} ${Math.abs(pct)}${NBSP}% vs juste valeur`,
41 + };
42 + }
43 + if (rdl && l.bedrooms != null) {
44 + const k = String(Math.round(l.bedrooms));
45 + const b = rdl.by_rooms?.[k];
46 + const ref = b && b.n >= 5 ? b.median : (rdl.median_recent ?? rdl.median);
47 + if (!ref) return null;
48 + const pct = Math.round(((l.price - ref) / ref) * 100);
49 + const tone = pct <= -10 ? "good" : pct <= 8 ? "ok" : "high";
50 + return {
51 + tone, pct, base: "rdl", ref, n: b && b.n >= 5 ? b.n : rdl.n,
52 + refLabel: b && b.n >= 5 ? `médiane ${k}${NBSP}ch. du secteur` : "médiane du secteur",
53 + label: tone === "good" ? "Très bon prix" : tone === "ok" ? "Dans le marché" : "Au-dessus du marché",
54 + court: `${pct < 0 ? "↓" : pct > 0 ? "↑" : "≈"} ${Math.abs(pct)}${NBSP}% vs loyers déclarés`,
55 + };
56 + }
57 + return null;
58 +}
59 +
60 +/* --- Lou-Ka Score ------------------------------------------------------------- */
61 +export interface LouKaScore {
62 + value: number | null; // score affiché (0-100) ou null si rien de fiable
63 + partial: boolean; // une composante manque
64 + emplacement: number | null; // KA Score global
65 + prix: number | null; // composante prix (0-100)
66 + label: string | null;
67 + explication: string;
68 +}
69 +
70 +export function scorePrix(deviation: number | null | undefined): number | null {
71 + if (deviation == null) return null;
72 + // 0 % d'écart → 65 ; −30 % → 100 ; +30 % → 20 (borné 5-100)
73 + return Math.round(Math.max(5, Math.min(100, 65 - deviation * 150)));
74 +}
75 +
76 +export function louKaScore(l: Listing): LouKaScore {
77 + const emplacement = l.kascores?.global ?? l.ks_global ?? null;
78 + const prix = l.fv_verdict ? scorePrix(l.fv_deviation) : null;
79 + let value: number | null = null;
80 + let partial = false;
81 + if (emplacement != null && prix != null) value = Math.round(emplacement * 0.6 + prix * 0.4);
82 + else if (emplacement != null) { value = Math.round(emplacement); partial = true; }
83 + else if (prix != null) { value = prix; partial = true; }
84 + const explication =
85 + "Le Lou-Ka Score combine l'emplacement (KA Score global : marche, transport, vélo, calme, " +
86 + "services — 60 %) et le prix (écart du loyer à la juste valeur estimée sur les annonces " +
87 + "comparables — 40 %). Quand une composante manque, le score est dit partiel et repose sur " +
88 + "la seule composante disponible. Il n'intègre ni l'état du logement ni le gestionnaire.";
89 + return { value, partial, emplacement: emplacement != null ? Math.round(emplacement) : null,
90 + prix, label: value != null ? kaLabel(value) : null, explication };
91 +}
92 +
93 +/* --- En bref ------------------------------------------------------------------ */
94 +export interface Constat { tone: Tone; titre: string; detail: string; cle: string; }
95 +
96 +export function enBref(l: Listing, x: {
97 + rdl: RdlNearby | null; air: AirNearby | null; inondation: Inondation | null;
98 + cout: CoutReel | null; tal: TalHistory | null; loadingRisques: boolean;
99 +}): Constat[] {
100 + const out: Constat[] = [];
101 + const cmp = comparaisonPrix(l, x.rdl);
102 +
103 + // 1. prix
104 + if (cmp) {
105 + const refTxt = `${fmtPrice(cmp.ref)} (${cmp.refLabel}${cmp.n ? `, ${cmp.n} loyers` : ""})`;
106 + out.push({
107 + cle: "prix",
108 + tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral",
109 + titre: cmp.tone === "good" ? "Très bon prix" : cmp.tone === "high" ? "Loyer au-dessus du marché" : "Loyer dans le marché",
110 + detail: `${Math.abs(cmp.pct)}${NBSP}% ${cmp.pct < 0 ? "sous" : cmp.pct > 0 ? "au-dessus de" : "≈"} ${refTxt}`,
111 + });
112 + } else if (l.price != null) {
113 + out.push({ cle: "prix", tone: "neutral", titre: "Prix non comparé",
114 + detail: "Pas assez d'annonces ni de loyers déclarés comparables dans ce secteur." });
115 + }
116 +
117 + // 2. accessibilité (KA Scores) ou proximité StatCan
118 + const ks = l.kascores;
119 + if (ks && (ks.walk != null || ks.transit != null)) {
120 + const g = ks.global ?? Math.max(ks.walk ?? 0, ks.transit ?? 0);
121 + const metro = ks.details?.transit?.station_metro_m;
122 + const bus = ks.details?.transit?.arret_bus_m;
123 + const bits: string[] = [];
124 + if (ks.walk != null) bits.push(`marche ${Math.round(ks.walk)}`);
125 + if (ks.transit != null) bits.push(`transport ${Math.round(ks.transit)}`);
126 + if (metro != null) bits.push(`métro à ${fmtDist(metro)}`);
127 + else if (bus != null) bits.push(`bus à ${fmtDist(bus)}`);
128 + out.push({
129 + cle: "acces",
130 + tone: g >= 70 ? "good" : g >= 45 ? "neutral" : "warn",
131 + titre: g >= 85 ? "Quartier exceptionnellement accessible" : g >= 70 ? "Quartier très accessible"
132 + : g >= 45 ? "Accessibilité moyenne" : "Secteur peu accessible sans voiture",
133 + detail: bits.join(" · "),
134 + });
135 + } else if (l.quartier?.proximite) {
136 + const p = l.quartier.proximite;
137 + const vals = ["prox_epicerie", "prox_transport", "prox_pharmacie", "prox_parc"]
138 + .map((k) => p[k]).filter((v): v is number => typeof v === "number");
139 + if (vals.length) {
140 + const m = Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100);
141 + out.push({ cle: "acces", tone: m >= 70 ? "good" : m >= 45 ? "neutral" : "warn",
142 + titre: m >= 70 ? "Services à proximité" : m >= 45 ? "Accessibilité moyenne" : "Peu de services à proximité",
143 + detail: `Indice de proximité StatCan ${m}/100 (épiceries, transport, pharmacies, parcs)` });
144 + }
145 + }
146 +
147 + // 3. qualité de l'air
148 + if (x.air?.station) {
149 + const pm = x.air.mesures?.["PM2.5"];
150 + if (pm?.ref) {
151 + const r = pm.moyenne / pm.ref;
152 + out.push({
153 + cle: "air", tone: r <= 2 ? "good" : r <= 3 ? "warn" : "bad",
154 + titre: r <= 1 ? "Air de très bonne qualité" : r <= 2 ? "Bonne qualité de l'air" : r <= 3 ? "Qualité de l'air passable" : "Particules fines élevées",
155 + detail: `PM2,5 ${pm.moyenne.toLocaleString("fr-CA")}${NBSP}µg/m³ · station ${x.air.station}${x.air.distance_km != null ? ` (${x.air.distance_km.toLocaleString("fr-CA")} km)` : ""} · ${pm.annee}`,
156 + });
157 + }
158 + }
159 +
160 + // 4. inondation
161 + if (x.inondation) {
162 + const d = x.inondation;
163 + if (d.statut === "en_zone")
164 + out.push({ cle: "inond", tone: d.severite === "eleve" ? "bad" : "warn", titre: "Adresse en zone inondable",
165 + detail: d.zones[0] ? `${d.zones[0].type}${d.zones[0].recurrence ? ` (${d.zones[0].recurrence})` : ""} — carte officielle BDZI` : "Cartographie officielle BDZI" });
166 + else if (d.statut === "a_proximite")
167 + out.push({ cle: "inond", tone: "warn", titre: "Zone inondable à proximité",
168 + detail: d.zones[0] ? `${d.zones[0].type} à ~${d.zones[0].distance_m} m` : "Selon la cartographie BDZI" });
169 + else if (d.statut === "hors_zone")
170 + out.push({ cle: "inond", tone: "good", titre: "Hors zone inondable", detail: "Secteur cartographié (BDZI, gouvernement du Québec)" });
171 + else
172 + out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation indéterminé", detail: "Secteur non couvert par la cartographie officielle" });
173 + } else if (x.loadingRisques && l.lat != null) {
174 + out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation", detail: "Vérification en cours…" });
175 + }
176 +
177 + // 5. coûts : inclusions puis coût réel
178 + const inc = l.details?.inclusions ?? {};
179 + if (inc.heating && inc.electricity && inc.hot_water)
180 + out.push({ cle: "cout", tone: "good", titre: "Chauffage, électricité et eau chaude inclus", detail: "Aucun frais énergétique à ajouter au loyer" });
181 + else if (x.cout?.total_estime != null && l.price != null && x.cout.total_estime > l.price)
182 + out.push({ cle: "cout", tone: "neutral", titre: `Coût réel estimé ≈ ${fmtPrice(x.cout.total_estime)}${NBSP}/mois`,
183 + detail: `Loyer + ${x.cout.lignes.filter((li) => li.statut === "estimated" && li.montant).map((li) => li.poste.toLowerCase()).join(", ") || "frais estimés"}` });
184 +
185 + // 6. TAL (seulement si décisions à l'initiative du propriétaire / éviction)
186 + if (x.tal?.status === "ok" && ((x.tal.contre_locataire ?? 0) > 0 || (x.tal.eviction ?? 0) > 0))
187 + out.push({ cle: "tal", tone: "warn", titre: `${x.tal.n} décision${(x.tal.n ?? 0) > 1 ? "s" : ""} au TAL à cette adresse`,
188 + detail: `${x.tal.contre_locataire ?? 0} à l'initiative du propriétaire — voir le dossier de l'immeuble` });
189 +
190 + // 7. îlot de chaleur marqué
191 + if (l.quartier?.chaleur && l.quartier.chaleur.classe >= 8)
192 + out.push({ cle: "chaleur", tone: "warn", titre: "Îlot de chaleur urbain",
193 + detail: `Secteur parmi les plus chauds (classe ${l.quartier.chaleur.classe}/9${l.quartier.chaleur.ecart != null ? `, +${l.quartier.chaleur.ecart.toFixed(1)}${NBSP}°C` : ""}) — INSPQ` });
194 +
195 + // 8. baisse de prix observée
196 + const hist = (l.price_history ?? []).filter((h) => h.price != null);
197 + if (hist.length >= 2 && hist[0].price! < hist[1].price!)
198 + out.push({ cle: "baisse", tone: "good", titre: "Prix en baisse",
199 + detail: `${fmtPrice(hist[1].price!)} → ${fmtPrice(hist[0].price!)} (${fmtPct(((hist[0].price! - hist[1].price!) / hist[1].price!) * 100)})` });
200 +
201 + return out.slice(0, 6);
202 +}
203 +
204 +/** Ligne résumé du logement : « 2 chambres · 4½ · disponible maintenant » */
205 +export function ligneResume(l: Listing, dispo: string | null): string[] {
206 + const p: string[] = [];
207 + if (l.bedrooms != null) p.push(l.bedrooms === 0 ? "Studio" : `${Math.round(l.bedrooms)} chambre${l.bedrooms > 1 ? "s" : ""}`);
208 + if (l.unit_type && !(l.bedrooms === 0 && /studio/i.test(l.unit_type))) p.push(l.unit_type);
209 + if (l.area_sqft) p.push(`${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`);
210 + if (dispo) p.push(dispo === "Maintenant" ? "disponible maintenant" : `disponible le ${dispo}`);
211 + return p;
212 +}
added frontend/src/fiche/ui.tsx +167 −0
@@ -0,0 +1,167 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ui.tsx : primitives d'interface de la fiche logement (refonte 2026-09-04)
5 +// SectionCard · Accordion · StatTile · StatusBadge · Skeleton · EmptyState ·
6 +// ErrorState · MoreButton · SourceLine · useToast — sans dépendance externe,
7 +// ARIA correcte, cibles tactiles ≥ 44 px, animations légères.
8 +// -----------------------------------------------------------------------------
9 +import { ReactNode, useCallback, useEffect, useId, useState } from "react";
10 +import { createPortal } from "react-dom";
11 +import { IcoChevronDown } from "../components/Icons";
12 +
13 +export type Tone = "good" | "warn" | "bad" | "neutral" | "info";
14 +
15 +/* --- carte de section ------------------------------------------------------ */
16 +export function SectionCard({ id, title, icon, sub, aside, children, className = "", label }: {
17 + id?: string; title?: ReactNode; icon?: ReactNode; sub?: ReactNode; aside?: ReactNode;
18 + children: ReactNode; className?: string; label?: string;
19 +}) {
20 + return (
21 + <section id={id} className={`lk-card ${className}`} aria-label={label}>
22 + {(title || aside) && (
23 + <div className="lk-card-head">
24 + <div>
25 + {title && <h2 className="lk-card-title">{icon}{title}</h2>}
26 + {sub && <p className="lk-card-sub">{sub}</p>}
27 + </div>
28 + {aside && <div className="lk-card-aside">{aside}</div>}
29 + </div>
30 + )}
31 + {children}
32 + </section>
33 + );
34 +}
35 +
36 +/* --- accordéon accessible (bouton + région, animation grid-rows) ----------- */
37 +export function Accordion({ title, meta, children, defaultOpen = false, small = false, onToggle }: {
38 + title: ReactNode; meta?: ReactNode; children: ReactNode; defaultOpen?: boolean;
39 + small?: boolean; onToggle?: (open: boolean) => void;
40 +}) {
41 + const [open, setOpen] = useState(defaultOpen);
42 + const id = useId();
43 + return (
44 + <div className={`lk-acc ${small ? "sm" : ""}`}>
45 + <button type="button" className="lk-acc-btn" aria-expanded={open}
46 + aria-controls={`${id}-body`} id={`${id}-btn`}
47 + onClick={() => { setOpen(!open); onToggle?.(!open); }}>
48 + <span>{title}</span>
49 + {meta && <span className="lk-acc-meta">{meta}</span>}
50 + <IcoChevronDown size={18} className="chev" />
51 + </button>
52 + <div className={`lk-acc-body ${open ? "open" : ""}`} id={`${id}-body`}
53 + role="region" aria-labelledby={`${id}-btn`}>
54 + <div><div className="lk-acc-inner">{children}</div></div>
55 + </div>
56 + </div>
57 + );
58 +}
59 +
60 +/* --- tuile KPI ------------------------------------------------------------- */
61 +export function StatTile({ value, unit, label, accent = false, anim = true }: {
62 + value: ReactNode; unit?: ReactNode; label: ReactNode; accent?: boolean; anim?: boolean;
63 +}) {
64 + return (
65 + <div className={`lk-kpi ${accent ? "accent" : ""} ${anim ? "anim" : ""}`}>
66 + <div className={`lk-kpi-v ${typeof value === "string" && value.length > 8 ? "wrap" : ""}`}>{value}{unit && <small>{unit}</small>}</div>
67 + <div className="lk-kpi-l">{label}</div>
68 + </div>
69 + );
70 +}
71 +
72 +/* --- pastille d'état ------------------------------------------------------- */
73 +export function StatusBadge({ tone = "neutral", children, lg = false }: {
74 + tone?: Tone; children: ReactNode; lg?: boolean;
75 +}) {
76 + return <span className={`lk-badge ${tone} ${lg ? "lg" : ""}`}>{children}</span>;
77 +}
78 +
79 +/* --- squelettes ------------------------------------------------------------ */
80 +export function Skeleton({ h = 14, w, r, className = "" }: { h?: number | string; w?: number | string; r?: number; className?: string }) {
81 + return <div className={`lk-skel ${className}`} style={{ height: h, width: w ?? "100%", borderRadius: r }} aria-hidden="true" />;
82 +}
83 +export function SkeletonLines({ n = 3 }: { n?: number }) {
84 + return (
85 + <div className="lk-skel-lines" aria-busy="true">
86 + {Array.from({ length: n }).map((_, i) => (
87 + <div key={i} className={`lk-skel ${i === n - 1 ? "short" : ""}`} />
88 + ))}
89 + </div>
90 + );
91 +}
92 +
93 +/* --- états vides / erreur -------------------------------------------------- */
94 +export function EmptyState({ children = "Aucune donnée disponible pour ce secteur." }: { children?: ReactNode }) {
95 + return <div className="lk-empty">{children}</div>;
96 +}
97 +export function ErrorState({ onRetry, children = "Données temporairement indisponibles." }: {
98 + onRetry?: () => void; children?: ReactNode;
99 +}) {
100 + return (
101 + <div className="lk-error" role="alert">
102 + <span>{children}</span>
103 + {onRetry && <button type="button" onClick={onRetry}>Réessayer</button>}
104 + </div>
105 + );
106 +}
107 +
108 +/* --- bouton « Voir plus » pleine largeur ----------------------------------- */
109 +export function MoreButton({ children, onClick, expanded }: {
110 + children: ReactNode; onClick: () => void; expanded?: boolean;
111 +}) {
112 + return (
113 + <button type="button" className="lk-more" onClick={onClick} aria-expanded={expanded}>
114 + {children}
115 + <IcoChevronDown size={16} style={expanded ? { transform: "rotate(180deg)" } : undefined} />
116 + </button>
117 + );
118 +}
119 +
120 +/* --- ligne « Source : … · Méthodologie » en pied de section ---------------- */
121 +export function SourceLine({ name, href, date, onMethod, methodLabel = "Méthodologie" }: {
122 + name: ReactNode; href?: string; date?: ReactNode; onMethod?: () => void; methodLabel?: string;
123 +}) {
124 + return (
125 + <div className="lk-source">
126 + <span>
127 + Source : <b>{href ? <a href={href} target="_blank" rel="noopener noreferrer">{name}</a> : name}</b>
128 + {date && <> · {date}</>}
129 + </span>
130 + {onMethod && <button type="button" onClick={onMethod}>{methodLabel}</button>}
131 + </div>
132 + );
133 +}
134 +
135 +/* --- toast minimal (retour d'action : favoris, lien copié) ------------------ */
136 +export function useToast(): [ReactNode, (msg: string) => void] {
137 + const [msg, setMsg] = useState<string | null>(null);
138 + useEffect(() => {
139 + if (!msg) return;
140 + const t = setTimeout(() => setMsg(null), 2200);
141 + return () => clearTimeout(t);
142 + }, [msg]);
143 + const show = useCallback((m: string) => setMsg(m), []);
144 + const node = msg
145 + ? createPortal(<div className="lk-toast" role="status" aria-live="polite">{msg}</div>, document.body)
146 + : null;
147 + return [node, show];
148 +}
149 +
150 +/* --- utilitaires de format -------------------------------------------------- */
151 +export const NBSP = " ";
152 +export const fmtN = (v: number, d = 0) =>
153 + v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: d });
154 +export const fmtPct = (v: number, signed = true) =>
155 + `${signed ? (v > 0 ? "+" : v < 0 ? "−" : "") : ""}${Math.abs(Math.round(v))}${NBSP}%`;
156 +/** ≈ minutes de marche (vol d'oiseau × 1,3 de détour, 4,8 km/h) */
157 +export const marcheMin = (m: number) => Math.max(1, Math.round((m * 1.3) / 80));
158 +export const fmtMarche = (m: number) => `${marcheMin(m)}${NBSP}min`;
159 +export const relTime = (ts: number | null | undefined): string | null => {
160 + if (!ts) return null;
161 + const s = Date.now() / 1000 - ts;
162 + if (s < 3600) return "à l'instant";
163 + if (s < 86400) return `il y a ${Math.round(s / 3600)}${NBSP}h`;
164 + const j = Math.round(s / 86400);
165 + if (j < 30) return `il y a ${j}${NBSP}jour${j > 1 ? "s" : ""}`;
166 + return new Date(ts * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" });
167 +};
added frontend/src/fiche/useFicheData.ts +132 −0
@@ -0,0 +1,132 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/useFicheData.ts : orchestration des données secondaires de la fiche.
5 +// Un seul endroit charge les API annexes (au lieu d'un fetch par bloc) :
6 +// · groupe CRITIQUE (au montage) : juste valeur détaillée, registre des
7 +// loyers, inondation, qualité de l'air, coût réel — nécessaires au score,
8 +// à « En bref » et au premier écran ;
9 +// · groupe DIFFÉRÉ (quand l'utilisateur approche la carte, ou après 2,5 s) :
10 +// commerces/transport, essence, historique, recyclées, TAL, gestionnaire,
11 +// Hydro-Québec (cache seulement).
12 +// Chaque ressource porte son statut (loading / ok / error / empty) pour des
13 +// squelettes et des états vides propres — jamais de undefined à l'écran.
14 +// -----------------------------------------------------------------------------
15 +import { useCallback, useEffect, useRef, useState } from "react";
16 +import {
17 + AirNearby, CommercesNearby, CoutReel, FairValueDetail, GazNearby, Gestionnaire,
18 + HistoriqueLouka, HydroEstimate, Inondation, Listing, RdlNearby, Recyclees, TalHistory,
19 + fetchAir, fetchCommerces, fetchCoutReel, fetchFairValue, fetchGaz, fetchGestionnaire,
20 + fetchHistorique, fetchHydro, fetchInondation, fetchRdl, fetchRecyclees, fetchTal,
21 +} from "../api";
22 +
23 +export type Res<T> =
24 + | { status: "idle" | "loading" }
25 + | { status: "ok"; data: T }
26 + | { status: "error"; error: string }
27 + | { status: "na" }; // non applicable (pas de coordonnées, pas d'adresse…)
28 +
29 +export interface FicheData {
30 + fv: Res<FairValueDetail>;
31 + rdl: Res<RdlNearby>;
32 + inondation: Res<Inondation>;
33 + air: Res<AirNearby>;
34 + cout: Res<CoutReel>;
35 + commerces: Res<CommercesNearby>;
36 + gaz: Res<GazNearby>;
37 + historique: Res<HistoriqueLouka>;
38 + recyclees: Res<Recyclees>;
39 + tal: Res<TalHistory>;
40 + gestionnaire: Res<Gestionnaire>;
41 + hydro: Res<HydroEstimate>;
42 + /** déclenche le groupe différé (appelé par le sentinelle de la carte) */
43 + wake: () => void;
44 + /** relance une ressource en erreur */
45 + retry: (key: keyof Omit<FicheData, "wake" | "retry">) => void;
46 +}
47 +
48 +type Key = keyof Omit<FicheData, "wake" | "retry">;
49 +const LOADING = { status: "loading" } as const;
50 +const NA = { status: "na" } as const;
51 +const CRITIQUE: Key[] = ["fv", "rdl", "inondation", "air", "cout"];
52 +const DIFFERE: Key[] = ["commerces", "gaz", "historique", "recyclees", "tal", "gestionnaire", "hydro"];
53 +
54 +export function dataOf<T>(r: Res<T>): T | null {
55 + return r.status === "ok" ? r.data : null;
56 +}
57 +
58 +export default function useFicheData(l: Listing | null): FicheData {
59 + const [state, setState] = useState<Record<Key, Res<unknown>>>({
60 + fv: LOADING, rdl: LOADING, inondation: LOADING, air: LOADING, cout: LOADING,
61 + commerces: { status: "idle" }, gaz: { status: "idle" }, historique: { status: "idle" },
62 + recyclees: { status: "idle" }, tal: { status: "idle" }, gestionnaire: { status: "idle" },
63 + hydro: { status: "idle" },
64 + });
65 + const [awake, setAwake] = useState(false);
66 + const uidRef = useRef<string | null>(null);
67 + const started = useRef<Set<Key>>(new Set());
68 +
69 + const set = useCallback((k: Key, r: Res<unknown>) =>
70 + setState((s) => ({ ...s, [k]: r })), []);
71 +
72 + const load = useCallback((k: Key, p: (() => Promise<unknown>) | null) => {
73 + if (!p) { set(k, NA); return; }
74 + const uid = uidRef.current;
75 + set(k, LOADING);
76 + p().then((d) => { if (uidRef.current === uid) set(k, { status: "ok", data: d }); })
77 + .catch((e: unknown) => {
78 + if (uidRef.current !== uid) return;
79 + // 404 = base absente ou hors couverture → état « non applicable » (pas une erreur)
80 + const msg = String(e);
81 + set(k, /API 404/.test(msg) ? NA : { status: "error", error: msg });
82 + });
83 + }, [set]);
84 +
85 + const loaders = useCallback((k: Key): (() => Promise<unknown>) | null => {
86 + if (!l) return null;
87 + const geo = l.lat != null && l.lng != null;
88 + const lat = l.lat as number, lng = l.lng as number;
89 + switch (k) {
90 + case "fv": return l.price != null ? () => fetchFairValue(l.uid) : null;
91 + case "rdl": return geo ? () => fetchRdl(lat, lng, 600, 300) : null;
92 + case "inondation": return geo ? () => fetchInondation(lat, lng) : null;
93 + case "air": return geo ? () => fetchAir(lat, lng) : null;
94 + case "cout": return () => fetchCoutReel(l.uid);
95 + case "commerces": return geo ? () => fetchCommerces(lat, lng) : null;
96 + case "gaz": return geo ? () => fetchGaz(lat, lng, 60) : null;
97 + case "historique": return () => fetchHistorique(l.uid);
98 + case "recyclees": return () => fetchRecyclees(l.uid);
99 + case "tal": return l.address ? () => fetchTal(l.address, l.city) : null;
100 + case "gestionnaire": return () => fetchGestionnaire(l.source);
101 + case "hydro": return (l.address || l.title)
102 + ? () => fetchHydro(l.address || l.title, { uid: l.uid, lat: l.lat, lng: l.lng }, false) : null;
103 + }
104 + }, [l]);
105 +
106 + // groupe critique : dès que l'annonce est connue
107 + useEffect(() => {
108 + if (!l) return;
109 + uidRef.current = l.uid;
110 + started.current = new Set();
111 + setAwake(false);
112 + for (const k of CRITIQUE) load(k, loaders(k));
113 + for (const k of DIFFERE) set(k, { status: "idle" });
114 + const t = setTimeout(() => setAwake(true), 2500); // filet : réveil après 2,5 s
115 + return () => clearTimeout(t);
116 + }, [l, load, loaders, set]);
117 +
118 + // groupe différé : au réveil (proximité de la carte ou délai)
119 + useEffect(() => {
120 + if (!l || !awake) return;
121 + for (const k of DIFFERE) {
122 + if (started.current.has(k)) continue;
123 + started.current.add(k);
124 + load(k, loaders(k));
125 + }
126 + }, [awake, l, load, loaders]);
127 +
128 + const wake = useCallback(() => setAwake(true), []);
129 + const retry = useCallback((k: Key) => load(k, loaders(k)), [load, loaders]);
130 +
131 + return { ...(state as unknown as Omit<FicheData, "wake" | "retry">), wake, retry };
132 +}
modified frontend/src/pages/Listing.tsx +159 −550
@@ -1,283 +1,61 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// pages/Listing.tsx : fiche d'un logement — refonte mobile-first
5 −// Ordre DOM = ordre visuel, identique mobile ET desktop (standard Groupe Ka
6 −// « Ordre des sections — pages détail ») : galerie → prix + badge marché +
7 −// adresse + chips → CTA/PDF → description → inclusions → détails pratiques →
8 −// analyse de prix → emplacement → KA Scores → quartier → à proximité.
9 −// Mobile : colonnes empilées ; desktop : grid 2 colonnes (col A | col B).
10 −// Interdit : réordonner via `order` / `column-reverse` (bug fiche v2 où les
11 −// blocs sans `order` passaient devant la galerie sur mobile).
4 +// pages/Listing.tsx : fiche d'un logement — refonte premium 2026-09-04
5 +// Ordre DOM = ordre visuel, identique mobile ET desktop (aucun `order`) :
6 +// héro (prix · adresse · résumé · galerie · actions) → Lou-Ka Score →
7 +// En bref → navigation sticky → Prix et marché → Le logement → Description
8 +// → Inclusions → Carte → À proximité → Transport → Quartier → KA Scores →
9 +// Registre des loyers → Coût réel → Risque d'inondation → Qualité de l'air →
10 +// Essence → Dossier de l'immeuble → Sources et méthodologie.
11 +// Desktop ≥ 1024 px : grille colonne principale + aside sticky (dupliquée,
12 +// masquée sur mobile). Données annexes : fiche/useFicheData (chargement
13 +// critique puis différé). Aucun scrollIntoView à l'ouverture : la page
14 +// s'ouvre en haut.
12 15 // -----------------------------------------------------------------------------
13 −import { lazy, Suspense, useEffect, useRef, useState } from "react";
16 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
14 17 import { Link, useParams } from "react-router-dom";
15 −import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api";
16 −import QuartierBlock from "../components/QuartierBlock";
17 −import SmartImg from "../components/SmartImg";
18 −import FairValueBadge from "../components/FairValueBadge";
19 −import PriceAnalysis from "../components/PriceAnalysis";
20 −import HistoriqueTAL from "../components/HistoriqueTAL";
21 −import RegistreLoyers from "../components/RegistreLoyers";
22 −import RisqueInondation from "../components/RisqueInondation";
23 −import QualiteAir from "../components/QualiteAir";
24 −import EssenceProche from "../components/EssenceProche";
25 −import HydroEstimation from "../components/HydroEstimation";
26 −import CommercesProches from "../components/CommercesProches";
27 −import CoutReel from "../components/CoutReel";
28 −import HistoriqueLouka from "../components/HistoriqueLouka";
29 −import ImmeubleBloc from "../components/ImmeubleBloc";
30 −import GestionnaireBloc from "../components/GestionnaireBloc";
31 −import HiverScore from "../components/HiverScore";
32 −import { IcoAlert, IcoDoc } from "../components/Icons";
33 −import AmenityIco from "../components/AmenityIco";
34 −import KaScoresBlock from "../components/KaScoresBlock";
18 +import { Listing, fetchListing, fetchSources, registerSourceNames } from "../api";
19 +import { useAccount } from "../account";
20 +import { IcoAlert } from "../components/Icons";
35 21 import { markSeen } from "../search/seen";
36 −
37 −// Mini-carte 3D (Mapbox) — chargée paresseusement, comme la grande carte.
38 −const ListingMap3D = lazy(() => import("../components/ListingMap3D"));
39 −
40 −// Icônes et libellés des commodités de proximité (louka/poi.py)
41 −const POI_META: Record<string, { icon: string; label: string }> = {
42 − epicerie: { icon: "🛒", label: "Épicerie" },
43 − depanneur: { icon: "🏪", label: "Dépanneur" },
44 − pharmacie: { icon: "💊", label: "Pharmacie" },
45 − ecole: { icon: "🏫", label: "École" },
46 − garderie: { icon: "🧸", label: "Garderie" },
47 − parc: { icon: "🌳", label: "Parc" },
48 − bus: { icon: "🚌", label: "Bus" },
49 − metro: { icon: "🚇", label: "Métro" },
50 − gym: { icon: "🏋️", label: "Gym" },
51 − cafe: { icon: "☕", label: "Café" },
52 − clinique: { icon: "🩺", label: "Clinique" },
53 − hopital: { icon: "🏥", label: "Hôpital" },
54 − bibliotheque: { icon: "📚", label: "Bibliothèque" },
55 −};
56 −
57 −// Regroupement des POI en catégories repliables
58 −const POI_GROUPES: { titre: string; icone: string; cats: string[] }[] = [
59 − { titre: "Courses", icone: "🛒", cats: ["epicerie", "depanneur"] },
60 − { titre: "Transport", icone: "🚌", cats: ["bus", "metro"] },
61 − { titre: "Études et famille", icone: "🎓", cats: ["ecole", "garderie", "bibliotheque"] },
62 − { titre: "Santé", icone: "🏥", cats: ["pharmacie", "clinique", "hopital"] },
63 − { titre: "Vie de quartier", icone: "☕", cats: ["cafe", "parc", "gym"] },
64 −];
65 −
66 −// Badge « prix vs marché » — seuils configurables
67 −const SEUILS_MARCHE = { bonDeal: -0.15, dansLeMarche: 0.10 };
68 −
69 −const PETS_LABEL: Record<string, string> = {
70 − oui: "Animaux acceptés", non: "Animaux refusés", conditions: "Animaux sous conditions",
71 −};
72 −
73 −const NBSP = " ";
74 −
75 −/** ≈ minutes de marche (vol d'oiseau × facteur de détour 1,3, 4,8 km/h) */
76 −const fmtMarche = (m: number): string =>
77 − `≈${NBSP}${Math.max(1, Math.round((m * 1.3) / 80))}${NBSP}min à pied`;
78 −
79 −function badgeMarche(price: number | null | undefined,
80 − loyerSecteur: number | null | undefined) {
81 − if (price == null || loyerSecteur == null || loyerSecteur <= 0) return null;
82 − const delta = (price - loyerSecteur) / loyerSecteur;
83 − const pct = `${delta > 0 ? "+" : "−"}${Math.abs(Math.round(delta * 100))}${NBSP}%`;
84 − if (delta <= SEUILS_MARCHE.bonDeal)
85 − return { cls: "deal-good", txt: `${pct} vs le secteur · Bon deal 🔥` };
86 − if (delta <= SEUILS_MARCHE.dansLeMarche)
87 − return { cls: "deal-ok", txt: `${pct} vs le secteur · Dans le marché` };
88 − return { cls: "deal-high", txt: `${pct} vs le secteur · Au-dessus du marché` };
89 −}
90 −
91 −/** Badges dérivés des détails structurés (inclusions confirmées ✓). */
92 −function badgesConfirmes(l: Listing): string[] {
93 − const d = l.details ?? {};
94 − const out: string[] = [];
95 − const inc = d.inclusions ?? {};
96 − if (inc.heating) out.push("Chauffage inclus");
97 − if (inc.electricity) out.push("Électricité incluse");
98 − if (inc.hot_water) out.push("Eau chaude incluse");
99 − if (inc.internet) out.push("Internet inclus");
100 − const app = d.appliances ?? {};
101 − if (app.dishwasher) out.push("Lave-vaisselle");
102 − if (app.washer_dryer) out.push("Laveuse-sécheuse");
103 − if (app.fridge && app.stove) out.push("Électroménagers");
104 − if (d.ac) out.push("Climatisation");
105 − if (d.elevator) out.push("Ascenseur");
106 − if (d.balcony) out.push("Balcon");
107 − if (d.pool) out.push("Piscine");
108 − if (d.gym) out.push("Gym");
109 − if (d.laundry) out.push("Buanderie");
110 − if (d.storage) out.push("Rangement");
111 − if (d.parking?.available)
112 − out.push(`Stationnement${d.parking.type ? ` ${d.parking.type}` : ""}${d.parking.included ? " inclus" : ""}`);
113 − if (l.furnished) out.push("Meublé");
114 − if (d.smoking === false) out.push("Non-fumeur");
115 − return out;
116 −}
117 −
118 −// --- Lightbox plein écran : balayage entre photos + pincement pour zoomer ---
119 −function Lightbox({ images, start, titre, onClose }:
120 − { images: string[]; start: number; titre: string; onClose: () => void }) {
121 − const [idx, setIdx] = useState(start);
122 − const [scale, setScale] = useState(1);
123 − const [tx, setTx] = useState(0);
124 − const [ty, setTy] = useState(0);
125 − const track = useRef<HTMLDivElement>(null);
126 − const pointers = useRef(new Map<number, { x: number; y: number }>());
127 − const pinch = useRef<{ d: number; scale: number } | null>(null);
128 − const lastTap = useRef(0);
129 −
130 − useEffect(() => { // position initiale + verrou du défilement de la page
131 − track.current?.scrollTo({ left: start * track.current.clientWidth });
132 − document.body.style.overflow = "hidden";
133 − const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
134 − window.addEventListener("keydown", onKey);
135 − return () => {
136 − document.body.style.overflow = "";
137 − window.removeEventListener("keydown", onKey);
138 − };
139 − // eslint-disable-next-line react-hooks/exhaustive-deps
140 − }, []);
141 −
142 − const resetZoom = () => { setScale(1); setTx(0); setTy(0); };
143 − const onScroll = () => {
144 − const el = track.current;
145 − if (el && scale === 1) {
146 − const i = Math.round(el.scrollLeft / el.clientWidth);
147 − if (i !== idx) { setIdx(i); resetZoom(); }
148 − }
149 − };
150 − const dist = () => {
151 − const [a, b] = [...pointers.current.values()];
152 − return Math.hypot(a.x - b.x, a.y - b.y);
153 − };
154 − const onPointerDown = (e: React.PointerEvent) => {
155 − pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
156 − if (pointers.current.size === 2)
157 − pinch.current = { d: dist(), scale };
158 − if (pointers.current.size === 1) { // double-tape = zoom ×2,5 / retour ×1
159 − const now = Date.now();
160 − if (now - lastTap.current < 300) {
161 − if (scale > 1) resetZoom(); else setScale(2.5);
162 − }
163 − lastTap.current = now;
164 − }
165 − };
166 − const onPointerMove = (e: React.PointerEvent) => {
167 − const prev = pointers.current.get(e.pointerId);
168 − if (!prev) return;
169 − pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
170 − if (pointers.current.size === 2 && pinch.current) {
171 − const s = Math.min(4, Math.max(1, pinch.current.scale * (dist() / pinch.current.d)));
172 − setScale(s);
173 − if (s === 1) { setTx(0); setTy(0); }
174 − } else if (pointers.current.size === 1 && scale > 1) {
175 − setTx((v) => v + (e.clientX - prev.x)); // panoramique quand zoomé
176 − setTy((v) => v + (e.clientY - prev.y));
177 − }
178 − };
179 − const onPointerUp = (e: React.PointerEvent) => {
180 − pointers.current.delete(e.pointerId);
181 − if (pointers.current.size < 2) pinch.current = null;
182 − };
183 −
22 +import "../fiche/fiche.css";
23 +import useFicheData, { dataOf } from "../fiche/useFicheData";
24 +import { comparaisonPrix, enBref, ligneResume, louKaScore } from "../fiche/synthese";
25 +import PropertyHero from "../fiche/PropertyHero";
26 +import LouKaScore from "../fiche/LouKaScore";
27 +import PropertySummary from "../fiche/PropertySummary";
28 +import SectionNav, { NavItem } from "../fiche/SectionNav";
29 +import MarketPriceCard from "../fiche/MarketPriceCard";
30 +import PropertyQuickFacts from "../fiche/PropertyQuickFacts";
31 +import PropertyDescription from "../fiche/PropertyDescription";
32 +import PropertyAmenities from "../fiche/PropertyAmenities";
33 +import InteractiveMap, { lieuxDepuis } from "../fiche/InteractiveMap";
34 +import NearbyPlaces from "../fiche/NearbyPlaces";
35 +import NeighborhoodStats from "../fiche/NeighborhoodStats";
36 +import KaScoresCard from "../fiche/KaScoresCard";
37 +import RentRegistryCard from "../fiche/RentRegistryCard";
38 +import TrueCostCard from "../fiche/TrueCostCard";
39 +import { AirQualityCard, FloodRiskCard, GasNearbyCard } from "../fiche/EnvironmentCards";
40 +import BuildingDossier from "../fiche/BuildingDossier";
41 +import SourceDisclosure from "../fiche/SourceDisclosure";
42 +import StickyListingCTA, { usePastElement } from "../fiche/StickyListingCTA";
43 +import DesktopAside from "../fiche/DesktopAside";
44 +import KaAssistant from "../fiche/KaAssistant";
45 +import { Skeleton, useToast } from "../fiche/ui";
46 +import { fmtAvailability } from "../api";
47 +
48 +function FicheSkeleton() {
184 49 return (
185 − <div className="lightbox lightbox-v2" role="dialog" aria-modal="true"
186 − aria-label={`Photos — ${titre}`}>
187 − <button className="lightbox-close" aria-label="Fermer" onClick={onClose}>✕</button>
188 − <span className="carousel-count lightbox-count" aria-live="polite">
189 − {idx + 1}/{images.length}
190 − </span>
191 − <div
192 − className="lightbox-track" ref={track} onScroll={onScroll}
193 − style={scale > 1 ? { overflow: "hidden", touchAction: "none" } : undefined}
194 − onPointerDown={onPointerDown} onPointerMove={onPointerMove}
195 − onPointerUp={onPointerUp} onPointerCancel={onPointerUp}
196 − >
197 − {images.map((u, i) => (
198 − <div className="lightbox-cell" key={u}>
199 − <SmartImg
200 − src={u} original alt={`${titre} — photo ${i + 1} de ${images.length}`}
201 − draggable={false}
202 − style={i === idx && scale > 1
203 − ? { transform: `translate(${tx}px, ${ty}px) scale(${scale})` }
204 − : undefined}
205 − />
206 − </div>
207 − ))}
208 − </div>
209 − {scale === 1 && idx > 0 && (
210 − <button className="carousel-nav prev" aria-label="Photo précédente"
211 − onClick={() => track.current?.scrollTo({
212 − left: (idx - 1) * track.current.clientWidth, behavior: "smooth" })}>‹</button>
213 − )}
214 − {scale === 1 && idx < images.length - 1 && (
215 − <button className="carousel-nav next" aria-label="Photo suivante"
216 − onClick={() => track.current?.scrollTo({
217 − left: (idx + 1) * track.current.clientWidth, behavior: "smooth" })}>›</button>
218 − )}
219 − </div>
220 − );
221 −}
222 −
223 −// --- Galerie avec balayage natif (scroll-snap) + compteur + plein écran -----
224 −function Galerie({ images, titre, unitType }:
225 − { images: string[]; titre: string; unitType?: string }) {
226 − const [idx, setIdx] = useState(0);
227 − const [zoom, setZoom] = useState(false);
228 − const track = useRef<HTMLDivElement>(null);
229 −
230 − const onScroll = () => {
231 − const el = track.current;
232 − if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
233 − };
234 − const goto = (i: number) =>
235 − track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
236 −
237 − if (images.length === 0)
238 − return (
239 − <div className="carousel">
240 − <SmartImg src={null} fallbackLabel={unitType}
241 − alt="Aucune photo fournie par la source" />
242 − </div>
243 − );
244 −
245 − return (
246 − <>
247 − <div className="carousel">
248 − <div className="carousel-track" ref={track} onScroll={onScroll}>
249 − {images.map((u, i) => (
250 − <SmartImg
251 − key={u} src={u} width_={800} fallbackLabel={unitType}
252 − loading={i === 0 ? "eager" : "lazy"} decoding="async"
253 − alt={`${titre} — photo ${i + 1} de ${images.length}`}
254 − onClick={() => setZoom(true)}
255 − />
256 − ))}
257 − </div>
258 − <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>
259 − {idx > 0 && (
260 − <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button>
261 − )}
262 − {idx < images.length - 1 && (
263 − <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button>
264 − )}
265 − </div>
266 − {images.length > 1 && (
267 − <div className="thumbs">
268 − {images.map((u, i) => (
269 − <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}
270 − aria-label={`Photo ${i + 1}`}>
271 − <SmartImg src={u} width_={160} alt="" loading="lazy" decoding="async" />
272 − </button>
273 − ))}
274 − </div>
275 − )}
276 − {zoom && (
277 − <Lightbox images={images} start={idx} titre={titre}
278 − onClose={() => setZoom(false)} />
279 − )}
280 − </>
50 + <div className="lk-fiche"><div className="lk-wrap" aria-busy="true" aria-label="Chargement de la fiche">
51 + <Skeleton h={14} w={180} /><div style={{ height: 10 }} />
52 + <Skeleton h={38} w={200} /><div style={{ height: 10 }} />
53 + <Skeleton h={16} w="70%" /><div style={{ height: 12 }} />
54 + <Skeleton h={0} className="lk-gallery" /><div className="lk-skel" style={{ aspectRatio: "4 / 3", borderRadius: 18 }} />
55 + <div style={{ height: 14 }} /><Skeleton h={46} /><div style={{ height: 14 }} />
56 + <div className="lk-card"><Skeleton h={88} w={88} r={44} /></div><div style={{ height: 14 }} />
57 + <div className="lk-card"><Skeleton h={14} /><div style={{ height: 8 }} /><Skeleton h={14} w="80%" /><div style={{ height: 8 }} /><Skeleton h={14} w="60%" /></div>
58 + </div></div>
281 59 );
282 60 }
283 61
@@ -285,299 +63,130 @@ export default function ListingPage() {
285 63 const { uid } = useParams<{ uid: string }>();
286 64 const [l, setL] = useState<Listing | null>(null);
287 65 const [error, setError] = useState<string | null>(null);
66 + const { me, favs, toggleFav } = useAccount();
67 + const [toast, showToast] = useToast();
68 + const actionsRef = useRef<HTMLDivElement>(null);
69 + const data = useFicheData(l);
70 + const pastHero = usePastElement(actionsRef); // CTA sticky + bouton Ka après le héro
288 71
289 72 useEffect(() => {
290 73 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
291 74 if (!uid) return;
75 + setL(null); setError(null);
292 76 fetchListing(uid).then(setL).catch((e) => setError(String(e)));
293 − markSeen(uid); // marqueur atténué « déjà vu » sur la carte de recherche
77 + markSeen(uid);
294 78 window.scrollTo(0, 0);
295 79 }, [uid]);
296 80
81 + // classe de page : fond cassé, tabbar et bulle KA Agent masquées, header compact
82 + useEffect(() => {
83 + document.body.classList.add("lk-fiche-page");
84 + return () => document.body.classList.remove("lk-fiche-page");
85 + }, []);
86 +
87 + // réveil du groupe différé à l'approche de la carte
88 + useEffect(() => {
89 + if (!l) return;
90 + const el = document.getElementById("carte");
91 + if (!el || !("IntersectionObserver" in window)) { data.wake(); return; }
92 + const io = new IntersectionObserver((e) => { if (e.some((x) => x.isIntersecting)) { data.wake(); io.disconnect(); } },
93 + { rootMargin: "900px 0px" });
94 + io.observe(el);
95 + return () => io.disconnect();
96 + // eslint-disable-next-line react-hooks/exhaustive-deps
97 + }, [l]);
98 +
99 + const fav = !!(l && favs.has(l.uid));
100 + const onFav = useCallback(() => {
101 + if (!l) return;
102 + if (!me) { window.location.href = "/api/auth/ka/login"; return; }
103 + toggleFav(l.uid);
104 + showToast(fav ? "Retiré des favoris" : "Ajouté à vos favoris");
105 + }, [l, me, fav, toggleFav, showToast]);
106 +
107 + const onShare = useCallback(async () => {
108 + if (!l) return;
109 + const url = `https://www.lou-ka.com/logement/${encodeURIComponent(l.uid)}`;
110 + const title = `${l.title || l.address} — Lou-Ka`;
111 + try {
112 + if (navigator.share) { await navigator.share({ title, url }); return; }
113 + await navigator.clipboard.writeText(url);
114 + showToast("Lien copié");
115 + } catch { /* partage annulé */ }
116 + }, [l, showToast]);
117 +
118 + const rdl = dataOf(data.rdl), air = dataOf(data.air), inondation = dataOf(data.inondation);
119 + const cout = dataOf(data.cout), tal = dataOf(data.tal), cm = dataOf(data.commerces), gaz = dataOf(data.gaz);
120 + const cmp = useMemo(() => (l ? comparaisonPrix(l, rdl) : null), [l, rdl]);
121 + const score = useMemo(() => (l ? louKaScore(l) : null), [l]);
122 + const brief = useMemo(() => (l ? enBref(l, { rdl, air, inondation, cout, tal,
123 + loadingRisques: data.inondation.status === "loading" }) : []), [l, rdl, air, inondation, cout, tal, data.inondation.status]);
124 + const lieux = useMemo(() => (l ? lieuxDepuis(l.poi ?? [], cm, gaz) : []), [l, cm, gaz]);
125 +
297 126 if (error)
298 127 return (
299 − <div className="notice container">
300 − <div className="big"><IcoAlert size={40} /></div>
128 + <div className="lk-fiche"><div className="lk-page-error">
129 + <IcoAlert size={40} />
301 130 <h2>Annonce introuvable</h2>
302 − <p>{error}</p>
303 − <Link className="btn btn-primary" to="/">Retour aux logements</Link>
304 − </div>
305 − );
306 −
307 − if (!l)
308 − return (
309 − <div className="container detail">
310 − <div className="fiche" aria-busy="true">
311 − <div className="skel"><div className="sk-img" /></div>
312 − <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
313 − </div>
314 − </div>
131 + <p>Cette annonce n'existe pas ou n'est plus disponible.</p>
132 + <Link className="lk-btn lk-btn-primary" to="/">Retour aux logements</Link>
133 + </div></div>
315 134 );
316 −
317 − const dg = l.digest ?? null;
318 − const f = dg?.faits;
319 − const conf = dg?.confiance ?? {};
320 − const deal = badgeMarche(l.price, l.quartier?.demographie?.loyer_moyen);
321 − const confirmes = badgesConfirmes(l);
322 − const autres = l.amenities.filter(
323 − (a) => !confirmes.some((b) => b.toLowerCase().includes(a.toLowerCase())));
324 − const inc = l.details?.inclusions ?? {};
325 − const zeroFrais = inc.heating && inc.electricity && inc.hot_water;
326 −
327 − const enLigneDepuis = l.first_seen
328 − ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null;
329 − const hist = (l.price_history ?? []).filter((h) => h.price != null);
330 − const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price
331 − ? { de: hist[1].price!, a: hist[0].price! } : null;
332 −
333 − const updated = l.updated_at
334 − ? new Date(l.updated_at * 1000).toLocaleDateString("fr-CA", {
335 − day: "numeric", month: "long", year: "numeric" }) : null;
336 −
337 − // chips clés (haute confiance seulement pour les faits extraits du texte)
338 − const chips: string[] = [];
339 − if (l.unit_type) chips.push(l.unit_type);
340 − const dispo = fmtAvailability(l.availability_date);
341 − if (dispo) chips.push(dispo === "Maintenant" ? "Libre maintenant" : `Dispo ${dispo}`);
342 − if (l.furnished) chips.push("Meublé");
343 − if (l.pets) chips.push(PETS_LABEL[l.pets] ?? l.pets);
344 − if (l.area_sqft) chips.push(`${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`);
345 − if (f?.nb_occupants_total && conf.nb_occupants_total !== "faible")
346 − chips.push(`${f.nb_occupants_total} occupants`);
347 − if (f?.salle_de_bain && conf.salle_de_bain !== "faible")
348 − chips.push(`Salle de bain ${f.salle_de_bain === "commune" ? "partagée" : "privée"}`);
349 − if (l.details?.floor != null) chips.push(`${l.details.floor}ᵉ étage`);
350 −
351 − const pois = l.poi ?? [];
135 + if (!l || !score) return <FicheSkeleton />;
136 +
137 + const geo = l.lat != null && l.lng != null;
138 + const nav: NavItem[] = [
139 + { id: "resume", label: "Résumé" },
140 + ...(l.price != null ? [{ id: "prix", label: "Prix" }] : []),
141 + { id: "logement", label: "Logement" },
142 + ...(geo ? [{ id: "carte", label: "Carte" }] : []),
143 + ...(l.quartier ? [{ id: "quartier", label: "Quartier" }] : []),
144 + ...(geo ? [{ id: "transport", label: "Transport" }] : []),
145 + ...(rdl && rdl.n > 0 ? [{ id: "loyers", label: "Loyers" }] : []),
146 + ...(geo ? [{ id: "risques", label: "Risques" }] : []),
147 + { id: "dossier", label: "Immeuble" },
148 + { id: "sources", label: "Sources" },
149 + ];
150 + const resume = ligneResume(l, fmtAvailability(l.availability_date));
352 151
353 152 return (
354 − <div className="container detail">
355 − <nav className="crumbs" aria-label="Fil d'Ariane">
356 − <Link to="/">Logements</Link> ›
357 − {l.city && <span>{l.city}</span>} ›
358 − <span>{l.title || l.address}</span>
359 − </nav>
360 −
361 − <div className="fiche">
362 − {/* ------- colonne gauche (desktop) : galerie, prix, description, ----
363 − ------- inclusions, pratique — l'ordre du DOM EST l'ordre visuel -- */}
364 − <div className="f-col">
365 − <section className="f-bloc f-galerie" aria-label="Photos">
366 − <Galerie images={l.images ?? []} titre={l.title || l.address}
367 − unitType={l.unit_type || undefined} />
368 − </section>
369 −
370 − <section className="f-bloc f-hero">
371 − <div className="price">
372 − {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/{NBSP}mois</small>}
373 − </div>
374 − {l.fv_verdict
375 − ? <div><FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} /></div>
376 − : deal && <div className={`deal-badge ${deal.cls}`}>{deal.txt}</div>}
377 − <h1>{l.title || l.address}</h1>
378 − <div className="loc">
379 − {[l.address !== l.title ? l.address : "", l.sector, l.city].filter(Boolean).join(" · ")}
380 − </div>
381 − <div className="chips-scroll" role="list" aria-label="Caractéristiques clés">
382 − {chips.map((c) => (
383 − <span className="chip-key" role="listitem" key={c}>
384 − <AmenityIco label={c} size={14} fallback="spark" /> {c}
385 − </span>
386 − ))}
387 − </div>
388 − <nav className="ancres" aria-label="Sections de la fiche">
389 − <a href="#description">Description</a>
390 − <a href="#analyse-prix">Prix</a>
391 − <a href="#inclusions">Inclusions</a>
392 − {l.lat != null && l.lng != null && <a href="#emplacement">Carte</a>}
393 − <a href="#quartier">Quartier</a>
394 − <a href="#proximite">À proximité</a>
395 − </nav>
396 − <a className="cta cta-desktop" href={`/passerelle/${encodeURIComponent(l.uid)}`}
397 − target="_blank" rel="noopener noreferrer">
398 − Voir l'annonce chez {sourceName(l.source)} ↗
399 − </a>
400 − <a className="btn btn-ghost btn-pdf"
401 − href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download>
402 − <IcoDoc size={14} /> Télécharger la fiche (PDF)
403 − </a>
404 − </section>
405 −
406 − <section className="f-bloc f-desc" id="description">
407 − <h2>Description</h2>
408 − {dg ? (
409 − <>
410 − {dg.en_bref && <p className="enbref">{dg.en_bref}</p>}
411 − {dg.sections.map((s) => (
412 − <div key={s.titre} className="desc-section">
413 − <h4>{s.titre}</h4>
414 − <p>{s.texte}</p>
415 − </div>
416 − ))}
417 − <details className="texte-original">
418 − <summary>Voir le texte original de la source</summary>
419 − <p>{l.description}</p>
420 − </details>
421 − </>
422 − ) : (
423 − l.description
424 − ? <p style={{ color: "var(--ink-2)" }}>{l.description}</p>
425 − : <p className="fine">La source ne fournit pas de description pour cette annonce.</p>
426 − )}
427 − </section>
428 −
429 − <section className="f-bloc f-incl" id="inclusions">
430 − <h2>Inclusions et commodités</h2>
431 − {zeroFrais && <div className="deal-badge deal-good">💡 Chauffage, électricité et eau chaude inclus — 0{NBSP}$ de frais cachés</div>}
432 − <div className="amenity-grid">
433 − {confirmes.map((b) => (
434 − <span className="amenity-it confirmed" key={`c-${b}`}>
435 − <span className="am-ico"><AmenityIco label={b} /></span>
436 − <span className="am-txt">{b}</span>
437 − <span className="am-conf" title="Confirmé par les données structurées de la source">✓</span>
438 − </span>
439 − ))}
440 − {autres.map((a) => (
441 − <span className="amenity-it unconfirmed" key={a} title="Mentionné par la source, sans confirmation structurée">
442 − <span className="am-ico"><AmenityIco label={a} fallback="spark" /></span>
443 − <span className="am-txt">{a}</span>
444 − </span>
445 − ))}
446 − </div>
447 − {confirmes.length === 0 && autres.length === 0 && (
448 − <p className="fine">La source ne précise pas les inclusions.</p>
449 − )}
450 − </section>
451 −
452 − <section className="f-bloc f-pratique">
453 − <h2>Détails pratiques</h2>
454 − <div className="kv">
455 − <div className="cell"><div className="k">Gestionnaire</div><div className="v">{sourceName(l.source)}</div></div>
456 − {l.price_label && (
457 − <div className="cell"><div className="k">Prix affiché</div><div className="v">{l.price_label}</div></div>
458 − )}
459 − {f?.duree_bail_minimale_mois && (
460 − <div className="cell"><div className="k">Bail minimum</div><div className="v">{f.duree_bail_minimale_mois} mois</div></div>
461 − )}
462 − {enLigneDepuis != null && (
463 − <div className="cell"><div className="k">En ligne depuis</div>
464 − <div className="v">{enLigneDepuis === 0 ? "aujourd'hui" : `${enLigneDepuis}${NBSP}jour${enLigneDepuis > 1 ? "s" : ""}`}</div></div>
465 − )}
466 − {updated && (
467 − <div className="cell"><div className="k">Synchronisé</div><div className="v">{updated}</div></div>
468 − )}
469 − </div>
470 − {baissePrix && (
471 − <div className={`prix-histo ${baissePrix.a < baissePrix.de ? "down" : "up"}`}>
472 − {baissePrix.a < baissePrix.de ? "📉" : "📈"} Prix passé de{" "}
473 − {fmtPrice(baissePrix.de)} à <b>{fmtPrice(baissePrix.a)}</b>
474 − {baissePrix.a < baissePrix.de && " — levier de négociation"}
475 − </div>
476 − )}
477 − </section>
478 − </div>
479 −
480 − {/* ------- colonne droite (desktop) : analyse de prix, carte, ---------
481 − ------- KA Scores, quartier, proximité ---------------------------- */}
482 − <div className="f-col">
483 − <PriceAnalysis uid={l.uid} price={l.price} />
484 −
485 − <CoutReel uid={l.uid} />
486 −
487 − <HistoriqueLouka uid={l.uid} />
488 −
489 − {l.immeuble && <ImmeubleBloc im={l.immeuble} />}
490 −
491 − <GestionnaireBloc source={l.source} />
492 −
493 − <RegistreLoyers lat={l.lat} lng={l.lng} price={l.price} bedrooms={l.bedrooms} />
494 −
495 − {l.lat != null && l.lng != null && (
496 − <section className="f-bloc f-carte" id="emplacement">
497 − <h2>Emplacement</h2>
498 − <Suspense fallback={<div className="lmap3d lmap3d-skel" aria-busy="true" />}>
499 − <ListingMap3D l={l} />
500 − </Suspense>
501 − <p className="fine">
502 − Vue 3D du secteur — l'immeuble de l'annonce est surligné en orange.
503 − Position selon l'adresse géocodée (Adresses Québec).
504 − </p>
505 − </section>
506 − )}
507 −
508 − <RisqueInondation lat={l.lat} lng={l.lng} />
509 −
510 − <HistoriqueTAL address={l.address} city={l.city} />
511 −
512 − <QualiteAir lat={l.lat} lng={l.lng} />
513 −
514 − <CommercesProches lat={l.lat} lng={l.lng} />
515 −
516 − <EssenceProche lat={l.lat} lng={l.lng} />
517 −
518 − <HydroEstimation adresse={l.address || l.title} uid={l.uid} lat={l.lat} lng={l.lng} />
519 −
520 − {l.kascores && <KaScoresBlock ks={l.kascores} />}
521 −
522 − {l.hiver && <HiverScore h={l.hiver} />}
523 −
524 − <section className="f-bloc f-quartier" id="quartier">
525 − {l.quartier ? <QuartierBlock q={l.quartier} /> : null}
526 − </section>
527 −
528 − <section className="f-bloc f-poi" id="proximite">
529 − {pois.length > 0 && (
530 − <>
531 − <h2>À proximité</h2>
532 − {POI_GROUPES.map((g, gi) => {
533 − const items = pois.filter((p) => g.cats.includes(p.cat));
534 − if (items.length === 0) return null;
535 − return (
536 − <details className="poi-groupe" key={g.titre} open={gi === 0}>
537 − <summary>
538 − <span>{g.icone} {g.titre}</span>
539 − <span className="poi-resume">
540 − {items.length} · le + proche à {fmtDist(items[0].dist_m)}
541 − </span>
542 − </summary>
543 − <ul className="poi-list">
544 − {items.map((p) => {
545 − const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat };
546 − return (
547 − <li key={p.cat} title={meta.label}>
548 − <span className="poi-ico" aria-hidden="true">{meta.icon}</span>
549 − <span className="poi-name">{p.name}</span>
550 − <span className="poi-dist">{fmtDist(p.dist_m)} · {fmtMarche(p.dist_m)}</span>
551 − </li>
552 − );
553 − })}
554 − </ul>
555 − </details>
556 − );
557 − })}
558 − <div className="fine">
559 − Temps de marche estimés (distance à vol d'oiseau ×{NBSP}1,3, 4,8{NBSP}km/h) — données OpenStreetMap.
560 − </div>
561 − </>
562 − )}
563 − </section>
153 + <div className="lk-fiche">
154 + <div className="lk-wrap">
155 + <nav className="lk-crumbs" aria-label="Fil d'Ariane">
156 + <Link to="/">Logements</Link><span aria-hidden="true">›</span>
157 + {l.city && <><Link to={`/ville/${encodeURIComponent(l.city.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-"))}`}>{l.city}</Link><span aria-hidden="true">›</span></>}
158 + <span>{l.title || l.address}</span>
159 + </nav>
160 + <div className="lk-grid">
161 + <div className="lk-main">
162 + <PropertyHero l={l} cmp={cmp} fav={fav} onFav={onFav} onShare={onShare} actionsRef={actionsRef} />
163 + <LouKaScore s={score} resume={resume} />
164 + <PropertySummary items={brief} loading={data.rdl.status === "loading" || data.inondation.status === "loading"} />
165 + <SectionNav items={nav} />
166 + <MarketPriceCard l={l} fv={data.fv} cmp={cmp} onRetry={() => data.retry("fv")} />
167 + <PropertyQuickFacts l={l} />
168 + <PropertyDescription l={l} />
169 + <PropertyAmenities l={l} />
170 + {geo && <InteractiveMap l={l} lieux={lieux} loadingLieux={data.commerces.status !== "ok" && data.commerces.status !== "error"} />}
171 + {geo && <NearbyPlaces pois={l.poi ?? []} commerces={data.commerces} onRetry={() => data.retry("commerces")} />}
172 + {l.quartier && <NeighborhoodStats q={l.quartier} />}
173 + {l.kascores && <KaScoresCard ks={l.kascores} />}
174 + {geo && <RentRegistryCard l={l} rdl={data.rdl} onRetry={() => data.retry("rdl")} />}
175 + <TrueCostCard cout={data.cout} hydro={data.hydro} adresse={l.address || l.title} uid={l.uid} lat={l.lat} lng={l.lng} onRetry={() => data.retry("cout")} />
176 + {geo && <FloodRiskCard r={data.inondation} onRetry={() => data.retry("inondation")} />}
177 + {geo && <AirQualityCard r={data.air} onRetry={() => data.retry("air")} />}
178 + {geo && <GasNearbyCard r={data.gaz} onRetry={() => data.retry("gaz")} />}
179 + <BuildingDossier source={l.source} immeuble={l.immeuble ?? null} hiver={l.hiver ?? null}
180 + historique={data.historique} recyclees={data.recyclees} tal={data.tal} gestionnaire={data.gestionnaire} />
181 + <SourceDisclosure l={l} onShare={onShare}
182 + dates={{ air: air ? Object.values(air.mesures)[0]?.annee : null, gaz: gaz?.maj ?? null, ks: l.kascores?.computed_at ?? null }} />
183 + </div>
184 + <DesktopAside l={l} cmp={cmp} score={score} brief={brief} fav={fav} onFav={onFav} onShare={onShare} />
564 185 </div>
565 186 </div>
566 −
567 − <div className="fine f-foot">
568 − {updated && <>Dernière synchronisation : {updated}. </>}
569 − Les prix et disponibilités sont ceux affichés par la source — chaque fiche
570 − renvoie à l'annonce originale.
571 − </div>
572 −
573 − {/* CTA sticky mobile — toujours visible */}
574 − <div className="cta-sticky">
575 − <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}{l.price != null && <small>/mois</small>}</span>
576 − <a className="cta" href={`/passerelle/${encodeURIComponent(l.uid)}`}
577 − target="_blank" rel="noopener noreferrer">
578 − Voir chez {sourceName(l.source)} ↗
579 − </a>
580 − </div>
187 + <StickyListingCTA l={l} cmp={cmp} show={pastHero} />
188 + <KaAssistant l={l} hidden={!pastHero} />
189 + {toast}
581 190 </div>
582 191 );
583 192 }
modified frontend/vite.config.ts +3 −1
@@ -15,7 +15,9 @@ export default defineConfig({
15 15 dedupe: ["react", "react-dom", "mapbox-gl"],
16 16 },
17 17 server: {
18 − proxy: { "/api": "http://localhost:8080" },
18 + // LOUKA_API : cible du proxy /api en dev (défaut : serveur local 8080 ;
19 + // sur un nœud, LOUKA_API=http://localhost:8095 pointe la prod locale)
20 + proxy: { "/api": process.env.LOUKA_API || "http://localhost:8080" },
19 21 },
20 22 build: { outDir: "dist" },
21 23 });
modified louka/gaz.py +2 −0
@@ -113,6 +113,8 @@ def nearby(lat: float, lng: float, radius_m: int = 5000,
113 113 mini = round(min(regs), 1) if regs else None
114 114 items = [{"nom": r["banniere"] or r["nom"], "adresse": r["adresse"],
115 115 "dist_m": round(dist),
116 + # position (2026-09-04) : filtre « Essence » de la carte de la fiche
117 + "lat": r["lat"], "lng": r["lng"],
116 118 "regulier": r["prix_regulier"], "super": r["prix_super"],
117 119 "diesel": r["prix_diesel"],
118 120 "moins_chere": bool(r["prix_regulier"] and mini
modified louka/poi.py +5 −1
@@ -183,7 +183,11 @@ def _nearest_by_cat(lat: float, lng: float, pois_by_cat: dict[str, list[dict]])
183 183 continue
184 184 d = _haversine_m(lat, lng, p["lat"], p["lng"])
185 185 if d <= radius and (best is None or d < best["dist_m"]):
186 − best = {"cat": cat, "name": p["name"], "dist_m": round(d)}
186 + # lat/lng conservés (2026-09-04) pour placer le lieu sur la
187 + # carte de la fiche ; les entrées de cache antérieures n'en ont
188 + # pas — le frontend tolère leur absence.
189 + best = {"cat": cat, "name": p["name"], "dist_m": round(d),
190 + "lat": round(p["lat"], 6), "lng": round(p["lng"], 6)}
187 191 if best:
188 192 out.append(best)
189 193 return sorted(out, key=lambda p: p["dist_m"])
modified louka/web.py +11 −6
@@ -635,10 +635,12 @@ def air_at(lat: float, lng: float):
635 635
636 636
637 637 @app.get("/api/gaz")
638 −def gaz_at(lat: float, lng: float):
639 − """Stations-service à proximité et prix courants (gazquebec.ca)."""
638 +def gaz_at(lat: float, lng: float, limit: int = 5):
639 + """Stations-service à proximité et prix courants (gazquebec.ca).
640 + `limit` (défaut 5, max 60) : la fiche demande la liste complète pour son
641 + panneau « Voir les N stations »."""
640 642 from . import gaz
641 − return gaz.nearby(lat, lng)
643 + return gaz.nearby(lat, lng, limit=max(1, min(limit, 60)))
642 644
643 645
644 646 @app.get("/api/inondation")
@@ -653,12 +655,15 @@ def inondation_at(lat: float, lng: float):
653 655
654 656
655 657 @app.get("/api/rdl")
656 −def rdl_nearby(lat: float, lng: float, radius: int = 600):
658 +def rdl_nearby(lat: float, lng: float, radius: int = 600, limit: int = 12):
657 659 """Loyers déclarés au Registre des loyers (registre-des-loyers.ca)
658 − autour d'un point — bloc « Registre des loyers » de la fiche."""
660 + autour d'un point — bloc « Registre des loyers » de la fiche.
661 + `limit` (défaut 12, max 300) : nombre de déclarations retournées dans
662 + `items` ; la fiche demande la liste complète pour son panneau
663 + « Voir les N loyers comparables »."""
659 664 from . import rdl
660 665 radius = max(100, min(radius, 2000))
661 − d = rdl.nearby(lat, lng, radius)
666 + d = rdl.nearby(lat, lng, radius, limit=max(1, min(limit, 300)))
662 667 if d is None:
663 668 raise HTTPException(404, "Registre des loyers non disponible")
664 669 return d
665 670