// Auteur : Simon-Pierre Boucher — contact@spboucher.ai "use client"; import { useState } from "react"; import Link from "next/link"; import { useLang } from "./LangContext"; import LeadForm from "./LeadForm"; import RadarMap from "./RadarMap"; import { BuildingGlyph, ConfidenceDial, CountUp, EraLine, locNum, LotDiagram, ValueSplit, } from "./MetricViz"; import Proforma from "./Proforma"; import type { EstimateResult } from "@/lib/engine"; import type { UnitEstimate } from "@/lib/estimator"; import type { TKey } from "@/lib/i18n"; const fmt = (v: number | null | undefined, lang: string) => v == null ? "—" : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0, }).format(v); const signed = (v: number, lang: string) => `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)), lang)}`; const pct = (v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(0)} %`; function doorsLabel(n: number | null | undefined, fr: boolean): string { if (!n) return "Plex"; const map: Record = { 2: "Duplex", 3: "Triplex", 4: "Quadruplex", 5: "Quintuplex", 6: "Sixplex", }; return map[n] ?? (fr ? `Multi ${n} portes` : `${n}-door multi`); } function median(xs: number[]): number | null { if (!xs.length) return null; const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; } function Chip({ k, v }: { k: string; v: string | number }) { return (

{k}

{v}

); } /** Frise des portes : une porte dessinée par logement (signature ValoPlex). */ function DoorsRow({ doors, perDoor, lang }: { doors: number; perDoor: number | null; lang: string }) { const shown = Math.min(doors, 10); const extra = doors - shown; return (
{Array.from({ length: shown }, (_, i) => ( {i + 1} ))} {extra > 0 && ( +{extra} )} {perDoor != null && ( ≈ {fmt(Math.round(perDoor / 100) * 100, lang)} / {lang === "fr" ? "porte" : "door"} )}
); } export default function ResultView({ data }: { data: UnitEstimate }) { const { lang, t } = useLang(); const [activeComp, setActiveComp] = useState(null); const r: EstimateResult = data.result; const u = data.unit; const s = u?.specs; const fr = lang === "fr"; const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1); const moneyFmt = (v: number) => new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0, }).format(Math.round(v / 100) * 100); const doors = u?.nbLogements ?? null; const hasComps = r.nCompsUsed > 0; const perDoor = doors && doors >= 2 ? r.estimate / doors : null; const perM2 = u?.aireEtagesM2 ? r.estimate / u.aireEtagesM2 : null; const age = u?.anneeConstruction ? 2026 - u.anneeConstruction : null; // benchmark : médianes du marché local à partir des comparables retenus const mktPerDoor = median( r.comps.filter((c) => c.doors && c.doors >= 2).map((c) => c.amount / c.doors!) ); const mktPerM2 = median( r.comps.filter((c) => c.floorArea && c.floorArea > 20).map((c) => c.amount / c.floorArea!) ); return (
{/* fil d'ariane */} {/* ---- Panneau prix ---- */}
{t("estimatedValue")} {doorsLabel(doors, fr)}
{u && (

{u.adresse} {u.municipalite ? · {u.municipalite} : null}

)}

{doors != null && doors >= 2 && (
)}
{perM2 != null && (

{fr ? "Prix au m²" : "Price / m²"}

{fmt(Math.round(perM2), lang)}

)}

{t("range")}

{fmt(r.low, lang)} {fmt(r.high, lang)}

{u?.valeurRole != null && u.valeurRole > 0 && (

{fr ? "Écart vs rôle" : "vs assessment"}

{pct((r.estimate / u.valeurRole - 1) * 100)}

)}
{u && ( )}

{t("disclaimer")}

