Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
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 FavButton from "./FavButton";9import {10 BuildingGlyph,11 ConfidenceDial,12 CountUp,13 EraLine,14 locNum,15 LotDiagram,16 MarketIndexChart,17 PercentileBar,18 RangeBar,19 ValueSplit,20} from "./MetricViz";21import type { EstimateResult } from "@/lib/engine";22import type { UnitEstimate } from "@/lib/estimator";23import type { TKey } from "@/lib/i18n";2425const fmt = (v: number | null | undefined, lang: string) =>26 v == null27 ? "—"28 : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {29 style: "currency",30 currency: "CAD",31 maximumFractionDigits: 0,32 }).format(v);3334const signed = (v: number, lang: string) =>35 `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)), lang)}`;3637function Chip({ k, v }: { k: string; v: string | number }) {38 return (39 <div className="kv-cell">40 <p className="k">{k}</p>41 <p className="v break-words">{v}</p>42 </div>43 );44}4546export default function ResultView({ data }: { data: UnitEstimate }) {47 const { lang, t } = useLang();48 const [activeComp, setActiveComp] = useState<string | null>(null);49 const r: EstimateResult = data.result;50 const u = data.unit;51 const ctx = data.context;52 const s = u?.specs;53 const fr = lang === "fr";54 const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1);55 const moneyFmt = (v: number) =>56 new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {57 style: "currency",58 currency: "CAD",59 maximumFractionDigits: 0,60 }).format(Math.round(v / 100) * 100);6162 return (63 <div className="space-y-10 py-8">64 {/* fil d'ariane */}65 <nav className="vp-mono flex flex-wrap items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3">66 <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green">67 Vrai-Prix68 </Link>69 <span>/</span>70 <span>{u ? `${u.adresse ?? ""} · ${u.municipalite ?? ""}` : t("manualTitle")}</span>71 </nav>7273 {/* ---- Panneau prix ---- */}74 <section className="vp-card rise p-6 sm:p-8">75 <div className="flex flex-wrap items-start justify-between gap-6">76 {/* basis large : sous ~380px de large la jauge passe dessous au lieu d'écraser la colonne */}77 <div className="min-w-0 flex-1 basis-[300px]">78 <div className="flex items-center justify-between gap-3">79 <span className="kicker">{t("estimatedValue")}</span>80 {/* ♥ Mon univers Ka — favori unifié Groupe KA */}81 {u && (82 <FavButton83 size="lg"84 item={{85 item_id: u.id,86 title: `${u.adresse ?? u.id}${s?.apt ? ` app. ${s.apt}` : ""}`,87 subtitle: u.municipalite ?? "",88 price_label: r.estimate > 0 ? moneyFmt(r.estimate) : "",89 url: `https://www.vrai-prix.com/estimation/${encodeURIComponent(u.id)}`,90 }}91 />92 )}93 </div>94 {u && (95 <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">96 {u.adresse}97 {s?.apt ? ` app. ${s.apt}` : ""}98 {u.municipalite ? <span className="text-ink-3"> · {u.municipalite}</span> : null}99 </h1>100 )}101 <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]">102 <CountUp value={r.estimate} format={moneyFmt} />103 </p>104 {/* faits rapides */}105 <div className="mt-4 flex flex-wrap gap-2">106 {[107 u ? t(u.typeProp as TKey) : null,108 u?.aireEtagesM2 && r.estimate > 0109 ? `${locNum(Math.round(r.estimate / u.aireEtagesM2), lang, { maxFrac: 0 })} $/m²`110 : null,111 u?.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : null,112 u?.anneeConstruction ? `${fr ? "constr." : "built"} ${u.anneeConstruction}` : null,113 u?.superficieTerrainM2114 ? `${fr ? "terrain" : "lot"} ${locNum(u.superficieTerrainM2, lang, { unit: "m²" })}`115 : null,116 ]117 .filter((x): x is string => x != null)118 .map((x) => (119 <span120 key={x}121 className="vp-mono whitespace-nowrap rounded-[5px] border-[1.5px] border-ink bg-surface-2 px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.04em]"122 >123 {x}124 </span>125 ))}126 </div>127 {/* fourchette visuelle P10-P90 avec position du rôle */}128 <div className="mt-6 max-w-2xl">129 <p className="klabel mb-1">{t("range")}</p>130 <RangeBar131 low={r.low}132 estimate={r.estimate}133 high={r.high}134 role={u?.valeurRole ?? null}135 lang={lang}136 />137 </div>138 <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">139 {u?.valeurRole != null && (140 <div>141 <p className="klabel">{t("assessed")}</p>142 <p className="vp-display text-[19px] font-bold text-ink-2">{fmt(u.valeurRole, lang)}</p>143 </div>144 )}145 {u?.valeurRole != null && u.valeurRole > 0 && r.estimate > 0 && (146 <div>147 <p className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</p>148 <p className="vp-display text-[19px] font-bold text-green-deep">149 {r.estimate >= u.valeurRole ? "+" : ""}150 {((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %151 </p>152 </div>153 )}154 </div>155 {u && (156 <div className="mt-6 flex flex-wrap gap-2.5">157 <a158 href={`/api/report?id=${encodeURIComponent(u.id)}`}159 className="btn btn-primary"160 download161 >162 {fr ? "Rapport PDF" : "PDF report"} ↓163 </a>164 <a165 href={`/api/report/pro?id=${encodeURIComponent(u.id)}`}166 className="btn btn-ghost"167 download168 title={fr ? "Structure de rapport d'évaluation — présentation bancaire" : "Appraisal-style structure — bank presentation"}169 >170 {fr ? "Rapport détaillé format professionnel (banque)" : "Detailed professional report (bank)"} ↓171 </a>172 </div>173 )}174 </div>175 <div className="shrink-0">176 <ConfidenceDial177 pct={r.confidencePct}178 level={r.confidenceLevel}179 label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`}180 />181 </div>182 </div>183 <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">184 {t("disclaimer")}185 </p>186 </section>187188 {/* ---- Portrait de la propriété (bento du registre) ---- */}189 {u && s && (190 <section>191 <span className="kicker">{fr ? "Registre officiel — dessiné par les données" : "Official record — drawn by the data"}</span>192 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">193 {fr ? "Portrait de la propriété" : "Property portrait"}194 </h2>195 <div className="mt-4 grid gap-4 lg:grid-cols-3">196 {/* Bâtiment */}197 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "60ms" }}>198 <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">199 {fr ? "Bâtiment" : "Building"}200 </p>201 <div className="flex justify-center py-2">202 <BuildingGlyph203 floors={s.nbEtages}204 dwellings={u.nbLogements}205 genre={s.genreConstruction}206 yearBuilt={u.anneeConstruction}207 floorAreaM2={u.aireEtagesM2}208 lang={lang}209 />210 </div>211 <EraLine year={u.anneeConstruction} lang={lang} />212 <div className="mt-3 grid grid-cols-2 gap-2">213 <Chip k={fr ? "Aire d'étages" : "Floor area"} v={u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} />214 <Chip k={fr ? "Étages × logements" : "Storeys × dwellings"} v={`${s.nbEtages ?? "—"} × ${u.nbLogements ?? "—"}`} />215 <Chip k={fr ? "Genre" : "Style"} v={s.genreConstruction ?? "—"} />216 <Chip k={fr ? "Lien physique" : "Physical link"} v={s.lienPhysique ?? "—"} />217 </div>218 </div>219 {/* Terrain */}220 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "140ms" }}>221 <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">222 {fr ? "Terrain — à l'échelle" : "Lot — to scale"}223 </p>224 <div className="flex justify-center py-2">225 <LotDiagram226 areaM2={u.superficieTerrainM2}227 frontageM={s.frontTerrainM}228 footprintM2={229 u.aireEtagesM2 && s.nbEtages ? u.aireEtagesM2 / Math.max(1, s.nbEtages) : u.aireEtagesM2230 }231 lang={lang}232 isCondo={!!s.apt || (u.nbLogements != null && u.nbLogements >= 1 && !u.superficieTerrainM2)}233 />234 </div>235 <div className="mt-3 grid grid-cols-2 gap-2">236 <Chip k={fr ? "Superficie" : "Area"} v={u.superficieTerrainM2 ? locNum(u.superficieTerrainM2, lang, { unit: "m²" }) : "—"} />237 <Chip k={fr ? "Frontage" : "Frontage"} v={s.frontTerrainM ? locNum(s.frontTerrainM, lang, { unit: "m" }) : "—"} />238 <Chip k="CUBF" v={s.cubf ?? "—"} />239 <Chip k={fr ? "Usage" : "Use"} v={s.cubfLibelle ?? "—"} />240 </div>241 </div>242 {/* Valeur au rôle */}243 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "220ms" }}>244 <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">245 {fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}246 </p>247 <p className="vp-display py-2 text-[26px] font-bold tracking-[-0.02em]">248 {fmt(u.valeurRole, lang)}249 </p>250 <ValueSplit251 land={s.valeurTerrain}252 building={s.valeurBatiment}253 previous={s.valeurAnterieure}254 total={u.valeurRole}255 lang={lang}256 labels={{257 land: fr ? "Terrain" : "Land",258 building: fr ? "Bâtiment" : "Building",259 previous: fr ? "Rôle antérieur" : "Previous roll",260 }}261 />262 <div className="mt-3 grid grid-cols-2 gap-2">263 <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} />264 <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} />265 </div>266 </div>267 </div>268 {/* registre brut complet */}269 <details className="vp-card mt-4 p-5">270 <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-green-deep">271 {fr ? "▸ Toutes les données du registre (brutes)" : "▸ All registry data (raw)"}272 </summary>273 <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4">274 <Chip k={fr ? "Année construction" : "Year built"} v={u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? (fr ? " (estimée)" : " (est.)") : ""}` : "—"} />275 <Chip k={fr ? "Locaux non résid." : "Non-res. units"} v={s.nbLocauxNonResid ?? "—"} />276 <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? "—"} />277 <Chip k={fr ? "Unité de voisinage" : "Neighbourhood"} v={s.uniteVoisinage || "—"} />278 <Chip k={fr ? "Arrondissement" : "Borough"} v={s.arrond || "—"} />279 <Chip k={fr ? "Adresses sur l'unité" : "Addresses"} v={s.nAdresses ?? "—"} />280 <Chip k={fr ? "Valeur terrain" : "Land value"} v={fmt(s.valeurTerrain, lang)} />281 <Chip k={fr ? "Valeur bâtiment" : "Building value"} v={fmt(s.valeurBatiment, lang)} />282 <Chip k={fr ? "Rôle antérieur" : "Previous roll"} v={fmt(s.valeurAnterieure, lang)} />283 <Chip k="ID provincial" v={u.id} />284 </div>285 </details>286 </section>287 )}288289 {/* ---- Transparence du calcul ---- */}290 <section>291 <span className="kicker">{fr ? "Transparence" : "Transparency"}</span>292 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">293 {t("whyTitle")}294 </h2>295 <div className="mt-4 grid gap-4 sm:grid-cols-2">296 <div className="vp-card vp-card-hover rise p-5">297 <p className="klabel">{t("modelPart")}</p>298 <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">299 {r.modelEstimate != null ? (300 <CountUp value={r.modelEstimate} format={moneyFmt} />301 ) : (302 "—"303 )}304 </p>305 <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">306 {t("weightModel")} : {(r.modelWeight * 100).toFixed(0)} %307 </p>308 </div>309 <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "100ms" }}>310 <p className="klabel">{t("compsPart")}</p>311 <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">312 {r.compsEstimate != null ? (313 <CountUp value={r.compsEstimate} format={moneyFmt} />314 ) : (315 "—"316 )}317 </p>318 <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">319 {t("weightComps")} : {((1 - r.modelWeight) * 100).toFixed(0)} % · {r.nCompsUsed} comps320 {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}321 </p>322 </div>323 </div>324 </section>325326 {/* ---- Marché local ---- */}327 {ctx && (ctx.index.length >= 6 || ctx.sector || ctx.muniPercentile != null) && (328 <section>329 <span className="kicker">{fr ? "Contexte de marché" : "Market context"}</span>330 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">331 {fr ? "Le marché autour de cette propriété" : "The market around this property"}332 </h2>333 <div className="mt-4 grid items-stretch gap-4 lg:grid-cols-[1.35fr_1fr]">334 {/* trajectoire du marché */}335 {ctx.index.length >= 6 && (336 <div className="vp-card rise p-5">337 <div className="flex flex-wrap items-center justify-between gap-2">338 <p className="klabel">339 {fr340 ? `Indice de marché — ${t(ctx.typeProp as TKey)} (base 100 en ${ctx.index[0].month.slice(0, 4)})`341 : `Market index — ${t(ctx.typeProp as TKey)} (base 100 in ${ctx.index[0].month.slice(0, 4)})`}342 </p>343 <div className="flex gap-2">344 {ctx.index12mPct != null && (345 <span className={`vp-mono rounded-[4px] border border-ink px-1.5 py-0.5 text-[10px] font-bold ${ctx.index12mPct >= 0 ? "bg-lime text-ink" : "bg-danger text-white"}`}>346 12 {fr ? "mois" : "mo"} {ctx.index12mPct >= 0 ? "+" : ""}347 {ctx.index12mPct.toFixed(1)} %348 </span>349 )}350 {ctx.indexSince2021Pct != null && (351 <span className="vp-mono rounded-[4px] border border-ink bg-surface-2 px-1.5 py-0.5 text-[10px] font-bold text-ink-2">352 {ctx.index[0].month.slice(0, 4)}→ {ctx.indexSince2021Pct >= 0 ? "+" : ""}353 {ctx.indexSince2021Pct.toFixed(0)} %354 </span>355 )}356 </div>357 </div>358 <div className="mt-3">359 <MarketIndexChart series={ctx.index} lang={lang} />360 </div>361 <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">362 {fr363 ? "Indice utilisé pour l'ajustement « marché » des comparables — même donnée, même calcul."364 : "Index used for the comparables' market adjustment — same data, same math."}365 </p>366 </div>367 )}368 <div className="flex flex-col gap-4">369 {/* ventes du secteur */}370 {ctx.sector && (371 <div className="vp-card rise p-5" style={{ animationDelay: "80ms" }}>372 <p className="klabel">373 {fr374 ? `Ventes réelles — rayon ${ctx.sector.radiusKm} km · 12 mois`375 : `Real sales — ${ctx.sector.radiusKm} km radius · 12 months`}376 </p>377 <div className="mt-3 grid grid-cols-3 gap-2">378 <div className="kv-cell">379 <p className="k">{fr ? "Ventes" : "Sales"}</p>380 <p className="v">{ctx.sector.nSales12m}</p>381 </div>382 <div className="kv-cell">383 <p className="k">{fr ? "Prix médian" : "Median price"}</p>384 <p className="v">{ctx.sector.medianPrice != null ? fmt(Math.round(ctx.sector.medianPrice), lang) : "—"}</p>385 </div>386 <div className="kv-cell">387 <p className="k">$/m² {fr ? "médian" : "median"}</p>388 <p className="v">389 {ctx.sector.medianPpm2 != null390 ? locNum(Math.round(ctx.sector.medianPpm2), lang, { maxFrac: 0 })391 : "—"}392 </p>393 </div>394 </div>395 {ctx.sector.medianPrice != null && r.estimate > 0 && (396 <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-2">397 {fr ? "Cette propriété" : "This property"} :{" "}398 <b className="text-ink">399 {r.estimate >= ctx.sector.medianPrice ? "+" : "−"}400 {Math.abs((r.estimate / ctx.sector.medianPrice - 1) * 100).toFixed(0)} %401 </b>{" "}402 {fr ? "vs médiane du secteur" : "vs sector median"}403 </p>404 )}405 </div>406 )}407 {/* position dans la municipalité */}408 {ctx.muniPercentile != null && u?.municipalite && (409 <div className="vp-card rise flex-1 p-5" style={{ animationDelay: "160ms" }}>410 <p className="klabel">411 {fr ? `Position dans ${u.municipalite}` : `Position in ${u.municipalite}`}412 </p>413 <div className="mt-3">414 <PercentileBar pct={ctx.muniPercentile} lang={lang} />415 </div>416 <p className="mt-3 text-[13px] text-ink-2">417 {fr ? (418 <>419 Vaut plus que{" "}420 <b className="text-ink">{Math.round(ctx.muniPercentile)} %</b> des{" "}421 {ctx.muniTotal != null ? locNum(ctx.muniTotal, lang, { maxFrac: 0 }) : ""}{" "}422 propriétés estimées de la municipalité.423 </>424 ) : (425 <>426 Worth more than{" "}427 <b className="text-ink">{Math.round(ctx.muniPercentile)} %</b> of the{" "}428 {ctx.muniTotal != null ? locNum(ctx.muniTotal, lang, { maxFrac: 0 }) : ""}{" "}429 estimated properties in the municipality.430 </>431 )}432 </p>433 </div>434 )}435 </div>436 </div>437 </section>438 )}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]">445 {t("history")}446 </h2>447 <div className="vp-card rise mt-4 p-5 sm:p-6">448 <div className="flex flex-col gap-2">449 {u.history.map((h, i) => {450 const prev = i > 0 ? u.history[i - 1].value : null;451 const yoy =452 h.value != null && prev != null && prev > 0453 ? ((h.value / prev - 1) * 100)454 : null;455 return (456 <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">457 <span className="vp-mono text-[12px] font-bold">{h.year}</span>458 <span className="hbar-track">459 <span460 className="hbar-fill grow-x"461 style={{462 width: `${((h.value ?? 0) / histMax) * 100}%`,463 animationDelay: `${i * 90}ms`,464 background: h.year === 2026 ? "var(--ink)" : undefined,465 }}466 />467 </span>468 <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">469 {fmt(h.value, lang)}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 {yoy >= 0 ? "+" : ""}473 {yoy.toFixed(0)} %474 </span>475 )}476 </span>477 </div>478 );479 })}480 </div>481 </div>482 </section>483 )}484485 {/* ---- Plan des comparables (carte maison) ---- */}486 <section>487 <span className="kicker">488 {r.nCompsUsed} {fr ? "ventes réelles" : "real sales"}489 </span>490 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">491 {t("compsTitle")}492 </h2>493 <div className="mt-4 grid items-start gap-4 lg:grid-cols-[1.05fr_1fr]">494 <div className="overflow-hidden rounded-[10px] border-[1.5px] border-ink shadow-[8px_8px_0_rgba(20,24,20,0.08)]">495 <RadarMap496 subject={{497 lat: u?.lat ?? r.comps[0]?.lat ?? 46.81,498 lng: u?.lng ?? r.comps[0]?.lng ?? -71.21,499 label: u?.adresse ?? (fr ? "Propriété estimée" : "Subject property"),500 }}501 comps={r.comps.map((c) => ({502 id: c.id,503 lat: c.lat,504 lng: c.lng,505 label: `${c.street ?? ""}, ${c.city ?? ""}`,506 price: fmt(c.amount, lang),507 adjusted: fmt(Math.round(c.adjustedPrice), lang),508 date: c.date,509 distanceM: c.distanceM,510 weight: c.weight,511 }))}512 highlight={activeComp}513 onHover={setActiveComp}514 />515 </div>516 <div className="src-wrap max-h-[560px] overflow-y-auto">517 <table className="src-table">518 <thead className="sticky top-0 z-10">519 <tr>520 <th>#</th>521 <th>{fr ? "Vente" : "Sale"}</th>522 <th className="text-right">{t("adjusted")}</th>523 </tr>524 </thead>525 <tbody>526 {r.comps.map((c, i) => (527 <tr528 key={c.id}529 onMouseEnter={() => setActiveComp(c.id)}530 onMouseLeave={() => setActiveComp(null)}531 className={activeComp === c.id ? "!bg-lime-soft" : ""}532 >533 <td>534 <span535 className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[11px] font-bold ${536 activeComp === c.id ? "bg-lime text-ink" : "bg-green text-paper"537 }`}538 >539 {i + 1}540 </span>541 </td>542 <td>543 <p className="font-semibold leading-tight">544 {c.street}, {c.city}545 </p>546 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">547 {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "}548 {t("sold")} {fmt(c.amount, lang)}549 {c.floorArea && c.floorArea > 20550 ? ` (${locNum(Math.round(c.amount / c.floorArea), lang, { maxFrac: 0 })} $/m²)`551 : ""}552 </p>553 {(c.floorArea || c.yearBuilt) && (554 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">555 {[556 c.floorArea ? locNum(c.floorArea, lang, { unit: "m²" }) : null,557 c.yearBuilt ? `${fr ? "constr." : "built"} ${c.yearBuilt}` : null,558 ]559 .filter(Boolean)560 .join(" · ")}561 </p>562 )}563 <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2">564 {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "}565 {t("adjAge")} {signed(c.adjAge, lang)}566 </p>567 </td>568 <td className="vp-display text-right font-bold">569 {fmt(Math.round(c.adjustedPrice), lang)}570 <p className="vp-mono text-[10px] font-normal text-ink-3">571 {t("weight")} {(c.weight * 100).toFixed(0)} %572 </p>573 </td>574 </tr>575 ))}576 </tbody>577 </table>578 </div>579 </div>580 </section>581582 {/* ---- Lead ---- */}583 <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">584 <h2 className="vp-display text-[22px] font-bold uppercase tracking-[-0.02em] text-lime">585 {t("leadTitle")}586 </h2>587 <div className="mt-4 max-w-xl">588 <LeadForm unitId={u?.id ?? null} estimate={r.estimate} />589 </div>590 </section>591 </div>592 );593}594