SPB Git

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

28.5 KB · 621 lines tsx
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
Files · d2a75de81caf185050f2d57989b8d9e762d7ffd3
Menu

Theme