{/* ---- L'économie du plex, porte par porte ---- */} {u && s && doors != null && doors >= 2 && (
{fr ? "Porte par porte" : "Door by door"}

{fr ? "L'économie du plex" : "The plex economics"}

{( [ [fr ? "Valeur estimée / porte" : "Estimated / door", perDoor != null ? fmt(Math.round(perDoor / 100) * 100, lang) : "—", true], [fr ? "Rôle / porte" : "Assessed / door", u.valeurRole ? fmt(Math.round(u.valeurRole / doors), lang) : "—", false], [fr ? "Aire habitable / porte" : "Living area / door", u.aireEtagesM2 ? locNum(Math.round(u.aireEtagesM2 / doors), lang, { unit: "m²" }) : "—", false], [fr ? "Terrain / porte" : "Land / door", u.superficieTerrainM2 ? locNum(Math.round(u.superficieTerrainM2 / doors), lang, { unit: "m²" }) : "—", false], ] as [string, string, boolean][] ).map(([k, v, hero], i) => (

{v}

{k}

))}
{/* benchmark vs marché local */} {(mktPerDoor != null || mktPerM2 != null) && (

{fr ? "Vs le marché local (médiane des comparables)" : "Vs local market (comps median)"}

{mktPerDoor != null && perDoor != null && (

{fr ? "$/porte marché" : "market $/door"} : {fmt(Math.round(mktPerDoor), lang)} = mktPerDoor ? "bg-lime text-ink" : "bg-amber-soft text-ink"}`}> {fr ? "votre plex" : "your plex"} {pct((perDoor / mktPerDoor - 1) * 100)}

)} {mktPerM2 != null && perM2 != null && (

{fr ? "$/m² marché" : "market $/m²"} : {fmt(Math.round(mktPerM2), lang)} = mktPerM2 ? "bg-lime text-ink" : "bg-amber-soft text-ink"}`}> {fr ? "votre plex" : "your plex"} {pct((perM2 / mktPerM2 - 1) * 100)}

)}
)}
)} {/* ---- Pro forma de l'investisseur ---- */} {u && doors != null && doors >= 2 && r.estimate > 0 && ( )} {/* ---- Portrait du plex (bento du registre) ---- */} {u && s && (
{fr ? "Registre officiel — dessiné par les données du plex" : "Official record — drawn by the plex data"}

{fr ? "Portrait du plex" : "Plex portrait"}

{/* adresses officielles de chaque porte (table des adresses du rôle) */} {s.portesAdresses.length > 0 && (

{fr ? "Les portes officielles du plex (rôle foncier)" : "The plex's official doors (assessment roll)"}

{s.nAdresses ?? s.portesAdresses.length} {fr ? "inscription(s) d'adresse" : "address record(s)"}

{s.portesAdresses.map((a, i) => ( {i + 1} {a} ))}

{fr ? "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é." : "The public roll does not detail each dwelling's interior (rooms, rents) — these are the unit's official address records."}

)}
{/* Bâtiment */}

{fr ? `Bâtiment — ${doorsLabel(doors, fr).toLowerCase()}` : `Building — ${doorsLabel(doors, fr).toLowerCase()}`}

{/* Terrain */}

{fr ? "Terrain — à l'échelle" : "Lot — to scale"}

{/* Valeur au rôle */}

{fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}

{fmt(u.valeurRole, lang)}

{/* registre brut complet */}
{fr ? "▸ Toutes les données du registre (brutes)" : "▸ All registry data (raw)"}
)} {/* ---- Transparence du calcul ---- */}
{fr ? "Transparence" : "Transparency"}

{t("whyTitle")}

{t("modelPart")}

{r.modelEstimate != null ? : "—"}

{t("weightModel")} : {(r.modelWeight * 100).toFixed(0)} %

{t("compsPart")}

{hasComps ? ( <>

{r.compsEstimate != null ? : "—"}

{t("weightComps")} : {((1 - r.modelWeight) * 100).toFixed(0)} % · {r.nCompsUsed} plex {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}

) : ( <>

{fr ? "Aucun plex comparable" : "No comparable plex"}

{fr ? "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." : "Out-of-scale building for its area: the estimate relies 100% on the specialized model, and the calibrated range widens accordingly."}

)}
{/* ---- Historique ---- */} {u && u.history.some((h) => h.value != null) && (
2021 → 2026

{t("history")}

{u.history.map((h, i) => { const prev = i > 0 ? u.history[i - 1].value : null; const yoy = h.value != null && prev != null && prev > 0 ? (h.value / prev - 1) * 100 : null; return (
{h.year} {fmt(h.value, lang)} {doors != null && doors >= 2 && h.value != null && ( ({fmt(Math.round(h.value / doors), lang)}/{fr ? "porte" : "door"}) )} {yoy != null && ( = 0 ? "bg-lime-soft text-green-deep" : "bg-[var(--danger-soft)] text-[var(--danger)]"}`}> {pct(yoy)} )}
); })}
)} {/* ---- Plan des comparables OU état vide honnête ---- */}
{hasComps ? `${r.nCompsUsed} ${fr ? "ventes de plex" : "plex sales"}` : fr ? "Comparables" : "Comparables"}

{t("compsTitle")}

{hasComps ? (
({ id: c.id, lat: c.lat, lng: c.lng, label: `${c.street ?? ""}, ${c.city ?? ""}`, price: fmt(c.amount, lang), adjusted: fmt(Math.round(c.adjustedPrice), lang), date: c.date, distanceM: c.distanceM, weight: c.weight, }))} highlight={activeComp} onHover={setActiveComp} />
{r.comps.map((c, i) => ( setActiveComp(c.id)} onMouseLeave={() => setActiveComp(null)} className={activeComp === c.id ? "!bg-lime-soft" : ""} > ))}
# {fr ? "Vente" : "Sale"} {t("adjusted")}
{i + 1}

{c.street}, {c.city} {c.doors != null && ( {doorsLabel(c.doors, fr)} )}

{c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "} {t("sold")} {fmt(c.amount, lang)} {c.doors ? ` (${fmt(Math.round(c.amount / c.doors), lang)}/${fr ? "porte" : "door"})` : ""}

{t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "} {t("adjAge")} {signed(c.adjAge, lang)} · {t("adjPortes")} {signed(c.adjDoors, lang)}

{fmt(Math.round(c.adjustedPrice), lang)}

{t("weight")} {(c.weight * 100).toFixed(0)} %

) : (

{fr ? "Aucun plex comparable dans le secteur" : "No comparable plex in the area"}

{fr ? `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.` : `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.`}

)}
{/* ---- Lead ---- */}

{t("leadTitle")}

); }