SPB Git

spb/lou-ka

Public

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

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 (Sep 5, 2026) parent 7a4e7b2 Browse files

54 changed files +4,182 −2,266

54 changed files
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
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
deleted frontend/src/components/QualiteAir.tsx +0 −86
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
deleted frontend/src/components/QuartierBlock.tsx +0 −163
Large diff (163 lines) is not loaded by default to keep the page fast.Load diff
deleted frontend/src/components/RegistreLoyers.tsx +0 −206
Large diff (206 lines) is not loaded by default to keep the page fast.Load diff
deleted frontend/src/components/RisqueInondation.tsx +0 −76
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/BottomSheet.tsx +72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/BuildingDossier.tsx +260 −0
Large diff (260 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/DesktopAside.tsx +57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/EnvironmentCards.tsx +201 −0
Large diff (201 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/InteractiveMap.tsx +124 −0
Large diff (124 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/KaAssistant.tsx +92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/KaScoresCard.tsx +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/LouKaScore.tsx +66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/MapInner.tsx +108 −0
Large diff (108 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/MarketPriceCard.tsx +99 −0
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/NearbyPlaces.tsx +187 −0
Large diff (187 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/NeighborhoodStats.tsx +119 −0
Large diff (119 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/PropertyAmenities.tsx +94 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/PropertyDescription.tsx +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/PropertyGallery.tsx +176 −0
Large diff (176 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/PropertyHero.tsx +84 −0
Large diff (84 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/PropertyQuickFacts.tsx +86 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/PropertySummary.tsx +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/RentRegistryCard.tsx +204 −0
Large diff (204 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/SectionNav.tsx +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/SourceDisclosure.tsx +56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/StickyListingCTA.tsx +48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/TrueCostCard.tsx +91 −0
Large diff (91 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/fiche.css +631 −0
Large diff (631 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/synthese.ts +212 −0
Large diff (212 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/ui.tsx +167 −0
Large diff (167 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/fiche/useFicheData.ts +132 −0
Large diff (132 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/src/pages/Listing.tsx +159 −550
Large diff (741 lines) is not loaded by default to keep the page fast.Load diff
modified frontend/vite.config.ts +3 −1
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
modified louka/gaz.py +2 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified louka/poi.py +5 −1
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified louka/web.py +11 −6
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme