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