SPB Git

spb/vrai-prix

Public

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

27.7 KB · 594 lines tsx
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
Files · f6acb63c21361daa63f63170d2edfc86171bce42
Menu

Theme