spb/valoplex Public
ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.
TypeScript 90.3%
Python 7.1%
CSS 2.5%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2"use client";3import { useState } from "react";4import Link from "next/link";5import { useLang } from "./LangContext";6import LeadForm from "./LeadForm";7import RadarMap from "./RadarMap";8import {9 BuildingGlyph,10 ConfidenceDial,11 CountUp,12 EraLine,13 locNum,14 LotDiagram,15 ValueSplit,16} from "./MetricViz";17import Proforma from "./Proforma";18import type { EstimateResult } from "@/lib/engine";19import type { UnitEstimate } from "@/lib/estimator";20import type { TKey } from "@/lib/i18n";2122const fmt = (v: number | null | undefined, lang: string) =>23 v == null24 ? "—"25 : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {26 style: "currency",27 currency: "CAD",28 maximumFractionDigits: 0,29 }).format(v);3031const signed = (v: number, lang: string) =>32 `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)), lang)}`;3334const pct = (v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(0)} %`;3536function doorsLabel(n: number | null | undefined, fr: boolean): string {37 if (!n) return "Plex";38 const map: Record<number, string> = {39 2: "Duplex", 3: "Triplex", 4: "Quadruplex", 5: "Quintuplex", 6: "Sixplex",40 };41 return map[n] ?? (fr ? `Multi ${n} portes` : `${n}-door multi`);42}4344function median(xs: number[]): number | null {45 if (!xs.length) return null;46 const s = [...xs].sort((a, b) => a - b);47 return s[Math.floor(s.length / 2)];48}4950function Chip({ k, v }: { k: string; v: string | number }) {51 return (52 <div className="kv-cell">53 <p className="k">{k}</p>54 <p className="v break-words">{v}</p>55 </div>56 );57}5859/** Frise des portes : une porte dessinée par logement (signature ValoPlex). */60function DoorsRow({ doors, perDoor, lang }: { doors: number; perDoor: number | null; lang: string }) {61 const shown = Math.min(doors, 10);62 const extra = doors - shown;63 return (64 <div className="flex flex-wrap items-end gap-1.5" aria-label={`${doors} portes`}>65 {Array.from({ length: shown }, (_, i) => (66 <svg key={i} width="30" height="44" viewBox="0 0 30 44" className="rise" style={{ animationDelay: `${i * 60}ms` }}>67 <rect x="2" y="4" width="26" height="38" fill="var(--surface)" stroke="var(--ink)" strokeWidth="1.6" />68 <rect x="5.5" y="8" width="19" height="30" fill="var(--lime)" stroke="var(--ink)" strokeWidth="1.3" />69 <rect x="8" y="11" width="14" height="8" fill="var(--surface)" stroke="var(--ink)" strokeWidth="1" />70 <circle cx="20.5" cy="25" r="1.6" fill="var(--ink)" />71 <text x="15" y="41.5" textAnchor="middle" fontSize="6.5" fontWeight="700" fontFamily="var(--font-jetbrains)" fill="var(--ink-3)">72 {i + 1}73 </text>74 </svg>75 ))}76 {extra > 0 && (77 <span className="vp-mono mb-1 rounded-md border-[1.5px] border-ink bg-ink px-2 py-1 text-[11px] font-bold text-lime">78 +{extra}79 </span>80 )}81 {perDoor != null && (82 <span className="vp-mono mb-1 ml-2 text-[11px] font-bold uppercase tracking-[0.06em] text-green-deep">83 ≈ {fmt(Math.round(perDoor / 100) * 100, lang)} / {lang === "fr" ? "porte" : "door"}84 </span>85 )}86 </div>87 );88}8990export default function ResultView({ data }: { data: UnitEstimate }) {91 const { lang, t } = useLang();92 const [activeComp, setActiveComp] = useState<string | null>(null);93 const r: EstimateResult = data.result;94 const u = data.unit;95 const s = u?.specs;96 const fr = lang === "fr";97 const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1);98 const moneyFmt = (v: number) =>99 new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {100 style: "currency",101 currency: "CAD",102 maximumFractionDigits: 0,103 }).format(Math.round(v / 100) * 100);104105 const doors = u?.nbLogements ?? null;106 const hasComps = r.nCompsUsed > 0;107 const perDoor = doors && doors >= 2 ? r.estimate / doors : null;108 const perM2 = u?.aireEtagesM2 ? r.estimate / u.aireEtagesM2 : null;109 const age = u?.anneeConstruction ? 2026 - u.anneeConstruction : null;110111 // benchmark : médianes du marché local à partir des comparables retenus112 const mktPerDoor = median(113 r.comps.filter((c) => c.doors && c.doors >= 2).map((c) => c.amount / c.doors!)114 );115 const mktPerM2 = median(116 r.comps.filter((c) => c.floorArea && c.floorArea > 20).map((c) => c.amount / c.floorArea!)117 );118119 return (120 <div className="space-y-10 py-8">121 {/* fil d'ariane */}122 <nav className="vp-mono flex flex-wrap items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3">123 <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green">124 ValoPlex125 </Link>126 <span>/</span>127 <span>{u ? `${u.adresse ?? ""} · ${u.municipalite ?? ""}` : t("manualTitle")}</span>128 </nav>129130 {/* ---- Panneau prix ---- */}131 <section className="vp-card rise p-6 sm:p-8">132 <div className="flex flex-wrap items-start justify-between gap-6">133 <div className="min-w-0 flex-1">134 <div className="flex flex-wrap items-center gap-3">135 <span className="kicker">{t("estimatedValue")}</span>136 <span className="vp-mono rotate-[-1deg] rounded-md border-[1.5px] border-ink bg-lime px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.08em] text-ink">137 {doorsLabel(doors, fr)}138 </span>139 </div>140 {u && (141 <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">142 {u.adresse}143 {u.municipalite ? <span className="text-ink-3"> · {u.municipalite}</span> : null}144 </h1>145 )}146 <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]">147 <CountUp value={r.estimate} format={moneyFmt} />148 </p>149 {doors != null && doors >= 2 && (150 <div className="mt-4">151 <DoorsRow doors={doors} perDoor={perDoor} lang={lang} />152 </div>153 )}154 <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">155 {perM2 != null && (156 <div>157 <p className="klabel">{fr ? "Prix au m²" : "Price / m²"}</p>158 <p className="vp-display text-[19px] font-bold">{fmt(Math.round(perM2), lang)}</p>159 </div>160 )}161 <div>162 <p className="klabel">{t("range")}</p>163 <p className="vp-display text-[19px] font-bold">164 {fmt(r.low, lang)} <span className="text-ink-3">→</span> {fmt(r.high, lang)}165 </p>166 </div>167 {u?.valeurRole != null && u.valeurRole > 0 && (168 <div>169 <p className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</p>170 <p className="vp-display text-[19px] font-bold text-green-deep">171 {pct((r.estimate / u.valeurRole - 1) * 100)}172 </p>173 </div>174 )}175 </div>176 {u && (177 <div className="mt-6 flex flex-wrap gap-2.5">178 <a href={`/api/report?id=${encodeURIComponent(u.id)}`} className="btn btn-primary" download>179 {fr ? "Rapport PDF" : "PDF report"} ↓180 </a>181 <a href={`/api/report/pro?id=${encodeURIComponent(u.id)}`} className="btn btn-ghost" download>182 {fr ? "Rapport détaillé format professionnel (banque)" : "Detailed professional report (bank)"} ↓183 </a>184 </div>185 )}186 </div>187 <div className="shrink-0">188 <ConfidenceDial189 pct={r.confidencePct}190 level={r.confidenceLevel}191 label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`}192 />193 </div>194 </div>195 <p className="vp-mono mt-5 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">196 {t("disclaimer")}197 </p>198 </section>199200 {/* ---- L'économie du plex, porte par porte ---- */}201 {u && s && doors != null && doors >= 2 && (202 <section>203 <span className="kicker">{fr ? "Porte par porte" : "Door by door"}</span>204 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">205 {fr ? "L'économie du plex" : "The plex economics"}206 </h2>207 <div className="mt-4 grid grid-cols-2 gap-3.5 lg:grid-cols-4">208 {(209 [210 [fr ? "Valeur estimée / porte" : "Estimated / door", perDoor != null ? fmt(Math.round(perDoor / 100) * 100, lang) : "—", true],211 [fr ? "Rôle / porte" : "Assessed / door", u.valeurRole ? fmt(Math.round(u.valeurRole / doors), lang) : "—", false],212 [fr ? "Aire habitable / porte" : "Living area / door", u.aireEtagesM2 ? locNum(Math.round(u.aireEtagesM2 / doors), lang, { unit: "m²" }) : "—", false],213 [fr ? "Terrain / porte" : "Land / door", u.superficieTerrainM2 ? locNum(Math.round(u.superficieTerrainM2 / doors), lang, { unit: "m²" }) : "—", false],214 ] as [string, string, boolean][]215 ).map(([k, v, hero], i) => (216 <div key={k} className={`vp-card vp-card-hover rise p-4 ${hero ? "!bg-ink" : ""}`} style={{ animationDelay: `${i * 70}ms` }}>217 <p className={`vp-display text-[clamp(17px,2.2vw,24px)] font-bold tracking-[-0.03em] ${hero ? "text-lime" : ""}`}>{v}</p>218 <p className={`vp-mono mt-1.5 text-[9.5px] font-bold uppercase tracking-[0.09em] ${hero ? "text-[rgba(255,159,69,0.7)]" : "text-ink-3"}`}>{k}</p>219 </div>220 ))}221 </div>222 {/* benchmark vs marché local */}223 {(mktPerDoor != null || mktPerM2 != null) && (224 <div className="vp-card mt-3.5 flex flex-wrap items-center gap-x-6 gap-y-2 p-4">225 <p className="klabel">{fr ? "Vs le marché local (médiane des comparables)" : "Vs local market (comps median)"}</p>226 {mktPerDoor != null && perDoor != null && (227 <p className="vp-mono text-[12px]">228 {fr ? "$/porte marché" : "market $/door"} : <b>{fmt(Math.round(mktPerDoor), lang)}</b>229 <span className={`ml-2 rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${perDoor >= mktPerDoor ? "bg-lime text-ink" : "bg-amber-soft text-ink"}`}>230 {fr ? "votre plex" : "your plex"} {pct((perDoor / mktPerDoor - 1) * 100)}231 </span>232 </p>233 )}234 {mktPerM2 != null && perM2 != null && (235 <p className="vp-mono text-[12px]">236 {fr ? "$/m² marché" : "market $/m²"} : <b>{fmt(Math.round(mktPerM2), lang)}</b>237 <span className={`ml-2 rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${perM2 >= mktPerM2 ? "bg-lime text-ink" : "bg-amber-soft text-ink"}`}>238 {fr ? "votre plex" : "your plex"} {pct((perM2 / mktPerM2 - 1) * 100)}239 </span>240 </p>241 )}242 </div>243 )}244 </section>245 )}246247 {/* ---- Pro forma de l'investisseur ---- */}248 {u && doors != null && doors >= 2 && r.estimate > 0 && (249 <Proforma250 valeur={r.estimate}251 doors={doors}252 valeurRole={u.valeurRole}253 municipalite={u.municipalite}254 />255 )}256257 {/* ---- Portrait du plex (bento du registre) ---- */}258 {u && s && (259 <section>260 <span className="kicker">{fr ? "Registre officiel — dessiné par les données du plex" : "Official record — drawn by the plex data"}</span>261 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">262 {fr ? "Portrait du plex" : "Plex portrait"}263 </h2>264 {/* adresses officielles de chaque porte (table des adresses du rôle) */}265 {s.portesAdresses.length > 0 && (266 <div className="vp-card rise mt-4 p-5">267 <div className="flex flex-wrap items-baseline justify-between gap-2">268 <p className="klabel">269 {fr ? "Les portes officielles du plex (rôle foncier)" : "The plex's official doors (assessment roll)"}270 </p>271 <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">272 {s.nAdresses ?? s.portesAdresses.length} {fr ? "inscription(s) d'adresse" : "address record(s)"}273 </p>274 </div>275 <div className="mt-3 flex flex-wrap gap-2">276 {s.portesAdresses.map((a, i) => (277 <span key={a} className="stat-chip !py-1.5 rise" style={{ animationDelay: `${i * 50}ms` }}>278 <b>{i + 1}</b> {a}279 </span>280 ))}281 </div>282 <p className="vp-mono mt-3 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">283 {fr284 ? "Le rôle public ne détaille pas l'intérieur de chaque logement (pièces, loyers) — voici les inscriptions d'adresses officielles de l'unité."285 : "The public roll does not detail each dwelling's interior (rooms, rents) — these are the unit's official address records."}286 </p>287 </div>288 )}289 <div className="mt-4 grid gap-4 lg:grid-cols-3">290 {/* Bâtiment */}291 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "60ms" }}>292 <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">293 {fr ? `Bâtiment — ${doorsLabel(doors, fr).toLowerCase()}` : `Building — ${doorsLabel(doors, fr).toLowerCase()}`}294 </p>295 <div className="flex justify-center py-2">296 <BuildingGlyph297 floors={s.nbEtages}298 dwellings={doors}299 genre={s.genreConstruction}300 yearBuilt={u.anneeConstruction}301 floorAreaM2={u.aireEtagesM2}302 lang={lang}303 />304 </div>305 <EraLine year={u.anneeConstruction} lang={lang} />306 <div className="mt-3 grid grid-cols-2 gap-2">307 <Chip k={fr ? "Portes" : "Doors"} v={doors ?? "—"} />308 <Chip k={fr ? "Étages" : "Storeys"} v={s.nbEtages ?? "—"} />309 <Chip k={fr ? "Aire d'étages" : "Floor area"} v={u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} />310 <Chip k={fr ? "Âge" : "Age"} v={age != null ? `${age} ${fr ? "ans" : "yrs"} (${u.anneeConstruction})` : "—"} />311 <Chip k={fr ? "Genre" : "Style"} v={s.genreConstruction ?? "—"} />312 <Chip k={fr ? "Lien physique" : "Physical link"} v={s.lienPhysique ?? "—"} />313 <Chip314 k={fr ? "Mixité commerciale" : "Commercial mix"}315 v={s.nbLocauxNonResid ? `${s.nbLocauxNonResid} ${fr ? "local(aux)" : "unit(s)"}` : fr ? "Aucune" : "None"}316 />317 <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? (fr ? "Aucune" : "None")} />318 </div>319 </div>320 {/* Terrain */}321 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "140ms" }}>322 <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">323 {fr ? "Terrain — à l'échelle" : "Lot — to scale"}324 </p>325 <div className="flex justify-center py-2">326 <LotDiagram327 areaM2={u.superficieTerrainM2}328 frontageM={s.frontTerrainM}329 footprintM2={u.aireEtagesM2 && s.nbEtages ? u.aireEtagesM2 / Math.max(1, s.nbEtages) : u.aireEtagesM2}330 lang={lang}331 isCondo={false}332 />333 </div>334 <div className="mt-3 grid grid-cols-2 gap-2">335 <Chip k={fr ? "Superficie" : "Area"} v={u.superficieTerrainM2 ? locNum(u.superficieTerrainM2, lang, { unit: "m²" }) : "—"} />336 <Chip k={fr ? "Frontage" : "Frontage"} v={s.frontTerrainM ? locNum(s.frontTerrainM, lang, { unit: "m" }) : "—"} />337 <Chip338 k={fr ? "Emprise au sol ≈" : "Ground coverage ≈"}339 v={340 u.superficieTerrainM2 && u.aireEtagesM2 && s.nbEtages341 ? `${Math.min(100, Math.round((u.aireEtagesM2 / Math.max(1, s.nbEtages) / u.superficieTerrainM2) * 100))} %`342 : "—"343 }344 />345 <Chip k={fr ? "Unité de voisinage" : "Neighbourhood"} v={s.uniteVoisinage || "—"} />346 </div>347 </div>348 {/* Valeur au rôle */}349 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "220ms" }}>350 <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">351 {fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}352 </p>353 <p className="vp-display py-2 text-[26px] font-bold tracking-[-0.02em]">{fmt(u.valeurRole, lang)}</p>354 <ValueSplit355 land={s.valeurTerrain}356 building={s.valeurBatiment}357 previous={s.valeurAnterieure}358 total={u.valeurRole}359 lang={lang}360 labels={{361 land: fr ? "Terrain" : "Land",362 building: fr ? "Bâtiment" : "Building",363 previous: fr ? "Rôle antérieur" : "Previous roll",364 }}365 />366 <div className="mt-3 grid grid-cols-2 gap-2">367 <Chip368 k={fr ? "Bâtiment / m²" : "Building / m²"}369 v={s.valeurBatiment && u.aireEtagesM2 ? fmt(Math.round(s.valeurBatiment / u.aireEtagesM2), lang) : "—"}370 />371 <Chip k={fr ? "Rôle par porte" : "Assessed / door"} v={u.valeurRole && doors ? fmt(Math.round(u.valeurRole / doors), lang) : "—"} />372 <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} />373 <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} />374 </div>375 </div>376 </div>377 {/* registre brut complet */}378 <details className="vp-card mt-4 p-5">379 <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-green-deep">380 {fr ? "▸ Toutes les données du registre (brutes)" : "▸ All registry data (raw)"}381 </summary>382 <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4">383 <Chip k={fr ? "Année construction" : "Year built"} v={u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? (fr ? " (estimée)" : " (est.)") : ""}` : "—"} />384 <Chip k="CUBF" v={s.cubf ? `${s.cubf} — ${s.cubfLibelle ?? ""}` : "—"} />385 <Chip k={fr ? "Locaux non résid." : "Non-res. units"} v={s.nbLocauxNonResid ?? "—"} />386 <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? "—"} />387 <Chip k={fr ? "Arrondissement" : "Borough"} v={s.arrond || "—"} />388 <Chip k={fr ? "Adresses sur l'unité" : "Addresses"} v={s.nAdresses ?? "—"} />389 <Chip k={fr ? "Valeur terrain" : "Land value"} v={fmt(s.valeurTerrain, lang)} />390 <Chip k={fr ? "Valeur bâtiment" : "Building value"} v={fmt(s.valeurBatiment, lang)} />391 <Chip k={fr ? "Rôle antérieur" : "Previous roll"} v={fmt(s.valeurAnterieure, lang)} />392 <Chip k="ID provincial" v={u.id} />393 </div>394 </details>395 </section>396 )}397398 {/* ---- Transparence du calcul ---- */}399 <section>400 <span className="kicker">{fr ? "Transparence" : "Transparency"}</span>401 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{t("whyTitle")}</h2>402 <div className="mt-4 grid gap-4 sm:grid-cols-2">403 <div className="vp-card vp-card-hover rise p-5">404 <p className="klabel">{t("modelPart")}</p>405 <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">406 {r.modelEstimate != null ? <CountUp value={r.modelEstimate} format={moneyFmt} /> : "—"}407 </p>408 <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">409 {t("weightModel")} : {(r.modelWeight * 100).toFixed(0)} %410 </p>411 </div>412 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "100ms" }}>413 <p className="klabel">{t("compsPart")}</p>414 {hasComps ? (415 <>416 <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">417 {r.compsEstimate != null ? <CountUp value={r.compsEstimate} format={moneyFmt} /> : "—"}418 </p>419 <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">420 {t("weightComps")} : {((1 - r.modelWeight) * 100).toFixed(0)} % · {r.nCompsUsed} plex421 {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}422 </p>423 </>424 ) : (425 <>426 <p className="vp-display mt-2 text-[20px] font-bold tracking-[-0.02em] text-ink-3">427 {fr ? "Aucun plex comparable" : "No comparable plex"}428 </p>429 <p className="mt-1 max-w-sm text-[12.5px] text-ink-2">430 {fr431 ? "Immeuble hors gabarit pour son secteur : l'estimation repose à 100 % sur le modèle spécialisé, et la fourchette calibrée s'élargit en conséquence."432 : "Out-of-scale building for its area: the estimate relies 100% on the specialized model, and the calibrated range widens accordingly."}433 </p>434 </>435 )}436 </div>437 </div>438 </section>439440 {/* ---- Historique ---- */}441 {u && u.history.some((h) => h.value != null) && (442 <section>443 <span className="kicker">2021 → 2026</span>444 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{t("history")}</h2>445 <div className="vp-card rise mt-4 p-5 sm:p-6">446 <div className="flex flex-col gap-2">447 {u.history.map((h, i) => {448 const prev = i > 0 ? u.history[i - 1].value : null;449 const yoy = h.value != null && prev != null && prev > 0 ? (h.value / prev - 1) * 100 : null;450 return (451 <div key={h.year} className="hbar-row grid grid-cols-[44px_1fr_auto] items-center gap-2 sm:grid-cols-[52px_1fr_auto] sm:gap-3">452 <span className="vp-mono text-[12px] font-bold">{h.year}</span>453 <span className="hbar-track">454 <span455 className="hbar-fill grow-x"456 style={{457 width: `${((h.value ?? 0) / histMax) * 100}%`,458 animationDelay: `${i * 90}ms`,459 background: h.year === 2026 ? "var(--ink)" : undefined,460 }}461 />462 </span>463 <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">464 {fmt(h.value, lang)}465 {doors != null && doors >= 2 && h.value != null && (466 <span className="hidden text-[10px] font-normal text-ink-3 sm:inline">467 ({fmt(Math.round(h.value / doors), lang)}/{fr ? "porte" : "door"})468 </span>469 )}470 {yoy != null && (471 <span className={`rounded-[4px] border border-ink px-1 py-px text-[9px] ${yoy >= 0 ? "bg-lime-soft text-green-deep" : "bg-[var(--danger-soft)] text-[var(--danger)]"}`}>472 {pct(yoy)}473 </span>474 )}475 </span>476 </div>477 );478 })}479 </div>480 </div>481 </section>482 )}483484 {/* ---- Plan des comparables OU état vide honnête ---- */}485 <section>486 <span className="kicker">487 {hasComps ? `${r.nCompsUsed} ${fr ? "ventes de plex" : "plex sales"}` : fr ? "Comparables" : "Comparables"}488 </span>489 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{t("compsTitle")}</h2>490 {hasComps ? (491 <div className="mt-4 grid items-start gap-4 lg:grid-cols-[1.05fr_1fr]">492 <div className="overflow-hidden rounded-[10px] border-[1.5px] border-ink shadow-[8px_8px_0_rgba(20,24,20,0.08)]">493 <RadarMap494 subject={{495 lat: u?.lat ?? r.comps[0]?.lat ?? 46.81,496 lng: u?.lng ?? r.comps[0]?.lng ?? -71.21,497 label: u?.adresse ?? (fr ? "Plex évalué" : "Subject plex"),498 }}499 comps={r.comps.map((c) => ({500 id: c.id,501 lat: c.lat,502 lng: c.lng,503 label: `${c.street ?? ""}, ${c.city ?? ""}`,504 price: fmt(c.amount, lang),505 adjusted: fmt(Math.round(c.adjustedPrice), lang),506 date: c.date,507 distanceM: c.distanceM,508 weight: c.weight,509 }))}510 highlight={activeComp}511 onHover={setActiveComp}512 />513 </div>514 <div className="src-wrap max-h-[560px] overflow-y-auto">515 <table className="src-table">516 <thead className="sticky top-0 z-10">517 <tr>518 <th>#</th>519 <th>{fr ? "Vente" : "Sale"}</th>520 <th className="text-right">{t("adjusted")}</th>521 </tr>522 </thead>523 <tbody>524 {r.comps.map((c, i) => (525 <tr526 key={c.id}527 onMouseEnter={() => setActiveComp(c.id)}528 onMouseLeave={() => setActiveComp(null)}529 className={activeComp === c.id ? "!bg-lime-soft" : ""}530 >531 <td>532 <span533 className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[11px] font-bold ${534 activeComp === c.id ? "bg-lime text-ink" : "bg-green text-paper"535 }`}536 >537 {i + 1}538 </span>539 </td>540 <td>541 <p className="font-semibold leading-tight">542 {c.street}, {c.city}543 {c.doors != null && (544 <span className="vp-mono ml-1.5 rounded-[3px] border border-ink bg-surface-2 px-1 text-[9px] font-bold uppercase">545 {doorsLabel(c.doors, fr)}546 </span>547 )}548 </p>549 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">550 {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "}551 {t("sold")} {fmt(c.amount, lang)}552 {c.doors ? ` (${fmt(Math.round(c.amount / c.doors), lang)}/${fr ? "porte" : "door"})` : ""}553 </p>554 <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2">555 {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "}556 {t("adjAge")} {signed(c.adjAge, lang)} · {t("adjPortes")} {signed(c.adjDoors, lang)}557 </p>558 </td>559 <td className="vp-display text-right font-bold">560 {fmt(Math.round(c.adjustedPrice), lang)}561 <p className="vp-mono text-[10px] font-normal text-ink-3">562 {t("weight")} {(c.weight * 100).toFixed(0)} %563 </p>564 </td>565 </tr>566 ))}567 </tbody>568 </table>569 </div>570 </div>571 ) : (572 <div className="vp-card mt-4 flex flex-col items-center gap-3 p-10 text-center">573 <p className="vp-display text-[40px]">⌂</p>574 <p className="vp-display text-[18px] font-bold uppercase tracking-[-0.01em]">575 {fr ? "Aucun plex comparable dans le secteur" : "No comparable plex in the area"}576 </p>577 <p className="max-w-lg text-[13.5px] text-ink-2">578 {fr579 ? `Aucune vente récente de plex de gabarit semblable (${doorsLabel(doors, fr).toLowerCase()}) n'a été trouvée à proximité. L'estimation repose entièrement sur le modèle hédonique spécialisé, entraîné sur 91 060 ventes de plex à travers le Québec — la fourchette calibrée reflète cette incertitude supplémentaire.`580 : `No recent sale of a similar-scale plex (${doorsLabel(doors, fr).toLowerCase()}) was found nearby. The estimate relies entirely on the specialized hedonic model, trained on 91,060 plex sales across Québec — the calibrated range reflects this added uncertainty.`}581 </p>582 </div>583 )}584 </section>585586 {/* ---- Lead ---- */}587 <section className="rounded-[10px] border-[1.5px] border-ink bg-ink p-6 text-paper shadow-[8px_8px_0_rgba(20,24,20,0.2)] sm:p-8">588 <h2 className="vp-display text-[22px] font-bold uppercase tracking-[-0.02em] text-lime">{t("leadTitle")}</h2>589 <div className="mt-4 max-w-xl">590 <LeadForm unitId={u?.id ?? null} estimate={r.estimate} />591 </div>592 </section>593 </div>594 );595}596