SPB Git forge

spb/vrai-prix

Public

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

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
32.6 KB · 599 lines tsx
Raw Blame History
1// Vrai-Prix — fiche d'une propriété à vendre : toute l'information de l'annonce,2// la mesure Vrai-Prix déjà calculée, le portrait du rôle et l'accès à l'atelier.3// Ordre du DOM = ordre visuel, identique mobile et desktop (standard Groupe Ka) :4// galerie → prix/adresse/caractéristiques → description → inclusions → détails5// → analyses (Vrai-Prix, historique de prix) → registre → voisines.6"use client";7import { useEffect, useState } from "react";8import Link from "next/link";9import { useLang } from "@/components/LangContext";10import { RangeBar } from "@/components/MetricViz";11import ListingCardView, { groupLabel, RatioPill } from "./ListingCardView";12import { dateFr, epochDate, m2, money, num, pct, sqftToM2 } from "./fmt";13import type { EvalRow, ListingCard, PricePoint, TypeGroupKey } from "@/lib/immoka";1415export interface ListingPageData {16  listing: {17    uid: string;18    source: string;19    url: string | null;20    title: string | null;21    address: string | null;22    sector: string | null;23    city: string | null;24    region: string | null;25    propertyType: string | null;26    price: number;27    bedrooms: number | null;28    bathrooms: number | null;29    powderRooms: number | null;30    areaSqft: number | null;31    lotSqft: number | null;32    yearBuilt: number | null;33    mls: string | null;34    brokerName: string | null;35    agency: string | null;36    description: string | null;37    lat: number | null;38    lng: number | null;39    firstSeen: number | null;40    lastSeen: number | null;41  };42  group: TypeGroupKey;43  images: string[];44  features: string[];45  details: Record<string, unknown>;46  priceLog: PricePoint[];47  eval: EvalRow | null;48  unit: {49    id: string;50    adresse: string | null;51    apt: string | null;52    municipalite: string | null;53    typeProp: string;54    anneeConstruction: number | null;55    aireEtagesM2: number | null;56    superficieTerrainM2: number | null;57    nbEtages: number | null;58    nbLogements: number | null;59    genreConstruction: string | null;60    lienPhysique: string | null;61    valeurTerrain: number | null;62    valeurBatiment: number | null;63    valeurRole: number | null;64    est2026: number | null;65    p10: number | null;66    p90: number | null;67    history: { year: number; value: number | null }[];68  } | null;69  nearby: ListingCard[];70  /** analyse IA du bâtiment déjà complétée (chantier Coût) — null si aucune */71  aiSummary?: { analysisId: string; version: number; confidence: number | null; rcn: number | null; costValue: number | null; structure: string | null; foundation: string | null; roof: string | null; quality: string | null; condition: string | null; completedAt: string | null } | null;72}7374const SOURCE_LABELS: Record<string, string> = {75  remax_quebec: "RE/MAX Québec",76  duproprio: "DuProprio",77  kijiji: "Kijiji",78  via_capitale: "Via Capitale",79  sutton: "Sutton",80  lespac: "LesPAC",81  fb_marketplace: "Facebook Marketplace",82  proprio_direct: "Proprio Direct",83  royal_lepage: "Royal LePage",84  engel_volkers: "Engel & Völkers",85  sothebys_quebec: "Sotheby's",86  pmml: "PMML",87};88const sourceLabel = (s: string) => SOURCE_LABELS[s] ?? s.replace(/_/g, " ").replace(/\bag\b/g, "").trim();8990const HIDDEN_DETAIL_KEYS = new Set(["img_audited", "transaction", "pieces", "remarques_proprio", "prix_pi2", "prix_m2"]);9192function Sect({ num: n, kicker, title }: { num: string; kicker: string; title: string }) {93  return (94    <header className="sec">95      <span className="sec-num">96        {n} — {kicker}97      </span>98      <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{title}</h2>99    </header>100  );101}102103function Chip({ k, v }: { k: string; v: string | number | null | undefined }) {104  return (105    <div className="kv-cell">106      <p className="k">{k}</p>107      <p className="v break-words">{v == null || v === "" ? "—" : v}</p>108    </div>109  );110}111112/* --------------------------------- galerie --------------------------------- */113function Gallery({ images, alt }: { images: string[]; alt: string }) {114  const [i, setI] = useState(0);115  const [open, setOpen] = useState(false);116  useEffect(() => {117    if (!open) return;118    const onKey = (e: KeyboardEvent) => {119      if (e.key === "Escape") setOpen(false);120      if (e.key === "ArrowRight") setI((x) => (x + 1) % images.length);121      if (e.key === "ArrowLeft") setI((x) => (x - 1 + images.length) % images.length);122    };123    window.addEventListener("keydown", onKey);124    document.body.style.overflow = "hidden";125    return () => {126      window.removeEventListener("keydown", onKey);127      document.body.style.overflow = "";128    };129  }, [open, images.length]);130131  if (!images.length)132    return (133      <div className="vp-mono flex aspect-[16/7] w-full items-center justify-center border border-[var(--line)] bg-surface-2 text-[11px] uppercase tracking-[0.1em] text-ink-3">134        Aucune photo135      </div>136    );137  const cur = images[Math.min(i, images.length - 1)];138  return (139    <div>140      <div className="grid gap-2 md:grid-cols-[3fr_1fr]">141        <button type="button" onClick={() => setOpen(true)} className="relative block aspect-[16/10] w-full overflow-hidden bg-surface-2 md:aspect-auto md:h-[440px]">142          {/* eslint-disable-next-line @next/next/no-img-element */}143          <img src={cur} alt={alt} referrerPolicy="no-referrer" className="h-full w-full object-cover" />144          <span className="vp-mono absolute bottom-2 right-2 bg-ink/80 px-2 py-1 text-[10px] text-paper">145            {i + 1} / {images.length}146          </span>147        </button>148        <div className="flex gap-2 overflow-x-auto md:h-[440px] md:flex-col md:overflow-y-auto">149          {images.slice(0, 40).map((src, k) => (150            <button151              key={src + k}152              type="button"153              onClick={() => setI(k)}154              className={`relative aspect-[4/3] w-24 shrink-0 overflow-hidden border-2 md:w-full ${k === i ? "border-accent" : "border-transparent"}`}155            >156              {/* eslint-disable-next-line @next/next/no-img-element */}157              <img src={src} alt="" loading="lazy" referrerPolicy="no-referrer" className="h-full w-full object-cover" />158            </button>159          ))}160        </div>161      </div>162      {open && (163        <div className="vp-dark fixed inset-0 z-[var(--z-modal,900)] flex flex-col" role="dialog" aria-modal="true">164          <div className="flex h-12 shrink-0 items-center justify-between px-4">165            <span className="vp-mono text-[11px] uppercase tracking-[0.1em]">166              {i + 1} / {images.length}167            </span>168            <button type="button" onClick={() => setOpen(false)} className="vp-mono h-10 w-10 text-[16px]" aria-label="Fermer">169              ✕170            </button>171          </div>172          <div className="relative flex flex-1 items-center justify-center overflow-hidden px-2 pb-4">173            {/* eslint-disable-next-line @next/next/no-img-element */}174            <img src={cur} alt={alt} referrerPolicy="no-referrer" className="max-h-full max-w-full object-contain" />175            <button type="button" onClick={() => setI((x) => (x - 1 + images.length) % images.length)} className="vp-mono absolute left-2 top-1/2 h-12 w-12 -translate-y-1/2 bg-ink/60 text-[20px]" aria-label="Précédente">176              ‹177            </button>178            <button type="button" onClick={() => setI((x) => (x + 1) % images.length)} className="vp-mono absolute right-2 top-1/2 h-12 w-12 -translate-y-1/2 bg-ink/60 text-[20px]" aria-label="Suivante">179              ›180            </button>181          </div>182        </div>183      )}184    </div>185  );186}187188/* ---------------------------- historique de prix ---------------------------- */189function PriceHistory({ log, current, lastSeen, fr, lang }: { log: PricePoint[]; current: number; lastSeen: number | null; fr: boolean; lang: string }) {190  const pts = [...log];191  if (!pts.length || pts[pts.length - 1].price !== current) pts.push({ ts: lastSeen ?? pts[pts.length - 1]?.ts ?? 0, price: current });192  // déduplique les prix consécutifs identiques193  const series = pts.filter((p, i) => i === 0 || p.price !== pts[i - 1].price);194  if (series.length < 2)195    return <p className="text-[13px] text-ink-3">{fr ? "Aucun changement de prix observé depuis la première capture." : "No price change observed since first capture."}</p>;196  const first = series[0].price;197  return (198    <ol className="space-y-1.5">199      {series.map((p, i) => {200        const prev = i > 0 ? series[i - 1].price : null;201        const d = prev ? ((p.price - prev) / prev) * 100 : null;202        return (203          <li key={p.ts} className="flex flex-wrap items-baseline gap-x-4 border-b border-[var(--line-soft)] py-1.5 text-[13.5px]">204            <span className="vp-mono w-28 text-[11px] uppercase tracking-[0.05em] text-ink-3">{epochDate(p.ts, lang)}</span>205            <span className="vp-display font-bold">{money(p.price, lang)}</span>206            {d != null && <span className={`vp-mono text-[11px] ${d < 0 ? "text-[var(--danger)]" : "text-ink-2"}`}>{pct(d, lang, 1)}</span>}207          </li>208        );209      })}210      <li className="vp-mono pt-1 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">211        {fr ? "Cumul depuis la mise en marché" : "Since listing"} : {pct(((current - first) / first) * 100, lang, 1)}212      </li>213    </ol>214  );215}216217/* ------------------------------- composant -------------------------------- */218export default function ListingView({ data }: { data: ListingPageData }) {219  const { lang } = useLang();220  const fr = lang === "fr";221  const l = data.listing;222  const e = data.eval;223  const u = data.unit;224  const area = sqftToM2(l.areaSqft);225  const lot = sqftToM2(l.lotSqft);226  const ppm2 = area ? Math.round(l.price / area) : null;227  const where = [l.sector, l.city].filter(Boolean).join(" · ");228  const alt = [l.address, l.city].filter(Boolean).join(", ");229230  const detailEntries = Object.entries(data.details).filter(231    ([k, v]) => !HIDDEN_DETAIL_KEYS.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim() !== ""232  ) as [string, string | number][];233  const pieces = Array.isArray(data.details.pieces) ? (data.details.pieces as Record<string, string>[]) : [];234  const remarques = typeof data.details.remarques_proprio === "string" ? (data.details.remarques_proprio as string) : null;235236  const methods: { key: string; fr: string; en: string; v: number | null; main?: boolean }[] = e237    ? [238        { key: "est", fr: "Vrai-Prix — hybride 65/35 (mesure principale)", en: "Vrai-Prix — 65/35 hybrid (main measure)", v: e.est, main: true },239        { key: "model", fr: "Modèle hédonique (LightGBM)", en: "Hedonic model (LightGBM)", v: e.model_est },240        { key: "comps", fr: "Comparables ajustés (moteur)", en: "Adjusted comparables (engine)", v: e.comps_est },241        { key: "cost", fr: "Méthode du coût calibrée", en: "Calibrated cost approach", v: e.cost_est },242        { key: "role", fr: "Rôle indexé (ratios IAAO)", en: "Indexed assessment (IAAO ratios)", v: e.role_est },243        { key: "ens", fr: "Ensemble (médiane des méthodes)", en: "Ensemble (median of methods)", v: e.ens_est },244      ]245    : [];246247  return (248    <div className="space-y-12 py-8">249      {/* ================= fil d'Ariane ================= */}250      <nav className="vp-mono flex flex-wrap items-center gap-2 border-b-2 border-ink pb-2.5 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">251        <Link href="/" className="text-ink-2 hover:text-accent-deep">252          Vrai&nbsp;Prix253        </Link>254        <span aria-hidden="true">/</span>255        <Link href="/a-vendre" className="text-ink-2 hover:text-accent-deep">256          {fr ? "À vendre" : "For sale"}257        </Link>258        <span aria-hidden="true">/</span>259        <span className="truncate">{l.uid}</span>260      </nav>261262      {/* ================= 1. galerie ================= */}263      <section className="rise">264        <Gallery images={data.images} alt={alt} />265      </section>266267      {/* ================= 2. prix + adresse + caractéristiques ================= */}268      <section className="grid gap-x-12 gap-y-8 lg:grid-cols-[1.5fr_1fr]">269        <div className="min-w-0">270          <p className="vp-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-3">271            {groupLabel(data.group, fr)}272            {l.propertyType && l.propertyType !== groupLabel(data.group, fr) ? ` · ${l.propertyType}` : ""}273            {" · "}274            {sourceLabel(l.source)}275            {l.mls ? ` · MLS ${l.mls}` : ""}276          </p>277          <h1 className="vp-display mt-2 text-[clamp(20px,2.8vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">278            {l.address || l.title || (fr ? "Propriété à vendre" : "Property for sale")}279            {where ? <span className="text-ink-3"> · {where}</span> : null}280          </h1>281          <p className="klabel mt-5">{fr ? "Prix demandé" : "Asking price"}</p>282          <p className="vp-display mt-1 text-[clamp(44px,8vw,84px)] font-bold leading-none tracking-[-0.045em]">{money(l.price, lang)}</p>283          <div className="mt-3 flex flex-wrap items-center gap-3">284            <RatioPill ratio={e?.ratio ?? null} fr={fr} size="md" />285            {e?.est != null && (286              <span className="vp-mono text-[11px] uppercase tracking-[0.06em] text-ink-2">287                {fr ? "Mesure Vrai-Prix" : "Vrai-Prix measure"} <b className="text-ink">{money(e.est, lang)}</b>288                {e.confidence ? ` · ${fr ? "confiance" : "confidence"} ${e.confidence}` : ""}289              </span>290            )}291          </div>292          <p className="vp-mono mt-4 text-[11px] uppercase tracking-[0.06em] text-ink-2">293            {[294              l.bedrooms != null ? `${l.bedrooms} ${fr ? "chambres" : "bedrooms"}` : null,295              l.bathrooms != null ? `${l.bathrooms} ${fr ? "salles de bain" : "bathrooms"}` : null,296              l.powderRooms ? `${l.powderRooms} ${fr ? "salle d'eau" : "powder room"}` : null,297              area ? `${num(area, lang)} m² (${num(l.areaSqft, lang)} pi²)` : null,298              ppm2 ? `${num(ppm2, lang)} $/m²` : null,299              lot ? `${fr ? "terrain" : "lot"} ${num(lot, lang)} m²` : null,300              l.yearBuilt ? `${fr ? "constr." : "built"} ${l.yearBuilt}` : null,301            ]302              .filter((x): x is string => x != null)303              .join("  ·  ")}304          </p>305          <div className="mt-7 flex flex-wrap gap-2.5">306            <Link href={`/a-vendre/${encodeURIComponent(l.uid)}/evaluer`} className="btn btn-accent">307              {fr ? "Faire mon évaluation" : "Build my valuation"} →308            </Link>309            {u && (310              <Link href={`/estimation/${encodeURIComponent(u.id)}`} className="btn btn-ghost">311                {fr ? "Rapport de valeur Vrai-Prix" : "Vrai-Prix value report"}312              </Link>313            )}314            <Link href={`/a-vendre/${encodeURIComponent(l.uid)}/analyse-cout`} className="btn btn-ghost" title={fr ? "Des photos à la méthode du coût — Claude Sonnet 5" : "From photos to the cost approach — Claude Sonnet 5"}>315              {data.aiSummary ? (fr ? "Coût IA disponible" : "AI cost available") : fr ? "Analyser le coût avec l'IA" : "Analyse the cost with AI"} →316            </Link>317            {u && (318              <Link href={`/cout?property=${encodeURIComponent(u.id)}`} className="btn btn-ghost">319                {fr ? "Voir la méthode du coût" : "See the cost approach"} →320              </Link>321            )}322            {l.url && (323              <a href={l.url} target="_blank" rel="noopener noreferrer nofollow" className="btn btn-ghost">324                {fr ? "Annonce originale" : "Original listing"} ↗325              </a>326            )}327          </div>328        </div>329        <aside className="lg:border-l lg:border-[var(--line)] lg:pl-10">330          <dl className="grid grid-cols-2 gap-x-6 lg:grid-cols-1">331            {e?.est != null && (332              <div className="border-t border-[var(--line)] py-3">333                <dt className="klabel">{fr ? "Estimation Vrai-Prix" : "Vrai-Prix estimate"}</dt>334                <dd className="vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em]">{money(e.est, lang)}</dd>335                <dd className="vp-mono text-[10.5px] uppercase tracking-[0.05em] text-ink-3">336                  {money(e.low, lang)} – {money(e.high, lang)}337                </dd>338              </div>339            )}340            {e?.diff != null && (341              <div className="border-t border-[var(--line)] py-3">342                <dt className="klabel">{fr ? "Prix demandé − estimation" : "Asking − estimate"}</dt>343                <dd className={`vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] ${e.diff > 0 ? "text-ink" : "text-[var(--danger)]"}`}>344                  {e.diff >= 0 ? "+" : "−"}345                  {money(Math.abs(e.diff), lang)}346                </dd>347              </div>348            )}349            {(u?.valeurRole ?? e?.valeur_role) != null && (350              <div className="border-t border-[var(--line)] py-3">351                <dt className="klabel">{fr ? "Évaluation municipale (rôle)" : "Municipal assessment (roll)"}</dt>352                <dd className="vp-display mt-0.5 text-[22px] font-bold tracking-[-0.02em] text-ink-2">{money(u?.valeurRole ?? e?.valeur_role, lang)}</dd>353                {(u?.valeurRole ?? e?.valeur_role)! > 0 && (354                  <dd className="vp-mono text-[10.5px] uppercase tracking-[0.05em] text-ink-3">355                    {fr ? "prix / rôle" : "price / roll"} {(l.price / (u?.valeurRole ?? e?.valeur_role)!).toLocaleString(fr ? "fr-CA" : "en-CA", { maximumFractionDigits: 2 })}356                  </dd>357                )}358              </div>359            )}360            <div className="border-t border-[var(--line)] py-3">361              <dt className="klabel">{fr ? "En marché depuis" : "On market since"}</dt>362              <dd className="vp-display mt-0.5 text-[16px] font-bold tracking-[-0.02em]">{epochDate(l.firstSeen, lang)}</dd>363              <dd className="vp-mono text-[10.5px] uppercase tracking-[0.05em] text-ink-3">364                {fr ? "vu le" : "seen"} {epochDate(l.lastSeen, lang)}365              </dd>366            </div>367            {(l.brokerName || l.agency) && (368              <div className="border-t border-[var(--line)] py-3">369                <dt className="klabel">{fr ? "Courtier / vendeur" : "Broker / seller"}</dt>370                <dd className="mt-0.5 text-[13.5px] font-semibold">{l.brokerName || "—"}</dd>371                {l.agency && l.agency !== l.brokerName && <dd className="text-[12px] text-ink-2">{l.agency}</dd>}372              </div>373            )}374          </dl>375        </aside>376      </section>377378      {/* ================= 3. description ================= */}379      {(l.description || remarques) && (380        <section>381          <Sect num="01" kicker={fr ? "Annonce" : "Listing"} title={fr ? "Description" : "Description"} />382          <div className="mt-5 max-w-3xl whitespace-pre-line text-[14.5px] leading-relaxed text-ink-2">{l.description || remarques}</div>383          {l.description && remarques && remarques !== l.description && (384            <div className="mt-4 max-w-3xl whitespace-pre-line border-l-2 border-accent pl-4 text-[13.5px] leading-relaxed text-ink-2">{remarques}</div>385          )}386        </section>387      )}388389      {/* ================= 4. inclusions / caractéristiques ================= */}390      {data.features.length > 0 && (391        <section>392          <Sect num="02" kicker={fr ? "Annonce" : "Listing"} title={fr ? "Caractéristiques et inclusions" : "Features and inclusions"} />393          <ul className="mt-5 grid gap-x-8 gap-y-1.5 text-[13.5px] sm:grid-cols-2 lg:grid-cols-3">394            {data.features.map((f, i) => (395              <li key={i} className="flex gap-2 border-b border-[var(--line-soft)] py-1.5">396                <span aria-hidden="true" className="mt-[7px] inline-block h-[5px] w-[5px] shrink-0 bg-accent" />397                <span>{f}</span>398              </li>399            ))}400          </ul>401        </section>402      )}403404      {/* ================= 5. détails structurés ================= */}405      {(detailEntries.length > 0 || pieces.length > 0) && (406        <section>407          <Sect num="03" kicker={fr ? "Annonce" : "Listing"} title={fr ? "Détails pratiques" : "Practical details"} />408          {detailEntries.length > 0 && (409            <div className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-3 lg:grid-cols-4">410              {detailEntries.map(([k, v]) => (411                <Chip key={k} k={k.replace(/_/g, " ")} v={v} />412              ))}413            </div>414          )}415          {pieces.length > 0 && (416            <div className="src-wrap mt-6">417              <table className="src-table">418                <thead>419                  <tr>420                    <th>{fr ? "Pièce" : "Room"}</th>421                    <th>{fr ? "Niveau" : "Level"}</th>422                    <th>{fr ? "Dimensions" : "Dimensions"}</th>423                    <th>{fr ? "Revêtement" : "Flooring"}</th>424                  </tr>425                </thead>426                <tbody>427                  {pieces.map((p, i) => (428                    <tr key={i}>429                      <td className="font-semibold">{p.nom ?? p.name ?? "—"}</td>430                      <td>{p.niveau ?? p.level ?? "—"}</td>431                      <td className="vp-mono text-[12px]">{p.dimensions ?? "—"}</td>432                      <td>{p.revetement ?? p.flooring ?? "—"}</td>433                    </tr>434                  ))}435                </tbody>436              </table>437            </div>438          )}439        </section>440      )}441442      {/* ================= 6. analyses : mesure Vrai-Prix ================= */}443      <section>444        <Sect num="04" kicker={fr ? "Analyse" : "Analysis"} title={fr ? "La mesure Vrai-Prix face au prix demandé" : "The Vrai-Prix measure vs. the asking price"} />445        {e?.est ? (446          <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.4fr_1fr]">447            <div>448              <p className="klabel mb-1">{fr ? "Intervalle de confiance (P10-P90) et prix demandé" : "Confidence interval (P10-P90) and asking price"}</p>449              <RangeBar low={e.low ?? e.est} estimate={e.est} high={e.high ?? e.est} role={l.price} lang={lang} />450              <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">451                {fr ? "Le repère « rôle » de la barre marque ici le prix demandé." : "The “roll” marker on the bar shows the asking price here."}452              </p>453              <div className="src-wrap mt-6">454                <table className="src-table">455                  <thead>456                    <tr>457                      <th>{fr ? "Méthode" : "Method"}</th>458                      <th className="text-right">{fr ? "Valeur" : "Value"}</th>459                      <th className="text-right">{fr ? "vs prix demandé" : "vs asking"}</th>460                    </tr>461                  </thead>462                  <tbody>463                    {methods.map((m) => (464                      <tr key={m.key} className={m.main ? "font-bold" : ""}>465                        <td>{fr ? m.fr : m.en}</td>466                        <td className="vp-mono text-right">{money(m.v, lang)}</td>467                        <td className={`vp-mono text-right ${m.v != null && m.v < l.price ? "text-[var(--danger)]" : ""}`}>468                          {m.v != null ? pct((m.v / l.price - 1) * 100, lang, 1) : "—"}469                        </td>470                      </tr>471                    ))}472                    <tr className="bg-surface-2">473                      <td>{fr ? "Prix demandé" : "Asking price"}</td>474                      <td className="vp-mono text-right">{money(l.price, lang)}</td>475                      <td className="vp-mono text-right">—</td>476                    </tr>477                  </tbody>478                </table>479              </div>480              <p className="mt-4 max-w-3xl text-[13px] leading-relaxed text-ink-2">481                {fr482                  ? "Rappel : l'écart entre la mesure et le prix demandé additionne l'erreur du modèle ET la stratégie d'affichage du vendeur. Le prix demandé n'est pas la valeur marchande ; seule la vente la révèle."483                  : "Reminder: the gap between the measure and the asking price adds up the model's error AND the seller's pricing strategy. The asking price is not market value; only the sale reveals it."}484              </p>485            </div>486            <div className="lg:border-l lg:border-[var(--line)] lg:pl-10">487              <p className="klabel">{fr ? "Historique du prix demandé" : "Asking price history"}</p>488              <div className="mt-3">489                <PriceHistory log={data.priceLog} current={l.price} lastSeen={l.lastSeen} fr={fr} lang={lang} />490              </div>491              <p className="klabel mt-8">{fr ? "Jumelage au rôle" : "Match to the roll"}</p>492              <p className="mt-1 text-[13px] text-ink-2">493                {u494                  ? fr495                    ? `Unité ${u.id} (${u.adresse ?? "—"}) jumelée à ${e.match_m != null ? `${num(e.match_m, lang, 1)} m` : "—"} de l'annonce · ${e.n_comps ?? 0} comparables du moteur · évaluée le ${e.evaluated_at ?? "—"}.`496                    : `Unit ${u.id} (${u.adresse ?? "—"}) matched ${e.match_m != null ? `${num(e.match_m, lang, 1)} m` : "—"} from the listing · ${e.n_comps ?? 0} engine comparables · valued on ${e.evaluated_at ?? "—"}.`497                  : fr498                    ? "Aucune unité du rôle à moins de 250 m."499                    : "No assessment unit within 250 m."}500              </p>501              <Link href={`/a-vendre/${encodeURIComponent(l.uid)}/evaluer`} className="btn btn-primary mt-6">502                {fr ? "Choisir mes comparables et comparer" : "Pick my comparables and compare"} →503              </Link>504            </div>505          </div>506        ) : (507          <div className="mt-6 max-w-3xl text-[14px] leading-relaxed text-ink-2">508            <p>509              {fr510                ? "Cette annonce n'a pas de mesure Vrai-Prix : aucune unité du rôle d'évaluation n'a pu être jumelée à moins de 250 m de ses coordonnées (ou l'annonce n'est pas géolocalisée)."511                : "This listing has no Vrai-Prix measure: no assessment unit could be matched within 250 m of its coordinates (or the listing is not geolocated)."}512            </p>513            {l.lat != null && (514              <Link href={`/a-vendre/${encodeURIComponent(l.uid)}/evaluer`} className="btn btn-primary mt-5">515                {fr ? "Faire quand même mon évaluation par comparables" : "Build my valuation from comparables anyway"} →516              </Link>517            )}518          </div>519        )}520      </section>521522      {/* ================= 6b. analyse technique IA (si déjà calculée) ================= */}523      {data.aiSummary && (524        <section>525          <Sect num="04b" kicker={fr ? "Analyse IA" : "AI analysis"} title={fr ? "Analyse technique IA du bâtiment" : "AI technical analysis of the building"} />526          <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.06em] text-ink-3">527            Claude Sonnet 5 · v{data.aiSummary.version}{data.aiSummary.confidence != null ? ` · ${fr ? "confiance" : "confidence"} ${Math.round(data.aiSummary.confidence)} %` : ""}{data.aiSummary.completedAt ? ` · ${dateFr(data.aiSummary.completedAt, lang)}` : ""}528          </p>529          <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-3 lg:grid-cols-6">530            <Chip k={fr ? "Structure" : "Structure"} v={data.aiSummary.structure?.replace(/_/g, " ")} />531            <Chip k={fr ? "Fondation" : "Foundation"} v={data.aiSummary.foundation?.replace(/_/g, " ")} />532            <Chip k={fr ? "Toiture" : "Roof"} v={data.aiSummary.roof?.replace(/_/g, " ")} />533            <Chip k={fr ? "Qualité" : "Quality"} v={data.aiSummary.quality} />534            <Chip k={fr ? "Condition" : "Condition"} v={data.aiSummary.condition?.replace(/_/g, " ")} />535            <Chip k={fr ? "RCN estimé" : "Estimated RCN"} v={money(data.aiSummary.rcn, lang)} />536          </div>537          <p className="mt-3 text-[13px] text-ink-2">538            {fr ? "Inféré par IA à partir des photos et de l'annonce, puis chiffré par le moteur déterministe de la méthode du coût — estimation indicative." : "AI-inferred from the photos and listing, then costed by the deterministic cost-approach engine — indicative estimate."}539            {data.aiSummary.costValue != null ? ` ${fr ? "Indication par le coût" : "Cost approach indication"} : ${money(data.aiSummary.costValue, lang)}.` : ""}540          </p>541          <Link href={`/a-vendre/${encodeURIComponent(l.uid)}/analyse-cout`} className="vp-mono mt-3 inline-block text-[11px] font-bold uppercase tracking-[0.08em] text-accent">542            {fr ? "Voir le profil complet" : "See the full profile"} →543          </Link>544        </section>545      )}546547      {/* ================= 7. registre (unité jumelée) ================= */}548      {u && (549        <section>550          <Sect num="05" kicker={fr ? "Registre officiel" : "Official record"} title={fr ? "Portrait au rôle d'évaluation" : "Assessment roll portrait"} />551          <div className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-4">552            <Chip k={fr ? "Adresse au rôle" : "Address on roll"} v={`${u.adresse ?? "—"}${u.apt ? ` app. ${u.apt}` : ""}`} />553            <Chip k={fr ? "Municipalité" : "Municipality"} v={u.municipalite} />554            <Chip k={fr ? "Type (rôle)" : "Type (roll)"} v={u.typeProp} />555            <Chip k={fr ? "Année de construction" : "Year built"} v={u.anneeConstruction} />556            <Chip k={fr ? "Aire d'étages" : "Floor area"} v={m2(u.aireEtagesM2, lang)} />557            <Chip k={fr ? "Terrain" : "Lot"} v={m2(u.superficieTerrainM2, lang)} />558            <Chip k={fr ? "Étages × logements" : "Storeys × dwellings"} v={`${u.nbEtages ?? "—"} × ${u.nbLogements ?? "—"}`} />559            <Chip k={fr ? "Genre / lien" : "Style / link"} v={[u.genreConstruction, u.lienPhysique].filter(Boolean).join(" · ")} />560            <Chip k={fr ? "Valeur terrain (rôle)" : "Land value (roll)"} v={money(u.valeurTerrain, lang)} />561            <Chip k={fr ? "Valeur bâtiment (rôle)" : "Building value (roll)"} v={money(u.valeurBatiment, lang)} />562            <Chip k={fr ? "Valeur au rôle" : "Assessed value"} v={money(u.valeurRole, lang)} />563            <Chip k={fr ? "Modèle hédonique 2026" : "Hedonic model 2026"} v={money(u.est2026, lang)} />564          </div>565          {area && u.aireEtagesM2 && Math.abs(area - u.aireEtagesM2) / u.aireEtagesM2 > 0.25 && (566            <p className="mt-4 max-w-3xl border-l-2 border-[var(--amber,#c98a1b)] pl-4 text-[13px] leading-relaxed text-ink-2">567              {fr568                ? `Attention : la superficie annoncée (${num(area, lang)} m²) s'écarte de plus de 25 % de l'aire d'étages au rôle (${num(u.aireEtagesM2, lang)} m²). Le rôle mesure l'aire brute des étages hors sous-sol ; l'annonce peut inclure le sous-sol aménagé ou le terrain — ou le jumelage peut être imparfait (condos empilés).`569                : `Note: the advertised area (${num(area, lang)} m²) differs by more than 25% from the roll's floor area (${num(u.aireEtagesM2, lang)} m²). The roll measures gross floor area excluding basement; the listing may include a finished basement or the lot — or the match may be imperfect (stacked condos).`}570            </p>571          )}572          <Link href={`/estimation/${encodeURIComponent(u.id)}`} className="vp-mono mt-4 inline-block text-[11px] font-bold uppercase tracking-[0.08em] text-accent">573            {fr ? "Voir le rapport de valeur complet de cette unité" : "See this unit's full value report"} →574          </Link>575        </section>576      )}577578      {/* ================= 8. voisines à vendre ================= */}579      {data.nearby.length > 0 && (580        <section>581          <Sect num={u ? "06" : "05"} kicker={fr ? "Marché actif" : "Active market"} title={fr ? "À vendre à proximité (même catégorie)" : "For sale nearby (same category)"} />582          <div className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">583            {data.nearby.map((c) => (584              <ListingCardView key={c.uid} c={c} fr={fr} compact />585            ))}586          </div>587        </section>588      )}589590      <p className="vp-mono border-t border-[var(--line)] pt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">591        {fr592          ? `Annonce ${l.uid} — copie Immo-Ka, capturée le ${epochDate(l.lastSeen, lang)}. Photos, textes et prix appartiennent au vendeur ou au courtier ; ils sont reproduits à titre informatif. Estimations Vrai-Prix : statistiques, non certifiées (OEAQ).`593          : `Listing ${l.uid} — Immo-Ka copy, captured ${epochDate(l.lastSeen, lang)}. Photos, texts and prices belong to the seller or broker; reproduced for information purposes. Vrai-Prix estimates: statistical, not certified (OEAQ).`}594        {e?.evaluated_at ? ` · ${fr ? "évaluée le" : "valued on"} ${dateFr(e.evaluated_at, lang)}` : ""}595      </p>596    </div>597  );598}599