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