spb/vrai-prix Public
Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 96.7%
CSS 3.2%
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 type { EstimateResult } from "@/lib/engine";18import type { UnitEstimate } from "@/lib/estimator";19import type { TKey } from "@/lib/i18n";2021const fmt = (v: number | null | undefined, lang: string) =>22 v == null23 ? "—"24 : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {25 style: "currency",26 currency: "CAD",27 maximumFractionDigits: 0,28 }).format(v);2930const signed = (v: number, lang: string) =>31 `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)), lang)}`;3233function Chip({ k, v }: { k: string; v: string | number }) {34 return (35 <div className="kv-cell">36 <p className="k">{k}</p>37 <p className="v break-words">{v}</p>38 </div>39 );40}4142export default function ResultView({ data }: { data: UnitEstimate }) {43 const { lang, t } = useLang();44 const [activeComp, setActiveComp] = useState<string | null>(null);45 const r: EstimateResult = data.result;46 const u = data.unit;47 const s = u?.specs;48 const fr = lang === "fr";49 const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1);50 const moneyFmt = (v: number) =>51 new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {52 style: "currency",53 currency: "CAD",54 maximumFractionDigits: 0,55 }).format(Math.round(v / 100) * 100);5657 return (58 <div className="space-y-10 py-8">59 {/* fil d'ariane */}60 <nav className="vp-mono flex flex-wrap items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3">61 <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green">62 Vrai-Prix63 </Link>64 <span>/</span>65 <span>{u ? `${u.adresse ?? ""} · ${u.municipalite ?? ""}` : t("manualTitle")}</span>66 </nav>6768 {/* ---- Panneau prix ---- */}69 <section className="vp-card rise p-6 sm:p-8">70 <div className="flex flex-wrap items-start justify-between gap-6">71 <div className="min-w-0 flex-1">72 <span className="kicker">{t("estimatedValue")}</span>73 {u && (74 <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">75 {u.adresse}76 {s?.apt ? ` app. ${s.apt}` : ""}77 {u.municipalite ? <span className="text-ink-3"> · {u.municipalite}</span> : null}78 </h1>79 )}80 <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]">81 <CountUp value={r.estimate} format={moneyFmt} />82 </p>83 <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">84 <div>85 <p className="klabel">{t("range")}</p>86 <p className="vp-display text-[19px] font-bold">87 {fmt(r.low, lang)} <span className="text-ink-3">→</span> {fmt(r.high, lang)}88 </p>89 </div>90 {u?.valeurRole != null && (91 <div>92 <p className="klabel">{t("assessed")}</p>93 <p className="vp-display text-[19px] font-bold text-ink-2">{fmt(u.valeurRole, lang)}</p>94 </div>95 )}96 {u?.valeurRole != null && u.valeurRole > 0 && r.estimate > 0 && (97 <div>98 <p className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</p>99 <p className="vp-display text-[19px] font-bold text-green-deep">100 {r.estimate >= u.valeurRole ? "+" : ""}101 {((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %102 </p>103 </div>104 )}105 </div>106 {u && (107 <div className="mt-6 flex flex-wrap gap-2.5">108 <a109 href={`/api/report?id=${encodeURIComponent(u.id)}`}110 className="btn btn-primary"111 download112 >113 {fr ? "Rapport PDF" : "PDF report"} ↓114 </a>115 <a116 href={`/api/report/pro?id=${encodeURIComponent(u.id)}`}117 className="btn btn-ghost"118 download119 title={fr ? "Structure de rapport d'évaluation — présentation bancaire" : "Appraisal-style structure — bank presentation"}120 >121 {fr ? "Rapport détaillé format professionnel (banque)" : "Detailed professional report (bank)"} ↓122 </a>123 </div>124 )}125 </div>126 <div className="shrink-0">127 <ConfidenceDial128 pct={r.confidencePct}129 level={r.confidenceLevel}130 label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`}131 />132 </div>133 </div>134 <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">135 {t("disclaimer")}136 </p>137 </section>138139 {/* ---- Portrait de la propriété (bento du registre) ---- */}140 {u && s && (141 <section>142 <span className="kicker">{fr ? "Registre officiel — dessiné par les données" : "Official record — drawn by the data"}</span>143 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">144 {fr ? "Portrait de la propriété" : "Property portrait"}145 </h2>146 <div className="mt-4 grid gap-4 lg:grid-cols-3">147 {/* Bâtiment */}148 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "60ms" }}>149 <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">150 {fr ? "Bâtiment" : "Building"}151 </p>152 <div className="flex justify-center py-2">153 <BuildingGlyph154 floors={s.nbEtages}155 dwellings={u.nbLogements}156 genre={s.genreConstruction}157 yearBuilt={u.anneeConstruction}158 floorAreaM2={u.aireEtagesM2}159 lang={lang}160 />161 </div>162 <EraLine year={u.anneeConstruction} lang={lang} />163 <div className="mt-3 grid grid-cols-2 gap-2">164 <Chip k={fr ? "Aire d'étages" : "Floor area"} v={u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} />165 <Chip k={fr ? "Étages × logements" : "Storeys × dwellings"} v={`${s.nbEtages ?? "—"} × ${u.nbLogements ?? "—"}`} />166 <Chip k={fr ? "Genre" : "Style"} v={s.genreConstruction ?? "—"} />167 <Chip k={fr ? "Lien physique" : "Physical link"} v={s.lienPhysique ?? "—"} />168 </div>169 </div>170 {/* Terrain */}171 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "140ms" }}>172 <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">173 {fr ? "Terrain — à l'échelle" : "Lot — to scale"}174 </p>175 <div className="flex justify-center py-2">176 <LotDiagram177 areaM2={u.superficieTerrainM2}178 frontageM={s.frontTerrainM}179 footprintM2={180 u.aireEtagesM2 && s.nbEtages ? u.aireEtagesM2 / Math.max(1, s.nbEtages) : u.aireEtagesM2181 }182 lang={lang}183 isCondo={!!s.apt || (u.nbLogements != null && u.nbLogements >= 1 && !u.superficieTerrainM2)}184 />185 </div>186 <div className="mt-3 grid grid-cols-2 gap-2">187 <Chip k={fr ? "Superficie" : "Area"} v={u.superficieTerrainM2 ? locNum(u.superficieTerrainM2, lang, { unit: "m²" }) : "—"} />188 <Chip k={fr ? "Frontage" : "Frontage"} v={s.frontTerrainM ? locNum(s.frontTerrainM, lang, { unit: "m" }) : "—"} />189 <Chip k="CUBF" v={s.cubf ?? "—"} />190 <Chip k={fr ? "Usage" : "Use"} v={s.cubfLibelle ?? "—"} />191 </div>192 </div>193 {/* Valeur au rôle */}194 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "220ms" }}>195 <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">196 {fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}197 </p>198 <p className="vp-display py-2 text-[26px] font-bold tracking-[-0.02em]">199 {fmt(u.valeurRole, lang)}200 </p>201 <ValueSplit202 land={s.valeurTerrain}203 building={s.valeurBatiment}204 previous={s.valeurAnterieure}205 total={u.valeurRole}206 lang={lang}207 labels={{208 land: fr ? "Terrain" : "Land",209 building: fr ? "Bâtiment" : "Building",210 previous: fr ? "Rôle antérieur" : "Previous roll",211 }}212 />213 <div className="mt-3 grid grid-cols-2 gap-2">214 <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} />215 <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} />216 </div>217 </div>218 </div>219 {/* registre brut complet */}220 <details className="vp-card mt-4 p-5">221 <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-green-deep">222 {fr ? "▸ Toutes les données du registre (brutes)" : "▸ All registry data (raw)"}223 </summary>224 <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4">225 <Chip k={fr ? "Année construction" : "Year built"} v={u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? (fr ? " (estimée)" : " (est.)") : ""}` : "—"} />226 <Chip k={fr ? "Locaux non résid." : "Non-res. units"} v={s.nbLocauxNonResid ?? "—"} />227 <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? "—"} />228 <Chip k={fr ? "Unité de voisinage" : "Neighbourhood"} v={s.uniteVoisinage || "—"} />229 <Chip k={fr ? "Arrondissement" : "Borough"} v={s.arrond || "—"} />230 <Chip k={fr ? "Adresses sur l'unité" : "Addresses"} v={s.nAdresses ?? "—"} />231 <Chip k={fr ? "Valeur terrain" : "Land value"} v={fmt(s.valeurTerrain, lang)} />232 <Chip k={fr ? "Valeur bâtiment" : "Building value"} v={fmt(s.valeurBatiment, lang)} />233 <Chip k={fr ? "Rôle antérieur" : "Previous roll"} v={fmt(s.valeurAnterieure, lang)} />234 <Chip k="ID provincial" v={u.id} />235 </div>236 </details>237 </section>238 )}239240 {/* ---- Transparence du calcul ---- */}241 <section>242 <span className="kicker">{fr ? "Transparence" : "Transparency"}</span>243 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">244 {t("whyTitle")}245 </h2>246 <div className="mt-4 grid gap-4 sm:grid-cols-2">247 <div className="vp-card vp-card-hover rise p-5">248 <p className="klabel">{t("modelPart")}</p>249 <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">250 {r.modelEstimate != null ? (251 <CountUp value={r.modelEstimate} format={moneyFmt} />252 ) : (253 "—"254 )}255 </p>256 <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">257 {t("weightModel")} : {(r.modelWeight * 100).toFixed(0)} %258 </p>259 </div>260 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "100ms" }}>261 <p className="klabel">{t("compsPart")}</p>262 <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">263 {r.compsEstimate != null ? (264 <CountUp value={r.compsEstimate} format={moneyFmt} />265 ) : (266 "—"267 )}268 </p>269 <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">270 {t("weightComps")} : {((1 - r.modelWeight) * 100).toFixed(0)} % · {r.nCompsUsed} comps271 {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}272 </p>273 </div>274 </div>275 </section>276277 {/* ---- Historique ---- */}278 {u && u.history.some((h) => h.value != null) && (279 <section>280 <span className="kicker">2021 → 2026</span>281 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">282 {t("history")}283 </h2>284 <div className="vp-card rise mt-4 p-5 sm:p-6">285 <div className="flex flex-col gap-2">286 {u.history.map((h, i) => {287 const prev = i > 0 ? u.history[i - 1].value : null;288 const yoy =289 h.value != null && prev != null && prev > 0290 ? ((h.value / prev - 1) * 100)291 : null;292 return (293 <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">294 <span className="vp-mono text-[12px] font-bold">{h.year}</span>295 <span className="hbar-track">296 <span297 className="hbar-fill grow-x"298 style={{299 width: `${((h.value ?? 0) / histMax) * 100}%`,300 animationDelay: `${i * 90}ms`,301 background: h.year === 2026 ? "var(--ink)" : undefined,302 }}303 />304 </span>305 <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">306 {fmt(h.value, lang)}307 {yoy != null && (308 <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)]"}`}>309 {yoy >= 0 ? "+" : ""}310 {yoy.toFixed(0)} %311 </span>312 )}313 </span>314 </div>315 );316 })}317 </div>318 </div>319 </section>320 )}321322 {/* ---- Plan des comparables (carte maison) ---- */}323 <section>324 <span className="kicker">325 {r.nCompsUsed} {fr ? "ventes réelles" : "real sales"}326 </span>327 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">328 {t("compsTitle")}329 </h2>330 <div className="mt-4 grid items-start gap-4 lg:grid-cols-[1.05fr_1fr]">331 <div className="overflow-hidden rounded-[10px] border-[1.5px] border-ink shadow-[8px_8px_0_rgba(20,24,20,0.08)]">332 <RadarMap333 subject={{334 lat: u?.lat ?? r.comps[0]?.lat ?? 46.81,335 lng: u?.lng ?? r.comps[0]?.lng ?? -71.21,336 label: u?.adresse ?? (fr ? "Propriété estimée" : "Subject property"),337 }}338 comps={r.comps.map((c) => ({339 id: c.id,340 lat: c.lat,341 lng: c.lng,342 label: `${c.street ?? ""}, ${c.city ?? ""}`,343 price: fmt(c.amount, lang),344 adjusted: fmt(Math.round(c.adjustedPrice), lang),345 date: c.date,346 distanceM: c.distanceM,347 weight: c.weight,348 }))}349 highlight={activeComp}350 onHover={setActiveComp}351 />352 </div>353 <div className="src-wrap max-h-[560px] overflow-y-auto">354 <table className="src-table">355 <thead className="sticky top-0 z-10">356 <tr>357 <th>#</th>358 <th>{fr ? "Vente" : "Sale"}</th>359 <th className="text-right">{t("adjusted")}</th>360 </tr>361 </thead>362 <tbody>363 {r.comps.map((c, i) => (364 <tr365 key={c.id}366 onMouseEnter={() => setActiveComp(c.id)}367 onMouseLeave={() => setActiveComp(null)}368 className={activeComp === c.id ? "!bg-lime-soft" : ""}369 >370 <td>371 <span372 className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[11px] font-bold ${373 activeComp === c.id ? "bg-lime text-ink" : "bg-green text-paper"374 }`}375 >376 {i + 1}377 </span>378 </td>379 <td>380 <p className="font-semibold leading-tight">381 {c.street}, {c.city}382 </p>383 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">384 {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "}385 {t("sold")} {fmt(c.amount, lang)}386 </p>387 <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2">388 {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "}389 {t("adjAge")} {signed(c.adjAge, lang)}390 </p>391 </td>392 <td className="vp-display text-right font-bold">393 {fmt(Math.round(c.adjustedPrice), lang)}394 <p className="vp-mono text-[10px] font-normal text-ink-3">395 {t("weight")} {(c.weight * 100).toFixed(0)} %396 </p>397 </td>398 </tr>399 ))}400 </tbody>401 </table>402 </div>403 </div>404 </section>405406 {/* ---- Lead ---- */}407 <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">408 <h2 className="vp-display text-[22px] font-bold uppercase tracking-[-0.02em] text-lime">409 {t("leadTitle")}410 </h2>411 <div className="mt-4 max-w-xl">412 <LeadForm unitId={u?.id ?? null} estimate={r.estimate} />413 </div>414 </section>415 </div>416 );417}418