Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * ResultView — le « rapport de valeur » : l'écran central du produit.4 * Composition éditoriale v2 : masthead de rapport, estimation en très grands5 * chiffres, sections numérotées à filets (registre, calcul, marché, historique,6 * comparables), zéro carte — la hiérarchie vient de la typographie et des filets.7 */8"use client";9import { useState } from "react";10import Link from "next/link";11import { useLang } from "./LangContext";12import LeadForm from "./LeadForm";13import RadarMap from "./RadarMap";14import FavButton from "./FavButton";15import {16 BuildingGlyph,17 ConfidenceDial,18 CountUp,19 EraLine,20 locNum,21 LotDiagram,22 MarketIndexChart,23 PercentileBar,24 RangeBar,25 ValueSplit,26} from "./MetricViz";27import type { EstimateResult } from "@/lib/engine";28import type { UnitEstimate } from "@/lib/estimator";29import type { TKey } from "@/lib/i18n";3031const fmt = (v: number | null | undefined, lang: string) =>32 v == null33 ? "—"34 : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {35 style: "currency",36 currency: "CAD",37 maximumFractionDigits: 0,38 }).format(v);3940const signed = (v: number, lang: string) =>41 `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)), lang)}`;4243function Chip({ k, v }: { k: string; v: string | number }) {44 return (45 <div className="kv-cell">46 <p className="k">{k}</p>47 <p className="v break-words">{v}</p>48 </div>49 );50}5152/** En-tête de section du rapport : filet fort + numéro mono rouge. */53function Sect({ num, kicker, title }: { num: string; kicker: string; title: string }) {54 return (55 <header className="sec">56 <span className="sec-num">57 {num} — {kicker}58 </span>59 <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">60 {title}61 </h2>62 </header>63 );64}6566export default function ResultView({ data }: { data: UnitEstimate }) {67 const { lang, t } = useLang();68 const [activeComp, setActiveComp] = useState<string | null>(null);69 const r: EstimateResult = data.result;70 const u = data.unit;71 const ctx = data.context;72 const s = u?.specs;73 const fr = lang === "fr";74 const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1);75 const moneyFmt = (v: number) =>76 new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {77 style: "currency",78 currency: "CAD",79 maximumFractionDigits: 0,80 }).format(Math.round(v / 100) * 100);8182 return (83 <div className="space-y-12 py-8">84 {/* ================= masthead du rapport ================= */}85 <section className="rise">86 <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">87 <nav className="vp-mono flex min-w-0 flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">88 <Link href="/" className="text-ink-2 hover:text-accent-deep">89 Vrai-Prix90 </Link>91 <span aria-hidden="true">/</span>92 <span>{fr ? "Rapport de valeur" : "Value report"}</span>93 {u && (94 <>95 <span aria-hidden="true">/</span>96 <span className="truncate">{u.id}</span>97 </>98 )}99 </nav>100 {u && (101 <FavButton102 size="lg"103 item={{104 item_id: u.id,105 title: `${u.adresse ?? u.id}${s?.apt ? ` app. ${s.apt}` : ""}`,106 subtitle: u.municipalite ?? "",107 price_label: r.estimate > 0 ? moneyFmt(r.estimate) : "",108 url: `https://www.vrai-prix.com/estimation/${encodeURIComponent(u.id)}`,109 }}110 />111 )}112 </div>113114 <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.5fr_1fr]">115 {/* colonne principale : adresse + estimation en très grands chiffres */}116 <div className="min-w-0">117 {u && (118 <h1 className="vp-display text-[clamp(19px,2.6vw,26px)] font-bold uppercase leading-tight tracking-[-0.02em]">119 {u.adresse}120 {s?.apt ? ` app. ${s.apt}` : ""}121 {u.municipalite ? <span className="text-ink-3"> · {u.municipalite}</span> : null}122 </h1>123 )}124 <p className="klabel mt-5">{t("estimatedValue")}</p>125 <p className="vp-display mt-1 text-[clamp(52px,9vw,96px)] font-bold leading-none tracking-[-0.045em]">126 <CountUp value={r.estimate} format={moneyFmt} />127 </p>128 {/* faits rapides — ligne mono, pas de pastilles */}129 <p className="vp-mono mt-4 text-[11px] uppercase tracking-[0.06em] text-ink-2">130 {[131 u ? t(u.typeProp as TKey) : null,132 u?.aireEtagesM2 && r.estimate > 0133 ? `${locNum(Math.round(r.estimate / u.aireEtagesM2), lang, { maxFrac: 0 })} $/m²`134 : null,135 u?.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : null,136 u?.anneeConstruction ? `${fr ? "constr." : "built"} ${u.anneeConstruction}` : null,137 u?.superficieTerrainM2138 ? `${fr ? "terrain" : "lot"} ${locNum(u.superficieTerrainM2, lang, { unit: "m²" })}`139 : null,140 ]141 .filter((x): x is string => x != null)142 .join(" · ")}143 </p>144 {/* fourchette visuelle P10-P90 avec position du rôle */}145 <div className="mt-7 max-w-2xl">146 <p className="klabel mb-1">{t("range")}</p>147 <RangeBar148 low={r.low}149 estimate={r.estimate}150 high={r.high}151 role={u?.valeurRole ?? null}152 lang={lang}153 />154 </div>155 {u && (156 <div className="mt-7 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 ? "Format professionnel (banque)" : "Professional format (bank)"} ↓171 </a>172 </div>173 )}174 </div>175176 {/* colonne de lecture : confiance + rôle + écart, en cellules de données */}177 <aside className="lg:border-l lg:border-[var(--line)] lg:pl-10">178 <div className="flex items-start justify-between gap-6 lg:block">179 <ConfidenceDial180 pct={r.confidencePct}181 level={r.confidenceLevel}182 label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`}183 />184 <dl className="grid flex-1 grid-cols-1 gap-x-6 sm:grid-cols-2 lg:mt-6 lg:grid-cols-1">185 {u?.valeurRole != null && (186 <div className="border-t border-[var(--line)] py-3">187 <dt className="klabel">{t("assessed")}</dt>188 <dd className="vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] text-ink-2">189 {fmt(u.valeurRole, lang)}190 </dd>191 </div>192 )}193 {u?.valeurRole != null && u.valeurRole > 0 && r.estimate > 0 && (194 <div className="border-t border-[var(--line)] py-3">195 <dt className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</dt>196 <dd197 className={`vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] ${198 r.estimate >= u.valeurRole ? "text-ink" : "text-[var(--danger)]"199 }`}200 >201 {r.estimate >= u.valeurRole ? "+" : ""}202 {((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %203 </dd>204 </div>205 )}206 </dl>207 </div>208 </aside>209 </div>210 <p className="vp-mono mt-7 border-t border-[var(--line)] pt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">211 {t("disclaimer")}212 </p>213 </section>214215 {/* ================= 01 — portrait du registre ================= */}216 {u && s && (217 <section>218 <Sect219 num="01"220 kicker={fr ? "Registre officiel" : "Official record"}221 title={fr ? "Portrait de la propriété" : "Property portrait"}222 />223 <div className="mt-6 grid gap-x-10 gap-y-8 lg:grid-cols-3 lg:divide-x lg:divide-[var(--line)]">224 {/* Bâtiment */}225 <div className="rise" style={{ animationDelay: "60ms" }}>226 <p className="klabel">{fr ? "Bâtiment" : "Building"}</p>227 <div className="flex justify-center py-2">228 <BuildingGlyph229 floors={s.nbEtages}230 dwellings={u.nbLogements}231 genre={s.genreConstruction}232 yearBuilt={u.anneeConstruction}233 floorAreaM2={u.aireEtagesM2}234 lang={lang}235 />236 </div>237 <EraLine year={u.anneeConstruction} lang={lang} />238 <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2">239 <Chip k={fr ? "Aire d'étages" : "Floor area"} v={u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} />240 <Chip k={fr ? "Étages × logements" : "Storeys × dwellings"} v={`${s.nbEtages ?? "—"} × ${u.nbLogements ?? "—"}`} />241 <Chip k={fr ? "Genre" : "Style"} v={s.genreConstruction ?? "—"} />242 <Chip k={fr ? "Lien physique" : "Physical link"} v={s.lienPhysique ?? "—"} />243 </div>244 </div>245 {/* Terrain */}246 <div className="rise lg:pl-10" style={{ animationDelay: "140ms" }}>247 <p className="klabel">{fr ? "Terrain — à l'échelle" : "Lot — to scale"}</p>248 <div className="flex justify-center py-2">249 <LotDiagram250 areaM2={u.superficieTerrainM2}251 frontageM={s.frontTerrainM}252 footprintM2={253 u.aireEtagesM2 && s.nbEtages ? u.aireEtagesM2 / Math.max(1, s.nbEtages) : u.aireEtagesM2254 }255 lang={lang}256 isCondo={!!s.apt || (u.nbLogements != null && u.nbLogements >= 1 && !u.superficieTerrainM2)}257 />258 </div>259 <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2">260 <Chip k={fr ? "Superficie" : "Area"} v={u.superficieTerrainM2 ? locNum(u.superficieTerrainM2, lang, { unit: "m²" }) : "—"} />261 <Chip k={fr ? "Frontage" : "Frontage"} v={s.frontTerrainM ? locNum(s.frontTerrainM, lang, { unit: "m" }) : "—"} />262 <Chip k="CUBF" v={s.cubf ?? "—"} />263 <Chip k={fr ? "Usage" : "Use"} v={s.cubfLibelle ?? "—"} />264 </div>265 </div>266 {/* Valeur au rôle */}267 <div className="rise lg:pl-10" style={{ animationDelay: "220ms" }}>268 <p className="klabel">{fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}</p>269 <p className="vp-display py-2 text-[30px] font-bold tracking-[-0.025em]">270 {fmt(u.valeurRole, lang)}271 </p>272 <ValueSplit273 land={s.valeurTerrain}274 building={s.valeurBatiment}275 previous={s.valeurAnterieure}276 total={u.valeurRole}277 lang={lang}278 labels={{279 land: fr ? "Terrain" : "Land",280 building: fr ? "Bâtiment" : "Building",281 previous: fr ? "Rôle antérieur" : "Previous roll",282 }}283 />284 <div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2">285 <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} />286 <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} />287 </div>288 </div>289 </div>290 {/* registre brut complet */}291 <details className="mt-6 border-t border-[var(--line)] pt-4">292 <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-accent-deep hover:text-accent">293 ▸ {fr ? "Toutes les données du registre (brutes)" : "All registry data (raw)"}294 </summary>295 <div className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">296 <Chip k={fr ? "Année construction" : "Year built"} v={u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? (fr ? " (estimée)" : " (est.)") : ""}` : "—"} />297 <Chip k={fr ? "Locaux non résid." : "Non-res. units"} v={s.nbLocauxNonResid ?? "—"} />298 <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? "—"} />299 <Chip k={fr ? "Unité de voisinage" : "Neighbourhood"} v={s.uniteVoisinage || "—"} />300 <Chip k={fr ? "Arrondissement" : "Borough"} v={s.arrond || "—"} />301 <Chip k={fr ? "Adresses sur l'unité" : "Addresses"} v={s.nAdresses ?? "—"} />302 <Chip k={fr ? "Valeur terrain" : "Land value"} v={fmt(s.valeurTerrain, lang)} />303 <Chip k={fr ? "Valeur bâtiment" : "Building value"} v={fmt(s.valeurBatiment, lang)} />304 <Chip k={fr ? "Rôle antérieur" : "Previous roll"} v={fmt(s.valeurAnterieure, lang)} />305 <Chip k="ID provincial" v={u.id} />306 </div>307 </details>308 </section>309 )}310311 {/* ================= 02 — transparence du calcul ================= */}312 <section>313 <Sect num="02" kicker={fr ? "Transparence" : "Transparency"} title={t("whyTitle")} />314 <div className="mt-6 grid gap-x-10 gap-y-6 sm:grid-cols-2 sm:divide-x sm:divide-[var(--line)]">315 <div className="rise">316 <p className="klabel">{t("modelPart")}</p>317 <p className="vp-display mt-2 text-[clamp(30px,4vw,40px)] font-bold leading-none tracking-[-0.03em]">318 {r.modelEstimate != null ? (319 <CountUp value={r.modelEstimate} format={moneyFmt} />320 ) : (321 "—"322 )}323 </p>324 <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.06em] text-ink-2">325 {t("weightModel")} : <b className="text-ink">{(r.modelWeight * 100).toFixed(0)} %</b>326 </p>327 </div>328 <div className="rise sm:pl-10" style={{ animationDelay: "100ms" }}>329 <p className="klabel">{t("compsPart")}</p>330 <p className="vp-display mt-2 text-[clamp(30px,4vw,40px)] font-bold leading-none tracking-[-0.03em]">331 {r.compsEstimate != null ? (332 <CountUp value={r.compsEstimate} format={moneyFmt} />333 ) : (334 "—"335 )}336 </p>337 <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.06em] text-ink-2">338 {t("weightComps")} : <b className="text-ink">{((1 - r.modelWeight) * 100).toFixed(0)} %</b> · {r.nCompsUsed} comps339 {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}340 </p>341 </div>342 </div>343 </section>344345 {/* ================= 03 — marché local ================= */}346 {ctx && (ctx.index.length >= 6 || ctx.sector || ctx.muniPercentile != null) && (347 <section>348 <Sect349 num="03"350 kicker={fr ? "Contexte de marché" : "Market context"}351 title={fr ? "Le marché autour de cette propriété" : "The market around this property"}352 />353 <div className="mt-6 grid items-start gap-x-10 gap-y-8 lg:grid-cols-[1.4fr_1fr]">354 {/* trajectoire du marché */}355 {ctx.index.length >= 6 && (356 <div className="rise">357 <div className="flex flex-wrap items-center justify-between gap-2">358 <p className="klabel">359 {fr360 ? `Indice de marché — ${t(ctx.typeProp as TKey)} (base 100 en ${ctx.index[0].month.slice(0, 4)})`361 : `Market index — ${t(ctx.typeProp as TKey)} (base 100 in ${ctx.index[0].month.slice(0, 4)})`}362 </p>363 <p className="vp-mono flex gap-4 text-[11px] text-ink-2">364 {ctx.index12mPct != null && (365 <span>366 12 {fr ? "mois" : "mo"}{" "}367 <b className={ctx.index12mPct >= 0 ? "text-ink" : "text-[var(--danger)]"}>368 {ctx.index12mPct >= 0 ? "+" : ""}369 {ctx.index12mPct.toFixed(1)} %370 </b>371 </span>372 )}373 {ctx.indexSince2021Pct != null && (374 <span>375 {ctx.index[0].month.slice(0, 4)}→{" "}376 <b className={ctx.indexSince2021Pct >= 0 ? "text-ink" : "text-[var(--danger)]"}>377 {ctx.indexSince2021Pct >= 0 ? "+" : ""}378 {ctx.indexSince2021Pct.toFixed(0)} %379 </b>380 </span>381 )}382 </p>383 </div>384 <div className="mt-3">385 <MarketIndexChart series={ctx.index} lang={lang} />386 </div>387 <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">388 {fr389 ? "Indice utilisé pour l'ajustement « marché » des comparables — même donnée, même calcul."390 : "Index used for the comparables' market adjustment — same data, same math."}391 </p>392 </div>393 )}394 <div className="flex flex-col gap-7 lg:border-l lg:border-[var(--line)] lg:pl-10">395 {/* ventes du secteur */}396 {ctx.sector && (397 <div className="rise" style={{ animationDelay: "80ms" }}>398 <p className="klabel">399 {fr400 ? `Ventes réelles — rayon ${ctx.sector.radiusKm} km · 12 mois`401 : `Real sales — ${ctx.sector.radiusKm} km radius · 12 months`}402 </p>403 <div className="mt-2 grid grid-cols-3 gap-x-4">404 <div className="kv-cell">405 <p className="k">{fr ? "Ventes" : "Sales"}</p>406 <p className="v">{ctx.sector.nSales12m}</p>407 </div>408 <div className="kv-cell">409 <p className="k">{fr ? "Prix médian" : "Median price"}</p>410 <p className="v">{ctx.sector.medianPrice != null ? fmt(Math.round(ctx.sector.medianPrice), lang) : "—"}</p>411 </div>412 <div className="kv-cell">413 <p className="k">$/m² {fr ? "médian" : "median"}</p>414 <p className="v">415 {ctx.sector.medianPpm2 != null416 ? locNum(Math.round(ctx.sector.medianPpm2), lang, { maxFrac: 0 })417 : "—"}418 </p>419 </div>420 </div>421 {ctx.sector.medianPrice != null && r.estimate > 0 && (422 <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-2">423 {fr ? "Cette propriété" : "This property"} :{" "}424 <b className="text-ink">425 {r.estimate >= ctx.sector.medianPrice ? "+" : "−"}426 {Math.abs((r.estimate / ctx.sector.medianPrice - 1) * 100).toFixed(0)} %427 </b>{" "}428 {fr ? "vs médiane du secteur" : "vs sector median"}429 </p>430 )}431 </div>432 )}433 {/* position dans la municipalité */}434 {ctx.muniPercentile != null && u?.municipalite && (435 <div className="rise" style={{ animationDelay: "160ms" }}>436 <p className="klabel">437 {fr ? `Position dans ${u.municipalite}` : `Position in ${u.municipalite}`}438 </p>439 <div className="mt-3">440 <PercentileBar pct={ctx.muniPercentile} lang={lang} />441 </div>442 <p className="mt-3 text-[13px] text-ink-2">443 {fr ? (444 <>445 Vaut plus que{" "}446 <b className="text-ink">{Math.round(ctx.muniPercentile)} %</b> des{" "}447 {ctx.muniTotal != null ? locNum(ctx.muniTotal, lang, { maxFrac: 0 }) : ""}{" "}448 propriétés estimées de la municipalité.449 </>450 ) : (451 <>452 Worth more than{" "}453 <b className="text-ink">{Math.round(ctx.muniPercentile)} %</b> of the{" "}454 {ctx.muniTotal != null ? locNum(ctx.muniTotal, lang, { maxFrac: 0 }) : ""}{" "}455 estimated properties in the municipality.456 </>457 )}458 </p>459 </div>460 )}461 </div>462 </div>463 </section>464 )}465466 {/* ================= 04 — historique ================= */}467 {u && u.history.some((h) => h.value != null) && (468 <section>469 <Sect num="04" kicker="2021 → 2026" title={t("history")} />470 <div className="mt-6 max-w-3xl">471 <div className="flex flex-col gap-2">472 {u.history.map((h, i) => {473 const prev = i > 0 ? u.history[i - 1].value : null;474 const yoy =475 h.value != null && prev != null && prev > 0476 ? ((h.value / prev - 1) * 100)477 : null;478 return (479 <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">480 <span className="vp-mono text-[12px] font-bold">{h.year}</span>481 <span className="hbar-track">482 <span483 className="hbar-fill grow-x"484 style={{485 width: `${((h.value ?? 0) / histMax) * 100}%`,486 animationDelay: `${i * 90}ms`,487 background: h.year === 2026 ? "var(--accent)" : undefined,488 }}489 />490 </span>491 <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">492 {fmt(h.value, lang)}493 {yoy != null && (494 <span className={`text-[10px] font-medium ${yoy >= 0 ? "text-ink-2" : "text-[var(--danger)]"}`}>495 {yoy >= 0 ? "+" : ""}496 {yoy.toFixed(0)} %497 </span>498 )}499 </span>500 </div>501 );502 })}503 </div>504 </div>505 </section>506 )}507508 {/* ================= 05 — comparables ================= */}509 <section>510 <Sect511 num="05"512 kicker={`${r.nCompsUsed} ${fr ? "ventes réelles" : "real sales"}`}513 title={t("compsTitle")}514 />515 <div className="mt-6 grid items-start gap-x-8 gap-y-6 lg:grid-cols-[1.05fr_1fr]">516 <div className="overflow-hidden border border-[var(--line-strong)]">517 <RadarMap518 subject={{519 lat: u?.lat ?? r.comps[0]?.lat ?? 46.81,520 lng: u?.lng ?? r.comps[0]?.lng ?? -71.21,521 label: u?.adresse ?? (fr ? "Propriété estimée" : "Subject property"),522 }}523 comps={r.comps.map((c) => ({524 id: c.id,525 lat: c.lat,526 lng: c.lng,527 label: `${c.street ?? ""}, ${c.city ?? ""}`,528 price: fmt(c.amount, lang),529 adjusted: fmt(Math.round(c.adjustedPrice), lang),530 date: c.date,531 distanceM: c.distanceM,532 weight: c.weight,533 }))}534 highlight={activeComp}535 onHover={setActiveComp}536 />537 </div>538 <div className="src-wrap max-h-[560px] overflow-y-auto">539 <table className="src-table">540 <thead className="sticky top-0 z-10 bg-paper">541 <tr>542 <th>#</th>543 <th>{fr ? "Vente" : "Sale"}</th>544 <th className="text-right">{t("adjusted")}</th>545 </tr>546 </thead>547 <tbody>548 {r.comps.map((c, i) => (549 <tr550 key={c.id}551 onMouseEnter={() => setActiveComp(c.id)}552 onMouseLeave={() => setActiveComp(null)}553 className={activeComp === c.id ? "!bg-surface-2" : ""}554 >555 <td>556 <span557 className={`vp-mono inline-flex h-6 w-6 items-center justify-center text-[11px] font-bold ${558 activeComp === c.id ? "bg-accent text-white" : "bg-ink text-paper"559 }`}560 >561 {i + 1}562 </span>563 </td>564 <td>565 <p className="font-semibold leading-tight">566 {c.street}, {c.city}567 </p>568 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">569 {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "}570 {t("sold")} {fmt(c.amount, lang)}571 {c.floorArea && c.floorArea > 20572 ? ` (${locNum(Math.round(c.amount / c.floorArea), lang, { maxFrac: 0 })} $/m²)`573 : ""}574 </p>575 {(c.floorArea || c.yearBuilt) && (576 <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">577 {[578 c.floorArea ? locNum(c.floorArea, lang, { unit: "m²" }) : null,579 c.yearBuilt ? `${fr ? "constr." : "built"} ${c.yearBuilt}` : null,580 ]581 .filter(Boolean)582 .join(" · ")}583 </p>584 )}585 <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2">586 {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "}587 {t("adjAge")} {signed(c.adjAge, lang)}588 </p>589 </td>590 <td className="vp-display text-right font-bold">591 {fmt(Math.round(c.adjustedPrice), lang)}592 <p className="vp-mono text-[10px] font-normal text-ink-3">593 {t("weight")} {(c.weight * 100).toFixed(0)} %594 </p>595 </td>596 </tr>597 ))}598 </tbody>599 </table>600 </div>601 </div>602 </section>603604 {/* ================= alerte de valeur ================= */}605 <section className="bleed vp-dark">606 <div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-12">607 <span className="sec-num !text-[var(--accent-bright)]">608 06 — {fr ? "Suivi" : "Watch"}609 </span>610 <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase tracking-[-0.025em] text-[var(--paper-on-ink)]">611 {t("leadTitle")}612 </h2>613 <div className="mt-5 max-w-xl">614 <LeadForm unitId={u?.id ?? null} estimate={r.estimate} />615 </div>616 </div>617 </section>618 </div>619 );620}621