SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

feat(fiche): refonte premium mobile-first de la fiche propriété (2026-09-07)

Port de la fiche Lou-Ka v3 (2026-09-04) adapté à l'achat. Frontend
(frontend/src/fiche/, nouvelle arborescence) : héro prix → capsule « vs
estimation Vrai-Prix » → adresse → résumé → galerie → actions ; Immo-Ka Score
(60 % emplacement = indice de proximité StatCan du secteur + 40 % prix = écart
à l'estimation Vrai-Prix, partiel si une composante manque) ; « En bref »
déterministe (prix, rôle d'évaluation, accessibilité, air, inondation, charges
fixes taxes/copropriété, chaleur, baisse de prix, temps sur le marché,
publications multiples) ; navigation sticky par sections (scroll-spy) ; Prix et
marché (KPI, jauge fourchette/estimation/prix demandé, rôle d'évaluation
terrain+bâtiment, historique du prix demandé) ; La propriété (grille
icône/valeur + rangées agence/courtier + toutes les caractéristiques Centris et
les pièces en accordéons) ; description tronquée ; caractéristiques et
inclusions (AmenityIco) ; Financement et coût de propriété (ex-Financement :
calculateur hypothécaire sur taux réels, SCHL, stress test, renouvellement,
comparateur banques, historique, amortissement + coût mensuel = versement +
taxes publiées + copropriété + Hydro-Québec, postes étiquetés publié/estimé/
inconnu) ; grande carte 3D Ka Maps (bâtiment cerise) avec filtres de lieux
(transport, épiceries, pharmacies, commerces, écoles, parcs, essence) ; À
proximité / Transport en carrousels + bottom sheet ; Quartier en KPI compacts
(propriétaires, revenu, défavorisation…) ; risque d'inondation, qualité de l'air,
essence ; Dossier de l'annonce (suivi Immo-Ka, aussi publiée sur, courtier et
agence, identifiants) ; Sources et méthodologie. Primitives ik-* (SectionCard,
Accordion, BottomSheet, StatTile, StatusBadge, Skeleton, toast), tokens --ik-*
(fond papier #F5F3EE, cerise réservé CTA/prix/score). Desktop ≥ 1024 : aside
sticky (prix, CTA, score, constats, courtier, Demander à Ka). CTA sticky après
le héro ; « ✦ Demander à Ka » branché sur le widget KA Agent. Header compact
sur /propriete/ (favoris + partage via fiche/current.ts, ticker et badge
masqués, connexion KA en icône sur mobile). Aucun `order` CSS ; pas de
scrollIntoView à l'ouverture. 8 anciens composants de fiche supprimés
(QuartierBlock, RisqueInondation, QualiteAir, EssenceProche, HydroEstimation,
CommercesProches, PropertyMap, Financement). Icons.tsx : 30 pictos ajoutés +
IcoHeart. Scripts QA : check-order.mjs (ik-*), shots.mjs, interactions.mjs,
preview.mjs (build + proxy /api sans toucher dist/).

Backend (additif) : poi.py conserve lat/lng des POI ; gaz.py expose lat/lng ;
/api/gaz accepte `limit` (max 60). vite.config : proxy /api via IMMOKA_API.

Validé sur M4M64b : tsc + vite build, check-order OK sur 3 fiches (scrollY 0,
ordre croissant, sans overflow mobile/desktop), interactions (lightbox,
accordéons, filtre carte, sheets lieux/Ka, 404) sans erreur JS, captures 6
largeurs. Déployé : dist basculé, pm2 restart immo-ka-web.
Simon-Pierre Boucher committed 19 days ago (Sep 7, 2026) parent 28fd63b

46 changed files +3,961 −1,544

modified frontend/scripts/check-order.mjs +32 −16
@@ -1,41 +1,57 @@
1 1 // Validation ordre des sections — fiche Immo-Ka (ordre DOM = ordre visuel)
2 +// Refonte 2026-09-07 : sections `.ik-*` / 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 BASE = process.env.BASE || "http://localhost:18096";
5 9 const UID = process.argv[2];
10 +if (!UID) { console.error("usage: node check-order.mjs <uid> [baseUrl]"); process.exit(2); }
11 +const BASE = process.argv[3] || process.env.BASE || "http://localhost:8096";
6 12 const URL = `${BASE}/propriete/${encodeURIComponent(UID)}`;
7 −const SEL = [".f-galerie", ".f-hero", ".f-desc", "#caracteristiques", "#pieces", "#inclusions", "#carte", ".quartier"];
8 13
9 14 async function check(name, ctxOpts) {
10 15 const browser = await chromium.launch();
11 16 const ctx = await browser.newContext(ctxOpts);
12 17 const page = await ctx.newPage();
18 + const errors = [];
19 + page.on("pageerror", (e) => errors.push(String(e)));
20 + page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
13 21 await page.goto(URL, { waitUntil: "networkidle" });
14 − await page.waitForSelector(".f-galerie", { timeout: 15000 });
15 − await page.waitForTimeout(1200);
16 − const data = await page.evaluate((sel) => {
22 + await page.waitForSelector(".ik-hero", { timeout: 20000 });
23 + await page.waitForTimeout(2500);
24 + const data = await page.evaluate(() => {
25 + const sel = [".ik-hero", "#score", "#resume", ".ik-nav", "#prix", "#propriete", "#description", "#inclusions",
26 + "#financement", "#carte", "#proximite", "#transport", "#quartier", "#risques", "#air", "#essence",
27 + "#dossier", "#sources"];
17 28 const out = [];
18 29 for (const s of sel) {
19 30 const el = document.querySelector(s);
20 31 if (!el) { out.push({ s, missing: true }); continue; }
21 32 const r = el.getBoundingClientRect();
22 − out.push({ s, hidden: r.height === 0 && r.width === 0, top: Math.round(r.top + window.scrollY), left: Math.round(r.left), order: getComputedStyle(el).order });
33 + out.push({ s, hidden: r.height === 0 && r.width === 0, top: Math.round(r.top + window.scrollY),
34 + left: Math.round(r.left), order: getComputedStyle(el).order });
23 35 }
24 − return { out, scrollY: window.scrollY };
25 − }, SEL);
26 − console.log(`\n=== ${name} === scrollY: ${data.scrollY}`);
36 + return { out, scrollY: window.scrollY, overflow: document.documentElement.scrollWidth - window.innerWidth };
37 + });
38 + console.log(`\n=== ${name} === scrollY initial: ${data.scrollY} · débordement horizontal: ${data.overflow}px`);
27 39 for (const b of data.out)
28 − console.log(b.missing ? `${b.s.padEnd(18)} (non rendue)` : b.hidden ? `${b.s.padEnd(18)} (vide/masquée)` :
29 − `${b.s.padEnd(18)} top=${String(b.top).padStart(6)} left=${String(b.left).padStart(4)} order=${b.order}`);
40 + console.log(b.missing ? `${b.s.padEnd(14)} (non rendue)` : b.hidden ? `${b.s.padEnd(14)} (vide/masquée)` :
41 + `${b.s.padEnd(14)} top=${String(b.top).padStart(6)} left=${String(b.left).padStart(4)} order=${b.order}`);
42 + if (errors.length) console.log("console errors:", errors.slice(0, 5));
30 43 await browser.close();
31 − return data;
44 + return { ...data, errors };
32 45 }
33 46
34 47 const mob = await check("iPhone 14 (mobile)", { ...devices["iPhone 14"] });
35 −await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
36 −const vis = mob.out.filter(b => !b.missing && !b.hidden);
48 +const desk = await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
49 +
50 +const vis = mob.out.filter((b) => !b.missing && !b.hidden);
37 51 const sorted = vis.every((b, i) => i === 0 || b.top >= vis[i - 1].top);
38 −const ok = sorted && mob.scrollY === 0 && vis[0].s === ".f-galerie" && vis.every(b => b.order === "0");
39 −console.log(`\nMOBILE: ordre ${sorted ? "CROISSANT ✓" : "DÉSORDONNÉ ✗"} · scrollY=${mob.scrollY} · 1re=${vis[0].s} · sans order=${vis.every(b => b.order === "0")}`);
52 +const noOrder = vis.every((b) => b.order === "0");
53 +const ok = sorted && mob.scrollY === 0 && vis[0].s === ".ik-hero" && vis[0].top < 200 && noOrder
54 + && mob.overflow <= 0 && desk.overflow <= 0;
55 +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}`);
40 56 console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC");
41 57 process.exit(ok ? 0 : 1);
added frontend/scripts/interactions.mjs +48 −0
@@ -0,0 +1,48 @@
1 +// Parcours d'interactions Playwright sur la fiche (mobile) : galerie plein
2 +// écran, panneau « Voir les N lieux », « Demander à Ka », accordéons — vérifie
3 +// l'absence d'erreur JS et que chaque panneau s'ouvre puis se ferme.
4 +// Usage : node frontend/scripts/interactions.mjs <uid> [baseUrl]
5 +import { chromium, devices } from "playwright";
6 +const UID = process.argv[2]; const BASE = process.argv[3] || "http://127.0.0.1:8096";
7 +if (!UID) { console.error("usage: node interactions.mjs <uid> [baseUrl]"); process.exit(2); }
8 +const browser = await chromium.launch();
9 +const ctx = await browser.newContext({ ...devices["iPhone 14"] }); const page = await ctx.newPage();
10 +const errs = [];
11 +page.on("pageerror", (e) => errs.push("PAGEERROR " + e.message));
12 +page.on("console", (m) => { if (m.type() === "error") errs.push(m.text().slice(0, 160)); });
13 +await page.goto(`${BASE}/propriete/${encodeURIComponent(UID)}`, { waitUntil: "networkidle" });
14 +await page.waitForSelector(".ik-hero");
15 +const step = async (name, fn) => { try { await fn(); console.log("OK ", name); } catch (e) { console.log("FAIL", name, String(e).split("\n")[0]); } };
16 +await step("galerie → plein écran → fermer", async () => {
17 + await page.click(".ik-gallery-full"); await page.waitForSelector(".ik-lightbox", { timeout: 3000 });
18 + await page.click(".ik-lightbox-close"); await page.waitForSelector(".ik-lightbox", { state: "detached", timeout: 3000 });
19 +});
20 +await step("accordéon score", async () => { await page.click("#score .ik-acc-btn"); await page.waitForSelector("#score .ik-acc-body.open", { timeout: 2000 }); });
21 +await step("accordéon caractéristiques publiées", async () => {
22 + const b = page.locator("#propriete .ik-acc-btn").first(); await b.scrollIntoViewIfNeeded(); await b.click();
23 + await page.waitForSelector("#propriete .ik-acc-body.open", { timeout: 2000 });
24 +});
25 +await step("financement : test de résistance", async () => {
26 + await page.locator("#financement").scrollIntoViewIfNeeded(); await page.waitForTimeout(1500);
27 + const b = page.locator("#financement .ik-acc-btn").first(); await b.click(); await page.waitForSelector("#financement .ik-acc-body.open", { timeout: 2000 });
28 +});
29 +await step("carte : filtre Épiceries", async () => {
30 + await page.locator("#carte").scrollIntoViewIfNeeded(); await page.waitForTimeout(2500);
31 + const chip = page.locator("#carte .ik-chip:not([disabled])").first(); await chip.click(); await page.waitForTimeout(800);
32 +});
33 +await step("lieux : bottom sheet", async () => {
34 + await page.locator("#proximite").scrollIntoViewIfNeeded(); await page.waitForTimeout(800);
35 + await page.click("#proximite .ik-more"); await page.waitForSelector(".ik-sheet", { timeout: 3000 });
36 + await page.click(".ik-sheet-x"); await page.waitForSelector(".ik-sheet", { state: "detached", timeout: 3000 });
37 +});
38 +await step("Demander à Ka : sheet", async () => {
39 + await page.evaluate(() => window.scrollTo(0, 1500)); await page.waitForTimeout(500);
40 + await page.click(".ik-ka-btn"); await page.waitForSelector(".ik-sheet", { timeout: 3000 });
41 + await page.click(".ik-sheet-x"); await page.waitForSelector(".ik-sheet", { state: "detached", timeout: 3000 });
42 +});
43 +await step("404", async () => {
44 + await page.goto(`${BASE}/propriete/inexistant:0`, { waitUntil: "networkidle" }); await page.waitForSelector(".ik-page-error", { timeout: 8000 });
45 +});
46 +console.log("erreurs console:", errs.length, errs.slice(0, 5));
47 +await browser.close();
48 +process.exit(errs.filter((e) => !/Failed to fetch|api-ka|kaa/.test(e)).length ? 1 : 0);
added frontend/scripts/preview.mjs +28 −0
@@ -0,0 +1,28 @@
1 +// Serveur de prévisualisation QA (zéro dépendance) : sert un build Vite
2 +// (dist-next par défaut) et relaie /api vers le backend local — permet de
3 +// valider une fiche avec Playwright SANS toucher au dist/ servi en production.
4 +// Usage : node frontend/scripts/preview.mjs [dir=dist-next] [port=18097] [api=http://127.0.0.1:8096]
5 +import http from "node:http";
6 +import { createReadStream, existsSync, statSync } from "node:fs";
7 +import { extname, join, resolve } from "node:path";
8 +
9 +const DIR = resolve(process.argv[2] || "dist-next");
10 +const PORT = Number(process.argv[3] || 18097);
11 +const API = new URL(process.argv[4] || "http://127.0.0.1:8096");
12 +const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml",
13 + ".png": "image/png", ".json": "application/json", ".geojson": "application/geo+json", ".woff2": "font/woff2", ".ico": "image/x-icon" };
14 +
15 +http.createServer((req, res) => {
16 + const url = new URL(req.url, "http://x");
17 + if (url.pathname.startsWith("/api/")) {
18 + const p = http.request({ host: API.hostname, port: API.port, path: req.url, method: req.method, headers: { ...req.headers, host: API.host } },
19 + (r) => { res.writeHead(r.statusCode, r.headers); r.pipe(res); });
20 + p.on("error", () => { res.writeHead(502); res.end("api down"); });
21 + req.pipe(p);
22 + return;
23 + }
24 + let f = join(DIR, decodeURIComponent(url.pathname));
25 + if (!existsSync(f) || statSync(f).isDirectory()) f = join(DIR, "index.html");
26 + res.writeHead(200, { "content-type": MIME[extname(f)] || "application/octet-stream" });
27 + createReadStream(f).pipe(res);
28 +}).listen(PORT, "127.0.0.1", () => console.log(`preview ${DIR} → http://127.0.0.1:${PORT} (api → ${API.origin})`));
added frontend/scripts/shots.mjs +40 −0
@@ -0,0 +1,40 @@
1 +// Captures Playwright de la fiche propriété à 6 largeurs (QA visuelle).
2 +// Usage : node frontend/scripts/shots.mjs <uid> [baseUrl] [outDir]
3 +import { chromium, devices } from "playwright";
4 +const UID = process.argv[2];
5 +if (!UID) { console.error("usage: node shots.mjs <uid> [baseUrl] [outDir]"); process.exit(2); }
6 +const BASE = process.argv[3] || "http://127.0.0.1:8096";
7 +const OUT = process.argv[4] || "/tmp";
8 +const shots = [
9 + ["iphone14", { ...devices["iPhone 14"] }],
10 + ["w375", { viewport: { width: 375, height: 812 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }],
11 + ["w430", { viewport: { width: 430, height: 932 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }],
12 + ["w768", { viewport: { width: 768, height: 1024 }, deviceScaleFactor: 1 }],
13 + ["w1024", { viewport: { width: 1024, height: 800 }, deviceScaleFactor: 1 }],
14 + ["w1440", { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }],
15 +];
16 +const browser = await chromium.launch();
17 +for (const [name, opts] of shots) {
18 + const ctx = await browser.newContext(opts); const page = await ctx.newPage();
19 + const errs = [];
20 + page.on("pageerror", (e) => errs.push("PAGEERROR " + e.message));
21 + page.on("console", (m) => { if (m.type() === "error") errs.push(m.text().slice(0, 200)); });
22 + await page.goto(`${BASE}/propriete/${encodeURIComponent(UID)}`, { waitUntil: "networkidle" });
23 + await page.waitForSelector(".ik-hero", { timeout: 20000 });
24 + // html{scroll-behavior:smooth} du site fausse le retour en haut scripté → défilement instantané pour les captures
25 + await page.evaluate(() => { document.documentElement.style.scrollBehavior = "auto"; });
26 + // défilement complet (déclenche les chargements différés) puis retour en haut
27 + await page.evaluate(async () => {
28 + for (let y = 0; y < document.body.scrollHeight; y += 600) { window.scrollTo(0, y); await new Promise((r) => setTimeout(r, 120)); }
29 + window.scrollTo({ top: 0, behavior: "instant" });
30 + });
31 + await page.waitForTimeout(3500);
32 + const ov = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
33 + await page.screenshot({ path: `${OUT}/ik-${name}-full.png`, fullPage: true });
34 + await page.screenshot({ path: `${OUT}/ik-${name}-top.png` });
35 + await page.evaluate(() => window.scrollTo({ top: 900, behavior: "instant" })); await page.waitForTimeout(600);
36 + await page.screenshot({ path: `${OUT}/ik-${name}-scrolled.png` });
37 + console.log(name, "overflow", ov, "errors", errs.length, errs.slice(0, 3).join(" | "));
38 + await ctx.close();
39 +}
40 +await browser.close();
modified frontend/src/App.tsx +35 −4
@@ -4,11 +4,12 @@
4 4 // App.tsx : layout global (header + ticker en direct + footer Groupe KA) et
5 5 // routage. Connexion via KA ID (hub groupe-ka.com) — voir account.tsx.
6 6 // -----------------------------------------------------------------------------
7 −import { Ico } from "./components/Icons";
7 +import { Ico, IcoHeart } from "./components/Icons";
8 8 import { useEffect, useState } from "react";
9 9 import { NavLink, Route, Routes, useLocation } from "react-router-dom";
10 10 import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
11 11 import { kaLogin, useAccount } from "./account";
12 +import { useCurrentListing } from "./fiche/current";
12 13 import GroupeKaBadge from "./ka/GroupeKaBadge";
13 14 import KaFooter from "./ka/KaFooter";
14 15 import BannieresPage from "./pages/Bannieres";
@@ -132,9 +133,38 @@ function AccountMobile({ close }: { close: () => void }) {
132 133 );
133 134 }
134 135
136 +/** Actions du header sur une fiche propriété : favoris + partage (compactes).
137 + L'annonce affichée est publiée par la page fiche (fiche/current.ts). */
138 +function FicheHeaderActions() {
139 + const l = useCurrentListing();
140 + const { favs, toggleFav } = useAccount();
141 + if (!l) return null;
142 + const fav = favs.has(l.uid);
143 + const share = async () => {
144 + const url = `https://www.immo-ka.com/propriete/${encodeURIComponent(l.uid)}`;
145 + try {
146 + if (navigator.share) await navigator.share({ title: document.title, url });
147 + else await navigator.clipboard.writeText(url);
148 + } catch { /* annulé */ }
149 + };
150 + return (
151 + <div className="hdr-actions">
152 + <button type="button" className={`hdr-btn ${fav ? "on" : ""}`} aria-pressed={fav}
153 + aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={() => toggleFav(l)}>
154 + <IcoHeart size={18} filled={fav} />
155 + </button>
156 + <button type="button" className="hdr-btn" aria-label="Partager" onClick={share}>
157 + <Ico name="share" size={17} />
158 + </button>
159 + </div>
160 + );
161 +}
162 +
135 163 function Header() {
136 164 const [open, setOpen] = useState(false);
137 165 const location = useLocation();
166 + // fiche propriété : header compact (56 px), sans ticker ni badge, favoris + partage
167 + const isFiche = location.pathname.startsWith("/propriete/");
138 168
139 169 useEffect(() => { setOpen(false); }, [location]);
140 170 useEffect(() => {
@@ -144,12 +174,13 @@ function Header() {
144 174
145 175 return (
146 176 <>
147 − <header className="header">
177 + <header className={`header ${isFiche ? "header--fiche" : ""}`}>
148 178 <div className="container header-inner">
149 179 <NavLink to="/" className="brand" aria-label="Immo-Ka — accueil">
150 180 Immo<span className="ka">Ka</span>
151 181 </NavLink>
152 − <GroupeKaBadge />
182 + {!isFiche && <GroupeKaBadge />}
183 + {isFiche && <FicheHeaderActions />}
153 184 <nav className="nav" aria-label="Navigation principale">
154 185 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
155 186 Propriétés
@@ -218,7 +249,7 @@ function Header() {
218 249 </div>
219 250 </header>
220 251 {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
221 − <Ticker />
252 + {!isFiche && <Ticker />}
222 253 </>
223 254 );
224 255 }
modified frontend/src/api.ts +18 −4
@@ -55,6 +55,8 @@ export interface Listing {
55 55 poi?: Poi[]; // commodités de proximité (fiche seulement)
56 56 quartier?: Quartier | null; // stats de quartier (fiche seulement)
57 57 vraiprix?: VraiPrix | null; // estimation de valeur marchande (Vrai-Prix)
58 + agency?: string | null; // agence / bannière (si la source la publie)
59 + days_on_market?: number; // jours depuis la 1re observation Immo-Ka
58 60 first_seen?: number;
59 61 last_seen?: number;
60 62 updated_at?: number;
@@ -227,10 +229,20 @@ export interface VraiPrix {
227 229 confidence_pct: number | null;
228 230 confidence: string | null; // A | B | C | D
229 231 url: string; // page d'analyse détaillée
232 + // rôle d'évaluation foncière apparié par Vrai-Prix (quand disponible)
233 + valeur_role?: number | null;
234 + valeur_terrain?: number | null;
235 + valeur_batiment?: number | null;
236 + annee_construction_role?: number | null;
237 + superficie_terrain_role_m2?: number | null;
238 + aire_etages_role_m2?: number | null;
230 239 }
231 240
232 241 // --- Quartier (recensement 2021, proximité StatCan, chaleur INSPQ, crime) ----
233 −export interface Poi { cat: string; name: string; dist_m: number }
242 +export interface Poi {
243 + cat: string; name: string; dist_m: number;
244 + lat?: number; lng?: number; // position (entrées de cache récentes seulement — carte)
245 +}
234 246 export interface Quartier {
235 247 dauid?: string | null;
236 248 demographie?: {
@@ -516,6 +528,7 @@ export const fetchAir = (lat: number, lng: number) =>
516 528
517 529 export interface GazStation {
518 530 nom: string; adresse: string; dist_m: number;
531 + lat?: number; lng?: number; // position (carte de la fiche)
519 532 regulier: number | null; super: number | null; diesel: number | null;
520 533 moins_chere: boolean;
521 534 }
@@ -525,9 +538,10 @@ export interface GazNearby {
525 538 min_regulier: number | null; stations: GazStation[]; maj: string | null;
526 539 }
527 540
528 −/** Stations-service à proximité et prix courants (gazquebec.ca). */
529 −export const fetchGaz = (lat: number, lng: number) =>
530 − get<GazNearby>(`/api/gaz?lat=${lat}&lng=${lng}`);
541 +/** Stations-service à proximité et prix courants (gazquebec.ca).
542 + * `limit` : nombre de stations retournées (défaut serveur 5, max 60). */
543 +export const fetchGaz = (lat: number, lng: number, limit?: number) =>
544 + get<GazNearby>(`/api/gaz?lat=${lat}&lng=${lng}` + (limit ? `&limit=${limit}` : ""));
531 545
532 546 export interface CommerceItem {
533 547 id: string; commerce: string; nom: string; adresse: string;
deleted frontend/src/components/CommercesProches.tsx +0 −85
@@ -1,85 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Immo-Ka — Agrégateur de propriétés à vendre (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/EssenceProche.tsx +0 −74
@@ -1,74 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Immo-Ka — Agrégateur de propriétés à vendre (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/Financement.tsx +0 −373
@@ -1,373 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 −// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// components/Financement.tsx : « Financer cette propriété » (fiche) —
5 −// calculateur hypothécaire canadien branché sur les taux RÉELS observés
6 −// (immoka/mortgage). Composition semestrielle pour les taux fixes,
7 −// SCHL montrée séparément, test de résistance, comparateur par banque,
8 −// historique du taux. Chaque taux affiche sa provenance et sa fraîcheur.
9 −// -----------------------------------------------------------------------------
10 −import { useEffect, useMemo, useRef, useState } from "react";
11 −import { Link } from "react-router-dom";
12 −import {
13 − MortgageBest, MortgageCalc, calculateMortgage, fetchMortgageBest,
14 − fmtPrice, fmtRate,
15 −} from "../api";
16 −import TauxHistorique from "./TauxHistorique";
17 −
18 −const FREQS: [string, string][] = [
19 − ["monthly", "Mensuel"],
20 − ["semimonthly", "Bimensuel (24/an)"],
21 − ["biweekly", "Aux 2 semaines"],
22 − ["accelerated-biweekly", "Aux 2 semaines accéléré"],
23 − ["weekly", "Hebdomadaire"],
24 − ["accelerated-weekly", "Hebdomadaire accéléré"],
25 −];
26 −const TERMES: [number, string][] = [
27 − [12, "1 an"], [24, "2 ans"], [36, "3 ans"], [48, "4 ans"],
28 − [60, "5 ans"], [84, "7 ans"], [120, "10 ans"],
29 −];
30 −const KIND_FR: Record<string, string> = {
31 − posted: "taux affiché", special: "offre spéciale",
32 −};
33 −const INSURED_FR: Record<string, string> = {
34 − insured: "assuré", insurable: "assurable", uninsured: "non assuré",
35 − unknown: "",
36 −};
37 −
38 −const nf = (n: number) => n.toLocaleString("fr-CA", { maximumFractionDigits: 0 });
39 −const money = (n: number | null | undefined) =>
40 − n == null ? "—" : `${n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $`;
41 −
42 −/** Fraîcheur lisible d'une observation de taux. */
43 −function freshness(ageMinutes: number): string {
44 − if (ageMinutes < 60) return `il y a ${ageMinutes} min`;
45 − if (ageMinutes < 48 * 60) return `il y a ${Math.round(ageMinutes / 60)} h`;
46 − return `il y a ${Math.round(ageMinutes / 1440)} j`;
47 −}
48 −
49 −export default function Financement({ prix, taxesMensuelles }:
50 − { prix: number | null; taxesMensuelles: number | null }) {
51 − const [price, setPrice] = useState<number>(prix ?? 0);
52 − const [down, setDown] = useState<number>(Math.round((prix ?? 0) * 0.2));
53 − const [amort, setAmort] = useState(25);
54 − const [term, setTerm] = useState(60);
55 − const [rateType, setRateType] = useState<"fixed" | "variable">("fixed");
56 − const [freq, setFreq] = useState("monthly");
57 − const [res, setRes] = useState<MortgageCalc | null>(null);
58 − const [err, setErr] = useState<string | null>(null);
59 − const [best, setBest] = useState<MortgageBest | null>(null);
60 − const timer = useRef<number>();
61 −
62 − const downPct = price > 0 ? (down / price) * 100 : 0;
63 − const setDownPct = (pct: number) =>
64 − setDown(Math.round((price * Math.min(99, Math.max(0, pct))) / 100));
65 −
66 − // recalcul débobiné : les taux viennent du moteur, jamais du navigateur
67 − useEffect(() => {
68 − if (!price || price <= 0 || down < 0 || down >= price) { setRes(null); return; }
69 − window.clearTimeout(timer.current);
70 − timer.current = window.setTimeout(() => {
71 − calculateMortgage({
72 − price, down_payment: down, amortization_years: amort,
73 − term_months: term, frequency: freq, rate_type: rateType,
74 − })
75 − .then((r) => { setRes(r); setErr(null); })
76 − .catch(() => setErr("Taux momentanément indisponibles — réessayez plus tard."));
77 − }, 350);
78 − return () => window.clearTimeout(timer.current);
79 − }, [price, down, amort, term, rateType, freq]);
80 −
81 − // comparateur par banque (mêmes type/terme que le scénario)
82 − useEffect(() => {
83 − setBest(null);
84 − fetchMortgageBest(rateType, term).then(setBest).catch(() => setBest(null));
85 − }, [rateType, term]);
86 −
87 − const coutReel = useMemo(() => {
88 − if (!res) return null;
89 − const parts: { k: string; v: number }[] = [
90 − { k: "Versement hypothécaire (équiv. mensuel)", v: res.payment_monthly_equivalent },
91 − ];
92 − if (taxesMensuelles != null && taxesMensuelles > 0)
93 − parts.push({ k: "Taxes municipales et scolaires", v: taxesMensuelles });
94 − return { parts, total: parts.reduce((s, p) => s + p.v, 0) };
95 − }, [res, taxesMensuelles]);
96 −
97 − if (prix == null || prix <= 0) return null;
98 − const src = res?.rate_source ?? null;
99 − const ins = res?.insurance;
100 −
101 − return (
102 − <section className="f-bloc f-mtg" id="financement">
103 − <h2>Financer cette propriété</h2>
104 − <p className="mtg-intro">
105 − Simulation avec les <b>taux réels publiés par les banques canadiennes</b>,
106 − collectés en continu par Immo-Ka — composition semestrielle (norme
107 − canadienne) pour les taux fixes.{" "}
108 − <Link to="/taux-hypothecaires">Voir tous les taux ↗</Link>
109 − </p>
110 −
111 − <div className="mtg-form">
112 − <label>
113 − <span>Prix</span>
114 − <input type="number" inputMode="numeric" min={1} value={price || ""}
115 − onChange={(e) => setPrice(Number(e.target.value) || 0)} />
116 − </label>
117 − <label>
118 − <span>Mise de fonds ($)</span>
119 − <input type="number" inputMode="numeric" min={0} value={down || ""}
120 − onChange={(e) => setDown(Number(e.target.value) || 0)} />
121 − </label>
122 − <label>
123 − <span>Mise de fonds (%)</span>
124 − <input type="number" inputMode="decimal" min={0} max={99} step={1}
125 − value={downPct ? Math.round(downPct * 10) / 10 : ""}
126 − onChange={(e) => setDownPct(Number(e.target.value) || 0)} />
127 − </label>
128 − <label>
129 − <span>Amortissement</span>
130 − <select value={amort} onChange={(e) => setAmort(Number(e.target.value))}>
131 − {[10, 15, 20, 25, 30].map((a) => <option key={a} value={a}>{a} ans</option>)}
132 − </select>
133 − </label>
134 − <label>
135 − <span>Terme</span>
136 − <select value={term} onChange={(e) => setTerm(Number(e.target.value))}>
137 − {TERMES.map(([m, l]) => <option key={m} value={m}>{l}</option>)}
138 − </select>
139 − </label>
140 − <label>
141 − <span>Type de taux</span>
142 − <select value={rateType}
143 − onChange={(e) => setRateType(e.target.value as "fixed" | "variable")}>
144 − <option value="fixed">Fixe</option>
145 − <option value="variable">Variable</option>
146 − </select>
147 − </label>
148 − <label>
149 − <span>Fréquence</span>
150 − <select value={freq} onChange={(e) => setFreq(e.target.value)}>
151 − {FREQS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
152 − </select>
153 − </label>
154 − </div>
155 −
156 − {err && <p className="mtg-err">{err}</p>}
157 −
158 − {res && (
159 − <>
160 − <div className="mtg-resultat">
161 − <div className="mtg-kpi">
162 − <span className="mtg-kpi-k">Versement</span>
163 − <span className="mtg-kpi-v">{money(res.payment)}</span>
164 − <span className="mtg-kpi-sub">
165 − {FREQS.find(([v]) => v === freq)?.[1].toLowerCase()}
166 − {freq !== "monthly" && ` · équiv. ${money(res.payment_monthly_equivalent)}/mois`}
167 − </span>
168 − </div>
169 − <div className="mtg-kpi">
170 − <span className="mtg-kpi-k">Taux utilisé</span>
171 − <span className="mtg-kpi-v">{fmtRate(res.inputs.rate)}</span>
172 − {src && (
173 − <span className="mtg-kpi-sub">
174 − {src.institution} — {src.product_name}{" "}
175 − ({KIND_FR[src.kind] ?? src.kind}
176 − {INSURED_FR[src.insured_status ?? "unknown"]
177 − ? `, ${INSURED_FR[src.insured_status ?? "unknown"]}` : ""})
178 − </span>
179 − )}
180 − </div>
181 − <div className="mtg-kpi">
182 − <span className="mtg-kpi-k">Hypothèque</span>
183 − <span className="mtg-kpi-v">{fmtPrice(res.principal)}</span>
184 − <span className="mtg-kpi-sub">
185 − mise de fonds {fmtPrice(res.inputs.down_payment)} ({res.inputs.down_payment_pct.toLocaleString("fr-CA")} %)
186 − </span>
187 − </div>
188 − <div className="mtg-kpi">
189 − <span className="mtg-kpi-k">Test de résistance</span>
190 − <span className="mtg-kpi-v">{money(res.qualifying.payment)}</span>
191 − <span className="mtg-kpi-sub">qualification à {fmtRate(res.qualifying.rate)}</span>
192 − </div>
193 − </div>
194 −
195 − {src && (
196 − <p className="mtg-source fine">
197 − Taux observé chez <b>{src.institution}</b> {freshness(src.age_minutes)}
198 − {src.stale && " ⚠ donnée de plus de 24 h"} ·{" "}
199 − {src.source_url && (
200 − <a href={src.source_url} target="_blank" rel="noopener noreferrer">
201 − source officielle ↗
202 − </a>
203 − )}
204 − </p>
205 − )}
206 −
207 − {ins && ins.required && (
208 − <div className={`mtg-schl ${ins.eligible ? "" : "mtg-schl-no"}`}>
209 − <b>Assurance prêt hypothécaire (SCHL)</b>
210 − {ins.eligible ? (
211 − <ul>
212 − <li>Prime : <b>{fmtPrice(ins.premium)}</b> ({ins.premium_rate.toLocaleString("fr-CA")} % du prêt, ajoutée à l'hypothèque)</li>
213 − <li>TVQ sur la prime : <b>{money(ins.qc_tax)}</b> — payable comptant à la clôture</li>
214 − <li>Rapport prêt-valeur : {ins.ltv?.toLocaleString("fr-CA")} %</li>
215 − </ul>
216 − ) : null}
217 − {ins.issues.map((i, k) => <p className="mtg-issue" key={k}>⚠ {i}</p>)}
218 − </div>
219 − )}
220 −
221 − {coutReel && (
222 − <details className="mtg-detail">
223 − <summary>Coût réel mensuel estimé</summary>
224 − <div className="dtable">
225 − {coutReel.parts.map((p) => (
226 − <div className="drow" key={p.k}><span>{p.k}</span><b>{money(p.v)}</b></div>
227 − ))}
228 − <div className="drow mtg-total"><span>Total estimé</span><b>{money(coutReel.total)}</b></div>
229 − </div>
230 − <p className="fine">
231 − {taxesMensuelles == null &&
232 − "Taxes non publiées pour cette annonce — versement hypothécaire seulement. "}
233 − Chauffage, électricité, assurance habitation et copropriété en sus.
234 − </p>
235 − </details>
236 − )}
237 −
238 − <details className="mtg-detail">
239 − <summary>Et si les taux montent ? (test de résistance)</summary>
240 − <div className="dtable">
241 − {res.stress.map((s) => (
242 − <div className="drow" key={s.bump}>
243 − <span>{s.bump === 0 ? "Taux actuel" : `+${s.bump} point${s.bump > 1 ? "s" : ""}`} — {fmtRate(s.rate)}</span>
244 − <b>{money(s.payment)}</b>
245 − </div>
246 − ))}
247 − </div>
248 − <p className="fine">{res.qualifying.note}</p>
249 − </details>
250 −
251 − <details className="mtg-detail">
252 − <summary>Au renouvellement ({TERMES.find(([m]) => m === term)?.[1]})</summary>
253 − <p className="fine">
254 − Solde restant à l'échéance : <b>{fmtPrice(res.renewal.balance_at_renewal)}</b>{" "}
255 − (amortissement résiduel {res.renewal.remaining_amortization_years} ans).
256 − Intérêts payés pendant le terme : {fmtPrice(res.term.interest_paid)}.
257 − </p>
258 − <div className="dtable">
259 − {res.renewal.scenarios.map((s) => (
260 − <div className="drow" key={s.bump}>
261 − <span>Renouvelé à {fmtRate(s.rate)} ({s.bump >= 0 ? "+" : ""}{s.bump} pt)</span>
262 − <b>{money(s.payment)}</b>
263 − </div>
264 − ))}
265 − </div>
266 − </details>
267 −
268 − {best && best.per_institution.length > 1 && (
269 − <details className="mtg-detail">
270 − <summary>Comparer les banques ({best.institutions_count} institutions)</summary>
271 − <div className="rooms-wrap">
272 − <table className="rooms mtg-comp">
273 − <thead>
274 − <tr><th>Institution</th><th>Taux</th><th>Nature</th><th>Versement</th><th>Fraîcheur</th></tr>
275 − </thead>
276 − <tbody>
277 − {best.per_institution.map((r) => (
278 − <tr key={r.provider}>
279 − <td>
280 − {r.source_url
281 − ? <a href={r.source_url} target="_blank" rel="noopener noreferrer">{r.institution}</a>
282 − : r.institution}
283 − </td>
284 − <td><b>{fmtRate(r.rate)}</b>{r.apr != null ? ` (TAP ${fmtRate(r.apr)})` : ""}</td>
285 − <td>
286 − {KIND_FR[r.kind]}
287 − {INSURED_FR[r.insured_status] ? ` · ${INSURED_FR[r.insured_status]}` : ""}
288 − </td>
289 − <td>{res.principal > 0 ? money(estimatePayment(res, r.rate)) : "—"}</td>
290 − <td className={r.stale ? "mtg-stale" : ""}>{freshness(r.age_minutes)}</td>
291 − </tr>
292 − ))}
293 − </tbody>
294 − </table>
295 − </div>
296 − <p className="fine">
297 − Produits comparables seulement (même type, même terme) — un taux
298 − « affiché » et une « offre spéciale » ne sont pas la même chose,
299 − d'où la colonne Nature. Versements estimés sur votre scénario.
300 − </p>
301 − </details>
302 − )}
303 −
304 − <details className="mtg-detail">
305 − <summary>Historique du taux ({rateType === "fixed" ? "fixe" : "variable"} {TERMES.find(([m]) => m === term)?.[1]})</summary>
306 − <TauxHistorique rateType={rateType} termMonths={term} />
307 − </details>
308 −
309 − <details className="mtg-detail">
310 − <summary>Amortissement année par année</summary>
311 − <div className="rooms-wrap">
312 − <table className="rooms">
313 − <thead>
314 − <tr><th>Année</th><th>Intérêts</th><th>Capital</th><th>Solde</th></tr>
315 − </thead>
316 − <tbody>
317 − {res.annual.map((a) => (
318 − <tr key={a.year}>
319 − <td>{a.year}</td>
320 − <td>{nf(a.interest)} $</td>
321 − <td>{nf(a.principal)} $</td>
322 − <td>{nf(a.balance)} $</td>
323 − </tr>
324 − ))}
325 − </tbody>
326 − </table>
327 − </div>
328 − {res.payoff_years < res.inputs.amortization_years && (
329 − <p className="fine">
330 − Avec la fréquence accélérée choisie, le prêt s'éteint en{" "}
331 − <b>{res.payoff_years} ans</b> au lieu de {res.inputs.amortization_years}.
332 − </p>
333 − )}
334 − </details>
335 − </>
336 − )}
337 −
338 − <p className="fine">
339 − Outil indicatif seulement — ne constitue ni une offre de financement ni
340 − une préapprobation. Les taux affichés sont ceux publiés par les
341 − institutions (source et fraîcheur indiquées) ; vérifiez auprès de la
342 − banque ou d'un courtier hypothécaire.
343 − </p>
344 − </section>
345 − );
346 −}
347 −
348 −/** Versement estimé au taux d'une autre banque, même scénario (approximation
349 − * frontend par règle de trois sur le facteur d'annuité — les chiffres
350 − * officiels du scénario viennent toujours du moteur). */
351 −function estimatePayment(res: MortgageCalc, rate: number): number {
352 − const { amortization_years, frequency, compounding } = res.inputs;
353 − const f = ({ monthly: 12, semimonthly: 24, biweekly: 26,
354 − "accelerated-biweekly": 26, weekly: 52, "accelerated-weekly": 52 } as
355 − Record<string, number>)[frequency] ?? 12;
356 − const per = (pct: number, k: number) =>
357 − compounding === "monthly"
358 − ? Math.pow(1 + pct / 100 / 12, 12 / k) - 1
359 − : Math.pow(1 + pct / 100 / 2, 2 / k) - 1;
360 − const pay = (pct: number) => {
361 − if (frequency.startsWith("accelerated")) {
362 − const m = pay0(pct, 12);
363 − return Math.round((m / (frequency === "accelerated-biweekly" ? 2 : 4)) * 100) / 100;
364 − }
365 − return pay0(pct, f);
366 − };
367 − const pay0 = (pct: number, k: number) => {
368 − const i = per(pct, k);
369 − const n = Math.round(amortization_years * k);
370 − return Math.round(((res.principal * i) / (1 - Math.pow(1 + i, -n))) * 100) / 100;
371 − };
372 − return pay(rate);
373 −}
deleted frontend/src/components/HydroEstimation.tsx +0 −81
@@ -1,81 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Immo-Ka — Agrégateur de propriétés à vendre (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 de la propriété. 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 +103 −0
@@ -159,6 +159,97 @@ const P: Record<string, ReactNode> = {
159 159 <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" />
160 160 <path d="M15.4 5.4a3.2 3.2 0 0 1 0 5.9M17 14.8a5.4 5.4 0 0 1 3.4 5.2" />
161 161 </>),
162 +
163 + // --- fiche propriété (refonte premium 2026-09-07) — même trait 1,7, grille 24 ---
164 + chevdown: <path d="m6 9 6 6 6-6" />,
165 + chevleft: <path d="m14.5 5-7 7 7 7" />,
166 + chevright: <path d="m9.5 5 7 7-7 7" />,
167 + close: <path d="M18 6 6 18M6 6l12 12" />,
168 + expand: <path d="M15 3.5h5.5V9M9 20.5H3.5V15M20.5 3.5l-7 7M3.5 20.5l7-7" />,
169 + share: (<>
170 + <circle cx="18" cy="5" r="2.5" /><circle cx="6" cy="12" r="2.5" /><circle cx="18" cy="19" r="2.5" />
171 + <path d="M8.2 10.8 15.8 6.3M8.2 13.2l7.6 4.5" />
172 + </>),
173 + sparkles: (<>
174 + <path d="M12 3l1.9 5.6 5.6 1.9-5.6 1.9L12 18l-1.9-5.6-5.6-1.9 5.6-1.9L12 3z" />
175 + <path d="M19 16l.8 2.2 2.2.8-2.2.8L19 22l-.8-2.2-2.2-.8 2.2-.8L19 16z" />
176 + </>),
177 + scale: <path d="M12 3v18M5 21h14M12 6l7 2-3 7h-4M12 6 5 8l3 7h4" />,
178 + wallet: (<>
179 + <path d="M3 7a2 2 0 0 1 2-2h13v4" />
180 + <rect x="3" y="7" width="18" height="12" rx="2" />
181 + <path d="M16 13h.01" />
182 + </>),
183 + droplets: <path d="M12 3s-6 6.5-6 10.5a6 6 0 0 0 12 0C18 9.5 12 3 12 3z" />,
184 + wind: (<>
185 + <path d="M3 8h10a3 3 0 1 0-3-3" />
186 + <path d="M3 12h15a3 3 0 1 1-3 3" />
187 + <path d="M3 16h7" />
188 + </>),
189 + fuel: (<>
190 + <path d="M4 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16" />
191 + <path d="M3 21h12M6 8h6" />
192 + <path d="M14 10h2a2 2 0 0 1 2 2v5a1.5 1.5 0 0 0 3 0V9l-2.5-2.5" />
193 + </>),
194 + train: (<>
195 + <rect x="4" y="3" width="16" height="14" rx="3" />
196 + <path d="M4 11h16M8 21l2-4M16 21l-2-4M9 7h6" />
197 + <path d="M8.5 14h.01M15.5 14h.01" />
198 + </>),
199 + folder: <path d="M3 6h6l2 2.5h10V19H3z" />,
200 + doc: (<>
201 + <path d="M6 3h8l4 4v14H6z" />
202 + <path d="M14 3v4h4" />
203 + <path d="M9 12h6M9 15.5h6" />
204 + </>),
205 + layers: (<>
206 + <path d="m12 3 9 5-9 5-9-5 9-5z" /><path d="m3 13 9 5 9-5M3 17.5l9 5 9-5" />
207 + </>),
208 + info: (<>
209 + <circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" />
210 + </>),
211 + ruler: (<>
212 + <path d="M3.5 16 16 3.5l4.5 4.5L8 20.5 3.5 16z" />
213 + <path d="M7.5 16l1.5 1.5M10.5 13l1.5 1.5M13.5 10l1.5 1.5M16.5 7 18 8.5" />
214 + </>),
215 + key: (<>
216 + <circle cx="8" cy="15" r="4.5" />
217 + <path d="m11.2 11.8 8.3-8.3M15.5 7.5l3 3M18 5l2 2" />
218 + </>),
219 + bank: (<>
220 + <path d="m3 9.5 9-5.5 9 5.5H3z" />
221 + <path d="M5 9.5v8M9.7 9.5v8M14.3 9.5v8M19 9.5v8M3.5 20.5h17" />
222 + </>),
223 + percent: (<>
224 + <path d="M19 5 5 19" /><circle cx="7" cy="7" r="2.5" /><circle cx="17" cy="17" r="2.5" />
225 + </>),
226 + car: (<>
227 + <path d="M4 16v-4.5l2-5A1.6 1.6 0 0 1 7.5 5.5h9a1.6 1.6 0 0 1 1.5 1l2 5V16" />
228 + <path d="M3 16h18M6.2 16v2.6M17.8 16v2.6M4.5 11.5h15" />
229 + <path d="M7.5 13.8h.01M16.5 13.8h.01" />
230 + </>),
231 + hammer: (<>
232 + <path d="m14.5 3.5 6 6-2.5 2.5-6-6z" />
233 + <path d="M13 8 4.5 16.5a1.8 1.8 0 0 0 2.5 2.5L15.5 10.5" />
234 + </>),
235 + store: (<>
236 + <path d="M3 9 5 4h14l2 5M3 9h18v3a2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-5 0 2.5 2.5 0 0 1-3 2.4V9z" />
237 + <path d="M5 14v6h14v-6M10 20v-4h4v4" />
238 + </>),
239 + coffee: <path d="M4 9h12v6a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V9zM16 10h2a2.5 2.5 0 0 1 0 5h-2M7 3v3M11 3v3" />,
240 + book: (<>
241 + <path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2V5z" />
242 + <path d="M4 19a2 2 0 0 0 2 2h13" />
243 + </>),
244 + dumbbell: <path d="M6.7 6.7v10.6M17.3 6.7v10.6M3.5 9.2v5.6M20.5 9.2v5.6M6.7 12h10.6" />,
245 + baby: (<>
246 + <circle cx="12" cy="9" r="5" />
247 + <path d="M7 20a5 5 0 0 1 10 0M10 8.5h.01M14 8.5h.01M10.5 11.5c.8.7 2.2.7 3 0" />
248 + </>),
249 + history: (<>
250 + <path d="M3.5 12a8.5 8.5 0 1 0 2.5-6" />
251 + <path d="M3.5 4v4h4M12 7.5V12l3 2" />
252 + </>),
162 253 };
163 254
164 255 export type IconName = keyof typeof P;
@@ -179,6 +270,18 @@ export function Ico({ name, size = 18, className = "", stroke = 1.7 }:
179 270 );
180 271 }
181 272
273 +/** Cœur (favoris) — plein quand actif ; même grille que le reste. */
274 +export function IcoHeart({ size = 18, filled = false, className = "" }:
275 + { size?: number; filled?: boolean; className?: string }) {
276 + return (
277 + <svg className={`ico ${className}`} width={size} height={size} viewBox="0 0 24 24"
278 + fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth={1.7}
279 + strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
280 + <path d="M12 20.5C7 16.5 3.5 13 3.5 9.3 3.5 6.8 5.5 5 7.8 5c1.7 0 3.2 1 4.2 2.6C13 6 14.5 5 16.2 5c2.3 0 4.3 1.8 4.3 4.3 0 3.7-3.5 7.2-8.5 11.2z" />
281 + </svg>
282 + );
283 +}
284 +
182 285 /** Version « chaîne HTML » pour les popups MapLibre (hors React). */
183 286 export function icoHTML(name: IconName, size = 30): string {
184 287 const d: Record<string, string> = {
deleted frontend/src/components/PropertyMap.tsx +0 −71
@@ -1,71 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Author: Simon-Pierre Boucher
3 −// Contact: contact@spboucher.ai
4 −// Project: Groupe Ka / Ka Maps (Immo-Ka integration)
5 −// components/PropertyMap.tsx : mini-carte 3D de la fiche — MÊME composant que
6 −// Lou-Ka (KaSpotlightMap) : caméra serrée sur l'adresse, fond Mapbox Standard
7 −// réaliste, bâtiment de l'annonce surligné en CERISE Immo-Ka. Chargée
8 −// 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 { immoKaMapTheme } from "../kamaps/theme";
17 −import { MAPBOX_TOKEN } from "../kamaps/config";
18 −
19 −/** Cerise signal Immo-Ka — même langage que l'accent de marque. */
20 −export const BUILDING_RED = "#e23744";
21 −
22 −export interface PropertyMapProps {
23 − uid: string;
24 − lat: number;
25 − lng: number;
26 − price?: number | null;
27 − propertyType?: string;
28 − address?: string;
29 − city?: string;
30 − image?: string | null;
31 − deal?: boolean;
32 −}
33 −
34 −export default function PropertyMap({
35 − uid, lat, lng, price, propertyType, address, city, image, deal,
36 −}: PropertyMapProps) {
37 − const property = useMemo<MapProperty>(() => ({
38 − id: uid,
39 − appSource: "immo-ka",
40 − latitude: lat,
41 − longitude: lng,
42 − kind: "listing",
43 − listingType: "sale",
44 − price: price ?? undefined,
45 − propertyType: propertyType || undefined,
46 − address: address || undefined,
47 − city: city || undefined,
48 − thumbnailUrl: image ?? undefined,
49 − highlight: deal ?? false,
50 − }), [uid, lat, lng, price, propertyType, address, city, image, deal]);
51 −
52 − return (
53 − <div
54 − className="lmap3d" role="img"
55 − aria-label={`Carte 3D — ${address || "propriété"}, bâtiment de l'annonce en rouge`}
56 − >
57 − <KaSpotlightMap
58 − theme={immoKaMapTheme}
59 − mapboxToken={MAPBOX_TOKEN}
60 − property={property}
61 − buildingColor={BUILDING_RED}
62 − >
63 − <KaBrandBadge />
64 − <MetroLignes />
65 − </KaSpotlightMap>
66 − <span className="lmap3d-legende" aria-hidden="true">
67 − <i /> Bâtiment de l'annonce
68 − </span>
69 − </div>
70 − );
71 −}
deleted frontend/src/components/QualiteAir.tsx +0 −86
@@ -1,86 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Immo-Ka — Agrégateur de propriétés à vendre (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 −164
@@ -1,164 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Immo-Ka — Agrégateur de propriétés à vendre (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 −import { Ico } from "./Icons";
10 −
11 −const fmtMoney = (v: number | null | undefined) =>
12 − v == null ? null : v.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $";
13 −const fmtPct = (v: number | null | undefined) =>
14 − v == null ? null : Math.round(v) + " %";
15 −
16 −// scores PMD affichés (clé backend -> libellé)
17 −const PROX_LABELS: [string, string, string][] = [
18 − ["prox_epicerie", "Épiceries", "cart"],
19 − ["prox_transport", "Transport en commun", "bus"],
20 − ["prox_parc", "Parcs", "tree"],
21 − ["prox_ecole_prim", "Écoles primaires", "school"],
22 − ["prox_sante", "Soins de santé", "health"],
23 − ["prox_pharmacie", "Pharmacies", "pill"],
24 −];
25 −
26 −function chaleurBadge(classe: number, ecart: number | null) {
27 − if (classe <= 3)
28 − return { txt: "Îlot de fraîcheur", cls: "q-badge cool", ico: "leaf" };
29 − if (classe >= 7)
30 − return {
31 − txt: `Îlot de chaleur${ecart != null ? ` (+${ecart.toFixed(1)} °C)` : ""}`,
32 − cls: "q-badge hot", ico: "thermo",
33 − };
34 − return { txt: "Température de quartier moyenne", cls: "q-badge neutral", ico: "sun" };
35 −}
36 −
37 −export default function QuartierBlock({ q }: { q: Quartier }) {
38 − const d = q.demographie;
39 − const stats: [string, string | null][] = d
40 − ? [
41 − ["Revenu médian des ménages", fmtMoney(d.revenu_median)],
42 − ["Ménages locataires", fmtPct(d.pct_locataires)],
43 − ["Loyer moyen du secteur", fmtMoney(d.loyer_moyen)],
44 − ["Âge médian", d.age_median != null ? `${Math.round(d.age_median)} ans` : null],
45 − ["Français à la maison", fmtPct(d.pct_francais)],
46 − ["Diplôme universitaire", fmtPct(d.pct_univ)],
47 − ]
48 − : [];
49 − const statsOk = stats.filter(([, v]) => v != null) as [string, string][];
50 − const prox = q.proximite ?? {};
51 − const proxOk = PROX_LABELS.filter(([k]) => typeof prox[k] === "number");
52 −
53 − if (statsOk.length === 0 && proxOk.length === 0 && !q.chaleur && !q.crime)
54 − return null;
55 −
56 − return (
57 − <section className="quartier">
58 − <h2>Le quartier</h2>
59 − <p className="q-sub">
60 − Secteur immédiat de l'immeuble (aire de diffusion du recensement, ± 500 habitants).
61 − </p>
62 −
63 − {statsOk.length > 0 && (
64 − <div className="q-grid">
65 − {statsOk.map(([label, val]) => (
66 − <div className="q-cell" key={label}>
67 − <div className="q-val">{val}</div>
68 − <div className="q-label">{label}</div>
69 − </div>
70 − ))}
71 − </div>
72 − )}
73 −
74 − {proxOk.length > 0 && (
75 − <div className="q-prox">
76 − {proxOk.map(([k, label, ico]) => {
77 − const v = Math.max(0, Math.min(1, prox[k]));
78 − return (
79 − <div className="q-bar" key={k}>
80 − <span className="q-bar-label"><Ico name={ico} size={14} /> {label}</span>
81 − <span className="q-bar-track">
82 − <span className="q-bar-fill" style={{ width: `${Math.round(v * 100)}%` }} />
83 − </span>
84 − <span className="q-bar-num">{Math.round(v * 100)}</span>
85 − </div>
86 − );
87 − })}
88 − <div className="fine">Accessibilité 0–100 — mesures de proximité de Statistique Canada.</div>
89 − </div>
90 − )}
91 −
92 − <div className="q-badges">
93 − {q.chaleur && (() => {
94 − const b = chaleurBadge(q.chaleur.classe, q.chaleur.ecart);
95 − return <span className={b.cls}><Ico name={b.ico} size={14} /> {b.txt}</span>;
96 − })()}
97 − {q.crime?.type === "points" && (
98 − <div className="q-crime">
99 − <span className="q-badge neutral">
100 − <Ico name="shield" size={14} /> {q.crime.douze_mois} acte{q.crime.douze_mois > 1 ? "s" : ""} criminel{q.crime.douze_mois > 1 ? "s" : ""} à
101 − moins de 500 m (12 mois)
102 − {q.crime.douze_mois_precedents > 0 && (
103 − q.crime.douze_mois <= q.crime.douze_mois_precedents
104 − ? ` · en baisse (${q.crime.douze_mois_precedents} l'année d'avant)`
105 − : ` · en hausse (${q.crime.douze_mois_precedents} l'année d'avant)`
106 − )}
107 − </span>
108 − {(q.crime.categories?.length ?? 0) > 0 && (
109 − <ul className="q-crime-cats">
110 − {q.crime.categories!.filter((c) => c.n + c.n_prec > 0).map((c) => {
111 − const max = Math.max(...q.crime!.type === "points"
112 − ? q.crime!.categories!.map((x) => x.n) : [1], 1);
113 − const delta = c.n - c.n_prec;
114 − return (
115 − <li key={c.nom}>
116 − <span className="q-crime-nom">{c.nom}</span>
117 − <span className="q-crime-barre" aria-hidden="true">
118 − <i style={{ width: `${Math.max(3, (c.n / max) * 100)}%` }} />
119 − </span>
120 − <span className="q-crime-n">{c.n}
121 − <small>{delta === 0 ? " =" : delta > 0
122 − ? ` ▲${delta}` : ` ▼${-delta}`}</small>
123 − </span>
124 − </li>
125 − );
126 − })}
127 − </ul>
128 − )}
129 − <p className="fine q-crime-src">
130 − Actes criminels enregistrés par le SPVM (données ouvertes,
131 − position approximée à l'intersection) — 12 derniers mois,
132 − variation vs les 12 précédents.
133 − </p>
134 − </div>
135 − )}
136 − {q.crime?.type === "igc" && (() => {
137 − const c = q.crime;
138 − if (c.indice_canada != null && c.indice_canada > 0) {
139 − const delta = Math.round(100 * (c.indice - c.indice_canada) / c.indice_canada);
140 − const sous = delta <= 0;
141 − return (
142 − <span className={`q-badge ${sous ? "cool" : "neutral"}`}>
143 − <Ico name="shield" size={14} /> Criminalité {Math.abs(delta)} % {sous ? "sous" : "au-dessus de"} la
144 − moyenne canadienne
145 − <small className="q-badge-sub">({c.ville} {c.annee} : {c.indice} · Canada : {c.indice_canada})</small>
146 − </span>
147 − );
148 − }
149 − return (
150 − <span className="q-badge neutral">
151 − <Ico name="shield" size={14} /> Gravité de la criminalité ({c.ville}, {c.annee}) : <b>{c.indice}</b>
152 − </span>
153 − );
154 − })()}
155 − </div>
156 −
157 − <div className="fine">
158 − Sources : Statistique Canada (Recensement 2021, licence ouverte), INSPQ
159 − (CC-BY 4.0){q.crime?.type === "points" ? ", Ville de Montréal (CC-BY 4.0)" : ""}.
160 − Statistiques du secteur, pas de l'immeuble.
161 − </div>
162 − </section>
163 − );
164 −}
deleted frontend/src/components/RisqueInondation.tsx +0 −76
@@ -1,76 +0,0 @@
1 −// -----------------------------------------------------------------------------
2 −// Immo-Ka — Agrégateur de propriétés à vendre (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 +68 −0
@@ -0,0 +1,68 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/BottomSheet.tsx : panneau générique — bottom sheet sur mobile (hauteur
5 +// initiale 68 % du viewport, glisser vers le haut = plein écran, glisser
6 +// vers le bas = fermeture), modale centrée à partir de 900 px (CSS).
7 +// Portail à la racine, verrou du défilement (.ka-scroll-lock), Escape,
8 +// focus initial, aria-modal. Utilisé pour lieux, stations, assistant Ka.
9 +// -----------------------------------------------------------------------------
10 +import { ReactNode, useEffect, useRef, useState } from "react";
11 +import { createPortal } from "react-dom";
12 +import { Ico } from "../components/Icons";
13 +
14 +export default function BottomSheet({ open, onClose, title, sub, children, footer, tall = false }: {
15 + open: boolean; onClose: () => void; title: ReactNode; sub?: ReactNode;
16 + children: ReactNode; footer?: ReactNode; tall?: boolean;
17 +}) {
18 + const [full, setFull] = useState(tall);
19 + const panel = useRef<HTMLDivElement>(null);
20 + const drag = useRef<{ y0: number; t0: number } | null>(null);
21 +
22 + useEffect(() => {
23 + if (!open) return;
24 + setFull(tall);
25 + document.documentElement.classList.add("ka-scroll-lock");
26 + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
27 + window.addEventListener("keydown", onKey);
28 + const t = setTimeout(() => panel.current?.focus(), 30);
29 + return () => {
30 + document.documentElement.classList.remove("ka-scroll-lock");
31 + window.removeEventListener("keydown", onKey);
32 + clearTimeout(t);
33 + };
34 + }, [open, onClose, tall]);
35 +
36 + if (!open) return null;
37 +
38 + const onDown = (e: React.PointerEvent) => { drag.current = { y0: e.clientY, t0: Date.now() }; };
39 + const onUp = (e: React.PointerEvent) => {
40 + if (!drag.current) return;
41 + const dy = e.clientY - drag.current.y0;
42 + drag.current = null;
43 + if (dy > 90) onClose();
44 + else if (dy < -60) setFull(true);
45 + };
46 +
47 + return createPortal(
48 + <>
49 + <div className="ik-sheet-backdrop" onClick={onClose} aria-hidden="true" />
50 + <div className={`ik-sheet ${full ? "full" : ""}`} role="dialog" aria-modal="true"
51 + aria-label={typeof title === "string" ? title : undefined} ref={panel} tabIndex={-1}>
52 + <div className="ik-sheet-handle" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp} />
53 + <div className="ik-sheet-head" onPointerDown={onDown} onPointerUp={onUp} onPointerCancel={onUp}>
54 + <div style={{ minWidth: 0 }}>
55 + <h3 className="ik-sheet-title">{title}</h3>
56 + {sub && <p className="ik-sheet-sub">{sub}</p>}
57 + </div>
58 + <button type="button" className="ik-sheet-x" onClick={onClose} aria-label="Fermer">
59 + <Ico name="close" size={18} />
60 + </button>
61 + </div>
62 + <div className="ik-sheet-body">{children}</div>
63 + {footer && <div className="ik-sheet-foot">{footer}</div>}
64 + </div>
65 + </>,
66 + document.body,
67 + );
68 +}
added frontend/src/fiche/DesktopAside.tsx +68 −0
@@ -0,0 +1,68 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (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, Immo-Ka Score en miniature, constats clés,
6 +// favoris / partage, courtier. Masquée sur mobile (display:none, pas de
7 +// réordonnancement).
8 +// -----------------------------------------------------------------------------
9 +import { Listing, fmtPrice, sourceName } from "../api";
10 +import { Ico, IcoHeart } from "../components/Icons";
11 +import { ScoreRing } from "./ImmoKaScore";
12 +import { PriceCapsule } from "./PropertyHero";
13 +import { BriefList } from "./PropertySummary";
14 +import { ComparaisonPrix, Constat, ImmoKaScore, estLocation } from "./synthese";
15 +import { NBSP, relTime } from "./ui";
16 +
17 +export default function DesktopAside({ l, cmp, score, brief, fav, onFav, onShare }: {
18 + l: Listing; cmp: ComparaisonPrix | null; score: ImmoKaScore; brief: Constat[];
19 + fav: boolean; onFav: () => void; onShare: () => void;
20 +}) {
21 + const maj = relTime(l.updated_at);
22 + return (
23 + <aside className="ik-aside" aria-label="Résumé et actions">
24 + <div className="ik-card ik-aside-card">
25 + <div>
26 + <div className="ik-price">{fmtPrice(l.price, l.price_label)}{estLocation(l) && l.price != null && <small>/{NBSP}mois</small>}</div>
27 + <div className="ik-price-row" style={{ marginTop: 8 }}><PriceCapsule cmp={cmp} /></div>
28 + </div>
29 + <div className="ik-aside-addr">{l.address || l.title}{l.city ? <><br />{[l.sector, l.city].filter(Boolean).join(", ")}</> : null}</div>
30 + <a className="ik-btn ik-btn-primary" href={l.url} target="_blank" rel="noopener noreferrer">
31 + Voir l'annonce chez {sourceName(l.source)} <Ico name="external" size={16} />
32 + </a>
33 + <div className="ik-actions">
34 + <button type="button" className={`ik-btn ik-btn-ghost ${fav ? "on" : ""}`} style={{ flex: 1 }} aria-pressed={fav} onClick={onFav}>
35 + <IcoHeart size={17} filled={fav} /> {fav ? "Favori" : "Favoris"}
36 + </button>
37 + <button type="button" className="ik-btn ik-btn-ghost" style={{ flex: 1 }} onClick={onShare}><Ico name="share" size={16} /> Partager</button>
38 + <a className="ik-btn ik-btn-ghost ik-btn-icon" aria-label="Fiche PDF" title="Fiche PDF" href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download><Ico name="doc" size={17} /></a>
39 + </div>
40 + <button type="button" className="ik-btn ik-btn-ghost ik-ka-inline" onClick={() => window.dispatchEvent(new Event("ik:askka"))}>
41 + <Ico name="sparkles" size={16} /> Demander à Ka
42 + </button>
43 + {(score.value != null || brief.length > 0) && <hr className="ik-aside-sep" />}
44 + {score.value != null && (
45 + <div className="ik-aside-score">
46 + <ScoreRing value={score.value} partial={score.partial} size={56} />
47 + <div className="ik-aside-score-txt">
48 + <b>Immo-Ka Score · {score.label}</b>
49 + {score.partial ? "Score partiel" : `Emplacement ${score.emplacement} · Prix ${score.prix}`}
50 + </div>
51 + </div>
52 + )}
53 + {brief.length > 0 && <BriefList items={brief.slice(0, 4)} compact />}
54 + {(l.broker_name || l.broker_phone) && (
55 + <>
56 + <hr className="ik-aside-sep" />
57 + <div className="ik-aside-broker">
58 + <span className="ik-aside-broker-k">Courtier</span>
59 + {l.broker_name && <b>{l.broker_name}</b>}
60 + {l.broker_phone && <a href={`tel:${l.broker_phone.replace(/\s/g, "")}`}><Ico name="phone" size={14} /> {l.broker_phone}</a>}
61 + </div>
62 + </>
63 + )}
64 + {maj && <div className="ik-aside-meta">Synchronisé {maj} · {sourceName(l.source)}</div>}
65 + </div>
66 + </aside>
67 + );
68 +}
added frontend/src/fiche/EnvironmentCards.tsx +199 −0
@@ -0,0 +1,199 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (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 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 { Ico } 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})` : ""}. Un acheteur doit vérifier l'assurabilité et les restrictions de construction.`;
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={<Ico name="droplets" 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="ik-status"><StatusBadge tone={tone} lg>{titre}</StatusBadge></div>
49 + <p className="ik-status-d">{texte}</p>
50 + {d.zones.length > 1 && (
51 + <ul className="ik-list" style={{ marginTop: 6 }}>
52 + {d.zones.slice(1, 4).map((z, i) => (
53 + <li className="ik-item" key={i} style={{ padding: "6px 0" }}>
54 + <div className="ik-item-main"><div className="ik-item-s" style={{ color: "var(--ik-text-2)" }}>{z.type}{z.recurrence ? ` (${z.recurrence})` : ""}</div></div>
55 + <div className="ik-item-r"><div className="ik-item-m">{z.distance_m === 0 ? "à l'adresse" : `~${z.distance_m} m`}</div></div>
56 + </li>
57 + ))}
58 + </ul>
59 + )}
60 + <a className="ik-btn ik-btn-ghost" href={ZI_URL} target="_blank" rel="noopener noreferrer" style={{ marginTop: 12, width: "100%" }}>
61 + Voir la carte officielle
62 + </a>
63 + <SourceLine name="Gouvernement du Québec (BDZI)" />
64 + <Accordion title="En savoir plus" small>
65 + <p>
66 + Base de données des zones à risque d'inondation (BDZI), gouvernement du Québec — indicatif seulement,
67 + selon la position géocodée de l'adresse. L'absence de zone dans un secteur non cartographié ne signifie
68 + pas une absence de risque.{" "}
69 + <a href={ZI_URL} target="_blank" rel="noopener noreferrer">La cartographie officielle fait foi</a>.
70 + </p>
71 + </Accordion>
72 + </>
73 + )}
74 + </SectionCard>
75 + );
76 +}
77 +
78 +/* --- Qualité de l'air ------------------------------------------------------- */
79 +const ORDRE = ["PM2.5", "NO2", "O3", "PST", "PM10", "SO2", "CO"];
80 +const NOMS: Record<string, string> = { "PM2.5": "PM2,5", PST: "PST", PM10: "PM10", NO2: "NO₂", O3: "O₃", SO2: "SO₂", CO: "CO" };
81 +const LONG: Record<string, string> = {
82 + "PM2.5": "Particules fines", PST: "Particules totales", PM10: "Particules PM10", NO2: "Dioxyde d'azote",
83 + O3: "Ozone", SO2: "Dioxyde de soufre", CO: "Monoxyde de carbone",
84 +};
85 +
86 +export function AirQualityCard({ r, onRetry }: { r: Res<AirNearby>; onRetry: () => void }) {
87 + if (r.status === "na") return null;
88 + const d = r.status === "ok" ? r.data : null;
89 + const pols = d ? ORDRE.filter((p) => d.mesures[p]) : [];
90 + if (d && (!d.station || pols.length === 0)) return null;
91 + let tone: Tone = "neutral", verdict = "Mesures disponibles";
92 + if (d) {
93 + const pm = d.mesures["PM2.5"];
94 + if (pm?.ref) {
95 + const rr = pm.moyenne / pm.ref;
96 + [tone, verdict] = rr <= 1 ? ["good", "Très bonne"] : rr <= 2 ? ["good", "Bonne"] : rr <= 3 ? ["warn", "Passable"] : ["bad", "Particules élevées"];
97 + } else if (d.mesures.PST?.ref) {
98 + [tone, verdict] = d.mesures.PST.moyenne <= d.mesures.PST.ref ? ["good", "Sous la norme"] : ["bad", "Au-dessus de la norme"];
99 + }
100 + }
101 + const annee = d ? Object.values(d.mesures)[0]?.annee : null;
102 + return (
103 + <SectionCard id="air" title="Qualité de l'air" icon={<Ico name="wind" size={18} />}
104 + aside={d && <StatusBadge tone={tone} lg>{verdict}</StatusBadge>}>
105 + {r.status === "loading" && <SkeletonLines n={3} />}
106 + {r.status === "error" && <ErrorState onRetry={onRetry}>Données de qualité de l'air temporairement indisponibles.</ErrorState>}
107 + {d && (
108 + <>
109 + <div className="ik-air">
110 + {pols.slice(0, 3).map((p) => {
111 + const m = d.mesures[p];
112 + const ratio = m.ref ? m.moyenne / m.ref : null;
113 + const t: Tone = ratio == null ? "neutral" : ratio <= 1 ? "good" : ratio <= 2 ? "warn" : "bad";
114 + 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`;
115 + return (
116 + <div className="ik-air-c" key={p} title={LONG[p]}>
117 + <div className="ik-air-p">{NOMS[p] ?? p}</div>
118 + <div className="ik-air-v">{m.moyenne.toLocaleString("fr-CA")}<small>{m.unite}</small></div>
119 + <div className={`ik-air-s ${t}`}>{s}{m.ref_nom && ratio != null ? ` ${m.ref_nom}` : ""}</div>
120 + </div>
121 + );
122 + })}
123 + </div>
124 + {pols.length > 3 && (
125 + <Accordion title={`Autres mesures (${pols.length - 3})`} small>
126 + <div className="ik-rows">
127 + {pols.slice(3).map((p) => {
128 + const m = d.mesures[p];
129 + return (
130 + <div className="ik-row" key={p}>
131 + <span className="ik-row-name">{LONG[p] ?? p}</span>
132 + <span className="ik-row-val">{m.moyenne.toLocaleString("fr-CA")} <small style={{ fontWeight: 500, color: "var(--ik-muted)" }}>{m.unite}</small></span>
133 + <span className="ik-row-lbl">{m.ref != null ? `repère ${m.ref}` : ""}</span>
134 + </div>
135 + );
136 + })}
137 + </div>
138 + </Accordion>
139 + )}
140 + <SourceLine name="MELCCFP (RSQAQ)" date={<>station {d.station}{d.distance_km != null ? ` · ${d.distance_km.toLocaleString("fr-CA")} km` : ""}{annee ? ` · données ${annee}` : ""}</>} />
141 + <Accordion title="Méthodologie" small>
142 + <p>
143 + Moyennes annuelles {annee} mesurées à la station <b>{d.station}</b> ({d.ville}, à {d.distance_km} km) du Réseau de
144 + surveillance de la qualité de l'air du Québec (MELCCFP, données ouvertes). Chaque mesure est située par
145 + rapport à son repère annuel (lignes directrices OMS 2021, ou norme québécoise RAA pour les PST).
146 + L'air à l'adresse peut différer localement.
147 + </p>
148 + </Accordion>
149 + </>
150 + )}
151 + </SectionCard>
152 + );
153 +}
154 +
155 +/* --- Essence ------------------------------------------------------------------ */
156 +const cents = (v: number | null | undefined) => (v == null ? "—" : `${v.toLocaleString("fr-CA", { minimumFractionDigits: 1 })}${NBSP}¢`);
157 +
158 +export function GasNearbyCard({ r, onRetry }: { r: Res<GazNearby>; onRetry: () => void }) {
159 + const [sheet, setSheet] = useState(false);
160 + if (r.status === "na" || r.status === "idle") return null;
161 + const d = r.status === "ok" ? r.data : null;
162 + if (d && d.stations.length === 0) return null;
163 + const Row = ({ s, full = false }: { s: GazNearby["stations"][number]; full?: boolean }) => (
164 + <li className="ik-item">
165 + <span className="ik-item-ico" aria-hidden="true"><Ico name="fuel" size={17} /></span>
166 + <div className="ik-item-main">
167 + <div className="ik-item-t">{s.nom}{s.moins_chere && <span className="ik-item-best">la moins chère</span>}</div>
168 + <div className="ik-item-s">{fmtDist(s.dist_m)}{full && s.adresse ? ` · ${s.adresse}` : ""}{full ? ` · super ${cents(s.super)} · diesel ${cents(s.diesel)}` : ""}</div>
169 + </div>
170 + <div className="ik-item-r">
171 + <div className="ik-item-v">{cents(s.regulier)}<small style={{ fontWeight: 500, color: "var(--ik-muted)" }}>/L</small></div>
172 + <div className="ik-item-m">régulier</div>
173 + </div>
174 + </li>
175 + );
176 + return (
177 + <SectionCard id="essence" title="Essence" icon={<Ico name="fuel" size={18} />}>
178 + {r.status === "loading" && <SkeletonLines n={3} />}
179 + {r.status === "error" && <ErrorState onRetry={onRetry}>Prix de l'essence temporairement indisponibles.</ErrorState>}
180 + {d && (
181 + <>
182 + <div className="ik-kpis cols-3">
183 + <StatTile accent value={d.min_regulier != null ? d.min_regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 }) : "—"} unit="¢/L" label="Meilleur prix" />
184 + <StatTile value={d.mediane_regulier != null ? d.mediane_regulier.toLocaleString("fr-CA", { minimumFractionDigits: 1 }) : "—"} unit="¢/L" label="Médiane du secteur" />
185 + <StatTile value={d.n} label={`Stations à moins de ${fmtDist(d.rayon_m)}`} />
186 + </div>
187 + <ul className="ik-list" style={{ marginTop: 10 }}>
188 + {d.stations.slice(0, 3).map((s, i) => <Row s={s} key={i} />)}
189 + </ul>
190 + {d.stations.length > 3 && <MoreButton onClick={() => setSheet(true)}>Voir les {d.n} stations</MoreButton>}
191 + <SourceLine name="gazquebec.ca" href="https://gazquebec.ca" date={d.maj ? `mis à jour ${d.maj}` : undefined} />
192 + <BottomSheet open={sheet} onClose={() => setSheet(false)} title="Stations-service" sub={`${d.n} stations à moins de ${fmtDist(d.rayon_m)} · prix en ¢/L`} tall>
193 + <ul className="ik-list">{d.stations.map((s, i) => <Row s={s} full key={i} />)}</ul>
194 + </BottomSheet>
195 + </>
196 + )}
197 + </SectionCard>
198 + );
199 +}
added frontend/src/fiche/FinancingCard.tsx +295 −0
@@ -0,0 +1,295 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/FinancingCard.tsx : « Financement et coût de propriété » — calculateur
5 +// hypothécaire canadien branché sur les taux RÉELS observés (immoka/mortgage :
6 +// composition semestrielle pour les fixes, SCHL, test de résistance,
7 +// renouvellement, comparateur par banque, historique du taux, amortissement),
8 +// puis COÛT DE PROPRIÉTÉ MENSUEL : versement + taxes municipales/scolaires
9 +// publiées + copropriété + électricité (estimation Hydro-Québec quand elle
10 +// existe), chaque poste étiqueté (publié / estimé / inconnu). Reprend toute
11 +// la logique de l'ancien composant Financement, en accordéons compacts.
12 +// -----------------------------------------------------------------------------
13 +import { useEffect, useMemo, useRef, useState } from "react";
14 +import { Link } from "react-router-dom";
15 +import {
16 + HydroEstimate, Listing, MortgageBest, MortgageCalc, calculateMortgage, fetchHydro, fetchMortgageBest,
17 + fmtPrice, fmtRate,
18 +} from "../api";
19 +import { Ico } from "../components/Icons";
20 +import TauxHistorique from "../components/TauxHistorique";
21 +import { estLocation, fraisCoproMensuels, taxesAnnuelles } from "./synthese";
22 +import { Accordion, ErrorState, SectionCard, StatTile, NBSP } from "./ui";
23 +import { Res } from "./useFicheData";
24 +
25 +const FREQS: [string, string][] = [
26 + ["monthly", "Mensuel"], ["semimonthly", "Bimensuel (24/an)"], ["biweekly", "Aux 2 semaines"],
27 + ["accelerated-biweekly", "Aux 2 semaines accéléré"], ["weekly", "Hebdomadaire"], ["accelerated-weekly", "Hebdomadaire accéléré"],
28 +];
29 +const TERMES: [number, string][] = [[12, "1 an"], [24, "2 ans"], [36, "3 ans"], [48, "4 ans"], [60, "5 ans"], [84, "7 ans"], [120, "10 ans"]];
30 +const KIND_FR: Record<string, string> = { posted: "taux affiché", special: "offre spéciale" };
31 +const INSURED_FR: Record<string, string> = { insured: "assuré", insurable: "assurable", uninsured: "non assuré", unknown: "" };
32 +
33 +const nf = (n: number) => n.toLocaleString("fr-CA", { maximumFractionDigits: 0 });
34 +const money = (n: number | null | undefined) =>
35 + n == null ? "—" : `${n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $`;
36 +const freshness = (m: number) => (m < 60 ? `il y a ${m} min` : m < 48 * 60 ? `il y a ${Math.round(m / 60)} h` : `il y a ${Math.round(m / 1440)} j`);
37 +
38 +/** Versement estimé au taux d'une autre banque, même scénario (approximation
39 + * frontend par facteur d'annuité — les chiffres officiels du scénario viennent
40 + * toujours du moteur). */
41 +function estimatePayment(res: MortgageCalc, rate: number): number {
42 + const { amortization_years, frequency, compounding } = res.inputs;
43 + const f = ({ monthly: 12, semimonthly: 24, biweekly: 26, "accelerated-biweekly": 26, weekly: 52, "accelerated-weekly": 52 } as Record<string, number>)[frequency] ?? 12;
44 + const per = (pct: number, k: number) =>
45 + compounding === "monthly" ? Math.pow(1 + pct / 100 / 12, 12 / k) - 1 : Math.pow(1 + pct / 100 / 2, 2 / k) - 1;
46 + const pay0 = (pct: number, k: number) => {
47 + const i = per(pct, k);
48 + const n = Math.round(amortization_years * k);
49 + return Math.round(((res.principal * i) / (1 - Math.pow(1 + i, -n))) * 100) / 100;
50 + };
51 + if (frequency.startsWith("accelerated")) {
52 + const m = pay0(rate, 12);
53 + return Math.round((m / (frequency === "accelerated-biweekly" ? 2 : 4)) * 100) / 100;
54 + }
55 + return pay0(rate, f);
56 +}
57 +
58 +export default function FinancingCard({ l, hydro }: { l: Listing; hydro: Res<HydroEstimate> }) {
59 + const prix = l.price;
60 + const [price, setPrice] = useState<number>(prix ?? 0);
61 + const [down, setDown] = useState<number>(Math.round((prix ?? 0) * 0.2));
62 + const [amort, setAmort] = useState(25);
63 + const [term, setTerm] = useState(60);
64 + const [rateType, setRateType] = useState<"fixed" | "variable">("fixed");
65 + const [freq, setFreq] = useState("monthly");
66 + const [res, setRes] = useState<MortgageCalc | null>(null);
67 + const [err, setErr] = useState<string | null>(null);
68 + const [best, setBest] = useState<MortgageBest | null>(null);
69 + const [h, setH] = useState<HydroEstimate | null>(null);
70 + const [busy, setBusy] = useState(false);
71 + const timer = useRef<number>();
72 +
73 + const downPct = price > 0 ? (down / price) * 100 : 0;
74 + const setDownPct = (pct: number) => setDown(Math.round((price * Math.min(99, Math.max(0, pct))) / 100));
75 +
76 + // recalcul débobiné : les taux viennent du moteur, jamais du navigateur
77 + useEffect(() => {
78 + if (!price || price <= 0 || down < 0 || down >= price) { setRes(null); return; }
79 + window.clearTimeout(timer.current);
80 + timer.current = window.setTimeout(() => {
81 + calculateMortgage({ price, down_payment: down, amortization_years: amort, term_months: term, frequency: freq, rate_type: rateType })
82 + .then((r) => { setRes(r); setErr(null); })
83 + .catch(() => setErr("Taux momentanément indisponibles — réessayez plus tard."));
84 + }, 350);
85 + return () => window.clearTimeout(timer.current);
86 + }, [price, down, amort, term, rateType, freq]);
87 +
88 + // comparateur par banque (mêmes type/terme que le scénario)
89 + useEffect(() => {
90 + setBest(null);
91 + fetchMortgageBest(rateType, term).then(setBest).catch(() => setBest(null));
92 + }, [rateType, term]);
93 +
94 + const taxes = taxesAnnuelles(l);
95 + const copro = fraisCoproMensuels(l);
96 + const hy = h ?? (hydro.status === "ok" ? hydro.data : null);
97 + const hydroVisible = hy && (hy.disponible || hy.en_attente || !/captcha|configuré|incomplète/.test(hy.raison || ""));
98 + const lancerHydro = () => {
99 + setBusy(true);
100 + fetchHydro(l.address || l.title, { uid: l.uid, lat: l.lat, lng: l.lng }, true).then(setH).catch(() => {}).finally(() => setBusy(false));
101 + };
102 +
103 + const cout = useMemo(() => {
104 + if (!res) return null;
105 + const lignes: { poste: string; statut: "publié" | "estimé" | "inconnu"; montant: number | null }[] = [
106 + { poste: "Versement hypothécaire (équiv. mensuel)", statut: "estimé", montant: res.payment_monthly_equivalent },
107 + { poste: "Taxes municipales et scolaires", statut: taxes ? "publié" : "inconnu", montant: taxes ? Math.round(taxes.total / 12) : null },
108 + ];
109 + if (copro != null || /condo|copropri/i.test(l.property_type || ""))
110 + lignes.push({ poste: "Frais de copropriété", statut: copro != null ? "publié" : "inconnu", montant: copro });
111 + lignes.push({ poste: "Électricité (Hydro-Québec)", statut: hy?.disponible ? "estimé" : "inconnu", montant: hy?.disponible ? Math.round(hy.cout_mensuel!) : null });
112 + lignes.push({ poste: "Assurance habitation", statut: "inconnu", montant: null });
113 + const total = lignes.reduce((s, x) => s + (x.montant ?? 0), 0);
114 + const inconnus = lignes.filter((x) => x.montant == null).map((x) => x.poste.toLowerCase());
115 + return { lignes, total, inconnus };
116 + }, [res, taxes, copro, hy, l.property_type]);
117 +
118 + if (prix == null || prix <= 0 || estLocation(l)) return null;
119 + const src = res?.rate_source ?? null;
120 + const ins = res?.insurance;
121 +
122 + return (
123 + <SectionCard id="financement" title="Financement et coût de propriété" icon={<Ico name="bank" size={18} />}
124 + sub="Taux réels publiés par les banques canadiennes, collectés en continu par Immo-Ka">
125 + <div className="ik-form">
126 + <label><span>Prix</span>
127 + <input type="number" inputMode="numeric" min={1} value={price || ""} onChange={(e) => setPrice(Number(e.target.value) || 0)} /></label>
128 + <label><span>Mise de fonds ($)</span>
129 + <input type="number" inputMode="numeric" min={0} value={down || ""} onChange={(e) => setDown(Number(e.target.value) || 0)} /></label>
130 + <label><span>Mise de fonds (%)</span>
131 + <input type="number" inputMode="decimal" min={0} max={99} step={1} value={downPct ? Math.round(downPct * 10) / 10 : ""}
132 + onChange={(e) => setDownPct(Number(e.target.value) || 0)} /></label>
133 + <label><span>Amortissement</span>
134 + <select value={amort} onChange={(e) => setAmort(Number(e.target.value))}>
135 + {[10, 15, 20, 25, 30].map((a) => <option key={a} value={a}>{a} ans</option>)}
136 + </select></label>
137 + <label><span>Terme</span>
138 + <select value={term} onChange={(e) => setTerm(Number(e.target.value))}>
139 + {TERMES.map(([m, t]) => <option key={m} value={m}>{t}</option>)}
140 + </select></label>
141 + <label><span>Type de taux</span>
142 + <select value={rateType} onChange={(e) => setRateType(e.target.value as "fixed" | "variable")}>
143 + <option value="fixed">Fixe</option><option value="variable">Variable</option>
144 + </select></label>
145 + <label><span>Fréquence</span>
146 + <select value={freq} onChange={(e) => setFreq(e.target.value)}>
147 + {FREQS.map(([v, t]) => <option key={v} value={v}>{t}</option>)}
148 + </select></label>
149 + </div>
150 +
151 + {err && <ErrorState>{err}</ErrorState>}
152 +
153 + {res && (
154 + <>
155 + <div className="ik-kpis" style={{ marginTop: 12 }}>
156 + <StatTile accent value={money(res.payment)} label={<>{FREQS.find(([v]) => v === freq)?.[1]}{freq !== "monthly" && <> · équiv. {money(res.payment_monthly_equivalent)}/mois</>}</>} anim={false} />
157 + <StatTile value={fmtRate(res.inputs.rate)} label={src ? `${src.institution} — ${KIND_FR[src.kind] ?? src.kind}${INSURED_FR[src.insured_status ?? "unknown"] ? `, ${INSURED_FR[src.insured_status ?? "unknown"]}` : ""}` : "Taux utilisé"} anim={false} />
158 + <StatTile value={fmtPrice(res.principal)} label={`Hypothèque · mise de fonds ${res.inputs.down_payment_pct.toLocaleString("fr-CA")}${NBSP}%`} anim={false} />
159 + <StatTile value={money(res.qualifying.payment)} label={`Test de résistance à ${fmtRate(res.qualifying.rate)}`} anim={false} />
160 + </div>
161 + {src && (
162 + <p className="ik-fine" style={{ marginTop: 8 }}>
163 + Taux observé chez <b>{src.institution}</b> {freshness(src.age_minutes)}{src.stale && " ⚠ donnée de plus de 24 h"}
164 + {src.source_url && <> · <a href={src.source_url} target="_blank" rel="noopener noreferrer">source officielle ↗</a></>}
165 + {" "}· <Link to="/taux-hypothecaires">Tous les taux</Link>
166 + </p>
167 + )}
168 + {ins && ins.required && (
169 + <div className={`ik-note ${ins.eligible ? "info" : "warn"}`}>
170 + <b>Assurance prêt hypothécaire (SCHL)</b>
171 + {ins.eligible && (
172 + <> — prime <b>{fmtPrice(ins.premium)}</b> ({ins.premium_rate.toLocaleString("fr-CA")}{NBSP}% du prêt, ajoutée à l'hypothèque),
173 + TVQ sur la prime <b>{money(ins.qc_tax)}</b> payable à la clôture, rapport prêt-valeur {ins.ltv?.toLocaleString("fr-CA")}{NBSP}%.</>
174 + )}
175 + {ins.issues.map((i, k) => <div key={k}>⚠ {i}</div>)}
176 + </div>
177 + )}
178 +
179 + {cout && (
180 + <>
181 + <h3 className="ik-card-sub ik-subtitle">Coût de propriété mensuel estimé</h3>
182 + <ul className="ik-cost">
183 + {cout.lignes.map((li) => (
184 + <li key={li.poste}>
185 + <span className="n">{li.poste}<span className={`ik-pill ${li.statut === "publié" ? "observed" : li.statut === "estimé" ? "estimated" : "unknown"}`}>{li.statut}</span></span>
186 + <span className={`v ${li.montant == null ? "na" : ""}`}>{li.montant != null ? fmtPrice(li.montant) : "—"}</span>
187 + </li>
188 + ))}
189 + <li className="total"><span className="n">Total estimé</span><span className="v">≈{NBSP}{fmtPrice(Math.round(cout.total))}{NBSP}/mois</span></li>
190 + <li className="annuel"><span className="n">soit sur 12 mois</span><span className="v">≈{NBSP}{fmtPrice(Math.round(cout.total * 12))}</span></li>
191 + </ul>
192 + {cout.inconnus.length > 0 && (
193 + <p className="ik-cost-note">Non chiffrables avec les données publiées : {cout.inconnus.join(", ")} — le total réel est plus élevé.</p>
194 + )}
195 + {taxes && (
196 + <p className="ik-cost-note">Taxes publiées par la source : {taxes.municipales != null ? `municipales ${fmtPrice(Math.round(taxes.municipales))}` : ""}{taxes.municipales != null && taxes.scolaires != null ? " · " : ""}{taxes.scolaires != null ? `scolaires ${fmtPrice(Math.round(taxes.scolaires))}` : ""} par année.</p>
197 + )}
198 + {hydroVisible && hy && (
199 + <div className="ik-note info" style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
200 + <Ico name="drop" size={16} />
201 + {hy.disponible ? (
202 + <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 de la propriété.</span>
203 + ) : hy.en_attente ? (
204 + <>
205 + <span style={{ flex: 1 }}>Estimation Hydro-Québec du coût d'électricité disponible à la demande.</span>
206 + <button type="button" className="ik-btn ik-btn-ghost" style={{ minHeight: 38 }} onClick={lancerHydro} disabled={busy}>
207 + {busy ? "Estimation en cours…" : "Estimer"}
208 + </button>
209 + </>
210 + ) : (
211 + <span>Hydro-Québec n'a pas d'estimation pour cette adresse.</span>
212 + )}
213 + </div>
214 + )}
215 + </>
216 + )}
217 +
218 + <Accordion title="Et si les taux montent ? (test de résistance)" small>
219 + <div className="ik-facts-rows" style={{ marginTop: 0 }}>
220 + {res.stress.map((s) => (
221 + <div className="ik-kv" key={s.bump}>
222 + <span className="k">{s.bump === 0 ? "Taux actuel" : `+${s.bump} point${s.bump > 1 ? "s" : ""}`} — {fmtRate(s.rate)}</span>
223 + <span className="v">{money(s.payment)}</span>
224 + </div>
225 + ))}
226 + </div>
227 + <p>{res.qualifying.note}</p>
228 + </Accordion>
229 +
230 + <Accordion title={`Au renouvellement (${TERMES.find(([m]) => m === term)?.[1]})`} small>
231 + <p>
232 + Solde restant à l'échéance : <b>{fmtPrice(res.renewal.balance_at_renewal)}</b> (amortissement résiduel {res.renewal.remaining_amortization_years} ans).
233 + Intérêts payés pendant le terme : {fmtPrice(res.term.interest_paid)}.
234 + </p>
235 + <div className="ik-facts-rows" style={{ marginTop: 0 }}>
236 + {res.renewal.scenarios.map((s) => (
237 + <div className="ik-kv" key={s.bump}>
238 + <span className="k">Renouvelé à {fmtRate(s.rate)} ({s.bump >= 0 ? "+" : ""}{s.bump} pt)</span>
239 + <span className="v">{money(s.payment)}</span>
240 + </div>
241 + ))}
242 + </div>
243 + </Accordion>
244 +
245 + {best && best.per_institution.length > 1 && (
246 + <Accordion title={`Comparer les banques (${best.institutions_count} institutions)`} small>
247 + <div className="ik-table-wrap">
248 + <table className="ik-table">
249 + <thead><tr><th>Institution</th><th>Taux</th><th>Nature</th><th>Versement</th><th>Fraîcheur</th></tr></thead>
250 + <tbody>
251 + {best.per_institution.map((r) => (
252 + <tr key={r.provider}>
253 + <td>{r.source_url ? <a href={r.source_url} target="_blank" rel="noopener noreferrer">{r.institution}</a> : r.institution}</td>
254 + <td><b>{fmtRate(r.rate)}</b>{r.apr != null ? ` (TAP ${fmtRate(r.apr)})` : ""}</td>
255 + <td>{KIND_FR[r.kind]}{INSURED_FR[r.insured_status] ? ` · ${INSURED_FR[r.insured_status]}` : ""}</td>
256 + <td>{res.principal > 0 ? money(estimatePayment(res, r.rate)) : "—"}</td>
257 + <td style={r.stale ? { color: "var(--ik-warning)" } : undefined}>{freshness(r.age_minutes)}</td>
258 + </tr>
259 + ))}
260 + </tbody>
261 + </table>
262 + </div>
263 + <p>Produits comparables seulement (même type, même terme) — un taux « affiché » et une « offre spéciale » ne sont pas la même chose. Versements estimés sur votre scénario.</p>
264 + </Accordion>
265 + )}
266 +
267 + <Accordion title={`Historique du taux (${rateType === "fixed" ? "fixe" : "variable"} ${TERMES.find(([m]) => m === term)?.[1]})`} small>
268 + <TauxHistorique rateType={rateType} termMonths={term} />
269 + </Accordion>
270 +
271 + <Accordion title="Amortissement année par année" small>
272 + <div className="ik-table-wrap">
273 + <table className="ik-table">
274 + <thead><tr><th>Année</th><th>Intérêts</th><th>Capital</th><th>Solde</th></tr></thead>
275 + <tbody>
276 + {res.annual.map((a) => (
277 + <tr key={a.year}><td>{a.year}</td><td>{nf(a.interest)} $</td><td>{nf(a.principal)} $</td><td>{nf(a.balance)} $</td></tr>
278 + ))}
279 + </tbody>
280 + </table>
281 + </div>
282 + {res.payoff_years < res.inputs.amortization_years && (
283 + <p>Avec la fréquence accélérée choisie, le prêt s'éteint en <b>{res.payoff_years} ans</b> au lieu de {res.inputs.amortization_years}.</p>
284 + )}
285 + </Accordion>
286 + </>
287 + )}
288 +
289 + <p className="ik-fine" style={{ marginTop: 10 }}>
290 + Outil indicatif seulement — ni offre de financement ni préapprobation. Les taux affichés sont ceux publiés par les
291 + institutions (source et fraîcheur indiquées) ; vérifiez auprès de la banque ou d'un courtier hypothécaire.
292 + </p>
293 + </SectionCard>
294 + );
295 +}
added frontend/src/fiche/ImmoKaScore.tsx +65 −0
@@ -0,0 +1,65 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ImmoKaScore.tsx : carte « Immo-Ka Score » — anneau 0-100 animé,
5 +// libellé, composantes (emplacement / prix) et méthodologie en accordéon.
6 +// Quand une composante manque, le score est présenté comme partiel ; quand
7 +// aucune n'est fiable, la carte affiche la synthèse sans chiffre.
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useState } from "react";
10 +import { Ico } from "../components/Icons";
11 +import { ImmoKaScore as Score } from "./synthese";
12 +import { Accordion, SectionCard } from "./ui";
13 +
14 +export function ScoreRing({ value, partial, size = 88 }: { value: number | null; partial?: boolean; size?: number }) {
15 + const [v, setV] = useState(0);
16 + useEffect(() => {
17 + const t = requestAnimationFrame(() => setV(value ?? 0));
18 + return () => cancelAnimationFrame(t);
19 + }, [value]);
20 + const r = 40, c = 2 * Math.PI * r;
21 + return (
22 + <div className="ik-score-ring" style={{ width: size, height: size }} role="img"
23 + aria-label={value != null ? `Immo-Ka Score ${value} sur 100${partial ? ", partiel" : ""}` : "Score non calculable"}>
24 + <svg viewBox="0 0 100 100" style={{ width: size, height: size }} aria-hidden="true">
25 + <circle className="bg" cx="50" cy="50" r={r} />
26 + <circle className={`arc ${partial ? "partial" : ""}`} cx="50" cy="50" r={r}
27 + strokeDasharray={`${(c * Math.max(0, Math.min(100, v))) / 100} ${c}`} />
28 + </svg>
29 + <div className="ik-score-val">
30 + <div>{value != null ? value : "—"}<small>/ 100</small></div>
31 + </div>
32 + </div>
33 + );
34 +}
35 +
36 +export default function ImmoKaScore({ s, resume }: { s: Score; resume: string[] }) {
37 + return (
38 + <SectionCard id="score" title="Immo-Ka Score" icon={<Ico name="sparkles" size={18} />}
39 + sub={s.partial ? "Score partiel — une composante manque" : undefined}>
40 + <div className="ik-score">
41 + <ScoreRing value={s.value} partial={s.partial} />
42 + <div>
43 + <div className="ik-score-lbl">
44 + {s.value != null ? s.label : "Pas assez de données pour un score"}
45 + </div>
46 + {resume.length > 0 && <div className="ik-score-sub">{resume.join(" · ")}</div>}
47 + <div className="ik-score-parts">
48 + <span className={`ik-score-part ${s.emplacement == null ? "na" : ""}`}>
49 + Emplacement {s.emplacement != null ? <b>{s.emplacement}</b> : "non évalué"}
50 + </span>
51 + <span className={`ik-score-part ${s.prix == null ? "na" : ""}`}>
52 + Prix {s.prix != null ? <b>{s.prix}</b> : "non évalué"}
53 + </span>
54 + </div>
55 + </div>
56 + </div>
57 + <Accordion title="Comment est calculé ce score ?" small>
58 + <p>{s.explication}</p>
59 + <p>
60 + <a href="https://www.vrai-prix.com" target="_blank" rel="noopener noreferrer">Méthodologie Vrai-Prix ↗</a> · <a href="/sources">Données et sources Immo-Ka</a>
61 + </p>
62 + </Accordion>
63 + </SectionCard>
64 + );
65 +}
added frontend/src/fiche/InteractiveMap.tsx +124 −0
@@ -0,0 +1,124 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (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 +// bâtiment surligné en cerise, 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 le bâtiment 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 { Ico } 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, deal }: { l: Listing; lieux: Lieu[]; loadingLieux: boolean; deal: 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={<Ico name="pin" size={18} />}
95 + sub="Bâtiment de l'annonce en cerise · position selon l'adresse géocodée (Adresses Québec)">
96 + <div className="ik-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={`ik-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="ik-map" role="img" aria-label={`Carte 3D — ${l.address || l.title}`}>
110 + {visible ? (
111 + <Suspense fallback={<Skeleton className="ik-map-skel" h="100%" r={0} />}>
112 + <MapInner l={l} lieux={actifs} categories={CATEGORIES} deal={deal} />
113 + </Suspense>
114 + ) : <Skeleton className="ik-map-skel" h="100%" r={0} />}
115 + <span className="ik-map-legend" aria-hidden="true"><i /> Bâtiment de l'annonce</span>
116 + </div>
117 + {actifs.length > 0 && (
118 + <p className="ik-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 +// Immo-Ka — Agrégateur de propriétés à vendre (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 de la propriété, suggestions
7 +// prédéfinies, champ libre. L'envoi passe par le widget KA Agent existant
8 +// (ka-agent.js, backend api-ka inchangé) : on ouvre son panneau et on lui
9 +// transmet la question, enrichie du contexte de la fiche (adresse, ville,
10 +// prix, type, lien).
11 +// -----------------------------------------------------------------------------
12 +import { useEffect, useState } from "react";
13 +import { Listing, fmtPrice } from "../api";
14 +import { Ico } from "../components/Icons";
15 +import BottomSheet from "./BottomSheet";
16 +
17 +const SUGGESTIONS = [
18 + "Est-ce un bon prix pour ce secteur ?",
19 + "Compare cette propriété au quartier",
20 + "Quels frais dois-je prévoir en plus du prix ?",
21 + "Quels sont les points de vigilance ?",
22 + "Explique l'estimation Vrai-Prix",
23 +];
24 +
25 +/** Ouvre le panneau KA Agent (widget partagé) et envoie la question. */
26 +function envoyerAKa(question: string): boolean {
27 + const btn = document.querySelector<HTMLButtonElement>(".kaa-btn");
28 + const ta = document.querySelector<HTMLTextAreaElement>(".kaa-panel textarea");
29 + const send = document.querySelector<HTMLButtonElement>(".kaa-in button");
30 + if (!btn || !ta || !send) return false;
31 + btn.click();
32 + ta.value = question;
33 + setTimeout(() => send.click(), 60);
34 + return true;
35 +}
36 +
37 +export default function KaAssistant({ l, hidden }: { l: Listing; hidden?: boolean }) {
38 + const [open, setOpen] = useState(false);
39 + const [q, setQ] = useState("");
40 + const [err, setErr] = useState(false);
41 + useEffect(() => {
42 + const on = () => setOpen(true);
43 + window.addEventListener("ik:askka", on);
44 + return () => window.removeEventListener("ik:askka", on);
45 + }, []);
46 +
47 + const contexte = () =>
48 + `(Propriété consultée sur Immo-Ka : ${l.address || l.title}${l.city ? `, ${l.city}` : ""}` +
49 + `${l.price != null ? ` — ${fmtPrice(l.price)}` : ""}${l.property_type ? ` — ${l.property_type}` : ""}` +
50 + ` — https://www.immo-ka.com/propriete/${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={`ik-ka-btn ${hidden ? "hide" : ""}`} onClick={() => setOpen(true)}
60 + aria-label="Demander à Ka, l'assistant Groupe KA">
61 + <Ico name="sparkles" 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="ik-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 cette propriété…" aria-label="Votre question" />
67 + <button type="submit" aria-label="Envoyer" disabled={!q.trim()}><Ico name="chevright" size={20} /></button>
68 + </form>
69 + }>
70 + <div className="ik-ka-intro">
71 + <span className="ik-ka-avatar" aria-hidden="true"><Ico name="sparkles" size={18} /></span>
72 + <p>Je peux situer ce prix dans le quartier, expliquer les données de la fiche, estimer les frais et chercher dans les autres plateformes Groupe KA.</p>
73 + </div>
74 + <div className="ik-ka-ctx">
75 + {l.images?.[0] && <img src={l.images[0]} alt="" />}
76 + <span style={{ minWidth: 0 }}>
77 + <b>{l.address || l.title}</b>
78 + {[l.city, l.price != null ? fmtPrice(l.price) : null, l.property_type].filter(Boolean).join(" · ")}
79 + </span>
80 + </div>
81 + <div className="ik-ka-sugs">
82 + {SUGGESTIONS.map((s) => (
83 + <button type="button" className="ik-ka-sug" key={s} onClick={() => poser(s)}>
84 + {s} <Ico name="chevright" size={16} />
85 + </button>
86 + ))}
87 + </div>
88 + {err && <p className="ik-note warn">L'assistant n'est pas encore chargé — réessayez dans un instant.</p>}
89 + </BottomSheet>
90 + </>
91 + );
92 +}
added frontend/src/fiche/MapInner.tsx +109 −0
@@ -0,0 +1,109 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (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, thème Immo-Ka) + lignes de
6 +// métro + couche des lieux filtrés (cercles colorés par catégorie +
7 +// étiquettes) + caméra qui 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 { immoKaMapTheme } from "../kamaps/theme";
18 +import { MAPBOX_TOKEN } from "../kamaps/config";
19 +import type { Categorie, Lieu } from "./InteractiveMap";
20 +
21 +const SRC = "ik-lieux";
22 +/** Cerise signal Immo-Ka — même langage que l'accent de marque. */
23 +export const BUILDING_RED = "#e23744";
24 +
25 +function LieuxLayer({ lieux, categories, center }: { lieux: Lieu[]; categories: Categorie[]; center: [number, number] }) {
26 + const ka = useKaMap();
27 + const couleurs = useMemo(() => {
28 + const m: unknown[] = ["match", ["get", "cat"]];
29 + for (const c of categories) m.push(c.key, c.color);
30 + m.push("#666");
31 + return m;
32 + }, [categories]);
33 +
34 + useEffect(() => {
35 + if (!ka) return;
36 + const map = (ka as unknown as { map: mapboxgl.Map }).map;
37 + if (!map) return;
38 + const data = {
39 + type: "FeatureCollection" as const,
40 + features: lieux.map((x) => ({
41 + type: "Feature" as const, properties: { cat: x.cat, name: x.name },
42 + geometry: { type: "Point" as const, coordinates: [x.lng, x.lat] },
43 + })),
44 + };
45 + const ensure = () => {
46 + const src = map.getSource(SRC) as mapboxgl.GeoJSONSource | undefined;
47 + if (src) { src.setData(data); return; }
48 + map.addSource(SRC, { type: "geojson", data });
49 + map.addLayer({
50 + id: `${SRC}-halo`, type: "circle", source: SRC,
51 + paint: { "circle-radius": 9, "circle-color": "#ffffff", "circle-opacity": 0.95 },
52 + });
53 + map.addLayer({
54 + id: `${SRC}-dot`, type: "circle", source: SRC,
55 + paint: { "circle-radius": 6, "circle-color": couleurs as mapboxgl.ExpressionSpecification },
56 + });
57 + map.addLayer({
58 + id: `${SRC}-lbl`, type: "symbol", source: SRC,
59 + layout: {
60 + "text-field": ["get", "name"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top",
61 + "text-font": ["DIN Pro Medium", "Arial Unicode MS Regular"], "text-optional": true,
62 + },
63 + paint: { "text-color": "#141814", "text-halo-color": "#ffffff", "text-halo-width": 1.4 },
64 + });
65 + };
66 + const apply = () => { try { ensure(); } catch { /* style pas prêt */ } };
67 + if (map.isStyleLoaded()) apply();
68 + map.on("style.load", apply);
69 + map.on("load", apply);
70 + // caméra : englober les lieux + le bâtiment ; sans lieu → retour sur le bâtiment
71 + if (lieux.length > 0) {
72 + let w = center[0], e = center[0], s = center[1], n = center[1];
73 + const proches = lieux.filter((x) => x.dist_m <= 2000);
74 + 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); }
75 + map.fitBounds([[w, s], [e, n]], { padding: { top: 60, bottom: 40, left: 40, right: 40 }, pitch: 30, maxZoom: 16, duration: 700 });
76 + } else {
77 + map.easeTo({ center, zoom: 17, pitch: 62, duration: 700 });
78 + }
79 + return () => { map.off("style.load", apply); map.off("load", apply); };
80 + }, [ka, lieux, couleurs, center]);
81 +
82 + useEffect(() => () => {
83 + if (!ka) return;
84 + const map = (ka as unknown as { map: mapboxgl.Map }).map;
85 + try {
86 + for (const id of [`${SRC}-lbl`, `${SRC}-dot`, `${SRC}-halo`]) if (map.getLayer(id)) map.removeLayer(id);
87 + if (map.getSource(SRC)) map.removeSource(SRC);
88 + } catch { /* carte détruite */ }
89 + }, [ka]);
90 + return null;
91 +}
92 +
93 +export default function MapInner({ l, lieux, categories, deal }: { l: Listing; lieux: Lieu[]; categories: Categorie[]; deal: boolean }) {
94 + const property = useMemo<MapProperty>(() => ({
95 + id: l.uid, appSource: "immo-ka", latitude: l.lat as number, longitude: l.lng as number,
96 + kind: "listing", listingType: "sale", price: l.price ?? undefined,
97 + propertyType: l.property_type || undefined, address: l.address || l.title || undefined,
98 + city: l.city || undefined, thumbnailUrl: l.images?.[0], highlight: deal,
99 + }), [l.uid, l.lat, l.lng, l.price, l.property_type, l.address, l.title, l.city, l.images, deal]);
100 + const center = useMemo<[number, number]>(() => [l.lng as number, l.lat as number], [l.lat, l.lng]);
101 +
102 + return (
103 + <KaSpotlightMap theme={immoKaMapTheme} mapboxToken={MAPBOX_TOKEN} property={property} buildingColor={BUILDING_RED}>
104 + <KaBrandBadge />
105 + <MetroLignes />
106 + <LieuxLayer lieux={lieux} categories={categories} center={center} />
107 + </KaSpotlightMap>
108 + );
109 +}
added frontend/src/fiche/MarketPriceCard.tsx +136 −0
@@ -0,0 +1,136 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/MarketPriceCard.tsx : « Prix et marché » — estimation Vrai-Prix
5 +// (valeur, fourchette, confiance) avec jauge SVG (fourchette, estimation,
6 +// prix demandé), écart en %, prix au pi², rôle d'évaluation foncière
7 +// (terrain + bâtiment, année, écart), historique du prix demandé observé par
8 +// Immo-Ka et temps sur le marché, méthodologie en accordéon. Toutes les
9 +// données arrivent avec l'annonce : aucun chargement différé.
10 +// -----------------------------------------------------------------------------
11 +import { Listing, fmtDate, fmtPrice } from "../api";
12 +import { Ico } from "../components/Icons";
13 +import { PriceCapsule } from "./PropertyHero";
14 +import { ComparaisonPrix, estLocation, evaluationMunicipale } from "./synthese";
15 +import { Accordion, SectionCard, StatTile, NBSP } from "./ui";
16 +
17 +const CONF: Record<string, string> = { A: "Confiance élevée", B: "Confiance bonne", C: "Confiance moyenne", D: "Estimation indicative" };
18 +
19 +function Gauge({ lo, hi, est, ask }: { lo: number; hi: number; est: number; ask: number | null }) {
20 + const W = 320, H = 54;
21 + const min = Math.min(lo, ask ?? lo) * 0.97, max = Math.max(hi, ask ?? hi) * 1.03;
22 + const x = (v: number) => 8 + ((Math.min(Math.max(v, min), max) - min) / (max - min)) * (W - 16);
23 + const anchor = (px: number) => (px < 56 ? "start" : px > W - 56 ? "end" : "middle");
24 + return (
25 + <div className="ik-gauge">
26 + <svg viewBox={`0 0 ${W} ${H}`} role="img"
27 + aria-label={`Fourchette Vrai-Prix de ${fmtPrice(lo)} à ${fmtPrice(hi)}, estimation ${fmtPrice(est)}${ask != null ? `, prix demandé ${fmtPrice(ask)}` : ""}`}>
28 + <rect className="ik-gauge-track" x="8" y="24" width={W - 16} height="8" rx="4" />
29 + <rect className="ik-gauge-box" x={x(lo)} y="22" width={Math.max(4, x(hi) - x(lo))} height="12" rx="4" />
30 + <line className="ik-gauge-med" x1={x(est)} x2={x(est)} y1="16" y2="40" />
31 + <text className="ik-gauge-txt" x={x(est)} y="11" textAnchor={anchor(x(est))}>estimation {fmtPrice(est)}</text>
32 + {ask != null && (
33 + <>
34 + <circle className="ik-gauge-me" cx={x(ask)} cy="28" r="6.5" />
35 + <text className="ik-gauge-txt me" x={x(ask)} y="52" textAnchor={anchor(x(ask))}>prix demandé {fmtPrice(ask)}</text>
36 + </>
37 + )}
38 + </svg>
39 + <div className="ik-gauge-lbls"><span>{fmtPrice(lo)}</span><span>{fmtPrice(hi)}</span></div>
40 + </div>
41 + );
42 +}
43 +
44 +export default function MarketPriceCard({ l, cmp }: { l: Listing; cmp: ComparaisonPrix | null }) {
45 + if (l.price == null) return null;
46 + const location = estLocation(l);
47 + const vp = l.vraiprix && l.vraiprix.value != null && !location ? l.vraiprix : null;
48 + const ev = evaluationMunicipale(l);
49 + const hist = (l.price_history ?? []).filter((h) => h.price != null);
50 + const pi2 = l.area_sqft != null && l.area_sqft > 200 && !location ? Math.round(l.price / l.area_sqft) : null;
51 + const evPct = ev && !location ? Math.round(((l.price - ev.total) / ev.total) * 100) : null;
52 + const jours = l.days_on_market ?? null;
53 +
54 + return (
55 + <SectionCard id="prix" title="Prix et marché" icon={<Ico name="scale" size={18} />}
56 + aside={cmp && <PriceCapsule cmp={cmp} short />}>
57 + {vp ? (
58 + <>
59 + <div className="ik-kpis cols-3">
60 + <StatTile accent value={fmtPrice(l.price)} label="Prix demandé" />
61 + <StatTile value={fmtPrice(vp.value)} label="Estimation Vrai-Prix" />
62 + <StatTile value={vp.low != null && vp.high != null ? `${fmtPrice(vp.low)} – ${fmtPrice(vp.high)}` : "—"} label="Fourchette estimée" />
63 + </div>
64 + {vp.low != null && vp.high != null && vp.high > vp.low && (
65 + <Gauge lo={vp.low} hi={vp.high} est={vp.value as number} ask={cmp ? l.price : null} />
66 + )}
67 + {!cmp && (
68 + <p className="ik-note info">Prix demandé trop éloigné de l'estimation pour être comparé (type de bien ou annonce atypique).</p>
69 + )}
70 + <div className="ik-meta">
71 + <span>{(vp.confidence && CONF[vp.confidence]) || "Estimation automatisée"}{vp.confidence ? ` (${vp.confidence})` : ""}</span>
72 + {pi2 != null && <span><b>{pi2.toLocaleString("fr-CA")}{NBSP}$</b> par pi² habitable</span>}
73 + {jours != null && jours > 0 && <span><b>{jours}</b> jour{jours > 1 ? "s" : ""} sur le marché (observé)</span>}
74 + </div>
75 + </>
76 + ) : (
77 + <>
78 + <div className="ik-kpis cols-3">
79 + <StatTile accent value={fmtPrice(l.price)} label={location ? "Loyer mensuel" : "Prix demandé"} />
80 + {pi2 != null && <StatTile value={`${pi2.toLocaleString("fr-CA")}${NBSP}$`} label="Par pi² habitable" />}
81 + {jours != null && jours > 0 && <StatTile value={jours} label="Jours sur le marché (observé)" />}
82 + </div>
83 + {!location && <p className="ik-fine meth">Pas d'estimation Vrai-Prix pour cette annonce (adresse non appariée au rôle ou type de bien non couvert).</p>}
84 + </>
85 + )}
86 +
87 + {ev && !location && (
88 + <div className="ik-role">
89 + <h3 className="ik-card-sub ik-subtitle">Rôle d'évaluation foncière{ev.annee ? ` · ${ev.annee}` : ""}</h3>
90 + <div className="ik-kpis cols-3">
91 + <StatTile value={fmtPrice(ev.total)} label="Évaluation municipale" anim={false} />
92 + {ev.terrain != null && <StatTile value={fmtPrice(ev.terrain)} label="Terrain" anim={false} />}
93 + {ev.batiment != null && <StatTile value={fmtPrice(ev.batiment)} label="Bâtiment" anim={false} />}
94 + </div>
95 + {evPct != null && Math.abs(evPct) <= 300 && (
96 + <p className="ik-fine" style={{ marginTop: 8 }}>
97 + Prix demandé <b>{Math.abs(evPct)}{NBSP}% {evPct >= 0 ? "au-dessus" : "sous"}</b> l'évaluation municipale — le rôle triennal
98 + reflète la valeur au 1ᵉʳ juillet de l'année de référence, pas le marché actuel.
99 + </p>
100 + )}
101 + </div>
102 + )}
103 +
104 + {hist.length >= 2 && (
105 + <>
106 + <h3 className="ik-card-sub ik-subtitle">Historique du prix demandé</h3>
107 + <ul className="ik-tl">
108 + {hist.slice(0, 6).map((h, i) => {
109 + const prev = hist[i + 1];
110 + const cls = prev && prev.price != null ? (h.price! < prev.price ? "baisse" : "hausse") : "";
111 + return (
112 + <li key={h.ts} className={cls}>
113 + <span className="ik-tl-date">{fmtDate(h.ts)}</span>
114 + {prev && prev.price != null
115 + ? <>{h.price! < prev.price ? "Baissé" : "Monté"} de {fmtPrice(prev.price)} à <b>{fmtPrice(h.price!)}</b></>
116 + : <>Premier prix observé : <b>{fmtPrice(h.price!)}</b></>}
117 + </li>
118 + );
119 + })}
120 + </ul>
121 + </>
122 + )}
123 +
124 + <Accordion title="Méthodologie" small>
125 + <p>
126 + L'estimation Vrai-Prix (service Groupe KA) est produite par un modèle statistique fondé sur les ventes
127 + comparables récentes, les caractéristiques du bien et le rôle d'évaluation foncière ; la fourchette exprime
128 + l'incertitude du modèle et la lettre de confiance (A à D) sa fiabilité. Ce n'est pas une évaluation
129 + agréée. L'historique de prix et le temps sur le marché sont ceux observés par les synchronisations
130 + Immo-Ka (première observation, pas la date d'inscription à la source).
131 + {vp && <> <a href={vp.url} target="_blank" rel="noopener noreferrer">Analyse détaillée sur Vrai-Prix ↗</a></>}
132 + </p>
133 + </Accordion>
134 + </SectionCard>
135 + );
136 +}
added frontend/src/fiche/NearbyPlaces.tsx +181 −0
@@ -0,0 +1,181 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (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 (immoka/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 { Ico } from "../components/Icons";
13 +import BottomSheet from "./BottomSheet";
14 +import { ErrorState, MoreButton, SectionCard, SkeletonLines, SourceLine, fmtMarche, NBSP } from "./ui";
15 +import { Res } from "./useFicheData";
16 +
17 +const POI_META: Record<string, { ico: ReactNode; label: string }> = {
18 + epicerie: { ico: <Ico name="cart" size={17} />, label: "Épicerie" },
19 + depanneur: { ico: <Ico name="store" size={17} />, label: "Dépanneur" },
20 + pharmacie: { ico: <Ico name="pill" size={17} />, label: "Pharmacie" },
21 + ecole: { ico: <Ico name="school" size={17} />, label: "École" },
22 + garderie: { ico: <Ico name="baby" size={17} />, label: "Garderie" },
23 + parc: { ico: <Ico name="tree" size={17} />, label: "Parc" },
24 + bus: { ico: <Ico name="bus" size={17} />, label: "Arrêt de bus" },
25 + metro: { ico: <Ico name="train" size={17} />, label: "Métro" },
26 + gym: { ico: <Ico name="dumbbell" size={17} />, label: "Gym" },
27 + cafe: { ico: <Ico name="coffee" size={17} />, label: "Café" },
28 + clinique: { ico: <Ico name="health" size={17} />, label: "Clinique" },
29 + hopital: { ico: <Ico name="health" size={17} />, label: "Hôpital" },
30 + bibliotheque: { ico: <Ico name="book" size={17} />, label: "Bibliothèque" },
31 +};
32 +const GROUPES: { titre: string; cats: string[] }[] = [
33 + { titre: "Transport", cats: ["metro", "bus"] },
34 + { titre: "Courses", cats: ["epicerie", "depanneur", "pharmacie"] },
35 + { titre: "Études et famille", cats: ["ecole", "garderie", "bibliotheque"] },
36 + { titre: "Santé", cats: ["clinique", "hopital"] },
37 + { titre: "Vie de quartier", cats: ["cafe", "parc", "gym"] },
38 +];
39 +const BANNIERE_CAT: Record<string, string> = {
40 + metro_station: "Station de métro", rem_station: "Station REM", arret_bus: "Arrêt de bus", gare_train: "Gare de train",
41 + costco: "Épicerie · entrepôt", walmart: "Grande surface", metro: "Épicerie", iga: "Épicerie", maxi: "Épicerie",
42 + superc: "Épicerie", provigo: "Épicerie", canadiantire: "Quincaillerie", dollarama: "Magasin à 1 $", saq: "Alcools",
43 + pharmaprix: "Pharmacie", jeancoutu: "Pharmacie", homedepot: "Rénovation", rona: "Rénovation",
44 +};
45 +const catDe = (c: CommerceItem) => BANNIERE_CAT[c.id] ?? c.commerce;
46 +const BANNIERES: Record<string, [string, string, string?]> = {
47 + metro_station: ["#0083C9", "M"], rem_station: ["#84BD00", "R"], arret_bus: ["#4E5357", "B"], gare_train: ["#6E5B3F", "T"],
48 + costco: ["#005DAA", "C"], walmart: ["#0071CE", "W"], metro: ["#EF3E42", "M"], iga: ["#D50032", "IGA"],
49 + maxi: ["#0079C1", "Mx"], superc: ["#E4002B", "SC"], provigo: ["#DA291C", "P"], canadiantire: ["#D6001C", "CT"],
50 + dollarama: ["#00B140", "D", "#FFDD00"], saq: ["#892034", "SAQ"], pharmaprix: ["#E11B22", "Ph"],
51 + jeancoutu: ["#003DA5", "JC"], homedepot: ["#F96302", "HD"], rona: ["#1B4298", "R"],
52 +};
53 +
54 +export function Pastille({ id }: { id: string }) {
55 + const [bg, mono, fg] = BANNIERES[id] ?? ["#777", "•"];
56 + const fs = mono.length >= 3 ? 9 : mono.length === 2 ? 11 : 14;
57 + const rond = ["metro_station", "rem_station", "arret_bus", "gare_train"].includes(id);
58 + return (
59 + <svg className="cm-ico" viewBox="0 0 28 28" width="26" height="26" aria-hidden="true">
60 + {rond ? <circle cx="14" cy="14" r="13" fill={bg} /> : <rect x="1" y="1" width="26" height="26" rx="7" fill={bg} />}
61 + <text x="14" y="14" textAnchor="middle" dominantBaseline="central" fontSize={fs} fontWeight="800" fontFamily="inherit" fill={fg ?? "#fff"}>{mono}</text>
62 + </svg>
63 + );
64 +}
65 +
66 +interface Carte { key: string; cat: string; nom: string; sous?: string; dist: number; ico: ReactNode; }
67 +
68 +function CCard({ c }: { c: Carte }) {
69 + return (
70 + <div className="ik-ccard" role="listitem">
71 + <div className="ik-ccard-top">
72 + <span className="ik-ccard-c">{c.cat}</span>
73 + <span aria-hidden="true">{c.ico}</span>
74 + </div>
75 + <div className="ik-ccard-d">{fmtDist(c.dist)}</div>
76 + <div className="ik-ccard-n" title={c.nom}>{c.nom}</div>
77 + <div className="ik-ccard-m">≈{NBSP}{fmtMarche(c.dist)} à pied{c.sous ? ` · ${c.sous}` : ""}</div>
78 + </div>
79 + );
80 +}
81 +
82 +export default function NearbyPlaces({ pois, commerces, onRetry }: { pois: Poi[]; commerces: Res<CommercesNearby>; onRetry: () => void }) {
83 + const [sheet, setSheet] = useState(false);
84 + const cm = commerces.status === "ok" ? commerces.data : null;
85 + const transit: CommerceItem[] = cm?.transit ?? [];
86 + const bannieres: CommerceItem[] = cm?.commerces ?? [];
87 +
88 + const transport: Carte[] = [
89 + ...transit.map((t) => ({ key: `t-${t.id}`, cat: catDe(t), nom: t.nom, dist: t.dist_m, ico: <Pastille id={t.id} /> })),
90 + ...pois.filter((p) => (p.cat === "metro" || p.cat === "bus") && !transit.some((t) => t.dist_m === p.dist_m))
91 + .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 })),
92 + ].sort((a, b) => a.dist - b.dist);
93 + const services: Carte[] = [
94 + ...bannieres.map((b) => ({ key: `b-${b.id}`, cat: catDe(b), nom: b.nom, dist: b.dist_m, ico: <Pastille id={b.id} /> })),
95 + ...pois.filter((p) => p.cat !== "metro" && p.cat !== "bus")
96 + .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 ?? <Ico name="pin" size={17} /> })),
97 + ].sort((a, b) => a.dist - b.dist);
98 + const total = transport.length + services.length;
99 + const loading = commerces.status === "loading" || commerces.status === "idle";
100 +
101 + if (total === 0 && !loading && commerces.status !== "error") return null;
102 +
103 + // « les plus pertinents » : métro/épicerie/pharmacie/école/parc les plus proches
104 + const prio = ["Station de métro", "Métro", "Épicerie", "École", "Pharmacie", "Parc", "Arrêt de bus"];
105 + const top = [...transport, ...services]
106 + .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)
107 + .filter((c, i, arr) => arr.findIndex((x) => x.cat === c.cat) === i)
108 + .slice(0, 4);
109 +
110 + return (
111 + <>
112 + <SectionCard id="proximite" title="À proximité" icon={<Ico name="pin" size={18} />}
113 + sub={total ? `${total} lieux repérés · temps de marche estimés` : undefined}>
114 + {loading && total === 0 && <SkeletonLines n={3} />}
115 + {commerces.status === "error" && <ErrorState onRetry={onRetry}>Commerces et transport temporairement indisponibles.</ErrorState>}
116 + {top.length > 0 && (
117 + <ul className="ik-list">
118 + {top.map((c) => (
119 + <li className="ik-item" key={c.key}>
120 + <span className="ik-item-ico" aria-hidden="true">{c.ico}</span>
121 + <div className="ik-item-main">
122 + <div className="ik-item-t">{c.nom}</div>
123 + <div className="ik-item-s">{c.cat}</div>
124 + </div>
125 + <div className="ik-item-r">
126 + <div className="ik-item-v">{fmtDist(c.dist)}</div>
127 + <div className="ik-item-m">≈{NBSP}{fmtMarche(c.dist)}</div>
128 + </div>
129 + </li>
130 + ))}
131 + </ul>
132 + )}
133 + {services.length > 0 && (
134 + <>
135 + <h3 className="ik-card-sub ik-subtitle">Courses et services</h3>
136 + <div className="ik-carousel" role="list" aria-label="Courses et services à proximité">
137 + {services.slice(0, 12).map((c) => <CCard c={c} key={c.key} />)}
138 + </div>
139 + </>
140 + )}
141 + {total > 4 && <MoreButton onClick={() => setSheet(true)}>Voir les {total} lieux à proximité</MoreButton>}
142 + <SourceLine name="OpenStreetMap · Mapbox Search"
143 + date={`distances à vol d'oiseau, marche ≈ distance × 1,3 à 4,8${NBSP}km/h`} />
144 + </SectionCard>
145 +
146 + {transport.length > 0 && (
147 + <SectionCard id="transport" title="Transport" icon={<Ico name="train" size={18} />}
148 + sub="Stations et arrêts les plus proches">
149 + <div className="ik-carousel" role="list" aria-label="Transport en commun à proximité">
150 + {transport.slice(0, 10).map((c) => <CCard c={c} key={c.key} />)}
151 + </div>
152 + </SectionCard>
153 + )}
154 +
155 + <BottomSheet open={sheet} onClose={() => setSheet(false)} title="Lieux à proximité" sub={`${total} lieux · distances à vol d'oiseau`} tall>
156 + {GROUPES.map((g) => {
157 + const items = [
158 + ...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 })),
159 + ...(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} /> })) : []),
160 + ...(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} /> })) : []),
161 + ].sort((a, b) => a.dist - b.dist);
162 + if (items.length === 0) return null;
163 + return (
164 + <div key={g.titre} style={{ marginBottom: 14 }}>
165 + <h4 className="ik-card-sub" style={{ fontWeight: 700, color: "var(--ik-text)", margin: "0 0 4px" }}>{g.titre} <small style={{ fontWeight: 500 }}>· {items.length}</small></h4>
166 + <ul className="ik-list">
167 + {items.map((c) => (
168 + <li className="ik-item" key={c.key + c.dist}>
169 + <span className="ik-item-ico" aria-hidden="true">{c.ico}</span>
170 + <div className="ik-item-main"><div className="ik-item-t">{c.nom}</div><div className="ik-item-s">{c.cat}</div></div>
171 + <div className="ik-item-r"><div className="ik-item-v">{fmtDist(c.dist)}</div><div className="ik-item-m">≈{NBSP}{fmtMarche(c.dist)}</div></div>
172 + </li>
173 + ))}
174 + </ul>
175 + </div>
176 + );
177 + })}
178 + </BottomSheet>
179 + </>
180 + );
181 +}
added frontend/src/fiche/NeighborhoodStats.tsx +125 −0
@@ -0,0 +1,125 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (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 »), défavorisation, îlot de chaleur,
7 +// criminalité (résumé + détail par catégorie en accordéon), sources.
8 +// -----------------------------------------------------------------------------
9 +import { useState } from "react";
10 +import { Quartier } from "../api";
11 +import { Ico } 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 des ménages", fmtMoney(d.revenu_median)],
30 + ["Propriétaires", d.pct_locataires != null ? fmtPct(100 - d.pct_locataires) : null],
31 + ["Âge médian", d.age_median != null ? `${Math.round(d.age_median)} ans` : null],
32 + ["Population du secteur", d.population != null ? Math.round(d.population).toLocaleString("fr-CA") : 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 + const def = q.defavorisation;
43 +
44 + if (kpisOk.length === 0 && proxOk.length === 0 && !q.chaleur && !crime) return null;
45 +
46 + return (
47 + <SectionCard id="quartier" title="Quartier" icon={<Ico name="people" size={18} />}
48 + sub="Secteur immédiat de la propriété (aire de diffusion du recensement, ±500 habitants)">
49 + {kpisOk.length > 0 && (
50 + <div className="ik-kpis">
51 + {kpisOk.map(([l, v]) => <StatTile key={l} value={v} label={l} />)}
52 + </div>
53 + )}
54 + {proxOk.length > 0 && (
55 + <>
56 + <h3 className="ik-card-sub ik-subtitle">Accessibilité du quartier <small>· indice 0–100 StatCan</small></h3>
57 + <div className="ik-rows">
58 + {shown.map(([l, v]) => {
59 + const [lbl, tone] = niveau(v);
60 + return (
61 + <div className="ik-row" key={l}>
62 + <span className="ik-row-name">{l}</span>
63 + <span className="ik-row-bar" aria-hidden="true"><i className={tone} style={{ width: `${v}%` }} /></span>
64 + <span className="ik-row-val">{v} <span className={`ik-row-lbl ${tone}`} style={{ minWidth: 0 }}>{lbl}</span></span>
65 + </div>
66 + );
67 + })}
68 + </div>
69 + {proxOk.length > 4 && <MoreButton onClick={() => setAll(!all)} expanded={all}>{all ? "Réduire" : "Voir tous les indicateurs"}</MoreButton>}
70 + </>
71 + )}
72 + {(q.chaleur || crime || def) && (
73 + <div className="ik-status" style={{ marginTop: 14 }}>
74 + {q.chaleur && (
75 + q.chaleur.classe <= 3 ? <StatusBadge tone="good">Îlot de fraîcheur</StatusBadge>
76 + : q.chaleur.classe >= 7 ? <StatusBadge tone="warn">Îlot de chaleur{q.chaleur.ecart != null ? ` (+${q.chaleur.ecart.toFixed(1)}${NBSP}°C)` : ""}</StatusBadge>
77 + : <StatusBadge tone="neutral">Température de quartier moyenne</StatusBadge>
78 + )}
79 + {def && def.quintile_materiel != null && (
80 + <StatusBadge tone={def.quintile_materiel <= 2 ? "good" : def.quintile_materiel >= 4 ? "warn" : "neutral"}>
81 + Défavorisation matérielle : quintile {def.quintile_materiel}/5
82 + </StatusBadge>
83 + )}
84 + {crime?.type === "points" && (
85 + <StatusBadge tone={crime.douze_mois <= crime.douze_mois_precedents ? "neutral" : "warn"}>
86 + {crime.douze_mois} acte{crime.douze_mois > 1 ? "s" : ""} criminel{crime.douze_mois > 1 ? "s" : ""} à moins de {crime.rayon_m} m (12 mois)
87 + {crime.douze_mois_precedents > 0 && (crime.douze_mois <= crime.douze_mois_precedents ? " · en baisse" : " · en hausse")}
88 + </StatusBadge>
89 + )}
90 + {crime?.type === "igc" && (() => {
91 + const c = crime;
92 + if (c.indice_canada != null && c.indice_canada > 0) {
93 + const delta = Math.round(100 * (c.indice - c.indice_canada) / c.indice_canada);
94 + return <StatusBadge tone={delta <= 0 ? "good" : "neutral"}>Criminalité {Math.abs(delta)}{NBSP}% {delta <= 0 ? "sous" : "au-dessus de"} la moyenne canadienne</StatusBadge>;
95 + }
96 + return <StatusBadge tone="neutral">Indice de gravité de la criminalité : {c.indice}</StatusBadge>;
97 + })()}
98 + </div>
99 + )}
100 + {crime?.type === "points" && (crime.categories?.length ?? 0) > 0 && (
101 + <Accordion title="Détail des actes criminels (SPVM)" small meta={`${crime.douze_mois} vs ${crime.douze_mois_precedents}`}>
102 + <div className="ik-rows">
103 + {crime.categories!.filter((c) => c.n + c.n_prec > 0).map((c) => {
104 + const max = Math.max(...crime.categories!.map((x) => x.n), 1);
105 + const delta = c.n - c.n_prec;
106 + return (
107 + <div className="ik-row" key={c.nom}>
108 + <span className="ik-row-name">{c.nom}</span>
109 + <span className="ik-row-bar" aria-hidden="true"><i style={{ width: `${Math.max(3, (c.n / max) * 100)}%` }} /></span>
110 + <span className="ik-row-val">{c.n}<small style={{ color: "var(--ik-muted)", fontWeight: 500 }}> {delta === 0 ? "=" : delta > 0 ? `▲${delta}` : `▼${-delta}`}</small></span>
111 + </div>
112 + );
113 + })}
114 + </div>
115 + <p className="ik-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>
116 + </Accordion>
117 + )}
118 + {crime?.type === "igc" && (
119 + <p className="ik-fine">Indice de gravité de la criminalité (Statistique Canada) — {crime.ville}, {crime.annee} : {crime.indice}{crime.indice_canada != null ? ` · Canada : ${crime.indice_canada}` : ""}.</p>
120 + )}
121 + <SourceLine name={`Statistique Canada (Recensement 2021)${q.chaleur ? ", INSPQ" : ""}${crime?.type === "points" ? ", Ville de Montréal" : ""}`}
122 + date="statistiques du secteur, pas de la propriété" />
123 + </SectionCard>
124 + );
125 +}
added frontend/src/fiche/PropertyAmenities.tsx +58 −0
@@ -0,0 +1,58 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertyAmenities.tsx : inclusions et caractéristiques — grille
5 +// compacte d'items avec icône contextuelle (AmenityIco), dédoublonnés ;
6 +// 8 visibles, « Voir les N ». Les « Inclusions » / « Exclusions » textuelles
7 +// de Centris (details) sont ajoutées en note quand elles existent.
8 +// -----------------------------------------------------------------------------
9 +import { useState } from "react";
10 +import { Listing } from "../api";
11 +import AmenityIco from "../components/AmenityIco";
12 +import { Accordion, MoreButton, SectionCard } from "./ui";
13 +
14 +const norm = (s: string) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\s+/g, " ").trim();
15 +
16 +export default function PropertyAmenities({ l }: { l: Listing }) {
17 + const [all, setAll] = useState(false);
18 + const seen = new Set<string>();
19 + const items = (l.features ?? []).map((f) => f.trim()).filter((f) => {
20 + const k = norm(f);
21 + if (!k || seen.has(k)) return false;
22 + seen.add(k); return true;
23 + });
24 + const d = l.details ?? {};
25 + const inclusions = typeof d["Inclusions"] === "string" ? String(d["Inclusions"]).trim() : "";
26 + const exclusions = typeof d["Exclusions"] === "string" ? String(d["Exclusions"]).trim() : "";
27 + const LIMIT = 8;
28 + const shown = all ? items : items.slice(0, LIMIT);
29 +
30 + if (items.length === 0 && !inclusions && !exclusions) return null;
31 +
32 + return (
33 + <SectionCard id="inclusions" title="Caractéristiques et inclusions"
34 + sub={items.length ? `${items.length} élément${items.length > 1 ? "s" : ""} publié${items.length > 1 ? "s" : ""} par la source` : undefined}>
35 + {items.length > 0 && (
36 + <div className="ik-amen" role="list">
37 + {shown.map((t) => (
38 + <div className="ik-amen-it" role="listitem" key={t}>
39 + <span className="ik-amen-ico" aria-hidden="true"><AmenityIco label={t} size={15} fallback="check" /></span>
40 + <span className="ik-amen-txt">{t}</span>
41 + </div>
42 + ))}
43 + </div>
44 + )}
45 + {items.length > LIMIT && (
46 + <MoreButton onClick={() => setAll(!all)} expanded={all}>
47 + {all ? "Réduire" : `Voir les ${items.length} caractéristiques`}
48 + </MoreButton>
49 + )}
50 + {(inclusions || exclusions) && (
51 + <Accordion title="Inclusions et exclusions de la vente" small defaultOpen={items.length === 0}>
52 + {inclusions && <p><b>Inclus :</b> {inclusions}</p>}
53 + {exclusions && <p><b>Exclus :</b> {exclusions}</p>}
54 + </Accordion>
55 + )}
56 + </SectionCard>
57 + );
58 +}
added frontend/src/fiche/PropertyDescription.tsx +30 −0
@@ -0,0 +1,30 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertyDescription.tsx : description de la source — texte tronqué à
5 +// ~200 px avec « Lire la suite », paragraphes conservés (pre-line).
6 +// -----------------------------------------------------------------------------
7 +import { useState } from "react";
8 +import { Listing } from "../api";
9 +import { MoreButton, SectionCard } from "./ui";
10 +
11 +export default function PropertyDescription({ l }: { l: Listing }) {
12 + const [open, setOpen] = useState(false);
13 + const texte = (l.description || "").trim();
14 + if (!texte) {
15 + return (
16 + <SectionCard id="description" title="Description">
17 + <p className="ik-fine">La source ne fournit pas de description pour cette annonce.</p>
18 + </SectionCard>
19 + );
20 + }
21 + const long = texte.length > 420;
22 + return (
23 + <SectionCard id="description" title="Description">
24 + <div className={`ik-desc ${long && !open ? "clamped" : ""}`}>
25 + <div className="ik-desc-body"><p>{texte}</p></div>
26 + </div>
27 + {long && <MoreButton onClick={() => setOpen(!open)} expanded={open}>{open ? "Réduire" : "Lire la suite"}</MoreButton>}
28 + </SectionCard>
29 + );
30 +}
added frontend/src/fiche/PropertyDossier.tsx +98 −0
@@ -0,0 +1,98 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertyDossier.tsx : « Dossier de l'annonce » — ce qu'Immo-Ka observe
5 +// réellement, en accordéons compacts : suivi Immo-Ka (première observation,
6 +// jours en ligne, variations de prix), autres publications de la même
7 +// propriété (doublons rattachés par la déduplication), courtier et agence,
8 +// identifiants de la source. Chaque volet a un état vide propre.
9 +// -----------------------------------------------------------------------------
10 +import { Listing, fmtDate, fmtPrice, sourceName } from "../api";
11 +import { Ico } from "../components/Icons";
12 +import { Accordion, SectionCard, StatTile, StatusBadge, NBSP } from "./ui";
13 +
14 +function Vide({ children }: { children: React.ReactNode }) { return <p style={{ color: "var(--ik-muted)", margin: 0 }}>{children}</p>; }
15 +
16 +export default function PropertyDossier({ l }: { l: Listing }) {
17 + const hist = (l.price_history ?? []).filter((h) => h.price != null);
18 + const jours = l.days_on_market ?? (l.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null);
19 + const variation = hist.length >= 2 ? (hist[0].price! - hist[hist.length - 1].price!) / hist[hist.length - 1].price! : null;
20 + const dups = l.duplicates ?? [];
21 + const d = l.details ?? {};
22 + const listedAt = typeof d.listed_at === "string" ? d.listed_at : null;
23 + const origin = typeof d.listing_origin_url === "string" ? d.listing_origin_url : null;
24 +
25 + return (
26 + <SectionCard id="dossier" title="Dossier de l'annonce" icon={<Ico name="folder" size={18} />}
27 + sub="Ce qu'Immo-Ka observe réellement : suivi, publications, courtier, source">
28 + <Accordion title={<><Ico name="history" size={15} className="ik-acc-ico" />Suivi Immo-Ka</>}
29 + meta={jours != null ? (jours === 0 ? "publiée aujourd'hui" : `${jours}${NBSP}j en ligne`) : undefined}>
30 + <div className="ik-kpis cols-3" style={{ marginBottom: 10 }}>
31 + <StatTile value={l.first_seen ? fmtDate(l.first_seen) : "—"} label="Première observation" anim={false} />
32 + <StatTile value={jours != null ? (jours === 0 ? "Aujourd'hui" : `${jours}${NBSP}j`) : "—"} label="Sur le marché (observé)" anim={false} />
33 + <StatTile value={Math.max(0, hist.length - 1)} label="Changement(s) de prix" anim={false} />
34 + </div>
35 + {variation != null && variation !== 0 && (
36 + <p style={{ margin: "0 0 8px" }}>
37 + Prix initial observé {fmtPrice(hist[hist.length - 1].price!)} → <b>{fmtPrice(hist[0].price!)}</b>{" "}
38 + <StatusBadge tone={variation < 0 ? "good" : "warn"}>{variation > 0 ? "+" : "−"}{Math.abs(Math.round(variation * 100))}{NBSP}%</StatusBadge>
39 + </p>
40 + )}
41 + {listedAt && <p style={{ margin: "0 0 8px" }}>Date d'inscription indiquée par la source : {listedAt}.</p>}
42 + <p className="ik-fine">Première observation, jours en ligne et variations sont mesurés par les synchronisations Immo-Ka
43 + (plusieurs fois par jour) — ils ne remplacent pas la date d'inscription officielle Centris.</p>
44 + </Accordion>
45 +
46 + <Accordion title={<><Ico name="layers" size={15} className="ik-acc-ico" />Aussi publiée sur</>}
47 + meta={dups.length ? `${dups.length} plateforme${dups.length > 1 ? "s" : ""}` : "aucune"}>
48 + {dups.length === 0 ? (
49 + <Vide>Aucune autre publication de cette propriété repérée parmi les sources Immo-Ka.</Vide>
50 + ) : (
51 + <>
52 + <p style={{ margin: "0 0 8px" }}>
53 + Cette propriété a été repérée sur {dups.length} autre{dups.length > 1 ? "s" : ""} plateforme{dups.length > 1 ? "s" : ""} —
54 + Immo-Ka affiche la version la plus complète.
55 + </p>
56 + <ul className="ik-list">
57 + {dups.map((x) => (
58 + <li className="ik-item" key={x.uid}>
59 + <span className="ik-item-ico" aria-hidden="true"><Ico name="building" size={17} /></span>
60 + <div className="ik-item-main">
61 + <div className="ik-item-t">{sourceName(x.source)}</div>
62 + <div className="ik-item-s">{x.broker_name || x.agency || x.price_label || ""}</div>
63 + </div>
64 + <div className="ik-item-r">
65 + <a className="ik-link" href={x.url} target="_blank" rel="noopener noreferrer">Voir ↗</a>
66 + </div>
67 + </li>
68 + ))}
69 + </ul>
70 + </>
71 + )}
72 + </Accordion>
73 +
74 + <Accordion title={<><Ico name="people" size={15} className="ik-acc-ico" />Courtier et agence</>}
75 + meta={l.broker_name || sourceName(l.source)}>
76 + <p style={{ margin: "0 0 6px" }}>
77 + <b style={{ fontSize: 15 }}>{l.broker_name || "Courtier non précisé"}</b>
78 + {l.agency ? <> — {l.agency}</> : null}
79 + </p>
80 + <p style={{ margin: "0 0 6px" }}>Annonce publiée par <b>{sourceName(l.source)}</b>
81 + {l.broker_phone && <> · <a href={`tel:${l.broker_phone.replace(/\s/g, "")}`}>{l.broker_phone}</a></>}.
82 + </p>
83 + <p className="ik-fine">Immo-Ka est un agrégateur indépendant : la relation contractuelle se fait avec le courtier ou l'agence
84 + de l'annonce originale. Consultez le registre de l'OACIQ pour vérifier un permis de courtier.</p>
85 + </Accordion>
86 +
87 + <Accordion title={<><Ico name="tag" size={15} className="ik-acc-ico" />Identifiants et source</>} meta={l.mls || l.external_id}>
88 + <div className="ik-facts-rows" style={{ marginTop: 0 }}>
89 + {l.mls && <div className="ik-kv"><span className="k">Nº Centris / MLS</span><span className="v">{l.mls}</span></div>}
90 + <div className="ik-kv"><span className="k">Identifiant source</span><span className="v">{l.external_id}</span></div>
91 + <div className="ik-kv"><span className="k">Identifiant Immo-Ka</span><span className="v">{l.uid}</span></div>
92 + <div className="ik-kv"><span className="k">Annonce originale</span><span className="v"><a href={l.url} target="_blank" rel="noopener noreferrer">{sourceName(l.source)} ↗</a></span></div>
93 + {origin && origin !== l.url && <div className="ik-kv"><span className="k">Fiche d'origine</span><span className="v"><a href={origin} target="_blank" rel="noopener noreferrer">Ouvrir ↗</a></span></div>}
94 + </div>
95 + </Accordion>
96 + </SectionCard>
97 + );
98 +}
added frontend/src/fiche/PropertyGallery.tsx +186 −0
@@ -0,0 +1,186 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (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 », légende de la source, bouton plein
6 +// écran, flèches au survol (desktop), vignettes ≥ 768 px, préchargement de
7 +// la photo suivante, lazy loading des autres. Les images qui ne chargent
8 +// pas sont retirées à la volée (jamais d'icône cassée) ; sans photo →
9 +// visuel de secours par type de bien (TypeFallback). Lightbox : balayage +
10 +// pincement pour zoomer + double tape (logique conservée de la fiche v2).
11 +// -----------------------------------------------------------------------------
12 +import { useEffect, useRef, useState } from "react";
13 +import { Ico } from "../components/Icons";
14 +import { TypeFallback } from "../components/PropertyImg";
15 +
16 +function Lightbox({ images, captions, start, titre, onClose }:
17 + { images: string[]; captions: 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="ik-lightbox" role="dialog" aria-modal="true" aria-label={`Photos — ${titre}`}>
89 + <button type="button" className="ik-lightbox-close" aria-label="Fermer" onClick={onClose}><Ico name="close" size={20} /></button>
90 + <span className="ik-lightbox-count" aria-live="polite">{captions[idx] ? `${captions[idx]} · ` : ""}{idx + 1} / {images.length}</span>
91 + <div className="ik-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="ik-lightbox-cell" key={u}>
97 + <img src={u} alt={`${titre} — photo ${i + 1} de ${images.length}`} draggable={false}
98 + loading={Math.abs(i - idx) <= 1 ? "eager" : "lazy"} decoding="async"
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="ik-gallery-nav prev" aria-label="Photo précédente" onClick={() => go(-1)}><Ico name="chevleft" size={20} /></button>
105 + )}
106 + {scale === 1 && idx < images.length - 1 && (
107 + <button type="button" className="ik-gallery-nav next" aria-label="Photo suivante" onClick={() => go(1)}><Ico name="chevright" size={20} /></button>
108 + )}
109 + </div>
110 + );
111 +}
112 +
113 +export default function PropertyGallery({ images, captions, titre, type }:
114 + { images: string[]; captions?: string[]; titre: string; type?: string }) {
115 + const [idx, setIdx] = useState(0);
116 + const [zoom, setZoom] = useState(false);
117 + const [dead, setDead] = useState<Set<string>>(new Set());
118 + const track = useRef<HTMLDivElement>(null);
119 +
120 + // images qui ne chargent pas : retirées de la galerie à la volée ; légendes alignées
121 + const alive = images
122 + .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" }))
123 + .filter(({ u }) => !dead.has(u));
124 + const markDead = (u: string) => setDead((d) => new Set(d).add(u));
125 + const urls = alive.map((a) => a.u);
126 + const caps = alive.map((a) => a.cap);
127 + const cur = Math.min(idx, Math.max(0, alive.length - 1));
128 +
129 + // préchargement discret de la photo suivante
130 + useEffect(() => {
131 + const next = urls[cur + 1];
132 + if (!next) return;
133 + const img = new Image();
134 + img.src = next;
135 + }, [cur, urls]);
136 +
137 + const onScroll = () => {
138 + const el = track.current;
139 + if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
140 + };
141 + const goto = (i: number) =>
142 + track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
143 +
144 + if (alive.length === 0)
145 + return (
146 + <div className="ik-gallery ik-gallery-empty" aria-label="Photos">
147 + <TypeFallback type={type} />
148 + </div>
149 + );
150 +
151 + return (
152 + <>
153 + <div className="ik-gallery" aria-roledescription="carrousel" aria-label="Photos de la propriété">
154 + <div className="ik-gallery-track" ref={track} onScroll={onScroll}>
155 + {alive.map(({ u }, i) => (
156 + <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async"
157 + alt={`${titre} — photo ${i + 1} de ${alive.length}`}
158 + onError={() => markDead(u)} onClick={() => setZoom(true)} />
159 + ))}
160 + </div>
161 + {caps[cur] && <span className="ik-gallery-caption">{caps[cur]}</span>}
162 + <span className="ik-gallery-count" aria-live="polite">{cur + 1} / {alive.length}</span>
163 + <button type="button" className="ik-gallery-full" aria-label="Voir en plein écran" onClick={() => setZoom(true)}>
164 + <Ico name="expand" size={17} />
165 + </button>
166 + {cur > 0 && (
167 + <button type="button" className="ik-gallery-nav prev" aria-label="Photo précédente" onClick={() => goto(cur - 1)}><Ico name="chevleft" size={20} /></button>
168 + )}
169 + {cur < alive.length - 1 && (
170 + <button type="button" className="ik-gallery-nav next" aria-label="Photo suivante" onClick={() => goto(cur + 1)}><Ico name="chevright" size={20} /></button>
171 + )}
172 + </div>
173 + {alive.length > 1 && (
174 + <div className="ik-thumbs" role="list">
175 + {alive.slice(0, 12).map(({ u }, i) => (
176 + <button type="button" key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)}
177 + aria-label={`Photo ${i + 1}`} aria-current={i === cur} role="listitem">
178 + <img src={u} alt="" loading="lazy" decoding="async" onError={() => markDead(u)} />
179 + </button>
180 + ))}
181 + </div>
182 + )}
183 + {zoom && <Lightbox images={urls} captions={caps} start={cur} titre={titre} onClose={() => setZoom(false)} />}
184 + </>
185 + );
186 +}
added frontend/src/fiche/PropertyHero.tsx +87 −0
@@ -0,0 +1,87 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertyHero.tsx : héro de la fiche — prix + capsule « vs estimation
5 +// Vrai-Prix », type de bien, prix au pi², adresse (H1) + secteur/ville, ligne
6 +// résumé, puis galerie, puis actions (favoris · partager · PDF · Voir
7 +// l'annonce). Ordre DOM = ordre visuel.
8 +// -----------------------------------------------------------------------------
9 +import { Listing, fmtPrice, sourceName } from "../api";
10 +import { Ico, IcoHeart } from "../components/Icons";
11 +import PropertyGallery from "./PropertyGallery";
12 +import { ComparaisonPrix, estLocation, ligneResume } from "./synthese";
13 +import { NBSP } from "./ui";
14 +
15 +export function PriceCapsule({ cmp, short = false }: { cmp: ComparaisonPrix | null; short?: boolean }) {
16 + if (!cmp) return null;
17 + return (
18 + <span className={`ik-capsule ${cmp.tone}`}>
19 + {short ? <b>{cmp.court}</b> : <><b>{cmp.label}</b><span aria-hidden="true">·</span>{cmp.court}</>}
20 + </span>
21 + );
22 +}
23 +
24 +export default function PropertyHero({ l, cmp, fav, onFav, onShare, actionsRef }: {
25 + l: Listing; cmp: ComparaisonPrix | null; fav: boolean;
26 + onFav: () => void; onShare: () => void;
27 + actionsRef: React.RefObject<HTMLDivElement>;
28 +}) {
29 + const resume = ligneResume(l);
30 + const titre = l.address || l.title;
31 + const where = [l.sector, l.city, l.region].filter(Boolean).join(" · ");
32 + const hist = (l.price_history ?? []).filter((h) => h.price != null);
33 + const baisse = hist.length >= 2 && hist[0].price! < hist[1].price! ? hist[1].price! : null;
34 + const location = estLocation(l);
35 + const pi2 = l.price != null && l.area_sqft != null && l.area_sqft > 200 && !location
36 + ? Math.round(l.price / l.area_sqft) : null;
37 + const captions = Array.isArray(l.details?.photo_captions) ? (l.details!.photo_captions as string[]) : undefined;
38 +
39 + return (
40 + <header className="ik-hero" aria-label="Résumé de la propriété">
41 + <div className="ik-hero-top">
42 + <div>
43 + <div className="ik-kicker">{location ? "Loyer mensuel" : "Prix demandé"}{l.details?.price_from ? " · à partir de" : ""}</div>
44 + <div className="ik-price">
45 + {fmtPrice(l.price, l.price_label)}
46 + {location && l.price != null && <small>/{NBSP}mois</small>}
47 + {baisse != null && <small style={{ textDecoration: "line-through", opacity: 0.7 }}>{fmtPrice(baisse)}</small>}
48 + </div>
49 + <div className="ik-price-row" style={{ marginTop: 8 }}>
50 + <PriceCapsule cmp={cmp} />
51 + {pi2 != null && <span className="ik-capsule neutral">{pi2.toLocaleString("fr-CA")}{NBSP}$/pi²</span>}
52 + {l.ka_reco && <span className="ik-capsule brand">Recommandé pour vous</span>}
53 + </div>
54 + </div>
55 + </div>
56 + <h1 className="ik-h1">
57 + {titre}
58 + {where && <span className="ik-city">{where}</span>}
59 + </h1>
60 + {resume.length > 0 && (
61 + <p className="ik-summary" aria-label="Résumé">
62 + {resume.map((r, i) => (
63 + <span key={r}>{i > 0 && <span className="sep" aria-hidden="true">· </span>}{r}</span>
64 + ))}
65 + </p>
66 + )}
67 + <PropertyGallery images={l.images ?? []} captions={captions} titre={titre} type={l.property_type} />
68 + <div className="ik-actions" ref={actionsRef}>
69 + <button type="button" className={`ik-btn ik-btn-ghost ik-btn-icon ${fav ? "on" : ""}`}
70 + aria-pressed={fav} aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} onClick={onFav}>
71 + <IcoHeart size={19} filled={fav} />
72 + </button>
73 + <button type="button" className="ik-btn ik-btn-ghost ik-btn-icon" aria-label="Partager" onClick={onShare}>
74 + <Ico name="share" size={18} />
75 + </button>
76 + <a className="ik-btn ik-btn-ghost ik-btn-icon" aria-label="Télécharger la fiche PDF" title="Fiche PDF"
77 + href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download>
78 + <Ico name="doc" size={18} />
79 + </a>
80 + <a className="ik-btn ik-btn-primary" href={l.url} target="_blank" rel="noopener noreferrer">
81 + Voir l'annonce <Ico name="external" size={16} />
82 + <span className="visually-hidden"> chez {sourceName(l.source)}</span>
83 + </a>
84 + </div>
85 + </header>
86 + );
87 +}
added frontend/src/fiche/PropertyQuickFacts.tsx +111 −0
@@ -0,0 +1,111 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/PropertyQuickFacts.tsx : « La propriété » — grille compacte icône +
5 +// valeur + libellé (type, chambres, salles de bain, salles d'eau, superficie,
6 +// terrain, année, stationnement, MLS), rangées pratiques (agence, courtier,
7 +// en ligne depuis, synchronisé), puis TOUTES les caractéristiques publiées
8 +// par la source et le tableau des pièces en accordéons (aucune donnée
9 +// supprimée par rapport à la fiche v2).
10 +// -----------------------------------------------------------------------------
11 +import { ReactNode } from "react";
12 +import { Listing, Room, fmtArea, fmtPrice, sourceName } from "../api";
13 +import { Ico } from "../components/Icons";
14 +import { Accordion, SectionCard, NBSP, relTime } from "./ui";
15 +
16 +// clés techniques de `details` jamais montrées dans « Caractéristiques »
17 +const DETAIL_HIDDEN = new Set([
18 + "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited",
19 + "needs_image_review", "postal_code", "region", "transaction",
20 + "prix_pi2", "prix_m2", "listing_origin_url", "listed_at",
21 +]);
22 +// clés déjà représentées dans la grille (évite le doublon dans le tableau)
23 +const IN_GRID = new Set(["Type de propriété", "Année de construction", "Superficie habitable", "Superficie du terrain", "Stationnement (total)"]);
24 +
25 +export default function PropertyQuickFacts({ l }: { l: Listing }) {
26 + const d = l.details ?? {};
27 + const facts: { ico: ReactNode; v: string; l: string }[] = [];
28 + if (l.property_type) facts.push({ ico: <Ico name="home" size={17} />, v: l.property_type, l: "Type" });
29 + if (l.bedrooms != null) facts.push({ ico: <Ico name="bed" size={17} />, v: `${Math.round(l.bedrooms)}`, l: `Chambre${l.bedrooms > 1 ? "s" : ""}` });
30 + if (l.bathrooms != null) facts.push({ ico: <Ico name="bath" size={17} />, v: `${l.bathrooms}`, l: `Salle${l.bathrooms > 1 ? "s" : ""} de bain` });
31 + if (l.powder_rooms != null && l.powder_rooms > 0) facts.push({ ico: <Ico name="drop" size={17} />, v: `${l.powder_rooms}`, l: `Salle${l.powder_rooms > 1 ? "s" : ""} d'eau` });
32 + if (l.area_sqft != null) facts.push({ ico: <Ico name="area" size={17} />, v: fmtArea(l.area_sqft)!, l: "Superficie habitable" });
33 + if (l.lot_sqft != null) facts.push({ ico: <Ico name="land" size={17} />, v: fmtArea(l.lot_sqft)!, l: "Terrain" });
34 + if (l.year_built != null) facts.push({ ico: <Ico name="calendar" size={17} />, v: String(l.year_built), l: "Année de construction" });
35 + const park = d["Stationnement (total)"] ?? d["Stationnement"];
36 + if (park != null && String(park).trim()) facts.push({ ico: <Ico name="car" size={17} />, v: String(park), l: "Stationnement" });
37 + const pieces = d["Nb de pièces"];
38 + if (pieces != null && String(pieces).trim()) facts.push({ ico: <Ico name="layers" size={17} />, v: String(pieces), l: "Pièces" });
39 + if (l.mls) facts.push({ ico: <Ico name="tag" size={17} />, v: l.mls, l: "Nº Centris / MLS" });
40 +
41 + const enLigne = l.days_on_market ?? (l.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null);
42 + const rows: [string, ReactNode][] = [["Agence / source", sourceName(l.source)]];
43 + if (l.agency && l.agency !== sourceName(l.source)) rows.push(["Bannière", l.agency]);
44 + if (l.broker_name) rows.push(["Courtier inscripteur", l.broker_name]);
45 + if (l.broker_phone) rows.push(["Téléphone", <a href={`tel:${l.broker_phone.replace(/\s/g, "")}`}>{l.broker_phone}</a>]);
46 + if (l.price_label && l.price != null && l.price_label.replace(/\s/g, "") !== fmtPrice(l.price).replace(/\s/g, ""))
47 + rows.push(["Prix affiché par la source", l.price_label]);
48 + if (enLigne != null) rows.push(["En ligne sur Immo-Ka", enLigne === 0 ? "depuis aujourd'hui" : `depuis ${enLigne}${NBSP}jour${enLigne > 1 ? "s" : ""}`]);
49 + const maj = relTime(l.updated_at);
50 + if (maj) rows.push(["Synchronisé", maj]);
51 +
52 + const detEntries = Object.entries(d)
53 + .filter(([k, v]) => !DETAIL_HIDDEN.has(k) && !IN_GRID.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim());
54 + const rooms: Room[] = Array.isArray(d.pieces) ? (d.pieces as Room[]) : [];
55 +
56 + if (facts.length === 0 && rows.length <= 1 && detEntries.length === 0 && rooms.length === 0) return null;
57 +
58 + return (
59 + <SectionCard id="propriete" title="La propriété">
60 + {facts.length > 0 && (
61 + <div className="ik-facts" role="list">
62 + {facts.map((x) => (
63 + <div className="ik-fact" role="listitem" key={x.l + x.v}>
64 + <span className="ik-fact-ico" aria-hidden="true">{x.ico}</span>
65 + <span className="ik-fact-txt">
66 + <div className="ik-fact-v" title={x.v}>{x.v}</div>
67 + <div className="ik-fact-l">{x.l}</div>
68 + </span>
69 + </div>
70 + ))}
71 + </div>
72 + )}
73 + <div className="ik-facts-rows">
74 + {rows.map(([k, v]) => (
75 + <div className="ik-kv" key={k}><span className="k">{k}</span><span className="v">{v}</span></div>
76 + ))}
77 + </div>
78 + {detEntries.length > 0 && (
79 + <Accordion title="Toutes les caractéristiques publiées" meta={`${detEntries.length}`}>
80 + <div className="ik-facts-rows" style={{ marginTop: 0 }}>
81 + {detEntries.map(([k, v]) => (
82 + <div className="ik-kv" key={k}>
83 + <span className="k">{k}</span>
84 + {/^https?:\/\//.test(String(v))
85 + ? <span className="v"><a href={String(v)} target="_blank" rel="noopener noreferrer">Ouvrir ↗</a></span>
86 + : <span className="v">{String(v)}</span>}
87 + </div>
88 + ))}
89 + </div>
90 + </Accordion>
91 + )}
92 + {rooms.length > 0 && (
93 + <Accordion title="Pièces et dimensions" meta={`${rooms.length} pièce${rooms.length > 1 ? "s" : ""}`}>
94 + <div className="ik-table-wrap">
95 + <table className="ik-table">
96 + <thead><tr><th>Pièce</th><th>Niveau</th><th>Dimensions</th><th>Revêtement</th></tr></thead>
97 + <tbody>
98 + {rooms.map((r, i) => (
99 + <tr key={i}>
100 + <td>{r.nom || "—"}</td><td>{r.niveau || "—"}</td>
101 + <td>{r.dimensions || "—"}</td><td>{r.revetement || "—"}</td>
102 + </tr>
103 + ))}
104 + </tbody>
105 + </table>
106 + </div>
107 + </Accordion>
108 + )}
109 + </SectionCard>
110 + );
111 +}
added frontend/src/fiche/PropertySummary.tsx +35 −0
@@ -0,0 +1,35 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (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="ik-brief">
14 + {items.map((c) => (
15 + <li key={c.cle}>
16 + <span className={`ik-brief-ico ${c.tone === "info" ? "neutral" : c.tone}`} aria-hidden="true">{glyph(c.tone)}</span>
17 + <div>
18 + <div className="ik-brief-t">{c.titre}</div>
19 + {!compact && c.detail && <div className="ik-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="ik-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/SectionNav.tsx +60 −0
@@ -0,0 +1,60 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/SectionNav.tsx : navigation sticky par sections — pastilles
5 +// défilables horizontalement, section active suivie au scroll
6 +// (IntersectionObserver), défilement doux au clic sans modifier l'URL
7 +// (pas de #hash : la page s'ouvre toujours en haut).
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useRef, useState } from "react";
10 +
11 +export interface NavItem { id: string; label: string; }
12 +
13 +export default function SectionNav({ items }: { items: NavItem[] }) {
14 + const [active, setActive] = useState(items[0]?.id);
15 + const track = useRef<HTMLDivElement>(null);
16 +
17 + useEffect(() => {
18 + const els = items.map((i) => document.getElementById(i.id)).filter((e): e is HTMLElement => !!e);
19 + if (els.length === 0) return;
20 + const visible = new Map<string, number>();
21 + const io = new IntersectionObserver((entries) => {
22 + for (const e of entries) visible.set((e.target as HTMLElement).id, e.isIntersecting ? e.intersectionRatio : 0);
23 + // section active = la première (ordre DOM) visible sous le header
24 + const first = items.find((i) => (visible.get(i.id) ?? 0) > 0);
25 + if (first) setActive(first.id);
26 + }, { rootMargin: "-120px 0px -55% 0px", threshold: [0, 0.1, 0.5] });
27 + els.forEach((e) => io.observe(e));
28 + return () => io.disconnect();
29 + }, [items]);
30 +
31 + // garder la pastille active visible dans la barre — défilement HORIZONTAL de
32 + // la piste seulement (jamais scrollIntoView : il ferait défiler la page
33 + // verticalement, y compris à l'ouverture)
34 + useEffect(() => {
35 + const t = track.current, a = t?.querySelector<HTMLElement>(".on");
36 + if (!t || !a) return;
37 + const left = a.offsetLeft, right = left + a.offsetWidth;
38 + if (left < t.scrollLeft) t.scrollTo({ left: Math.max(0, left - 12), behavior: "smooth" });
39 + else if (right > t.scrollLeft + t.clientWidth) t.scrollTo({ left: right - t.clientWidth + 12, behavior: "smooth" });
40 + }, [active]);
41 +
42 + const go = (id: string) => (e: React.MouseEvent) => {
43 + e.preventDefault();
44 + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
45 + setActive(id);
46 + };
47 +
48 + return (
49 + <nav className="ik-nav" aria-label="Sections de la fiche">
50 + <div className="ik-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 +// Immo-Ka — Agrégateur de propriétés à vendre (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 (sources,
6 +// signaler, partager, PDF).
7 +// -----------------------------------------------------------------------------
8 +import { Listing, sourceName } from "../api";
9 +import { Ico } 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 };
16 +}) {
17 + const maj = relTime(l.updated_at);
18 + const sources: Src[] = [
19 + { n: sourceName(l.source), r: "Annonce, prix, photos, caractéristiques et courtier (source originale)", d: maj ?? undefined, href: l.url },
20 + { n: "Vrai-Prix (Groupe KA)", r: "Estimation de valeur marchande et rôle d'évaluation apparié", href: l.vraiprix?.url ?? "https://www.vrai-prix.com" },
21 + { n: "Adresses Québec", r: "Géocodage de l'adresse (position sur la carte)" },
22 + { n: "Statistique Canada", r: "Recensement 2021 (aire de diffusion) et mesures de proximité", d: "2021" },
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" },
28 + { n: "Institutions financières", r: "Taux hypothécaires publiés, collectés en continu (source et fraîcheur par taux)", href: "/taux-hypothecaires" },
29 + { n: "Hydro-Québec", r: "Estimation du coût d'électricité à l'adresse" },
30 + { n: "Immo-Ka", r: "Immo-Ka Score, coût de propriété, suivi et déduplication — calculs maison, indicatifs" },
31 + ];
32 + return (
33 + <SectionCard id="sources" title="Sources et méthodologie" icon={<Ico name="folder" size={18} />}
34 + sub="Chaque donnée de cette fiche renvoie à sa source ; les calculs Immo-Ka sont indicatifs et documentés">
35 + <ul className="ik-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="ik-end" style={{ marginTop: 14 }}>
45 + <a href="/agences"><Ico name="folder" size={18} />Agences et sources<small>Toutes les sources Immo-Ka</small></a>
46 + <a href={`/contact?sujet=${encodeURIComponent(`Erreur sur la fiche ${l.uid}`)}`}><Ico name="alert" size={18} />Signaler une erreur<small>Prix, photos, adresse…</small></a>
47 + <button type="button" onClick={onShare}><Ico name="share" size={18} />Partager cette propriété<small>Lien de la fiche</small></button>
48 + <a href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download><Ico name="doc" size={18} />Fiche PDF<small>{maj ? `Mise à jour ${maj}` : "Télécharger"}</small></a>
49 + </div>
50 + <p className="ik-fine" style={{ marginTop: 12 }}>
51 + Les prix et disponibilités sont ceux affichés par la source — chaque fiche renvoie à l'annonce originale de l'agence.
52 + Immo-Ka est un agrégateur indépendant.
53 + </p>
54 + </SectionCard>
55 + );
56 +}
added frontend/src/fiche/StickyListingCTA.tsx +46 −0
@@ -0,0 +1,46 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (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 { Ico } from "../components/Icons";
11 +import { ComparaisonPrix, estLocation } 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 + const check = () => {
19 + const el = watch.current;
20 + if (!el) return;
21 + setPast(el.getBoundingClientRect().bottom < 0);
22 + };
23 + check();
24 + window.addEventListener("scroll", check, { passive: true });
25 + window.addEventListener("resize", check);
26 + return () => { window.removeEventListener("scroll", check); window.removeEventListener("resize", check); };
27 + }, [watch]);
28 + return past;
29 +}
30 +
31 +export default function StickyListingCTA({ l, cmp, show }: {
32 + l: Listing; cmp: ComparaisonPrix | null; show: boolean;
33 +}) {
34 + return (
35 + <div className={`ik-cta-bar ${show ? "show" : ""}`} aria-hidden={!show}>
36 + <div className="ik-cta-txt">
37 + <div className="ik-cta-price">{fmtPrice(l.price, l.price_label)}{estLocation(l) && l.price != null && <small>{NBSP}/ mois</small>}</div>
38 + {cmp && <div className={`ik-cta-sub ${cmp.tone === "good" ? "good" : ""}`}>{cmp.court}</div>}
39 + </div>
40 + <a className="ik-btn ik-btn-primary" href={l.url} target="_blank" rel="noopener noreferrer" tabIndex={show ? 0 : -1}>
41 + Voir l'annonce <Ico name="external" size={15} />
42 + <span className="visually-hidden"> chez {sourceName(l.source)}</span>
43 + </a>
44 + </div>
45 + );
46 +}
added frontend/src/fiche/current.ts +26 −0
@@ -0,0 +1,26 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/current.ts : mini-magasin « annonce affichée » partagé entre la page
5 +// fiche et le header compact (favoris ♥ + partage). Le toggle favoris du
6 +// compte KA exige l'objet Listing complet (titre, image, prix pour le hub) :
7 +// la page le publie ici, le header le lit sans re-fetch.
8 +// -----------------------------------------------------------------------------
9 +import { useSyncExternalStore } from "react";
10 +import type { Listing } from "../api";
11 +
12 +let current: Listing | null = null;
13 +const subs = new Set<() => void>();
14 +
15 +export function setCurrentListing(l: Listing | null) {
16 + current = l;
17 + subs.forEach((f) => f());
18 +}
19 +
20 +export function useCurrentListing(): Listing | null {
21 + return useSyncExternalStore(
22 + (cb) => { subs.add(cb); return () => { subs.delete(cb); }; },
23 + () => current,
24 + () => null,
25 + );
26 +}
added frontend/src/fiche/fiche.css +685 −0
@@ -0,0 +1,685 @@
1 +/* -----------------------------------------------------------------------------
2 + Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + fiche/fiche.css : fiche propriété premium (refonte 2026-09-07, même socle
5 + que la fiche Lou-Ka v3 du 2026-09-04)
6 + · Tokens `--ik-*` posés sur :root (les panneaux en portail y accèdent),
7 + styles SCOPÉS sous `.ik-fiche` / `.ik-*` — le reste du site conserve ses
8 + classes (.fiche/.f-* historiques restent inertes).
9 + · Fond papier Groupe KA #F5F3EE, cartes blanches à bord 1 px, ombres
10 + légères, rayons 10/14/18/22, CERISE Immo-Ka réservé aux CTA / prix /
11 + score / états actifs.
12 + · Mobile-first ; desktop ≥ 1024 px : colonne principale + aside sticky.
13 + · Aucune propriété `order` : ordre DOM = ordre visuel (standard Groupe Ka).
14 +----------------------------------------------------------------------------- */
15 +:root {
16 + --ik-bg: #f5f3ee;
17 + --ik-surface: #ffffff;
18 + --ik-surface-2: #faf9f5;
19 + --ik-border: #e6e3dc;
20 + --ik-border-2: #d3cfc6;
21 + --ik-text: #141814;
22 + --ik-text-2: #4d5551;
23 + --ik-muted: #7c837e;
24 + --ik-accent: #e23744;
25 + --ik-accent-deep: #a8232e;
26 + --ik-accent-soft: #fbe0e2;
27 + --ik-success: #1e7b4a;
28 + --ik-success-soft: #e7f4ec;
29 + --ik-warning: #a8690a;
30 + --ik-warning-soft: #fcf3e1;
31 + --ik-danger: #b3423a;
32 + --ik-danger-soft: #fbe9e7;
33 + --ik-info: #3b5bdb;
34 + --ik-info-soft: #e9edfb;
35 + --ik-r-sm: 10px;
36 + --ik-r-md: 14px;
37 + --ik-r-lg: 18px;
38 + --ik-r-xl: 22px;
39 + --ik-shadow-sm: 0 2px 12px rgba(0, 0, 0, 0.04);
40 + --ik-shadow-md: 0 8px 28px rgba(0, 0, 0, 0.07);
41 + --ik-header-h: 56px;
42 + --ik-nav-h: 52px;
43 +}
44 +
45 +/* ---- page : fond papier, header compact, chrome global effacé ---- */
46 +body.ik-fiche-page { background: var(--ik-bg); }
47 +body.ik-fiche-page .tabbar { display: none !important; }
48 +body.ik-fiche-page .kaa-btn { display: none !important; } /* remplacé par « Demander à Ka » */
49 +body.ik-fiche-page .kaa-hello { display: none !important; }
50 +body.ik-fiche-page .prefooter { margin-top: 8px; }
51 +body.ik-fiche-page .header--fiche { background: rgba(245, 243, 238, 0.92); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--ik-border); }
52 +.header--fiche .header-inner { height: var(--ik-header-h); gap: 10px; }
53 +.header--fiche .brand { font-size: 22px; }
54 +.header--fiche .brand .ka { padding: 1px 6px 3px; }
55 +.header--fiche .brand-tag { display: none; }
56 +.header--fiche .menu-btn { margin-left: 0; border-color: var(--ik-border); box-shadow: none; }
57 +.header--fiche .nav { margin-left: 0; }
58 +.header--fiche .header-acct { margin-left: 0; }
59 +.hdr-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
60 +.hdr-btn {
61 + display: inline-grid; place-items: center; width: 40px; height: 40px;
62 + border-radius: 999px; border: 1px solid var(--ik-border); background: var(--ik-surface);
63 + color: var(--ik-text); cursor: pointer; padding: 0; transition: background 0.15s, transform 0.15s;
64 +}
65 +.hdr-btn:active { transform: scale(0.95); }
66 +.hdr-btn.on { color: var(--ik-accent); border-color: var(--ik-accent); background: var(--ik-accent-soft); }
67 +@media (hover: hover) { .hdr-btn:hover { background: var(--ik-surface-2); } }
68 +/* connexion KA : capsule légère ; sur téléphone, le monogramme « KA » seul */
69 +.header--fiche .ka-login { box-shadow: none; }
70 +@media (max-width: 760px) {
71 + .header--fiche .ka-signup { display: none; }
72 + .header--fiche .ka-login { padding: 0; width: 40px; height: 40px; justify-content: center; border-radius: 999px; font-size: 0; gap: 0; }
73 + .header--fiche .ka-login b { font-size: 12px; transform: none; }
74 + .header--fiche .ka-acct { padding: 0; width: 40px; height: 40px; justify-content: center; border-radius: 999px; font-size: 0; gap: 0; }
75 + .header--fiche .ka-acct .ka-acct-pic, .header--fiche .ka-acct svg { display: block; }
76 +}
77 +
78 +/* ---- gabarit ---- */
79 +.ik-fiche { padding: 10px 0 110px; color: var(--ik-text); }
80 +.ik-wrap { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
81 +.ik-grid { display: block; }
82 +.ik-main { min-width: 0; display: flex; flex-direction: column; gap: 14px; }
83 +.ik-aside { display: none; }
84 +@media (min-width: 768px) {
85 + .ik-wrap { padding: 0 24px; }
86 + .ik-fiche { padding-top: 18px; }
87 + .ik-main { gap: 16px; }
88 +}
89 +@media (min-width: 1024px) {
90 + .ik-grid { display: grid; grid-template-columns: minmax(0, 1fr) 356px; gap: 32px; align-items: start; }
91 + .ik-aside { display: block; position: sticky; top: calc(var(--ik-header-h) + 16px); }
92 + .ik-fiche { padding-bottom: 80px; }
93 +}
94 +.ik-crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: var(--ik-muted); margin: 0 0 10px; }
95 +.ik-crumbs a { color: var(--ik-text-2); }
96 +.ik-crumbs span:last-child { color: var(--ik-text); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60vw; }
97 +.ik-fiche section[id] { scroll-margin-top: calc(var(--ik-header-h) + var(--ik-nav-h) + 10px); }
98 +
99 +/* ---- carte de section ---- */
100 +.ik-card {
101 + background: var(--ik-surface); border: 1px solid var(--ik-border);
102 + border-radius: var(--ik-r-lg); box-shadow: var(--ik-shadow-sm);
103 + padding: 16px; min-width: 0;
104 +}
105 +@media (min-width: 768px) { .ik-card { padding: 20px 22px; } }
106 +.ik-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
107 +.ik-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(--ik-text); }
108 +.ik-card-title svg { color: var(--ik-accent-deep); flex: none; }
109 +.ik-card-sub { font-size: 12.5px; color: var(--ik-muted); margin: 3px 0 0; }
110 +.ik-card-aside { flex: none; display: flex; align-items: center; gap: 8px; }
111 +.ik-link { background: none; border: 0; padding: 0; color: var(--ik-text-2); font: 600 13px var(--font-body); cursor: pointer; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--ik-border-2); }
112 +.ik-link:hover { color: var(--ik-accent-deep); }
113 +.ik-more {
114 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
115 + width: 100%; min-height: 44px; margin-top: 12px; padding: 8px 14px;
116 + border: 1px solid var(--ik-border); border-radius: var(--ik-r-sm); background: var(--ik-surface);
117 + color: var(--ik-text); font: 600 13.5px var(--font-body); cursor: pointer;
118 + transition: background 0.15s, border-color 0.15s;
119 +}
120 +.ik-more:hover { background: var(--ik-surface-2); border-color: var(--ik-border-2); }
121 +.ik-more svg { color: var(--ik-muted); }
122 +
123 +/* ---- boutons ---- */
124 +.ik-btn {
125 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
126 + min-height: 46px; padding: 10px 16px; border-radius: 12px; border: 1px solid transparent;
127 + font: 600 14.5px var(--font-body); cursor: pointer; text-decoration: none;
128 + transition: transform 0.12s, background 0.15s, box-shadow 0.15s; white-space: nowrap;
129 +}
130 +.ik-btn:active { transform: translateY(1px); }
131 +.ik-btn-primary { background: var(--ik-accent); color: #fff; box-shadow: 0 6px 18px rgba(226, 55, 68, 0.22); }
132 +.ik-btn-primary:hover { background: var(--ik-accent-deep); }
133 +.ik-btn-ghost { background: var(--ik-surface); border-color: var(--ik-border); color: var(--ik-text); }
134 +.ik-btn-ghost:hover { background: var(--ik-surface-2); border-color: var(--ik-border-2); }
135 +.ik-btn-icon { width: 46px; padding: 0; flex: none; }
136 +.ik-btn-icon.on { color: var(--ik-accent); border-color: var(--ik-accent); background: var(--ik-accent-soft); }
137 +
138 +/* ---- héro ---- */
139 +.ik-hero { display: flex; flex-direction: column; gap: 10px; padding: 4px 0 0; }
140 +.ik-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
141 +.ik-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(--ik-text); display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; }
142 +.ik-price small { font: 500 14px var(--font-body); color: var(--ik-muted); letter-spacing: 0; }
143 +.ik-price-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
144 +.ik-capsule {
145 + display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
146 + border-radius: 999px; font: 600 12.5px var(--font-body); border: 1px solid transparent; white-space: nowrap;
147 +}
148 +.ik-capsule b { font-weight: 700; }
149 +.ik-capsule.good { background: var(--ik-success-soft); color: var(--ik-success); }
150 +.ik-capsule.ok { background: var(--ik-surface); color: var(--ik-text-2); border-color: var(--ik-border); }
151 +.ik-capsule.high { background: var(--ik-warning-soft); color: var(--ik-warning); }
152 +.ik-capsule.neutral { background: var(--ik-surface-2); color: var(--ik-muted); border-color: var(--ik-border); }
153 +.ik-capsule.brand { background: var(--ik-accent-soft); color: var(--ik-accent-deep); }
154 +.ik-h1 { margin: 0; font-family: var(--font-body); font-weight: 600; font-size: 16px; line-height: 1.35; letter-spacing: 0; color: var(--ik-text); }
155 +.ik-h1 .ik-city { display: block; font-weight: 500; font-size: 14px; color: var(--ik-text-2); }
156 +.ik-summary { margin: 0; font-size: 14px; color: var(--ik-text-2); display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
157 +.ik-summary .sep { color: var(--ik-border-2); }
158 +.ik-actions { display: flex; gap: 8px; align-items: center; }
159 +.ik-actions .ik-btn-primary { flex: 1; }
160 +.ik-hero .ik-actions { margin-top: 2px; }
161 +@media (min-width: 1024px) {
162 + .ik-hero .ik-actions { display: none; } /* actions dans l'aside sticky */
163 +}
164 +
165 +/* ---- galerie ---- */
166 +.ik-gallery { position: relative; border-radius: var(--ik-r-lg); overflow: hidden; background: #ecece8; }
167 +.ik-gallery-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; aspect-ratio: 4 / 3; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
168 +.ik-gallery-track::-webkit-scrollbar { display: none; }
169 +.ik-gallery-track img, .ik-gallery > img { flex: 0 0 100%; width: 100%; height: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; display: block; }
170 +.ik-gallery > img { aspect-ratio: 4 / 3; }
171 +@media (min-width: 768px) { .ik-gallery-track, .ik-gallery > img { aspect-ratio: 16 / 9; } }
172 +.ik-gallery-count {
173 + position: absolute; left: 12px; bottom: 12px; z-index: 2;
174 + background: rgba(20, 24, 20, 0.66); color: #fff; font: 600 12px var(--font-body);
175 + padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
176 + font-variant-numeric: tabular-nums;
177 +}
178 +.ik-gallery-full, .ik-gallery-nav {
179 + position: absolute; z-index: 2; display: grid; place-items: center;
180 + width: 40px; height: 40px; border-radius: 999px; border: 0; cursor: pointer;
181 + background: rgba(255, 255, 255, 0.92); color: var(--ik-text); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
182 +}
183 +.ik-gallery-full { right: 12px; bottom: 12px; }
184 +.ik-gallery-nav { top: 50%; transform: translateY(-50%); display: none; }
185 +.ik-gallery-nav.prev { left: 12px; } .ik-gallery-nav.next { right: 12px; }
186 +@media (hover: hover) and (min-width: 768px) { .ik-gallery-nav { display: grid; } }
187 +.ik-thumbs { display: none; }
188 +@media (min-width: 768px) {
189 + .ik-thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: 6px; margin-top: 8px; }
190 + .ik-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; }
191 + .ik-thumbs button.on, .ik-thumbs button:hover { opacity: 1; }
192 + .ik-thumbs button.on { outline: 2px solid var(--ik-accent); }
193 + .ik-thumbs img { width: 100%; height: 100%; object-fit: cover; }
194 +}
195 +/* lightbox (plein écran) */
196 +.ik-lightbox { position: fixed; inset: 0; z-index: var(--z-modal, 900); background: rgba(12, 14, 12, 0.96); }
197 +.ik-lightbox-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; height: 100%; scrollbar-width: none; touch-action: pan-x pinch-zoom; }
198 +.ik-lightbox-track::-webkit-scrollbar { display: none; }
199 +.ik-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; }
200 +.ik-lightbox-cell img { max-width: 100%; max-height: 100%; border-radius: 10px; user-select: none; -webkit-user-drag: none; will-change: transform; }
201 +.ik-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; }
202 +.ik-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; }
203 +.ik-lightbox .ik-gallery-nav { display: none; }
204 +@media (hover: hover) { .ik-lightbox .ik-gallery-nav { display: grid; position: fixed; } }
205 +
206 +/* ---- Immo-Ka Score ---- */
207 +.ik-score { display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: center; }
208 +.ik-score-ring { position: relative; width: 88px; height: 88px; flex: none; }
209 +.ik-score-ring svg { width: 88px; height: 88px; transform: rotate(-90deg); }
210 +.ik-score-ring .bg { fill: none; stroke: var(--ik-surface-2); stroke-width: 7; }
211 +.ik-score-ring .arc { fill: none; stroke: var(--ik-accent); stroke-width: 7; stroke-linecap: round; transition: stroke-dasharray 0.9s cubic-bezier(0.2, 0.8, 0.2, 1); }
212 +.ik-score-ring .arc.partial { stroke: var(--ik-text-2); }
213 +.ik-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; }
214 +.ik-score-val small { display: block; text-align: center; font: 600 10px var(--font-body); color: var(--ik-muted); margin-top: 2px; letter-spacing: 0.04em; }
215 +.ik-score-lbl { font-family: var(--font-display); font-weight: 700; font-size: 17px; letter-spacing: -0.02em; }
216 +.ik-score-sub { font-size: 13px; color: var(--ik-text-2); margin-top: 2px; line-height: 1.4; }
217 +.ik-score-parts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
218 +.ik-score-part { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; padding: 4px 9px; border-radius: 8px; background: var(--ik-surface-2); border: 1px solid var(--ik-border); color: var(--ik-text-2); }
219 +.ik-score-part b { color: var(--ik-text); font-variant-numeric: tabular-nums; }
220 +.ik-score-part.na { color: var(--ik-muted); border-style: dashed; }
221 +
222 +/* ---- En bref ---- */
223 +.ik-brief { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
224 +.ik-brief li { display: grid; grid-template-columns: 24px 1fr; gap: 10px; align-items: start; }
225 +.ik-brief-ico { width: 24px; height: 24px; border-radius: 8px; display: grid; place-items: center; font-size: 12px; font-weight: 700; margin-top: 1px; }
226 +.ik-brief-ico.good { background: var(--ik-success-soft); color: var(--ik-success); }
227 +.ik-brief-ico.warn { background: var(--ik-warning-soft); color: var(--ik-warning); }
228 +.ik-brief-ico.bad { background: var(--ik-danger-soft); color: var(--ik-danger); }
229 +.ik-brief-ico.neutral { background: var(--ik-surface-2); color: var(--ik-muted); border: 1px solid var(--ik-border); }
230 +.ik-brief-t { font-weight: 600; font-size: 14px; line-height: 1.35; }
231 +.ik-brief-d { font-size: 13px; color: var(--ik-text-2); line-height: 1.4; margin-top: 1px; }
232 +
233 +/* ---- navigation par sections (sticky) ---- */
234 +.ik-nav {
235 + position: sticky; top: var(--ik-header-h); z-index: var(--z-sticky, 300);
236 + margin: 0 -16px; padding: 6px 16px;
237 + background: rgba(245, 243, 238, 0.97); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
238 + border-bottom: 1px solid var(--ik-border);
239 +}
240 +@media (min-width: 768px) { .ik-nav { margin: 0 -24px; padding: 6px 24px; } }
241 +@media (min-width: 1024px) { .ik-nav { margin: 0; padding: 6px 0; border-radius: 0; } }
242 +.ik-nav-track { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
243 +.ik-nav-track::-webkit-scrollbar { display: none; }
244 +.ik-nav a {
245 + flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 40px; padding: 8px 13px;
246 + border-radius: 999px; font: 600 13px var(--font-body); color: var(--ik-text-2);
247 + border: 1px solid transparent; transition: background 0.15s, color 0.15s; scroll-snap-align: start;
248 +}
249 +.ik-nav a:hover { background: var(--ik-surface); border-color: var(--ik-border); }
250 +.ik-nav a.on { background: var(--ik-text); color: #fff; border-color: var(--ik-text); }
251 +
252 +/* ---- caractéristiques ---- */
253 +.ik-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
254 +@media (min-width: 560px) { .ik-facts { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
255 +@media (min-width: 900px) { .ik-facts { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
256 +.ik-fact { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--ik-border); border-radius: 12px; background: var(--ik-surface-2); min-width: 0; }
257 +.ik-fact-ico { width: 32px; height: 32px; border-radius: 9px; background: var(--ik-surface); border: 1px solid var(--ik-border); display: grid; place-items: center; color: var(--ik-accent-deep); flex: none; }
258 +.ik-fact-v { font-weight: 700; font-size: 14px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
259 +.ik-fact-l { font-size: 11.5px; color: var(--ik-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
260 +.ik-fact-txt { min-width: 0; }
261 +.ik-facts-rows { margin-top: 12px; }
262 +.ik-kv { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--ik-border); font-size: 13.5px; }
263 +.ik-kv .k { color: var(--ik-muted); } .ik-kv .v { font-weight: 600; text-align: right; }
264 +.ik-note { margin: 10px 0 0; padding: 9px 12px; border-radius: 10px; font-size: 13px; line-height: 1.4; }
265 +.ik-note.good { background: var(--ik-success-soft); color: var(--ik-success); }
266 +.ik-note.info { background: var(--ik-info-soft); color: var(--ik-info); }
267 +.ik-note.warn { background: var(--ik-warning-soft); color: var(--ik-warning); }
268 +
269 +/* ---- description ---- */
270 +.ik-desc { position: relative; }
271 +.ik-desc-body p { font-size: 15px; line-height: 1.6; color: var(--ik-text-2); margin: 0 0 10px; white-space: pre-line; }
272 +.ik-desc-body h4 { margin: 12px 0 3px; font: 700 14px var(--font-body); color: var(--ik-text); }
273 +.ik-desc-lead { font-size: 15.5px !important; color: var(--ik-text) !important; font-weight: 500; }
274 +.ik-desc.clamped .ik-desc-body { max-height: 200px; overflow: hidden; -webkit-mask-image: linear-gradient(#000 62%, transparent); mask-image: linear-gradient(#000 62%, transparent); }
275 +.ik-desc-orig { margin-top: 8px; }
276 +.ik-desc-orig p { font-size: 13.5px; color: var(--ik-muted); }
277 +
278 +/* ---- inclusions ---- */
279 +.ik-amen { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
280 +@media (min-width: 640px) { .ik-amen { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
281 +.ik-amen-it { display: flex; align-items: center; gap: 9px; min-height: 42px; padding: 5px 0; border-bottom: 1px solid var(--ik-border); font-size: 13.5px; min-width: 0; }
282 +.ik-amen-ico { width: 28px; height: 28px; border-radius: 8px; background: var(--ik-accent-soft); color: var(--ik-accent-deep); display: grid; place-items: center; flex: none; }
283 +.ik-amen-it.unconfirmed { color: var(--ik-text-2); }
284 +.ik-amen-it.unconfirmed .ik-amen-ico { background: var(--ik-surface-2); color: var(--ik-muted); border: 1px dashed var(--ik-border-2); }
285 +.ik-amen-txt { min-width: 0; line-height: 1.25; }
286 +.ik-amen-conf { margin-left: auto; color: var(--ik-success); flex: none; }
287 +
288 +/* ---- KPI / tuiles ---- */
289 +.ik-kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
290 +@media (min-width: 600px) { .ik-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } .ik-kpi-v { font-size: 20px; } }
291 +.ik-kpis.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
292 +.ik-kpi { padding: 10px 12px; border: 1px solid var(--ik-border); border-radius: 12px; background: var(--ik-surface); min-width: 0; }
293 +.ik-kpi.accent { background: var(--ik-accent-soft); border-color: #f5c2c6; }
294 +.ik-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(--ik-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
295 +.ik-kpi-v.wrap { white-space: normal; font-size: 16px; line-height: 1.2; }
296 +.ik-kpi-v small { font: 600 11px var(--font-body); color: var(--ik-muted); margin-left: 3px; }
297 +.ik-kpi-l { font-size: 11.5px; color: var(--ik-muted); margin-top: 3px; line-height: 1.3; }
298 +.ik-kpi.anim .ik-kpi-v { animation: ik-rise 0.5s ease both; }
299 +@keyframes ik-rise { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
300 +
301 +/* ---- rangées compactes (accessibilité, mesures) ---- */
302 +.ik-rows { display: flex; flex-direction: column; gap: 4px; }
303 +.ik-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; min-height: 30px; font-size: 13.5px; }
304 +.ik-row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ik-text); }
305 +.ik-row-name small { color: var(--ik-muted); font-size: 12px; margin-left: 4px; }
306 +.ik-row-bar { width: 78px; height: 6px; border-radius: 3px; background: var(--ik-surface-2); border: 1px solid var(--ik-border); overflow: hidden; }
307 +.ik-row-bar i { display: block; height: 100%; background: var(--ik-text); border-radius: 3px; }
308 +.ik-row-bar i.good { background: var(--ik-success); }
309 +.ik-row-bar i.warn { background: var(--ik-warning); }
310 +.ik-row-bar i.bad { background: var(--ik-danger); }
311 +.ik-row-val { font-weight: 700; font-size: 13px; font-variant-numeric: tabular-nums; min-width: 28px; text-align: right; }
312 +.ik-row-lbl { font-size: 12px; color: var(--ik-muted); min-width: 68px; text-align: right; }
313 +.ik-row-lbl.good { color: var(--ik-success); } .ik-row-lbl.warn { color: var(--ik-warning); } .ik-row-lbl.bad { color: var(--ik-danger); }
314 +
315 +/* ---- pastilles d'état ---- */
316 +.ik-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; }
317 +.ik-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
318 +.ik-badge.good { background: var(--ik-success-soft); color: var(--ik-success); }
319 +.ik-badge.warn { background: var(--ik-warning-soft); color: var(--ik-warning); }
320 +.ik-badge.bad { background: var(--ik-danger-soft); color: var(--ik-danger); }
321 +.ik-badge.neutral { background: var(--ik-surface-2); color: var(--ik-text-2); border-color: var(--ik-border); }
322 +.ik-badge.info { background: var(--ik-info-soft); color: var(--ik-info); }
323 +.ik-badge.lg { font-size: 13.5px; padding: 6px 12px; }
324 +.ik-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; }
325 +.ik-pill.included { background: var(--ik-success-soft); color: var(--ik-success); }
326 +.ik-pill.observed { background: var(--ik-surface-2); color: var(--ik-text-2); border: 1px solid var(--ik-border); }
327 +.ik-pill.estimated { background: var(--ik-accent-soft); color: var(--ik-accent-deep); }
328 +.ik-pill.unknown { background: var(--ik-surface-2); color: var(--ik-muted); border: 1px dashed var(--ik-border-2); }
329 +
330 +/* ---- listes d'items (lieux, comparables, stations) ---- */
331 +.ik-list { list-style: none; margin: 0; padding: 0; }
332 +.ik-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--ik-border); min-width: 0; }
333 +.ik-list > .ik-item:first-child { border-top: 0; padding-top: 2px; }
334 +.ik-item-ico { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; flex: none; background: var(--ik-surface-2); border: 1px solid var(--ik-border); color: var(--ik-text-2); }
335 +.ik-item-ico svg.cm-ico { width: 26px; height: 26px; }
336 +.ik-item-main { flex: 1; min-width: 0; }
337 +.ik-item-t { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
338 +.ik-item-s { font-size: 12.5px; color: var(--ik-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
339 +.ik-item-r { text-align: right; flex: none; }
340 +.ik-item-v { font-weight: 700; font-size: 14px; font-variant-numeric: tabular-nums; }
341 +.ik-item-m { font-size: 12px; color: var(--ik-muted); }
342 +.ik-item-best { color: var(--ik-success); font-weight: 700; font-size: 11.5px; margin-left: 6px; }
343 +
344 +/* ---- carrousel horizontal ---- */
345 +.ik-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; }
346 +.ik-carousel::-webkit-scrollbar { display: none; }
347 +@media (min-width: 768px) { .ik-carousel { margin: 0; padding: 2px 0 6px; } }
348 +.ik-ccard { flex: 0 0 44%; min-width: 148px; max-width: 210px; scroll-snap-align: start; border: 1px solid var(--ik-border); border-radius: var(--ik-r-md); padding: 12px; background: var(--ik-surface); display: flex; flex-direction: column; gap: 6px; min-height: 104px; }
349 +@media (min-width: 768px) { .ik-ccard { flex-basis: 176px; } }
350 +.ik-ccard-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
351 +.ik-ccard-c { font: 600 10.5px var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; color: var(--ik-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
352 +.ik-ccard-d { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
353 +.ik-ccard-n { font-size: 12.5px; color: var(--ik-text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
354 +.ik-ccard-m { font-size: 11.5px; color: var(--ik-muted); }
355 +
356 +/* ---- filtres (chips) ---- */
357 +.ik-chips { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; padding-bottom: 10px; }
358 +.ik-chips::-webkit-scrollbar { display: none; }
359 +.ik-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(--ik-border); background: var(--ik-surface); color: var(--ik-text-2); font: 600 12.5px var(--font-body); cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; }
360 +.ik-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c, var(--ik-text)); flex: none; }
361 +.ik-chip small { font-weight: 600; opacity: 0.65; }
362 +.ik-chip.on { background: var(--ik-text); color: #fff; border-color: var(--ik-text); }
363 +.ik-chip:disabled { opacity: 0.45; cursor: default; }
364 +
365 +/* ---- carte ---- */
366 +.ik-map { position: relative; height: 340px; border-radius: var(--ik-r-lg); overflow: hidden; border: 1px solid var(--ik-border); background: #ecece8; }
367 +@media (min-width: 768px) { .ik-map { height: 440px; } }
368 +@media (min-width: 1024px) { .ik-map { height: 520px; } }
369 +.ik-map .ka-map {
370 + position: absolute; inset: 0;
371 + --ka-accent: var(--ik-accent); --ka-on-accent: #fff; --ka-surface: var(--ik-surface);
372 + --ka-ink: var(--ik-text); --ka-line: var(--ik-border-2); --ka-radius: 10px;
373 + --ka-shadow: 0 8px 24px rgba(20, 24, 20, 0.14); --ka-font: var(--font-body);
374 +}
375 +.ik-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(--ik-border); font: 500 11px var(--font-body); color: var(--ik-text-2); pointer-events: none; }
376 +.ik-map-legend i { width: 10px; height: 10px; border-radius: 3px; background: var(--ik-accent); }
377 +.ik-map-skel { position: absolute; inset: 0; }
378 +.ik-map-hint { font-size: 12px; color: var(--ik-muted); margin: 8px 0 0; }
379 +
380 +/* ---- accordéon ---- */
381 +.ik-acc { border-top: 1px solid var(--ik-border); }
382 +.ik-acc:first-child { border-top: 0; }
383 +.ik-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(--ik-text); cursor: pointer; text-align: left; }
384 +.ik-acc-btn .ik-acc-meta { font-weight: 500; font-size: 12.5px; color: var(--ik-muted); margin-left: auto; white-space: nowrap; }
385 +.ik-acc-btn .chev { color: var(--ik-muted); flex: none; transition: transform 0.2s ease; }
386 +.ik-acc-btn[aria-expanded="true"] .chev { transform: rotate(180deg); }
387 +.ik-acc-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.22s ease; }
388 +.ik-acc-body.open { grid-template-rows: 1fr; }
389 +.ik-acc-body > div { min-height: 0; overflow: hidden; }
390 +.ik-acc-inner { padding: 0 0 14px; font-size: 13.5px; color: var(--ik-text-2); line-height: 1.5; }
391 +.ik-acc-inner p { margin: 0 0 8px; }
392 +.ik-acc-inner a { color: var(--ik-text-2); text-decoration: underline; text-underline-offset: 2px; }
393 +.ik-acc.sm .ik-acc-btn { min-height: 40px; font-size: 13px; color: var(--ik-text-2); }
394 +
395 +/* ---- bottom sheet (mobile) / modale (desktop) ---- */
396 +.ik-sheet-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay, 800); background: rgba(20, 24, 20, 0.42); animation: ik-fade 0.18s ease; }
397 +.ik-sheet {
398 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
399 + height: var(--ik-sheet-h, 68dvh); max-height: 94dvh;
400 + background: var(--ik-surface); border-radius: 20px 20px 0 0;
401 + display: flex; flex-direction: column; box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.18);
402 + animation: ik-up 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
403 + padding-bottom: env(safe-area-inset-bottom);
404 + touch-action: pan-y;
405 +}
406 +.ik-sheet.full { height: 94dvh; }
407 +.ik-sheet-handle { width: 40px; height: 4px; border-radius: 2px; background: var(--ik-border-2); margin: 8px auto 0; flex: none; }
408 +.ik-sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px 10px; border-bottom: 1px solid var(--ik-border); flex: none; }
409 +.ik-sheet-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; letter-spacing: -0.02em; margin: 0; }
410 +.ik-sheet-sub { font-size: 12.5px; color: var(--ik-muted); margin: 2px 0 0; }
411 +.ik-sheet-x { width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--ik-border); background: var(--ik-surface-2); display: grid; place-items: center; cursor: pointer; color: var(--ik-text); flex: none; }
412 +.ik-sheet-body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: 12px 16px 18px; }
413 +.ik-sheet-foot { flex: none; padding: 10px 16px; border-top: 1px solid var(--ik-border); background: var(--ik-surface); }
414 +@keyframes ik-up { from { transform: translateY(40px); opacity: 0.6; } to { transform: none; opacity: 1; } }
415 +@keyframes ik-fade { from { opacity: 0; } to { opacity: 1; } }
416 +@keyframes ik-pop { from { transform: translate(-50%, -50%) scale(0.97); opacity: 0; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
417 +@media (min-width: 900px) {
418 + .ik-sheet, .ik-sheet.full {
419 + left: 50%; right: auto; top: 50%; bottom: auto; transform: translate(-50%, -50%);
420 + width: min(680px, calc(100vw - 48px)); height: auto; max-height: min(82vh, 780px);
421 + border-radius: var(--ik-r-lg); animation: ik-pop 0.18s ease; padding-bottom: 0;
422 + }
423 + .ik-sheet-handle { display: none; }
424 + .ik-sheet-head { padding: 16px 20px 12px; }
425 + .ik-sheet-body { padding: 14px 20px 20px; }
426 +}
427 +.ik-sheet-filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
428 +.ik-seg { display: inline-flex; border: 1px solid var(--ik-border); border-radius: 999px; overflow: hidden; background: var(--ik-surface); }
429 +.ik-seg button { border: 0; background: transparent; padding: 7px 12px; min-height: 36px; font: 600 12.5px var(--font-body); color: var(--ik-text-2); cursor: pointer; }
430 +.ik-seg button.on { background: var(--ik-text); color: #fff; }
431 +.ik-seg button + button { border-left: 1px solid var(--ik-border); }
432 +
433 +/* ---- CTA sticky ---- */
434 +.ik-cta-bar {
435 + position: fixed; left: 0; right: 0; bottom: var(--consent-h, 0px); z-index: var(--z-bottombar, 600);
436 + display: flex; align-items: center; gap: 12px;
437 + padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
438 + background: rgba(255, 255, 255, 0.94); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
439 + border-top: 1px solid var(--ik-border);
440 + transform: translateY(110%); transition: transform 0.25s ease; will-change: transform;
441 +}
442 +.ik-cta-bar.show { transform: none; }
443 +.ik-cta-txt { min-width: 0; flex: 0 1 auto; }
444 +.ik-cta-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; line-height: 1.1; white-space: nowrap; }
445 +.ik-cta-price small { font: 500 11px var(--font-body); color: var(--ik-muted); letter-spacing: 0; }
446 +.ik-cta-sub { font-size: 12px; color: var(--ik-text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
447 +.ik-cta-sub.good { color: var(--ik-success); font-weight: 600; }
448 +.ik-cta-bar .ik-btn-primary { flex: 1; min-height: 44px; margin-left: auto; max-width: 260px; }
449 +@media (min-width: 1024px) { .ik-cta-bar { display: none; } }
450 +
451 +/* ---- Demander à Ka ---- */
452 +.ik-ka-btn {
453 + position: fixed; right: 14px; bottom: calc(78px + env(safe-area-inset-bottom) + var(--consent-h, 0px)); z-index: var(--z-dropdown, 700);
454 + display: inline-flex; align-items: center; gap: 7px; min-height: 42px; padding: 8px 14px 8px 12px;
455 + border-radius: 999px; border: 0; background: var(--ik-text); color: #fff;
456 + font: 600 13px var(--font-body); cursor: pointer; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
457 + transition: transform 0.15s, opacity 0.2s;
458 +}
459 +.ik-ka-btn svg { color: var(--ik-accent); }
460 +.ik-ka-btn:active { transform: scale(0.97); }
461 +.ik-ka-btn.hide { opacity: 0; pointer-events: none; transform: translateY(8px); }
462 +@media (min-width: 1024px) { .ik-ka-btn { display: none; } } /* desktop : bouton dans l'aside */
463 +.ik-ka-intro { display: flex; gap: 12px; align-items: flex-start; padding: 4px 0 12px; }
464 +.ik-ka-avatar { width: 38px; height: 38px; border-radius: 12px; background: var(--ik-text); color: var(--ik-accent); display: grid; place-items: center; flex: none; }
465 +.ik-ka-intro p { margin: 0; font-size: 13.5px; color: var(--ik-text-2); line-height: 1.45; }
466 +.ik-ka-ctx { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; background: var(--ik-surface-2); border: 1px solid var(--ik-border); font-size: 12.5px; color: var(--ik-text-2); margin-bottom: 12px; }
467 +.ik-ka-ctx img { width: 44px; height: 34px; object-fit: cover; border-radius: 6px; flex: none; }
468 +.ik-ka-ctx b { color: var(--ik-text); display: block; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
469 +.ik-ka-sugs { display: flex; flex-direction: column; gap: 6px; }
470 +.ik-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(--ik-border); background: var(--ik-surface); font: 500 14px var(--font-body); color: var(--ik-text); cursor: pointer; text-align: left; transition: background 0.15s, border-color 0.15s; }
471 +.ik-ka-sug:hover { background: var(--ik-accent-soft); border-color: #f5c2c6; }
472 +.ik-ka-sug svg { color: var(--ik-muted); flex: none; }
473 +.ik-ka-in { display: flex; gap: 8px; align-items: center; }
474 +.ik-ka-in input { flex: 1; min-height: 46px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--ik-border); background: var(--ik-surface); font: 400 16px var(--font-body); color: var(--ik-text); }
475 +.ik-ka-in input:focus { outline: 2px solid var(--ik-accent); outline-offset: 1px; }
476 +.ik-ka-in button { width: 46px; height: 46px; border-radius: 12px; border: 0; background: var(--ik-accent); color: #fff; display: grid; place-items: center; cursor: pointer; flex: none; }
477 +.ik-ka-in button:disabled { opacity: 0.4; cursor: default; }
478 +
479 +/* ---- aside desktop ---- */
480 +.ik-aside-card { display: flex; flex-direction: column; gap: 12px; }
481 +.ik-aside .ik-btn-primary { white-space: normal; text-align: center; line-height: 1.25; }
482 +.ik-aside .ik-ka-inline { justify-content: flex-start; }
483 +.ik-aside .ik-ka-inline svg { color: var(--ik-accent); }
484 +.ik-aside .ik-price { font-size: 32px; }
485 +.ik-aside-addr { font-size: 14px; color: var(--ik-text-2); line-height: 1.4; }
486 +.ik-aside-sep { border: 0; border-top: 1px solid var(--ik-border); margin: 4px 0; }
487 +.ik-aside-score { display: flex; align-items: center; gap: 12px; }
488 +.ik-aside-score .ik-score-ring { width: 56px; height: 56px; }
489 +.ik-aside-score .ik-score-ring svg { width: 56px; height: 56px; }
490 +.ik-aside-score .ik-score-val { font-size: 18px; }
491 +.ik-aside-score .ik-score-val small { display: none; }
492 +.ik-aside-score-txt { font-size: 13px; color: var(--ik-text-2); line-height: 1.4; }
493 +.ik-aside-score-txt b { display: block; color: var(--ik-text); font-size: 14px; }
494 +.ik-aside .ik-brief { gap: 8px; }
495 +.ik-aside .ik-brief-t { font-size: 13.5px; }
496 +.ik-aside .ik-brief-d { display: none; }
497 +.ik-aside-meta { font-size: 12px; color: var(--ik-muted); text-align: center; }
498 +
499 +/* ---- source & méthodologie (pied de section) ---- */
500 +.ik-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(--ik-border); font-size: 12px; color: var(--ik-muted); }
501 +.ik-source b { font-weight: 600; color: var(--ik-text-2); }
502 +.ik-source a, .ik-source button { color: var(--ik-text-2); background: none; border: 0; padding: 0; font: inherit; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: var(--ik-border-2); cursor: pointer; }
503 +.ik-source a:hover, .ik-source button:hover { color: var(--ik-accent-deep); }
504 +.ik-fine { font-size: 12.5px; color: var(--ik-muted); line-height: 1.5; margin: 8px 0 0; }
505 +.ik-fine a { color: var(--ik-text-2); text-decoration: underline; text-underline-offset: 2px; }
506 +.ik-fine.meth { margin-top: 0; }
507 +
508 +/* ---- états : squelettes, vides, erreurs ---- */
509 +.ik-skel { border-radius: 10px; background: linear-gradient(90deg, #ecece8 25%, #f4f4f1 50%, #ecece8 75%); background-size: 400% 100%; animation: ik-shimmer 1.3s infinite linear; }
510 +@keyframes ik-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
511 +.ik-skel-lines { display: flex; flex-direction: column; gap: 8px; }
512 +.ik-skel-lines .ik-skel { height: 12px; }
513 +.ik-skel-lines .ik-skel.short { width: 55%; }
514 +.ik-empty { padding: 14px; border-radius: 12px; background: var(--ik-surface-2); border: 1px dashed var(--ik-border-2); color: var(--ik-muted); font-size: 13.5px; text-align: center; line-height: 1.45; }
515 +.ik-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; border-radius: 12px; background: var(--ik-warning-soft); color: var(--ik-warning); font-size: 13.5px; }
516 +.ik-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; }
517 +.ik-page-error { max-width: 520px; margin: 60px auto; text-align: center; padding: 0 16px; }
518 +.ik-page-error h2 { font-family: var(--font-display); letter-spacing: -0.02em; }
519 +.ik-page-error p { color: var(--ik-text-2); }
520 +.ik-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(--ik-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: ik-up 0.25s ease; max-width: calc(100vw - 32px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
521 +
522 +/* ---- prix / marché ---- */
523 +.ik-market-head { display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: start; }
524 +.ik-market-big { font-family: var(--font-display); font-weight: 700; font-size: 28px; letter-spacing: -0.03em; line-height: 1; }
525 +.ik-market-big small { font: 500 13px var(--font-body); color: var(--ik-muted); margin-left: 4px; letter-spacing: 0; }
526 +.ik-market-sub { font-size: 13px; color: var(--ik-text-2); margin-top: 4px; }
527 +.ik-histo { display: block; width: 100%; max-width: 520px; height: auto; margin: 12px 0 4px; }
528 +.ik-histo-bar { fill: #dedfd9; }
529 +.ik-histo-bar.on { fill: var(--ik-accent); }
530 +.ik-histo-lbl { font: 700 10px var(--font-body); fill: var(--ik-text); }
531 +.ik-histo-lbl.fv { fill: var(--ik-text-2); }
532 +.ik-histo-axis { font: 500 9.5px var(--font-body); fill: var(--ik-muted); }
533 +.ik-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12.5px; color: var(--ik-text-2); margin: 6px 0 0; }
534 +.ik-meta b { color: var(--ik-text); }
535 +
536 +/* jauge du registre des loyers */
537 +.ik-gauge { margin: 14px 0 6px; }
538 +.ik-gauge-lbls { display: flex; justify-content: space-between; font: 600 11px var(--font-body); color: var(--ik-muted); margin-bottom: 6px; }
539 +.ik-gauge-lbls .good { color: var(--ik-success); } .ik-gauge-lbls .bad { color: var(--ik-danger); }
540 +.ik-gauge svg { display: block; width: 100%; max-width: 520px; height: auto; overflow: visible; }
541 +.ik-gauge, .ik-gauge-lbls { max-width: 520px; }
542 +.ik-gauge-track { fill: var(--ik-surface-2); stroke: var(--ik-border); }
543 +.ik-gauge-box { fill: #ecece8; }
544 +.ik-gauge-med { stroke: var(--ik-text-2); stroke-width: 1.5; }
545 +.ik-gauge-me { fill: var(--ik-accent); stroke: #fff; stroke-width: 2.5; }
546 +.ik-gauge-txt { font: 600 10.5px var(--font-body); fill: var(--ik-muted); }
547 +.ik-gauge-txt.me { fill: var(--ik-text); font-weight: 700; }
548 +.ik-bars { display: block; width: 100%; max-width: 520px; height: auto; margin: 6px 0 0; }
549 +.ik-bar { fill: #dedfd9; }
550 +.ik-bar.on { fill: var(--ik-accent); }
551 +.ik-bar-val { font: 600 10px var(--font-body); fill: var(--ik-text-2); }
552 +.ik-bar-cat { font: 600 11px var(--font-body); fill: var(--ik-text-2); }
553 +.ik-bar-cat.on { fill: var(--ik-text); font-weight: 700; }
554 +.ik-bar-n { font: 500 9.5px var(--font-body); fill: var(--ik-muted); }
555 +.ik-bars-axe { stroke: var(--ik-border); }
556 +
557 +/* ---- risques / environnement ---- */
558 +.ik-status { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
559 +.ik-status-t { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -0.02em; }
560 +.ik-status-d { font-size: 13.5px; color: var(--ik-text-2); margin: 8px 0 0; line-height: 1.5; }
561 +.ik-air { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
562 +@media (max-width: 400px) { .ik-air { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
563 +.ik-air-c { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--ik-border); background: var(--ik-surface-2); min-width: 0; }
564 +.ik-air-p { font: 600 11.5px var(--font-body); color: var(--ik-muted); text-transform: uppercase; letter-spacing: 0.05em; }
565 +.ik-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; }
566 +.ik-air-v small { font: 500 11px var(--font-body); color: var(--ik-muted); margin-left: 3px; }
567 +.ik-air-s { font-size: 11.5px; margin-top: 3px; line-height: 1.3; }
568 +.ik-air-s.good { color: var(--ik-success); } .ik-air-s.warn { color: var(--ik-warning); } .ik-air-s.bad { color: var(--ik-danger); } .ik-air-s.neutral { color: var(--ik-muted); }
569 +
570 +/* ---- coût réel ---- */
571 +.ik-cost { list-style: none; margin: 0; padding: 0; }
572 +.ik-cost li { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 8px 0; border-top: 1px solid var(--ik-border); font-size: 13.5px; }
573 +.ik-cost li:first-child { border-top: 0; }
574 +.ik-cost .n { color: var(--ik-text-2); min-width: 0; }
575 +.ik-cost .v { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
576 +.ik-cost .v.na { color: var(--ik-muted); font-weight: 500; }
577 +.ik-cost li.total { border-top: 1px solid var(--ik-border-2); margin-top: 4px; padding-top: 10px; font-size: 15px; }
578 +.ik-cost li.total .n, .ik-cost li.total .v { color: var(--ik-text); font-weight: 700; }
579 +.ik-cost li.annuel { font-size: 12.5px; color: var(--ik-muted); border-top: 0; padding-top: 0; }
580 +.ik-cost-note { font-size: 12.5px; color: var(--ik-text-2); margin: 8px 0 0; }
581 +
582 +/* ---- dossier de l'immeuble (accordéons) ---- */
583 +.ik-tl { list-style: none; margin: 4px 0 0; padding: 0 0 0 12px; border-left: 2px solid var(--ik-border); display: flex; flex-direction: column; gap: 8px; }
584 +.ik-tl li { position: relative; font-size: 13px; color: var(--ik-text-2); }
585 +.ik-tl li::before { content: ""; position: absolute; left: -17.5px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--ik-surface); border: 2px solid var(--ik-border-2); }
586 +.ik-tl li.prix::before { border-color: var(--ik-accent); }
587 +.ik-tl li.disparition::before { border-color: var(--ik-danger); }
588 +.ik-tl li.reapparition::before { border-color: var(--ik-success); }
589 +.ik-tl-date { display: inline-block; min-width: 84px; font: 600 11.5px var(--font-body); color: var(--ik-muted); }
590 +.ik-star { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: var(--ik-text); display: inline-flex; align-items: center; gap: 5px; }
591 +.ik-star svg { color: #e0a800; }
592 +.ik-quote { margin: 10px 0 0; padding: 8px 12px; font-size: 13px; color: var(--ik-text-2); border-left: 3px solid var(--ik-border-2); background: var(--ik-surface-2); border-radius: 8px; }
593 +.ik-quote footer { margin-top: 4px; font-size: 11.5px; color: var(--ik-muted); }
594 +.ik-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 4px; }
595 +.ik-tag { font: 600 11.5px var(--font-body); padding: 2px 8px; border-radius: 999px; background: var(--ik-danger-soft); color: var(--ik-danger); }
596 +.ik-tag.n { background: var(--ik-surface-2); color: var(--ik-text-2); border: 1px solid var(--ik-border); }
597 +
598 +/* ---- KA Scores (cercles compacts) ---- */
599 +.ik-ks { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
600 +@media (max-width: 400px) { .ik-ks { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
601 +.ik-ks-c { text-align: center; padding: 8px 4px; border-radius: 12px; border: 1px solid var(--ik-border); background: var(--ik-surface-2); min-width: 0; }
602 +.ik-ks-c svg { width: 52px; height: 52px; transform: rotate(-90deg); }
603 +.ik-ks-c .bg { fill: none; stroke: #e6e6e1; stroke-width: 5; }
604 +.ik-ks-c .arc { fill: none; stroke: var(--ik-text); stroke-width: 5; stroke-linecap: round; }
605 +.ik-ks-c.haut .arc { stroke: var(--ik-success); } .ik-ks-c.bon .arc { stroke: var(--ik-text); } .ik-ks-c.moyen .arc { stroke: var(--ik-warning); } .ik-ks-c.bas .arc { stroke: var(--ik-danger); }
606 +.ik-ks-wrap { position: relative; width: 52px; height: 52px; margin: 0 auto; }
607 +.ik-ks-v { position: absolute; inset: 0; display: grid; place-items: center; font: 700 15px var(--font-display); letter-spacing: -0.02em; }
608 +.ik-ks-n { font: 600 11.5px var(--font-body); margin-top: 6px; color: var(--ik-text); }
609 +.ik-ks-l { font-size: 10.5px; color: var(--ik-muted); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
610 +.ik-ks-detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px 18px; }
611 +.ik-ks-detail h4 { margin: 8px 0 4px; font: 700 12.5px var(--font-body); color: var(--ik-text); }
612 +.ik-ks-detail ul { margin: 0; padding-left: 16px; font-size: 13px; color: var(--ik-text-2); }
613 +.ik-ks-detail li { margin: 2px 0; }
614 +
615 +/* ---- fin de fiche ---- */
616 +.ik-end { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
617 +@media (min-width: 640px) { .ik-end { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
618 +.ik-end a, .ik-end button { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; padding: 12px; border-radius: 12px; border: 1px solid var(--ik-border); background: var(--ik-surface); color: var(--ik-text); font: 600 13px var(--font-body); cursor: pointer; text-align: left; min-height: 64px; }
619 +.ik-end a:hover, .ik-end button:hover { background: var(--ik-surface-2); }
620 +.ik-end svg { color: var(--ik-accent-deep); }
621 +.ik-end small { font-weight: 500; color: var(--ik-muted); font-size: 12px; }
622 +.ik-sources { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
623 +.ik-sources li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; padding: 9px 0; border-top: 1px solid var(--ik-border); font-size: 13px; align-items: center; }
624 +.ik-sources li:first-child { border-top: 0; }
625 +.ik-sources .n { font-weight: 600; grid-column: 1; grid-row: 1; }
626 +.ik-sources .r { font-size: 12.5px; color: var(--ik-text-2); grid-column: 1; grid-row: 2; }
627 +.ik-sources .d { font-size: 12px; color: var(--ik-muted); grid-column: 2; grid-row: 1 / span 2; text-align: right; white-space: nowrap; align-self: start; }
628 +.ik-sources a { color: var(--ik-text-2); text-decoration: underline; text-underline-offset: 2px; }
629 +
630 +
631 +/* ---- ajouts Immo-Ka : héro, galerie, formulaire, tableaux, rôle ---- */
632 +.ik-kicker { font-family: var(--font-mono); font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ik-muted); margin-bottom: 4px; }
633 +.ik-gallery-empty { display: grid; place-items: center; aspect-ratio: 4 / 3; }
634 +@media (min-width: 768px) { .ik-gallery-empty { aspect-ratio: 16 / 9; } }
635 +.ik-gallery-empty .type-fallback { position: static; width: 100%; height: 100%; }
636 +.ik-gallery-caption {
637 + position: absolute; left: 12px; top: 12px; z-index: 2; max-width: calc(100% - 24px);
638 + background: rgba(20, 24, 20, 0.62); color: #fff; font: 500 12px var(--font-body);
639 + padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
640 + overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
641 +}
642 +.ik-subtitle { margin: 14px 0 8px !important; font-weight: 600 !important; color: var(--ik-text-2) !important; font-size: 13px !important; }
643 +.ik-subtitle small { font-weight: 500; color: var(--ik-muted); }
644 +.ik-more .flip { transform: rotate(180deg); }
645 +.ik-acc-ico { vertical-align: -2px; margin-right: 8px; color: var(--ik-muted); }
646 +.ik-role { margin-top: 14px; }
647 +.ik-tl li.baisse::before { border-color: var(--ik-success); }
648 +.ik-tl li.hausse::before { border-color: var(--ik-warning); }
649 +/* formulaire du calculateur */
650 +.ik-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
651 +@media (min-width: 640px) { .ik-form { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
652 +.ik-form label { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
653 +.ik-form label span { font-size: 11.5px; color: var(--ik-muted); font-weight: 600; }
654 +.ik-form input, .ik-form select {
655 + min-height: 42px; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--ik-border); background: var(--ik-surface);
656 + font: 500 15px var(--font-body); color: var(--ik-text); width: 100%; min-width: 0; appearance: none; -webkit-appearance: none;
657 +}
658 +.ik-form select { background-image: linear-gradient(45deg, transparent 50%, var(--ik-muted) 50%), linear-gradient(135deg, var(--ik-muted) 50%, transparent 50%); background-position: calc(100% - 16px) 50%, calc(100% - 11px) 50%; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 28px; }
659 +.ik-form input:focus, .ik-form select:focus { outline: 2px solid var(--ik-accent); outline-offset: 1px; }
660 +/* tableaux (pièces, banques, amortissement) */
661 +.ik-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 0 -4px; padding: 0 4px; }
662 +.ik-table { width: 100%; border-collapse: collapse; font-size: 13px; }
663 +.ik-table th { text-align: left; font: 600 11.5px var(--font-body); color: var(--ik-muted); text-transform: uppercase; letter-spacing: 0.04em; padding: 6px 8px; border-bottom: 1px solid var(--ik-border); white-space: nowrap; }
664 +.ik-table td { padding: 8px 8px; border-bottom: 1px solid var(--ik-border); vertical-align: top; color: var(--ik-text-2); }
665 +.ik-table td:first-child { color: var(--ik-text); font-weight: 500; }
666 +.ik-table tr:last-child td { border-bottom: 0; }
667 +.ik-table a { color: var(--ik-text-2); text-decoration: underline; text-underline-offset: 2px; }
668 +/* aside : courtier */
669 +.ik-aside-broker { display: flex; flex-direction: column; gap: 2px; font-size: 13.5px; color: var(--ik-text-2); }
670 +.ik-aside-broker-k { font: 600 11px var(--font-body); text-transform: uppercase; letter-spacing: 0.06em; color: var(--ik-muted); }
671 +.ik-aside-broker b { color: var(--ik-text); }
672 +.ik-aside-broker a { display: inline-flex; align-items: center; gap: 6px; color: var(--ik-text-2); }
673 +/* jauge Vrai-Prix : libellés */
674 +.ik-gauge-lbls { margin-top: 4px; margin-bottom: 0; }
675 +.ik-kv .v a { color: var(--ik-text-2); text-decoration: underline; text-underline-offset: 2px; }
676 +.ik-kv .v { min-width: 0; overflow-wrap: anywhere; }
677 +
678 +/* ---- accessibilité mouvement réduit ---- */
679 +@media (prefers-reduced-motion: reduce) {
680 + .ik-sheet, .ik-sheet-backdrop, .ik-toast, .ik-kpi.anim .ik-kpi-v { animation: none; }
681 + .ik-score-ring .arc, .ik-acc-body, .ik-cta-bar { transition: none; }
682 +}
683 +
684 +/* ---- utilitaire a11y ---- */
685 +.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 +254 −0
@@ -0,0 +1,254 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (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) — briques réutilisées par le héro, le score, « En bref »,
6 +// l'aside desktop et le CTA sticky :
7 +// · comparaisonPrix : position du prix demandé vs estimation Vrai-Prix ;
8 +// · immoKaScore : 0-100 = 60 % emplacement (indice de proximité StatCan du
9 +// secteur) + 40 % prix (écart à l'estimation Vrai-Prix : 0 % → 65,
10 +// −30 % → 100, +30 % → 20). Score PARTIEL et dit tel quel quand une
11 +// composante manque ;
12 +// · enBref : 4 à 6 constats priorisés (prix, rôle d'évaluation, accessibilité,
13 +// air, inondation, taxes/copropriété, chaleur, baisse de prix, temps sur le
14 +// marché, publications multiples), chacun avec son ton et sa preuve ;
15 +// · lecture des caractéristiques Centris (taxes, copropriété, évaluation).
16 +// -----------------------------------------------------------------------------
17 +import { AirNearby, Inondation, Listing, fmtPrice } from "../api";
18 +import { Tone, NBSP, fmtPct, parseMontant } from "./ui";
19 +
20 +/* --- caractéristiques chiffrées lues dans `details` --------------------------- */
21 +const pick = (l: Listing, keys: string[]): unknown => {
22 + const d = l.details ?? {};
23 + for (const k of keys) if (d[k] != null && String(d[k]).trim()) return d[k];
24 + return null;
25 +};
26 +
27 +export const estLocation = (l: Listing) => l.details?.transaction === "location";
28 +
29 +/** Taxes annuelles publiées (municipales + scolaires), en $ ; null si absentes. */
30 +export function taxesAnnuelles(l: Listing): { total: number; municipales: number | null; scolaires: number | null } | null {
31 + const m = parseMontant(pick(l, ["Taxes municipales", "Taxe municipale"]));
32 + const s = parseMontant(pick(l, ["Taxes scolaires", "Taxe scolaire"]));
33 + const tot = (m ?? 0) + (s ?? 0);
34 + if (tot <= 0 || tot > 500_000) return null;
35 + return { total: tot, municipales: m, scolaires: s };
36 +}
37 +
38 +/** Frais de copropriété mensuels publiés, en $ ; null si absents. */
39 +export function fraisCoproMensuels(l: Listing): number | null {
40 + const v = pick(l, ["Frais de copropriété", "Frais de condo", "Frais de copropriété (mensuels)", "Frais communs"]);
41 + const n = parseMontant(v);
42 + if (n == null) return null;
43 + // certaines sources publient un montant annuel « /an »
44 + return /an\b|année|annuel/i.test(String(v)) ? Math.round(n / 12) : n;
45 +}
46 +
47 +/** Évaluation municipale (rôle) : totale, terrain, bâtiment, année. */
48 +export function evaluationMunicipale(l: Listing): { total: number; terrain: number | null; batiment: number | null; annee: string | null } | null {
49 + const vp = l.vraiprix;
50 + let total = parseMontant(pick(l, ["Évaluation municipale", "Évaluation municipale (totale)", "Évaluation municipale totale"]));
51 + const terrain = parseMontant(pick(l, ["Évaluation municipale (terrain)"])) ?? vp?.valeur_terrain ?? null;
52 + const batiment = parseMontant(pick(l, ["Évaluation municipale (bâtiment)"])) ?? vp?.valeur_batiment ?? null;
53 + if (total == null && terrain != null && batiment != null) total = terrain + batiment;
54 + if (total == null && vp?.valeur_role) total = vp.valeur_role;
55 + if (total == null) return null;
56 + const annee = pick(l, ["Évaluation municipale (année)"]);
57 + return { total, terrain, batiment, annee: annee != null ? String(annee) : null };
58 +}
59 +
60 +/* --- comparaison de prix (Vrai-Prix) ------------------------------------------ */
61 +export interface ComparaisonPrix {
62 + tone: "good" | "ok" | "high";
63 + pct: number; // écart signé en % (négatif = sous l'estimation)
64 + label: string; // « Sous l'estimation » / « Prix aligné » / « Au-dessus de l'estimation »
65 + court: string; // « ↓ 8 % vs estimation »
66 + ref: number; // valeur de référence ($)
67 + refLabel: string;
68 + confidence: string | null; // A | B | C | D
69 +}
70 +
71 +export function comparaisonPrix(l: Listing): ComparaisonPrix | null {
72 + const vp = l.vraiprix;
73 + if (l.price == null || estLocation(l) || !vp || vp.value == null || vp.value <= 0) return null;
74 + const pct = Math.round(((l.price - vp.value) / vp.value) * 100);
75 + if (Math.abs(pct) > 60) return null; // prix non comparable (terrain, commerce, lot…)
76 + const tone = pct <= -5 ? "good" : pct <= 5 ? "ok" : "high";
77 + return {
78 + tone, pct, ref: vp.value, refLabel: "estimation Vrai-Prix", confidence: vp.confidence,
79 + label: tone === "good" ? "Sous l'estimation" : tone === "ok" ? "Prix aligné" : "Au-dessus de l'estimation",
80 + court: `${pct < 0 ? "↓" : pct > 0 ? "↑" : "≈"} ${Math.abs(pct)}${NBSP}% vs estimation`,
81 + };
82 +}
83 +
84 +/* --- Immo-Ka Score -------------------------------------------------------------- */
85 +export interface ImmoKaScore {
86 + value: number | null; // score affiché (0-100) ou null si rien de fiable
87 + partial: boolean; // une composante manque
88 + emplacement: number | null; // indice de proximité StatCan (0-100)
89 + prix: number | null; // composante prix (0-100)
90 + label: string | null;
91 + explication: string;
92 +}
93 +
94 +export function kaLabel(score: number | null | undefined): string | null {
95 + if (score == null) return null;
96 + if (score >= 85) return "Exceptionnel";
97 + if (score >= 70) return "Excellent";
98 + if (score >= 55) return "Très bon";
99 + if (score >= 40) return "Moyen";
100 + return "Faible";
101 +}
102 +
103 +export function scorePrix(deviation: number | null | undefined): number | null {
104 + if (deviation == null) return null;
105 + // 0 % d'écart → 65 ; −30 % → 100 ; +30 % → 20 (borné 5-100)
106 + return Math.round(Math.max(5, Math.min(100, 65 - deviation * 150)));
107 +}
108 +
109 +/** Indice d'emplacement 0-100 = moyenne des mesures de proximité StatCan
110 + * (épiceries, transport, pharmacies, parcs, écoles, santé…) du secteur. */
111 +export function indiceEmplacement(l: Listing): number | null {
112 + const p = l.quartier?.proximite;
113 + if (!p) return null;
114 + const vals = Object.entries(p)
115 + .filter(([k, v]) => k.startsWith("prox_") && typeof v === "number" && Number.isFinite(v))
116 + .map(([, v]) => Math.max(0, Math.min(1, v as number)));
117 + if (vals.length < 3) return null;
118 + return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100);
119 +}
120 +
121 +export function immoKaScore(l: Listing): ImmoKaScore {
122 + const emplacement = indiceEmplacement(l);
123 + const cmp = comparaisonPrix(l);
124 + const prix = cmp ? scorePrix(cmp.pct / 100) : null;
125 + let value: number | null = null;
126 + let partial = false;
127 + if (emplacement != null && prix != null) value = Math.round(emplacement * 0.6 + prix * 0.4);
128 + else if (emplacement != null) { value = emplacement; partial = true; }
129 + else if (prix != null) { value = prix; partial = true; }
130 + const explication =
131 + "L'Immo-Ka Score combine l'emplacement (indice de proximité aux services de Statistique Canada " +
132 + "pour le secteur : épiceries, transport en commun, pharmacies, parcs, écoles, soins de santé — 60 %) " +
133 + "et le prix (écart du prix demandé à l'estimation Vrai-Prix, fondée sur les ventes comparables et le " +
134 + "rôle d'évaluation — 40 %). Quand une composante manque, le score est dit partiel et repose sur la seule " +
135 + "composante disponible. Il n'intègre ni l'état du bâtiment ni l'inspection.";
136 + return { value, partial, emplacement, prix, label: value != null ? kaLabel(value) : null, explication };
137 +}
138 +
139 +/* --- En bref -------------------------------------------------------------------- */
140 +export interface Constat { tone: Tone; titre: string; detail: string; cle: string; }
141 +
142 +export function enBref(l: Listing, x: {
143 + air: AirNearby | null; inondation: Inondation | null; loadingRisques: boolean;
144 +}): Constat[] {
145 + const out: Constat[] = [];
146 + const cmp = comparaisonPrix(l);
147 + const location = estLocation(l);
148 +
149 + // 1. prix vs estimation Vrai-Prix
150 + if (cmp) {
151 + out.push({
152 + cle: "prix",
153 + tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral",
154 + titre: cmp.tone === "good" ? "Prix demandé sous l'estimation" : cmp.tone === "high" ? "Prix demandé au-dessus de l'estimation" : "Prix aligné sur l'estimation",
155 + detail: `${Math.abs(cmp.pct)}${NBSP}% ${cmp.pct < 0 ? "sous" : cmp.pct > 0 ? "au-dessus de" : "≈"} ${fmtPrice(cmp.ref)} (Vrai-Prix${cmp.confidence ? `, confiance ${cmp.confidence}` : ""})`,
156 + });
157 + } else if (l.price != null && !location) {
158 + out.push({ cle: "prix", tone: "neutral", titre: "Prix non comparé",
159 + detail: "Pas d'estimation Vrai-Prix exploitable pour ce type de bien." });
160 + }
161 +
162 + // 2. rôle d'évaluation
163 + const ev = evaluationMunicipale(l);
164 + if (ev && l.price != null && !location && ev.total > 0) {
165 + const pct = Math.round(((l.price - ev.total) / ev.total) * 100);
166 + if (Math.abs(pct) <= 150)
167 + out.push({ cle: "role", tone: "neutral", titre: `${Math.abs(pct)}${NBSP}% ${pct >= 0 ? "au-dessus" : "sous"} l'évaluation municipale`,
168 + detail: `Rôle ${ev.annee ? `${ev.annee} ` : ""}: ${fmtPrice(ev.total)}${ev.terrain != null && ev.batiment != null ? ` (terrain ${fmtPrice(ev.terrain)}, bâtiment ${fmtPrice(ev.batiment)})` : ""}` });
169 + }
170 +
171 + // 3. accessibilité (proximité StatCan)
172 + const m = indiceEmplacement(l);
173 + if (m != null)
174 + out.push({ cle: "acces", tone: m >= 70 ? "good" : m >= 45 ? "neutral" : "warn",
175 + titre: m >= 85 ? "Secteur exceptionnellement bien desservi" : m >= 70 ? "Services et transport à proximité" : m >= 45 ? "Accessibilité moyenne" : "Secteur peu desservi sans voiture",
176 + detail: `Indice de proximité StatCan ${m}/100 (épiceries, transport, pharmacies, parcs, écoles, santé)` });
177 +
178 + // 4. qualité de l'air
179 + if (x.air?.station) {
180 + const pm = x.air.mesures?.["PM2.5"];
181 + if (pm?.ref) {
182 + const r = pm.moyenne / pm.ref;
183 + out.push({
184 + cle: "air", tone: r <= 2 ? "good" : r <= 3 ? "warn" : "bad",
185 + 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",
186 + 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}`,
187 + });
188 + }
189 + }
190 +
191 + // 5. inondation
192 + if (x.inondation) {
193 + const d = x.inondation;
194 + if (d.statut === "en_zone")
195 + out.push({ cle: "inond", tone: d.severite === "eleve" ? "bad" : "warn", titre: "Adresse en zone inondable",
196 + detail: d.zones[0] ? `${d.zones[0].type}${d.zones[0].recurrence ? ` (${d.zones[0].recurrence})` : ""} — carte officielle BDZI` : "Cartographie officielle BDZI" });
197 + else if (d.statut === "a_proximite")
198 + out.push({ cle: "inond", tone: "warn", titre: "Zone inondable à proximité",
199 + detail: d.zones[0] ? `${d.zones[0].type} à ~${d.zones[0].distance_m} m` : "Selon la cartographie BDZI" });
200 + else if (d.statut === "hors_zone")
201 + out.push({ cle: "inond", tone: "good", titre: "Hors zone inondable", detail: "Secteur cartographié (BDZI, gouvernement du Québec)" });
202 + else
203 + out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation indéterminé", detail: "Secteur non couvert par la cartographie officielle" });
204 + } else if (x.loadingRisques && l.lat != null) {
205 + out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation", detail: "Vérification en cours…" });
206 + }
207 +
208 + // 6. charges : taxes et copropriété
209 + const tx = taxesAnnuelles(l);
210 + const copro = fraisCoproMensuels(l);
211 + if (tx || copro != null) {
212 + const parts: string[] = [];
213 + if (tx) parts.push(`taxes ${fmtPrice(Math.round(tx.total))}${NBSP}/an (≈${NBSP}${fmtPrice(Math.round(tx.total / 12))}${NBSP}/mois)`);
214 + if (copro != null) parts.push(`copropriété ${fmtPrice(copro)}${NBSP}/mois`);
215 + const mensuel = Math.round((tx ? tx.total / 12 : 0) + (copro ?? 0));
216 + out.push({ cle: "charges", tone: "neutral", titre: `Charges fixes ≈ ${fmtPrice(mensuel)}${NBSP}/mois hors hypothèque`,
217 + detail: parts.join(" · ") });
218 + }
219 +
220 + // 7. îlot de chaleur marqué
221 + if (l.quartier?.chaleur && l.quartier.chaleur.classe >= 8)
222 + out.push({ cle: "chaleur", tone: "warn", titre: "Îlot de chaleur urbain",
223 + 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` });
224 +
225 + // 8. baisse de prix observée
226 + const hist = (l.price_history ?? []).filter((h) => h.price != null);
227 + if (hist.length >= 2 && hist[0].price! < hist[1].price!)
228 + out.push({ cle: "baisse", tone: "good", titre: "Prix en baisse",
229 + detail: `${fmtPrice(hist[1].price!)} → ${fmtPrice(hist[0].price!)} (${fmtPct(((hist[0].price! - hist[1].price!) / hist[1].price!) * 100)})` });
230 +
231 + // 9. temps sur le marché (observé par Immo-Ka)
232 + if ((l.days_on_market ?? 0) >= 60)
233 + out.push({ cle: "marche", tone: "neutral", titre: `Sur le marché depuis ${l.days_on_market}${NBSP}jours`,
234 + detail: "Observé par Immo-Ka depuis la première synchronisation — marge de négociation possible" });
235 +
236 + // 10. publications multiples
237 + if ((l.duplicates?.length ?? 0) > 0)
238 + out.push({ cle: "dups", tone: "neutral", titre: `Aussi publiée sur ${l.duplicates!.length} autre${l.duplicates!.length > 1 ? "s" : ""} plateforme${l.duplicates!.length > 1 ? "s" : ""}`,
239 + detail: "Immo-Ka affiche la version la plus complète — voir le dossier" });
240 +
241 + return out.slice(0, 6);
242 +}
243 +
244 +/** Ligne résumé : « Maison · 3 chambres · 2 sdb · 1 500 pi² · construit en 1998 » */
245 +export function ligneResume(l: Listing): string[] {
246 + const p: string[] = [];
247 + if (l.property_type) p.push(l.property_type);
248 + if (l.bedrooms != null) p.push(`${Math.round(l.bedrooms)} chambre${l.bedrooms > 1 ? "s" : ""}`);
249 + if (l.bathrooms != null) p.push(`${l.bathrooms} sdb${l.powder_rooms ? ` + ${l.powder_rooms} s.e.` : ""}`);
250 + if (l.area_sqft) p.push(`${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`);
251 + else if (l.lot_sqft) p.push(`terrain ${Math.round(l.lot_sqft).toLocaleString("fr-CA")}${NBSP}pi²`);
252 + if (l.year_built) p.push(`construit en ${l.year_built}`);
253 + return p;
254 +}
added frontend/src/fiche/ui.tsx +177 −0
@@ -0,0 +1,177 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// fiche/ui.tsx : primitives d'interface de la fiche propriété (refonte
5 +// premium 2026-09-07, même socle que la fiche Lou-Ka v3) :
6 +// SectionCard · Accordion · StatTile · StatusBadge · Skeleton · EmptyState ·
7 +// ErrorState · MoreButton · SourceLine · useToast — sans dépendance externe,
8 +// ARIA correcte, cibles tactiles ≥ 44 px, animations légères.
9 +// -----------------------------------------------------------------------------
10 +import { ReactNode, useCallback, useEffect, useId, useState } from "react";
11 +import { createPortal } from "react-dom";
12 +import { Ico } from "../components/Icons";
13 +
14 +export type Tone = "good" | "warn" | "bad" | "neutral" | "info";
15 +
16 +/* --- carte de section ------------------------------------------------------ */
17 +export function SectionCard({ id, title, icon, sub, aside, children, className = "", label }: {
18 + id?: string; title?: ReactNode; icon?: ReactNode; sub?: ReactNode; aside?: ReactNode;
19 + children: ReactNode; className?: string; label?: string;
20 +}) {
21 + return (
22 + <section id={id} className={`ik-card ${className}`} aria-label={label}>
23 + {(title || aside) && (
24 + <div className="ik-card-head">
25 + <div>
26 + {title && <h2 className="ik-card-title">{icon}{title}</h2>}
27 + {sub && <p className="ik-card-sub">{sub}</p>}
28 + </div>
29 + {aside && <div className="ik-card-aside">{aside}</div>}
30 + </div>
31 + )}
32 + {children}
33 + </section>
34 + );
35 +}
36 +
37 +/* --- accordéon accessible (bouton + région, animation grid-rows) ----------- */
38 +export function Accordion({ title, meta, children, defaultOpen = false, small = false, onToggle }: {
39 + title: ReactNode; meta?: ReactNode; children: ReactNode; defaultOpen?: boolean;
40 + small?: boolean; onToggle?: (open: boolean) => void;
41 +}) {
42 + const [open, setOpen] = useState(defaultOpen);
43 + const id = useId();
44 + return (
45 + <div className={`ik-acc ${small ? "sm" : ""}`}>
46 + <button type="button" className="ik-acc-btn" aria-expanded={open}
47 + aria-controls={`${id}-body`} id={`${id}-btn`}
48 + onClick={() => { setOpen(!open); onToggle?.(!open); }}>
49 + <span>{title}</span>
50 + {meta && <span className="ik-acc-meta">{meta}</span>}
51 + <Ico name="chevdown" size={18} className="chev" />
52 + </button>
53 + <div className={`ik-acc-body ${open ? "open" : ""}`} id={`${id}-body`}
54 + role="region" aria-labelledby={`${id}-btn`}>
55 + <div><div className="ik-acc-inner">{children}</div></div>
56 + </div>
57 + </div>
58 + );
59 +}
60 +
61 +/* --- tuile KPI ------------------------------------------------------------- */
62 +export function StatTile({ value, unit, label, accent = false, anim = true }: {
63 + value: ReactNode; unit?: ReactNode; label: ReactNode; accent?: boolean; anim?: boolean;
64 +}) {
65 + return (
66 + <div className={`ik-kpi ${accent ? "accent" : ""} ${anim ? "anim" : ""}`}>
67 + <div className={`ik-kpi-v ${typeof value === "string" && value.length > 8 ? "wrap" : ""}`}>{value}{unit && <small>{unit}</small>}</div>
68 + <div className="ik-kpi-l">{label}</div>
69 + </div>
70 + );
71 +}
72 +
73 +/* --- pastille d'état ------------------------------------------------------- */
74 +export function StatusBadge({ tone = "neutral", children, lg = false }: {
75 + tone?: Tone; children: ReactNode; lg?: boolean;
76 +}) {
77 + return <span className={`ik-badge ${tone} ${lg ? "lg" : ""}`}>{children}</span>;
78 +}
79 +
80 +/* --- squelettes ------------------------------------------------------------ */
81 +export function Skeleton({ h = 14, w, r, className = "" }: { h?: number | string; w?: number | string; r?: number; className?: string }) {
82 + return <div className={`ik-skel ${className}`} style={{ height: h, width: w ?? "100%", borderRadius: r }} aria-hidden="true" />;
83 +}
84 +export function SkeletonLines({ n = 3 }: { n?: number }) {
85 + return (
86 + <div className="ik-skel-lines" aria-busy="true">
87 + {Array.from({ length: n }).map((_, i) => (
88 + <div key={i} className={`ik-skel ${i === n - 1 ? "short" : ""}`} />
89 + ))}
90 + </div>
91 + );
92 +}
93 +
94 +/* --- états vides / erreur -------------------------------------------------- */
95 +export function EmptyState({ children = "Aucune donnée disponible pour ce secteur." }: { children?: ReactNode }) {
96 + return <div className="ik-empty">{children}</div>;
97 +}
98 +export function ErrorState({ onRetry, children = "Données temporairement indisponibles." }: {
99 + onRetry?: () => void; children?: ReactNode;
100 +}) {
101 + return (
102 + <div className="ik-error" role="alert">
103 + <span>{children}</span>
104 + {onRetry && <button type="button" onClick={onRetry}>Réessayer</button>}
105 + </div>
106 + );
107 +}
108 +
109 +/* --- bouton « Voir plus » pleine largeur ----------------------------------- */
110 +export function MoreButton({ children, onClick, expanded }: {
111 + children: ReactNode; onClick: () => void; expanded?: boolean;
112 +}) {
113 + return (
114 + <button type="button" className="ik-more" onClick={onClick} aria-expanded={expanded}>
115 + {children}
116 + <Ico name="chevdown" size={16} className={expanded ? "flip" : ""} />
117 + </button>
118 + );
119 +}
120 +
121 +/* --- ligne « Source : … · Méthodologie » en pied de section ---------------- */
122 +export function SourceLine({ name, href, date, onMethod, methodLabel = "Méthodologie" }: {
123 + name: ReactNode; href?: string; date?: ReactNode; onMethod?: () => void; methodLabel?: string;
124 +}) {
125 + return (
126 + <div className="ik-source">
127 + <span>
128 + Source : <b>{href ? <a href={href} target="_blank" rel="noopener noreferrer">{name}</a> : name}</b>
129 + {date && <> · {date}</>}
130 + </span>
131 + {onMethod && <button type="button" onClick={onMethod}>{methodLabel}</button>}
132 + </div>
133 + );
134 +}
135 +
136 +/* --- toast minimal (retour d'action : favoris, lien copié) ------------------ */
137 +export function useToast(): [ReactNode, (msg: string) => void] {
138 + const [msg, setMsg] = useState<string | null>(null);
139 + useEffect(() => {
140 + if (!msg) return;
141 + const t = setTimeout(() => setMsg(null), 2200);
142 + return () => clearTimeout(t);
143 + }, [msg]);
144 + const show = useCallback((m: string) => setMsg(m), []);
145 + const node = msg
146 + ? createPortal(<div className="ik-toast" role="status" aria-live="polite">{msg}</div>, document.body)
147 + : null;
148 + return [node, show];
149 +}
150 +
151 +/* --- utilitaires de format -------------------------------------------------- */
152 +export const NBSP = " ";
153 +export const fmtN = (v: number, d = 0) =>
154 + v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: d });
155 +export const fmtPct = (v: number, signed = true) =>
156 + `${signed ? (v > 0 ? "+" : v < 0 ? "−" : "") : ""}${Math.abs(Math.round(v))}${NBSP}%`;
157 +/** ≈ minutes de marche (vol d'oiseau × 1,3 de détour, 4,8 km/h) */
158 +export const marcheMin = (m: number) => Math.max(1, Math.round((m * 1.3) / 80));
159 +export const fmtMarche = (m: number) => `${marcheMin(m)}${NBSP}min`;
160 +export const relTime = (ts: number | null | undefined): string | null => {
161 + if (!ts) return null;
162 + const s = Date.now() / 1000 - ts;
163 + if (s < 3600) return "à l'instant";
164 + if (s < 86400) return `il y a ${Math.round(s / 3600)}${NBSP}h`;
165 + const j = Math.round(s / 86400);
166 + if (j < 30) return `il y a ${j}${NBSP}jour${j > 1 ? "s" : ""}`;
167 + return new Date(ts * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "short", year: "numeric" });
168 +};
169 +/** « 19 989 $ (2026) » → 19989 ; « 1 640 pi² » → 1640 ; sinon null */
170 +export const parseMontant = (v: unknown): number | null => {
171 + if (v == null) return null;
172 + const s = String(v).replace(/\(.*?\)/g, "").replace(/[^\d.,]/g, "").replace(/\s/g, "");
173 + if (!s) return null;
174 + // « 19 989 » et « 19989,50 » : la virgule est décimale, le point aussi
175 + const n = parseFloat(s.replace(/,(\d{1,2})$/, ".$1").replace(/,/g, ""));
176 + return Number.isFinite(n) && n > 0 ? n : null;
177 +};
added frontend/src/fiche/useFicheData.ts +114 −0
@@ -0,0 +1,114 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (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) : inondation, qualité de l'air — nécessaires
7 +// à « En bref » et au premier écran ;
8 +// · groupe DIFFÉRÉ (quand l'utilisateur approche la carte, ou après 2,5 s) :
9 +// commerces/transport, essence, Hydro-Québec (cache seulement).
10 +// Le prix (Vrai-Prix), le quartier, les POI et l'historique de prix arrivent
11 +// déjà avec l'annonce (/api/listings/{uid}). Le financement a son propre
12 +// cycle (calculateur interactif). Chaque ressource porte son statut
13 +// (loading / ok / error / na) : squelettes et états vides propres.
14 +// -----------------------------------------------------------------------------
15 +import { useCallback, useEffect, useRef, useState } from "react";
16 +import {
17 + AirNearby, CommercesNearby, GazNearby, HydroEstimate, Inondation, Listing,
18 + fetchAir, fetchCommerces, fetchGaz, fetchHydro, fetchInondation,
19 +} from "../api";
20 +
21 +export type Res<T> =
22 + | { status: "idle" | "loading" }
23 + | { status: "ok"; data: T }
24 + | { status: "error"; error: string }
25 + | { status: "na" }; // non applicable (pas de coordonnées, pas d'adresse…)
26 +
27 +export interface FicheData {
28 + inondation: Res<Inondation>;
29 + air: Res<AirNearby>;
30 + commerces: Res<CommercesNearby>;
31 + gaz: Res<GazNearby>;
32 + hydro: Res<HydroEstimate>;
33 + /** déclenche le groupe différé (appelé par la sentinelle de la carte) */
34 + wake: () => void;
35 + /** relance une ressource en erreur */
36 + retry: (key: keyof Omit<FicheData, "wake" | "retry">) => void;
37 +}
38 +
39 +type Key = keyof Omit<FicheData, "wake" | "retry">;
40 +const LOADING = { status: "loading" } as const;
41 +const NA = { status: "na" } as const;
42 +const CRITIQUE: Key[] = ["inondation", "air"];
43 +const DIFFERE: Key[] = ["commerces", "gaz", "hydro"];
44 +
45 +export function dataOf<T>(r: Res<T>): T | null {
46 + return r.status === "ok" ? r.data : null;
47 +}
48 +
49 +export default function useFicheData(l: Listing | null): FicheData {
50 + const [state, setState] = useState<Record<Key, Res<unknown>>>({
51 + inondation: LOADING, air: LOADING,
52 + commerces: { status: "idle" }, gaz: { status: "idle" }, hydro: { status: "idle" },
53 + });
54 + const [awake, setAwake] = useState(false);
55 + const uidRef = useRef<string | null>(null);
56 + const started = useRef<Set<Key>>(new Set());
57 +
58 + const set = useCallback((k: Key, r: Res<unknown>) =>
59 + setState((s) => ({ ...s, [k]: r })), []);
60 +
61 + const load = useCallback((k: Key, p: (() => Promise<unknown>) | null) => {
62 + if (!p) { set(k, NA); return; }
63 + const uid = uidRef.current;
64 + set(k, LOADING);
65 + p().then((d) => { if (uidRef.current === uid) set(k, { status: "ok", data: d }); })
66 + .catch((e: unknown) => {
67 + if (uidRef.current !== uid) return;
68 + // 404 = base absente ou hors couverture → état « non applicable » (pas une erreur)
69 + const msg = String(e);
70 + set(k, /API 404/.test(msg) ? NA : { status: "error", error: msg });
71 + });
72 + }, [set]);
73 +
74 + const loaders = useCallback((k: Key): (() => Promise<unknown>) | null => {
75 + if (!l) return null;
76 + const geo = l.lat != null && l.lng != null;
77 + const lat = l.lat as number, lng = l.lng as number;
78 + switch (k) {
79 + case "inondation": return geo ? () => fetchInondation(lat, lng) : null;
80 + case "air": return geo ? () => fetchAir(lat, lng) : null;
81 + case "commerces": return geo ? () => fetchCommerces(lat, lng) : null;
82 + case "gaz": return geo ? () => fetchGaz(lat, lng, 60) : null;
83 + case "hydro": return (l.address || l.title)
84 + ? () => fetchHydro(l.address || l.title, { uid: l.uid, lat: l.lat, lng: l.lng }, false) : null;
85 + }
86 + }, [l]);
87 +
88 + // groupe critique : dès que l'annonce est connue
89 + useEffect(() => {
90 + if (!l) return;
91 + uidRef.current = l.uid;
92 + started.current = new Set();
93 + setAwake(false);
94 + for (const k of CRITIQUE) load(k, loaders(k));
95 + for (const k of DIFFERE) set(k, { status: "idle" });
96 + const t = setTimeout(() => setAwake(true), 2500); // filet : réveil après 2,5 s
97 + return () => clearTimeout(t);
98 + }, [l, load, loaders, set]);
99 +
100 + // groupe différé : au réveil (proximité de la carte ou délai)
101 + useEffect(() => {
102 + if (!l || !awake) return;
103 + for (const k of DIFFERE) {
104 + if (started.current.has(k)) continue;
105 + started.current.add(k);
106 + load(k, loaders(k));
107 + }
108 + }, [awake, l, load, loaders]);
109 +
110 + const wake = useCallback(() => setAwake(true), []);
111 + const retry = useCallback((k: Key) => load(k, loaders(k)), [load, loaders]);
112 +
113 + return { ...(state as unknown as Omit<FicheData, "wake" | "retry">), wake, retry };
114 +}
modified frontend/src/pages/Listing.tsx +157 −505
@@ -1,536 +1,188 @@
1 1 // -----------------------------------------------------------------------------
2 2 // Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 −// pages/Listing.tsx : fiche complète d'une propriété
5 −// galerie + lightbox · specs · caractéristiques (details) · pièces ·
6 −// inclusions · description · historique de prix · courtier · mini-carte
4 +// pages/Listing.tsx : fiche d'une propriété — refonte premium 2026-09-07
5 +// (même socle que la fiche Lou-Ka v3). Ordre DOM = ordre visuel, identique
6 +// mobile ET desktop (aucun `order`) : héro (prix · adresse · résumé ·
7 +// galerie · actions) → Immo-Ka Score → En bref → navigation sticky → Prix et
8 +// marché → La propriété → Description → Caractéristiques et inclusions →
9 +// Financement et coût de propriété → Carte → À proximité → Transport →
10 +// Quartier → Risque d'inondation → Qualité de l'air → Essence → Dossier de
11 +// l'annonce → Sources et méthodologie.
12 +// Desktop ≥ 1024 px : grille colonne principale + aside sticky (dupliquée,
13 +// masquée sur mobile). Données annexes : fiche/useFicheData (chargement
14 +// critique puis différé). Aucun scrollIntoView à l'ouverture : la page
15 +// s'ouvre en haut.
7 16 // -----------------------------------------------------------------------------
8 −import { Suspense, lazy, useEffect, useRef, useState } from "react";
17 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9 18 import { Link, useParams } from "react-router-dom";
10 −import {
11 − Listing, Room, fetchListing, fetchSources, fmtArea, fmtDate, fmtPrice,
12 − registerSourceNames, sourceName,
13 −} from "../api";
14 −
15 −const PropertyMap = lazy(() => import("../components/PropertyMap"));
16 −import Financement from "../components/Financement";
17 −import QuartierBlock from "../components/QuartierBlock";
18 −import RisqueInondation from "../components/RisqueInondation";
19 −import QualiteAir from "../components/QualiteAir";
20 −import EssenceProche from "../components/EssenceProche";
21 −import HydroEstimation from "../components/HydroEstimation";
22 −import CommercesProches from "../components/CommercesProches";
19 +import { Listing, fetchListing, fetchSources, registerSourceNames } from "../api";
20 +import { useAccount } from "../account";
23 21 import { Ico } from "../components/Icons";
24 −import AmenityIco from "../components/AmenityIco";
25 −import { TypeFallback } from "../components/PropertyImg";
26 −
27 −// --- Lightbox : pincement pour zoomer + panoramique + balayage entre photos ---
28 −function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) {
29 − const [t, setT] = useState({ scale: 1, x: 0, y: 0 });
30 − const pointers = useRef(new Map<number, { x: number; y: number }>());
31 − const start = useRef({ scale: 1, x: 0, y: 0, dist: 0, cx: 0, cy: 0, t: 0 });
32 − const lastTap = useRef(0);
33 −
34 − // repartir à zéro quand on change de photo
35 − useEffect(() => { setT({ scale: 1, x: 0, y: 0 }); }, [src]);
36 −
37 − const dist = () => {
38 − const p = [...pointers.current.values()];
39 − return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
40 − };
41 − const center = () => {
42 − const p = [...pointers.current.values()];
43 − return p.length < 2
44 − ? p[0] ?? { x: 0, y: 0 }
45 − : { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 };
46 − };
47 −
48 − const onDown = (e: React.PointerEvent) => {
49 − (e.target as HTMLElement).setPointerCapture(e.pointerId);
50 − pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
51 − const c = center();
52 − start.current = { scale: t.scale, x: t.x, y: t.y, dist: dist(), cx: c.x, cy: c.y, t: Date.now() };
53 − };
54 − const onMove = (e: React.PointerEvent) => {
55 − if (!pointers.current.has(e.pointerId)) return;
56 − pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
57 − const s = start.current;
58 − if (pointers.current.size >= 2 && s.dist > 0) {
59 − // pincement : zoom autour du centre des deux doigts
60 − const scale = Math.min(4, Math.max(1, (dist() / s.dist) * s.scale));
61 − const c = center();
62 − setT({ scale, x: s.x + (c.x - s.cx), y: s.y + (c.y - s.cy) });
63 − } else if (pointers.current.size === 1 && t.scale > 1) {
64 − // panoramique une fois zoomé
65 − const p = pointers.current.get(e.pointerId)!;
66 − setT({ scale: t.scale, x: s.x + (p.x - s.cx), y: s.y + (p.y - s.cy) });
67 − }
68 − };
69 − const onUp = (e: React.PointerEvent) => {
70 − const p = pointers.current.get(e.pointerId);
71 − pointers.current.delete(e.pointerId);
72 − const s = start.current;
73 − if (pointers.current.size === 0 && p) {
74 − const dx = p.x - s.cx, dy = p.y - s.cy, dt = Date.now() - s.t;
75 − if (t.scale <= 1.05 && Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) {
76 − onSwipe(dx < 0 ? 1 : -1); // balayage → photo suivante
77 − } else if (dt < 260 && Math.abs(dx) < 8 && Math.abs(dy) < 8) {
78 − const now = Date.now();
79 − if (now - lastTap.current < 320) // double-tap : zoom ×2.4
80 − setT(t.scale > 1 ? { scale: 1, x: 0, y: 0 } : { scale: 2.4, x: 0, y: 0 });
81 − lastTap.current = now;
82 − }
83 − if (t.scale <= 1.02) setT({ scale: 1, x: 0, y: 0 });
84 − }
85 − };
86 −
87 − return (
88 − <img
89 − src={src} alt="" draggable={false}
90 − style={{
91 − transform: `translate(${t.x}px, ${t.y}px) scale(${t.scale})`,
92 − transition: pointers.current.size ? "none" : "transform 0.15s ease",
93 − touchAction: "none", cursor: t.scale > 1 ? "grab" : "zoom-out",
94 − }}
95 − onClick={(e) => e.stopPropagation()}
96 − onPointerDown={onDown} onPointerMove={onMove}
97 − onPointerUp={onUp} onPointerCancel={onUp}
98 − />
99 − );
100 −}
101 −
102 −// --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran --------
103 −function Galerie({ images, captions, titre, type }:
104 − { images: string[]; captions?: string[]; titre: string; type?: string }) {
105 − const [idx, setIdx] = useState(0);
106 − const [zoom, setZoom] = useState(false);
107 − const [dead, setDead] = useState<Set<string>>(new Set());
108 − const track = useRef<HTMLDivElement>(null);
109 −
110 − // images qui ne chargent pas : retirées de la galerie à la volée (jamais
111 − // d'icône d'image cassée) ; légendes gardées alignées
112 − const alive = images
113 − .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" }))
114 − .filter(({ u }) => !dead.has(u));
115 − const markDead = (u: string) => setDead((d) => new Set(d).add(u));
116 −
117 − const onScroll = () => {
118 − const el = track.current;
119 − if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
120 − };
121 − const goto = (i: number) =>
122 − track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
123 −
124 − useEffect(() => {
125 − if (!zoom) return;
126 − const onKey = (e: KeyboardEvent) => {
127 − if (e.key === "Escape") setZoom(false);
128 − if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));
129 − if (e.key === "ArrowRight") setIdx((i) => Math.min(alive.length - 1, i + 1));
130 − };
131 − window.addEventListener("keydown", onKey);
132 − // figer l'arrière-plan pendant le plein écran (mobile)
133 − document.body.style.overflow = "hidden";
134 − return () => {
135 − window.removeEventListener("keydown", onKey);
136 − document.body.style.overflow = "";
137 − };
138 − }, [zoom, alive.length]);
139 −
140 − if (alive.length === 0)
141 − return <div className="carousel"><div className="carousel-empty"><TypeFallback type={type} /></div></div>;
142 −
143 − const cur = Math.min(idx, alive.length - 1);
144 − const swipe = (dir: 1 | -1) =>
145 − setIdx((i) => Math.min(alive.length - 1, Math.max(0, i + dir)));
146 −
22 +import "../fiche/fiche.css";
23 +import useFicheData, { dataOf } from "../fiche/useFicheData";
24 +import { setCurrentListing } from "../fiche/current";
25 +import { comparaisonPrix, enBref, immoKaScore, ligneResume } from "../fiche/synthese";
26 +import PropertyHero from "../fiche/PropertyHero";
27 +import ImmoKaScore from "../fiche/ImmoKaScore";
28 +import PropertySummary from "../fiche/PropertySummary";
29 +import SectionNav, { NavItem } from "../fiche/SectionNav";
30 +import MarketPriceCard from "../fiche/MarketPriceCard";
31 +import PropertyQuickFacts from "../fiche/PropertyQuickFacts";
32 +import PropertyDescription from "../fiche/PropertyDescription";
33 +import PropertyAmenities from "../fiche/PropertyAmenities";
34 +import FinancingCard from "../fiche/FinancingCard";
35 +import InteractiveMap, { lieuxDepuis } from "../fiche/InteractiveMap";
36 +import NearbyPlaces from "../fiche/NearbyPlaces";
37 +import NeighborhoodStats from "../fiche/NeighborhoodStats";
38 +import { AirQualityCard, FloodRiskCard, GasNearbyCard } from "../fiche/EnvironmentCards";
39 +import PropertyDossier from "../fiche/PropertyDossier";
40 +import SourceDisclosure from "../fiche/SourceDisclosure";
41 +import StickyListingCTA, { usePastElement } from "../fiche/StickyListingCTA";
42 +import DesktopAside from "../fiche/DesktopAside";
43 +import KaAssistant from "../fiche/KaAssistant";
44 +import { Skeleton, useToast } from "../fiche/ui";
45 +
46 +function FicheSkeleton() {
147 47 return (
148 − <>
149 − <div className="carousel">
150 − <div className="carousel-track" ref={track} onScroll={onScroll}>
151 − {alive.map(({ u }, i) => (
152 − <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async"
153 − alt={`${titre} — photo ${i + 1} de ${alive.length}`}
154 − onError={() => markDead(u)} onClick={() => setZoom(true)} />
155 − ))}
156 − </div>
157 − {alive[cur]?.cap && <span className="carousel-caption">{alive[cur].cap}</span>}
158 − <span className="carousel-count" aria-live="polite">{cur + 1}/{alive.length}</span>
159 − {cur > 0 && <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(cur - 1)}>‹</button>}
160 − {cur < alive.length - 1 && <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(cur + 1)}>›</button>}
161 − </div>
162 − {alive.length > 1 && (
163 − <div className="thumbs">
164 − {alive.map(({ u }, i) => (
165 − <button key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>
166 − <img src={u} alt="" loading="lazy" decoding="async" onError={() => markDead(u)} />
167 − </button>
168 − ))}
169 − </div>
170 − )}
171 − {zoom && (
172 − <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie">
173 − <button className="lb-close" aria-label="Fermer" onClick={() => setZoom(false)}>✕</button>
174 − {cur > 0 && <button className="lb-nav prev" aria-label="Précédente" onClick={(e) => { e.stopPropagation(); setIdx(cur - 1); }}>‹</button>}
175 − <ZoomImg src={alive[cur].u} onSwipe={swipe} />
176 − {cur < alive.length - 1 && <button className="lb-nav next" aria-label="Suivante" onClick={(e) => { e.stopPropagation(); setIdx(cur + 1); }}>›</button>}
177 − <span className="lb-count">
178 − {alive[cur]?.cap ? `${alive[cur].cap} · ` : ""}{cur + 1} / {alive.length}
179 − </span>
180 − </div>
181 − )}
182 − </>
48 + <div className="ik-fiche"><div className="ik-wrap" aria-busy="true" aria-label="Chargement de la fiche">
49 + <Skeleton h={14} w={180} /><div style={{ height: 10 }} />
50 + <Skeleton h={38} w={220} /><div style={{ height: 10 }} />
51 + <Skeleton h={16} w="70%" /><div style={{ height: 12 }} />
52 + <div className="ik-skel" style={{ aspectRatio: "4 / 3", borderRadius: 18 }} />
53 + <div style={{ height: 14 }} /><Skeleton h={46} /><div style={{ height: 14 }} />
54 + <div className="ik-card"><Skeleton h={88} w={88} r={44} /></div><div style={{ height: 14 }} />
55 + <div className="ik-card"><Skeleton h={14} /><div style={{ height: 8 }} /><Skeleton h={14} w="80%" /><div style={{ height: 8 }} /><Skeleton h={14} w="60%" /></div>
56 + </div></div>
183 57 );
184 58 }
185 59
186 −// clés techniques de `details` jamais montrées dans « Caractéristiques »
187 −const DETAIL_HIDDEN = new Set([
188 − "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited",
189 − "needs_image_review", "postal_code", "region", "transaction",
190 − "prix_pi2", "prix_m2", "listing_origin_url",
191 −]);
192 −
193 −// icône de chaque tuile « spec » (Icons.tsx)
194 −const SPEC_ICONS: Record<string, string> = {
195 − "Type": "home", "Chambres": "bed", "Salles de bain": "bath",
196 − "Salles d'eau": "drop", "Superficie": "area", "Terrain": "land",
197 − "Année": "calendar", "MLS / Centris": "tag",
198 −};
199 −
200 60 export default function ListingPage() {
201 61 const { uid } = useParams<{ uid: string }>();
202 62 const [l, setL] = useState<Listing | null>(null);
203 63 const [error, setError] = useState<string | null>(null);
204 − // re-render quand les noms de sources arrivent (sinon repli Title Case)
205 − const [, setSrcTick] = useState(0);
64 + const { user, favs, toggleFav } = useAccount();
65 + const [toast, showToast] = useToast();
66 + const actionsRef = useRef<HTMLDivElement>(null);
67 + const data = useFicheData(l);
68 + const pastHero = usePastElement(actionsRef); // CTA sticky + bouton Ka après le héro
206 69
207 70 useEffect(() => {
208 − fetchSources().then((r) => { registerSourceNames(r.sources); setSrcTick(1); }).catch(() => {});
71 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
209 72 if (!uid) return;
210 − setL(null); setError(null);
211 − fetchListing(uid).then(setL).catch((e) => setError(String(e)));
73 + setL(null); setError(null); setCurrentListing(null);
74 + fetchListing(uid).then((x) => { setL(x); setCurrentListing(x); }).catch((e) => setError(String(e)));
212 75 window.scrollTo(0, 0);
76 + return () => setCurrentListing(null);
213 77 }, [uid]);
214 78
79 + // classe de page : fond cassé, tabbar et bulle KA Agent masquées, header compact
80 + useEffect(() => {
81 + document.body.classList.add("ik-fiche-page");
82 + return () => document.body.classList.remove("ik-fiche-page");
83 + }, []);
84 +
85 + // réveil du groupe différé à l'approche de la carte
86 + useEffect(() => {
87 + if (!l) return;
88 + const el = document.getElementById("carte");
89 + if (!el || !("IntersectionObserver" in window)) { data.wake(); return; }
90 + const io = new IntersectionObserver((e) => { if (e.some((x) => x.isIntersecting)) { data.wake(); io.disconnect(); } },
91 + { rootMargin: "900px 0px" });
92 + io.observe(el);
93 + return () => io.disconnect();
94 + // eslint-disable-next-line react-hooks/exhaustive-deps
95 + }, [l]);
96 +
97 + const fav = !!(l && favs.has(l.uid));
98 + const onFav = useCallback(() => {
99 + if (!l) return;
100 + toggleFav(l); // non connecté → redirection KA ID (gérée par le contexte)
101 + if (user?.ka_id) showToast(fav ? "Retiré des favoris" : "Ajouté à vos favoris");
102 + }, [l, user, fav, toggleFav, showToast]);
103 +
104 + const onShare = useCallback(async () => {
105 + if (!l) return;
106 + const url = `https://www.immo-ka.com/propriete/${encodeURIComponent(l.uid)}`;
107 + const title = `${l.address || l.title} — Immo-Ka`;
108 + try {
109 + if (navigator.share) { await navigator.share({ title, url }); return; }
110 + await navigator.clipboard.writeText(url);
111 + showToast("Lien copié");
112 + } catch { /* partage annulé */ }
113 + }, [l, showToast]);
114 +
115 + const air = dataOf(data.air), inondation = dataOf(data.inondation);
116 + const cm = dataOf(data.commerces), gaz = dataOf(data.gaz);
117 + const cmp = useMemo(() => (l ? comparaisonPrix(l) : null), [l]);
118 + const score = useMemo(() => (l ? immoKaScore(l) : null), [l]);
119 + const brief = useMemo(() => (l ? enBref(l, { air, inondation, loadingRisques: data.inondation.status === "loading" }) : []),
120 + [l, air, inondation, data.inondation.status]);
121 + const lieux = useMemo(() => (l ? lieuxDepuis(l.poi ?? [], cm, gaz) : []), [l, cm, gaz]);
122 +
215 123 if (error)
216 124 return (
217 − <div className="notice container">
218 − <div className="big"><Ico name="alert" size={44} /></div>
125 + <div className="ik-fiche"><div className="ik-page-error">
126 + <Ico name="alert" size={40} />
219 127 <h2>Propriété introuvable</h2>
220 − <p>{error}</p>
221 − <Link className="btn btn-primary" to="/">Retour aux propriétés</Link>
222 − </div>
223 − );
224 −
225 − if (!l)
226 − return (
227 − <div className="container detail">
228 − <div className="fiche" aria-busy="true">
229 − <div className="skel"><div className="sk-img" /></div>
230 − <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
231 − </div>
232 − </div>
128 + <p>Cette annonce n'existe pas ou n'est plus disponible.</p>
129 + <Link className="ik-btn ik-btn-primary" to="/">Retour aux propriétés</Link>
130 + </div></div>
233 131 );
234 −
235 − const specs: { k: string; v: string }[] = [];
236 − if (l.property_type) specs.push({ k: "Type", v: l.property_type });
237 − if (l.bedrooms != null) specs.push({ k: "Chambres", v: String(l.bedrooms) });
238 − if (l.bathrooms != null) specs.push({ k: "Salles de bain", v: String(l.bathrooms) });
239 − if (l.powder_rooms != null) specs.push({ k: "Salles d'eau", v: String(l.powder_rooms) });
240 − if (l.area_sqft != null) specs.push({ k: "Superficie", v: fmtArea(l.area_sqft)! });
241 − if (l.lot_sqft != null) specs.push({ k: "Terrain", v: fmtArea(l.lot_sqft)! });
242 − if (l.year_built != null) specs.push({ k: "Année", v: String(l.year_built) });
243 − if (l.mls) specs.push({ k: "MLS / Centris", v: l.mls });
244 −
245 − const rooms: Room[] = Array.isArray(l.details?.pieces) ? (l.details!.pieces as Room[]) : [];
246 − const detEntries = Object.entries(l.details ?? {})
247 − .filter(([k, v]) => !DETAIL_HIDDEN.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim());
248 −
249 − // taxes annuelles publiées (Centris) → mensualisées pour le coût réel
250 − const taxesAnnuelles = ["Taxes municipales", "Taxes scolaires"]
251 − .map((k) => {
252 − const v = l.details?.[k];
253 − const n = parseInt(String(v ?? "").replace(/[^\d]/g, ""), 10);
254 − return Number.isFinite(n) && n > 0 && n < 200_000 ? n : 0;
255 − })
256 − .reduce((a, b) => a + b, 0);
257 − const estVente = l.details?.transaction !== "location";
258 −
259 − const hist = (l.price_history ?? []).filter((h) => h.price != null);
260 − const baisse = hist.length >= 2 && hist[0].price !== hist[1].price
261 − ? { de: hist[1].price!, a: hist[0].price! } : null;
262 − const updated = l.updated_at ? fmtDate(l.updated_at) : null;
132 + if (!l || !score) return <FicheSkeleton />;
133 +
134 + const geo = l.lat != null && l.lng != null;
135 + const location = l.details?.transaction === "location";
136 + const nav: NavItem[] = [
137 + { id: "resume", label: "Résumé" },
138 + ...(l.price != null ? [{ id: "prix", label: "Prix" }] : []),
139 + { id: "propriete", label: "Propriété" },
140 + ...(l.price != null && !location ? [{ id: "financement", label: "Financement" }] : []),
141 + ...(geo ? [{ id: "carte", label: "Carte" }] : []),
142 + ...(l.quartier ? [{ id: "quartier", label: "Quartier" }] : []),
143 + ...(geo ? [{ id: "risques", label: "Risques" }] : []),
144 + { id: "dossier", label: "Dossier" },
145 + { id: "sources", label: "Sources" },
146 + ];
147 + const resume = ligneResume(l);
148 + const deal = !!(cmp && cmp.tone === "good");
149 + const villeUrl = l.city ? `/?city=${encodeURIComponent(l.city)}` : "/";
263 150
264 151 return (
265 − <div className="container detail">
266 − <nav className="crumbs" aria-label="Fil d'Ariane">
267 − <Link to="/">Propriétés</Link> ›
268 − {l.city && <span>{l.city}</span>} ›
269 − <span>{l.address || l.title}</span>
270 − </nav>
271 −
272 − <div className="fiche">
273 − {/* -------- colonne gauche : galerie, prix/synthèse, description, ------
274 − -------- caractéristiques, pièces — ordre du DOM = ordre visuel ---- */}
275 − <div className="f-col">
276 − <section className="f-bloc f-galerie" aria-label="Photos">
277 − <Galerie
278 − images={l.images ?? []}
279 − captions={Array.isArray(l.details?.photo_captions)
280 − ? (l.details!.photo_captions as string[]) : undefined}
281 − titre={l.address || l.title}
282 − type={l.property_type}
283 − />
284 − </section>
285 −
286 − <section className="f-bloc f-hero">
287 − <div className="price-kicker">
288 − {l.details?.transaction === "location" ? "Loyer mensuel" : "Prix demandé"}
289 − </div>
290 − <div className="price-row">
291 − <div className="price">
292 − {fmtPrice(l.price, l.price_label)}
293 − {l.details?.transaction === "location" && <span className="per-month"> /mois</span>}
294 − </div>
295 − {l.property_type && (
296 − <span className="type-chip">
297 − <Ico name={SPEC_ICONS["Type"]} size={13} /> {l.property_type}
298 − {l.details?.transaction === "location" ? " · location" : ""}
299 − {l.details?.price_from ? " · à partir de" : ""}
300 − </span>
301 − )}
302 − </div>
303 − {l.price != null && l.area_sqft != null && l.area_sqft > 200 && (
304 − <div className="price-sub">{Math.round(l.price / l.area_sqft).toLocaleString("fr-CA")} $ / pi² habitable</div>
305 − )}
306 − <h1>{l.address || l.title}</h1>
307 − <div className="loc"><Ico name="pin" size={13} /> {[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div>
308 −
309 − <div className="spec-list">
310 − {specs.map((s) => (
311 − <div className="spec-row" key={s.k}>
312 − <span className="spec-badge"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={15} /></span>
313 − <span className="spec-k">{s.k}</span>
314 − <b className="spec-v">{s.v}</b>
315 − </div>
316 − ))}
317 − </div>
318 −
319 − {baisse && (
320 − <div className={`prix-histo ${baisse.a < baisse.de ? "down" : ""}`}>
321 − <Ico name={baisse.a < baisse.de ? "trenddown" : "trendup"} size={16} /> Prix passé de {fmtPrice(baisse.de)} à <b>{fmtPrice(baisse.a)}</b>
322 − </div>
323 − )}
324 −
325 − {l.vraiprix && l.vraiprix.value != null && (
326 − <div className="vraiprix">
327 − <div className="vp-head">
328 − <span className="vp-logo">Vrai‑Prix</span>
329 − {l.vraiprix.confidence && (
330 − <span className={`vp-conf vp-conf-${l.vraiprix.confidence}`}>
331 − confiance {l.vraiprix.confidence}
332 − </span>
333 − )}
334 − </div>
335 − <div className="vp-k">Valeur marchande estimée</div>
336 − <div className="vp-value">{fmtPrice(l.vraiprix.value)}</div>
337 − {l.vraiprix.low != null && l.vraiprix.high != null && (() => {
338 − const lo = l.vraiprix!.low as number, hi = l.vraiprix!.high as number;
339 − const span = Math.max(1, hi - lo);
340 − const pos = (v: number) => `${Math.max(2, Math.min(98, ((v - lo) / span) * 100))}%`;
341 − return (
342 − <div className="vp-gauge">
343 − <div className="vp-gauge-track">
344 − <span className="vp-gauge-band" />
345 − <span className="vp-gauge-est" style={{ left: pos(l.vraiprix!.value as number) }} />
346 − {l.price != null && l.price >= lo * 0.7 && l.price <= hi * 1.3 && (
347 − <span className="vp-gauge-ask" style={{ left: pos(l.price) }} />
348 − )}
349 − </div>
350 − <div className="vp-gauge-ends">
351 − <span>{fmtPrice(lo)}</span>
352 − <span>{fmtPrice(hi)}</span>
353 − </div>
354 − {l.price != null && l.price >= lo * 0.7 && l.price <= hi * 1.3 && (
355 − <div className="vp-gauge-legend">
356 − <span><i className="vp-dot-est" /> estimation</span>
357 − <span><i className="vp-dot-ask" /> prix demandé</span>
358 − </div>
359 − )}
360 − </div>
361 − );
362 − })()}
363 − {l.price != null && (
364 − (() => {
365 − const diff = l.price - (l.vraiprix!.value as number);
366 − const pct = Math.round((diff / (l.vraiprix!.value as number)) * 100);
367 − const cls = diff > 0 ? "over" : "under";
368 − const txt = diff > 0
369 − ? `Prix demandé ${pct}% au-dessus de l'estimation`
370 − : `Prix demandé ${Math.abs(pct)}% sous l'estimation`;
371 − if (Math.abs(pct) > 60) return null; // prix non comparable (loyer, terrain…)
372 − return Math.abs(pct) >= 1
373 − ? <div className={`vp-delta vp-${cls}`}>{diff > 0 ? "▲" : "▼"} {txt}</div>
374 − : <div className="vp-delta vp-fair">≈ Prix aligné sur l'estimation</div>;
375 − })()
376 − )}
377 − <a className="vp-link" href={l.vraiprix.url} target="_blank" rel="noopener noreferrer">
378 − Voir l'analyse détaillée ↗
379 − </a>
380 − </div>
381 − )}
382 −
383 − {(l.broker_name || l.broker_phone) && (
384 − <div className="broker">
385 − <div className="broker-k">Courtier</div>
386 − {l.broker_name && <div className="broker-name">{l.broker_name}</div>}
387 − {l.broker_phone && <a className="broker-tel" href={`tel:${l.broker_phone.replace(/\s/g, "")}`}><Ico name="phone" size={14} /> {l.broker_phone}</a>}
388 − </div>
389 − )}
390 −
391 − <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
392 − Voir l'annonce chez {sourceName(l.source)} <Ico name="external" size={15} />
393 − </a>
394 − <a className="cta cta-pdf" href={`/api/listings/${l.uid}/pdf`} download>
395 − Télécharger la fiche (PDF) <Ico name="download" size={15} />
396 − </a>
397 − <div className="fine">
398 − Agrégé par Immo-Ka — {sourceName(l.source)}{updated ? ` · synchronisé le ${updated}` : ""}.
399 − </div>
400 − </section>
401 −
402 − {l.duplicates && l.duplicates.length > 0 && (
403 − <section className="f-bloc" id="publications">
404 − <h2>Aussi publiée sur</h2>
405 − <p className="dups-note">
406 − Cette propriété a été repérée sur {l.duplicates.length}{" "}
407 − autre{l.duplicates.length > 1 ? "s" : ""} plateforme{l.duplicates.length > 1 ? "s" : ""} —
408 − Immo-Ka affiche la version la plus complète.
409 − </p>
410 − <div className="dups-list">
411 − {l.duplicates.map((d) => (
412 − <a key={d.uid} className="dup-item" href={d.url} target="_blank" rel="noopener noreferrer">
413 − <span className="dup-src">{sourceName(d.source)}</span>
414 − {(d.broker_name || d.agency) && (
415 − <span className="dup-broker">{d.broker_name || d.agency}</span>
416 − )}
417 − <span className="dup-go">Voir l'annonce <Ico name="external" size={13} /></span>
418 − </a>
419 − ))}
420 − </div>
421 − </section>
422 − )}
423 −
424 −
425 − {l.description && (
426 − <section className="f-bloc f-desc" id="description">
427 − <h2>Description</h2>
428 − <p className="desc-text">{l.description}</p>
429 − </section>
430 − )}
431 −
432 − {detEntries.length > 0 && (
433 − <section className="f-bloc" id="caracteristiques">
434 − <h2>Caractéristiques</h2>
435 − <div className="dtable">
436 − {detEntries.map(([k, v]) => (
437 − <div className="drow" key={k}>
438 − <span>{k}</span>
439 − {/^https?:\/\//.test(String(v))
440 − ? <b><a href={String(v)} target="_blank" rel="noopener noreferrer">Ouvrir ↗</a></b>
441 − : <b>{String(v)}</b>}
442 − </div>
443 − ))}
444 − </div>
445 − </section>
446 − )}
447 −
448 − {rooms.length > 0 && (
449 − <section className="f-bloc" id="pieces">
450 − <h2>Pièces</h2>
451 − <div className="rooms-wrap">
452 − <table className="rooms">
453 − <thead><tr><th>Pièce</th><th>Niveau</th><th>Dimensions</th><th>Revêtement</th></tr></thead>
454 − <tbody>
455 − {rooms.map((r, i) => (
456 − <tr key={i}>
457 − <td>{r.nom || "—"}</td><td>{r.niveau || "—"}</td>
458 − <td>{r.dimensions || "—"}</td><td>{r.revetement || "—"}</td>
459 − </tr>
460 − ))}
461 − </tbody>
462 − </table>
463 − </div>
464 − </section>
465 − )}
466 −
467 − </div>
468 −
469 − {/* -------- colonne droite (desktop) : inclusions, carte ------------- */}
470 − <div className="f-col">
471 − {l.features && l.features.length > 0 && (
472 − <section className="f-bloc" id="inclusions">
473 − <h2>Inclusions</h2>
474 − <div className="amenity-grid">
475 − {l.features.map((f, i) => (
476 − <span className="amenity-it" key={i}>
477 − <span className="am-ico"><AmenityIco label={f} /></span>
478 − <span className="am-txt">{f}</span>
479 − </span>
480 − ))}
481 − </div>
482 − </section>
483 − )}
484 −
485 − {l.lat != null && l.lng != null && (
486 − <section className="f-bloc" id="carte">
487 − <h2>Emplacement</h2>
488 − <Suspense fallback={<div className="lmap3d lmap3d-skel map-loading">Chargement de la carte…</div>}>
489 − <PropertyMap
490 − uid={l.uid} lat={l.lat} lng={l.lng} price={l.price}
491 − propertyType={l.property_type} address={l.address || l.title}
492 − city={l.city} image={l.images?.[0]}
493 − deal={l.price != null && l.vraiprix?.value != null
494 − && l.price <= l.vraiprix.value * 0.95}
495 − />
496 − </Suspense>
497 − </section>
498 − )}
152 + <div className="ik-fiche">
153 + <div className="ik-wrap">
154 + <nav className="ik-crumbs" aria-label="Fil d'Ariane">
155 + <Link to="/">Propriétés</Link><span aria-hidden="true">›</span>
156 + {l.city && <><Link to={villeUrl}>{l.city}</Link><span aria-hidden="true">›</span></>}
157 + <span>{l.address || l.title}</span>
158 + </nav>
159 + <div className="ik-grid">
160 + <div className="ik-main">
161 + <PropertyHero l={l} cmp={cmp} fav={fav} onFav={onFav} onShare={onShare} actionsRef={actionsRef} />
162 + <ImmoKaScore s={score} resume={resume} />
163 + <PropertySummary items={brief} loading={data.inondation.status === "loading" || data.air.status === "loading"} />
164 + <SectionNav items={nav} />
165 + <MarketPriceCard l={l} cmp={cmp} />
166 + <PropertyQuickFacts l={l} />
167 + <PropertyDescription l={l} />
168 + <PropertyAmenities l={l} />
169 + <FinancingCard l={l} hydro={data.hydro} />
170 + {geo && <InteractiveMap l={l} lieux={lieux} deal={deal} 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 + {geo && <FloodRiskCard r={data.inondation} onRetry={() => data.retry("inondation")} />}
174 + {geo && <AirQualityCard r={data.air} onRetry={() => data.retry("air")} />}
175 + {geo && <GasNearbyCard r={data.gaz} onRetry={() => data.retry("gaz")} />}
176 + <PropertyDossier l={l} />
177 + <SourceDisclosure l={l} onShare={onShare}
178 + dates={{ air: air ? Object.values(air.mesures)[0]?.annee : null, gaz: gaz?.maj ?? null }} />
179 + </div>
180 + <DesktopAside l={l} cmp={cmp} score={score} brief={brief} fav={fav} onFav={onFav} onShare={onShare} />
499 181 </div>
500 182 </div>
501 −
502 − {/* financement : taux hypothécaires réels + calculateur canadien */}
503 − {estVente && (
504 − <Financement
505 − prix={l.price}
506 − taxesMensuelles={taxesAnnuelles > 0 ? Math.round(taxesAnnuelles / 12) : null}
507 − />
508 − )}
509 −
510 − {/* quartier : pleine largeur, APRÈS les infos de propriété (ordre mobile correct) */}
511 − <RisqueInondation lat={l.lat} lng={l.lng} />
512 −
513 − <QualiteAir lat={l.lat} lng={l.lng} />
514 −
515 − <CommercesProches lat={l.lat} lng={l.lng} />
516 −
517 − <EssenceProche lat={l.lat} lng={l.lng} />
518 −
519 − <HydroEstimation adresse={l.address || l.title} uid={l.uid} lat={l.lat} lng={l.lng} />
520 −
521 − {l.quartier && <QuartierBlock q={l.quartier} />}
522 −
523 − <div className="fine f-foot">
524 − Les prix et disponibilités sont ceux affichés par la source — chaque fiche renvoie à
525 − l'annonce originale de l'agence.
526 − </div>
527 −
528 − <div className="cta-sticky">
529 − <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}</span>
530 − <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
531 − Voir chez {sourceName(l.source)} ↗
532 − </a>
533 − </div>
183 + <StickyListingCTA l={l} cmp={cmp} show={pastHero} />
184 + <KaAssistant l={l} hidden={!pastHero} />
185 + {toast}
534 186 </div>
535 187 );
536 188 }
modified frontend/vite.config.ts +3 −1
@@ -9,7 +9,9 @@ import react from "@vitejs/plugin-react";
9 9 export default defineConfig({
10 10 plugins: [react()],
11 11 server: {
12 − proxy: { "/api": "http://localhost:8090" },
12 + // IMMOKA_API : cible du proxy /api en dev (défaut : serveur local 8090 ;
13 + // sur un nœud, IMMOKA_API=http://localhost:8096 pointe la prod locale)
14 + proxy: { "/api": process.env.IMMOKA_API || "http://localhost:8090" },
13 15 },
14 16 build: { outDir: "dist", chunkSizeWarningLimit: 1200 },
15 17 });
modified immoka/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-07) : 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 immoka/poi.py +5 −1
@@ -179,7 +179,11 @@ def _nearest_by_cat(lat: float, lng: float, pois_by_cat: dict[str, list[dict]])
179 179 continue
180 180 d = _haversine_m(lat, lng, p["lat"], p["lng"])
181 181 if d <= radius and (best is None or d < best["dist_m"]):
182 − best = {"cat": cat, "name": p["name"], "dist_m": round(d)}
182 + # lat/lng conservés (2026-09-07) pour placer le lieu sur la
183 + # carte de la fiche ; les entrées de cache antérieures n'en ont
184 + # pas — le frontend tolère leur absence.
185 + best = {"cat": cat, "name": p["name"], "dist_m": round(d),
186 + "lat": round(p["lat"], 6), "lng": round(p["lng"], 6)}
183 187 if best:
184 188 out.append(best)
185 189 return sorted(out, key=lambda p: p["dist_m"])
modified immoka/web.py +5 −3
@@ -213,10 +213,12 @@ def air_at(lat: float, lng: float):
213 213
214 214
215 215 @app.get("/api/gaz")
216 −def gaz_at(lat: float, lng: float):
217 − """Stations-service à proximité et prix courants (gazquebec.ca)."""
216 +def gaz_at(lat: float, lng: float, limit: int = 5):
217 + """Stations-service à proximité et prix courants (gazquebec.ca).
218 + `limit` (défaut 5, max 60) : la fiche demande la liste complète pour son
219 + panneau « Voir les N stations »."""
218 220 from . import gaz
219 − return gaz.nearby(lat, lng)
221 + return gaz.nearby(lat, lng, limit=max(1, min(limit, 60)))
220 222
221 223
222 224 @app.get("/api/inondation")
223 225