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