Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
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 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