// Vrai-Prix — fiche d'une propriété à vendre : toute l'information de l'annonce, // la mesure Vrai-Prix déjà calculée, le portrait du rôle et l'accès à l'atelier. // Ordre du DOM = ordre visuel, identique mobile et desktop (standard Groupe Ka) : // galerie → prix/adresse/caractéristiques → description → inclusions → détails // → analyses (Vrai-Prix, historique de prix) → registre → voisines. "use client"; import { useEffect, useState } from "react"; import Link from "next/link"; import { useLang } from "@/components/LangContext"; import { RangeBar } from "@/components/MetricViz"; import ListingCardView, { groupLabel, RatioPill } from "./ListingCardView"; import { dateFr, epochDate, m2, money, num, pct, sqftToM2 } from "./fmt"; import type { EvalRow, ListingCard, PricePoint, TypeGroupKey } from "@/lib/immoka"; export interface ListingPageData { listing: { uid: string; source: string; url: string | null; title: string | null; address: string | null; sector: string | null; city: string | null; region: string | null; propertyType: string | null; price: number; bedrooms: number | null; bathrooms: number | null; powderRooms: number | null; areaSqft: number | null; lotSqft: number | null; yearBuilt: number | null; mls: string | null; brokerName: string | null; agency: string | null; description: string | null; lat: number | null; lng: number | null; firstSeen: number | null; lastSeen: number | null; }; group: TypeGroupKey; images: string[]; features: string[]; details: Record; priceLog: PricePoint[]; eval: EvalRow | null; unit: { id: string; adresse: string | null; apt: string | null; municipalite: string | null; typeProp: string; anneeConstruction: number | null; aireEtagesM2: number | null; superficieTerrainM2: number | null; nbEtages: number | null; nbLogements: number | null; genreConstruction: string | null; lienPhysique: string | null; valeurTerrain: number | null; valeurBatiment: number | null; valeurRole: number | null; est2026: number | null; p10: number | null; p90: number | null; history: { year: number; value: number | null }[]; } | null; nearby: ListingCard[]; /** analyse IA du bâtiment déjà complétée (chantier Coût) — null si aucune */ 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; } const SOURCE_LABELS: Record = { remax_quebec: "RE/MAX Québec", duproprio: "DuProprio", kijiji: "Kijiji", via_capitale: "Via Capitale", sutton: "Sutton", lespac: "LesPAC", fb_marketplace: "Facebook Marketplace", proprio_direct: "Proprio Direct", royal_lepage: "Royal LePage", engel_volkers: "Engel & Völkers", sothebys_quebec: "Sotheby's", pmml: "PMML", }; const sourceLabel = (s: string) => SOURCE_LABELS[s] ?? s.replace(/_/g, " ").replace(/\bag\b/g, "").trim(); const HIDDEN_DETAIL_KEYS = new Set(["img_audited", "transaction", "pieces", "remarques_proprio", "prix_pi2", "prix_m2"]); function Sect({ num: n, kicker, title }: { num: string; kicker: string; title: string }) { return (
{n} — {kicker}

{title}

); } function Chip({ k, v }: { k: string; v: string | number | null | undefined }) { return (

{k}

{v == null || v === "" ? "—" : v}

); } /* --------------------------------- galerie --------------------------------- */ function Gallery({ images, alt }: { images: string[]; alt: string }) { const [i, setI] = useState(0); const [open, setOpen] = useState(false); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); if (e.key === "ArrowRight") setI((x) => (x + 1) % images.length); if (e.key === "ArrowLeft") setI((x) => (x - 1 + images.length) % images.length); }; window.addEventListener("keydown", onKey); document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, [open, images.length]); if (!images.length) return (
Aucune photo
); const cur = images[Math.min(i, images.length - 1)]; return (
{images.slice(0, 40).map((src, k) => ( ))}
{open && (
{i + 1} / {images.length}
{/* eslint-disable-next-line @next/next/no-img-element */} {alt}
)}
); } /* ---------------------------- historique de prix ---------------------------- */ function PriceHistory({ log, current, lastSeen, fr, lang }: { log: PricePoint[]; current: number; lastSeen: number | null; fr: boolean; lang: string }) { const pts = [...log]; if (!pts.length || pts[pts.length - 1].price !== current) pts.push({ ts: lastSeen ?? pts[pts.length - 1]?.ts ?? 0, price: current }); // déduplique les prix consécutifs identiques const series = pts.filter((p, i) => i === 0 || p.price !== pts[i - 1].price); if (series.length < 2) return

{fr ? "Aucun changement de prix observé depuis la première capture." : "No price change observed since first capture."}

; const first = series[0].price; return (
    {series.map((p, i) => { const prev = i > 0 ? series[i - 1].price : null; const d = prev ? ((p.price - prev) / prev) * 100 : null; return (
  1. {epochDate(p.ts, lang)} {money(p.price, lang)} {d != null && {pct(d, lang, 1)}}
  2. ); })}
  3. {fr ? "Cumul depuis la mise en marché" : "Since listing"} : {pct(((current - first) / first) * 100, lang, 1)}
); } /* ------------------------------- composant -------------------------------- */ export default function ListingView({ data }: { data: ListingPageData }) { const { lang } = useLang(); const fr = lang === "fr"; const l = data.listing; const e = data.eval; const u = data.unit; const area = sqftToM2(l.areaSqft); const lot = sqftToM2(l.lotSqft); const ppm2 = area ? Math.round(l.price / area) : null; const where = [l.sector, l.city].filter(Boolean).join(" · "); const alt = [l.address, l.city].filter(Boolean).join(", "); const detailEntries = Object.entries(data.details).filter( ([k, v]) => !HIDDEN_DETAIL_KEYS.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim() !== "" ) as [string, string | number][]; const pieces = Array.isArray(data.details.pieces) ? (data.details.pieces as Record[]) : []; const remarques = typeof data.details.remarques_proprio === "string" ? (data.details.remarques_proprio as string) : null; const methods: { key: string; fr: string; en: string; v: number | null; main?: boolean }[] = e ? [ { key: "est", fr: "Vrai-Prix — hybride 65/35 (mesure principale)", en: "Vrai-Prix — 65/35 hybrid (main measure)", v: e.est, main: true }, { key: "model", fr: "Modèle hédonique (LightGBM)", en: "Hedonic model (LightGBM)", v: e.model_est }, { key: "comps", fr: "Comparables ajustés (moteur)", en: "Adjusted comparables (engine)", v: e.comps_est }, { key: "cost", fr: "Méthode du coût calibrée", en: "Calibrated cost approach", v: e.cost_est }, { key: "role", fr: "Rôle indexé (ratios IAAO)", en: "Indexed assessment (IAAO ratios)", v: e.role_est }, { key: "ens", fr: "Ensemble (médiane des méthodes)", en: "Ensemble (median of methods)", v: e.ens_est }, ] : []; return (
{/* ================= fil d'Ariane ================= */} {/* ================= 1. galerie ================= */}
{/* ================= 2. prix + adresse + caractéristiques ================= */}

{groupLabel(data.group, fr)} {l.propertyType && l.propertyType !== groupLabel(data.group, fr) ? ` · ${l.propertyType}` : ""} {" · "} {sourceLabel(l.source)} {l.mls ? ` · MLS ${l.mls}` : ""}

{l.address || l.title || (fr ? "Propriété à vendre" : "Property for sale")} {where ? · {where} : null}

{fr ? "Prix demandé" : "Asking price"}

{money(l.price, lang)}

{e?.est != null && ( {fr ? "Mesure Vrai-Prix" : "Vrai-Prix measure"} {money(e.est, lang)} {e.confidence ? ` · ${fr ? "confiance" : "confidence"} ${e.confidence}` : ""} )}

{[ l.bedrooms != null ? `${l.bedrooms} ${fr ? "chambres" : "bedrooms"}` : null, l.bathrooms != null ? `${l.bathrooms} ${fr ? "salles de bain" : "bathrooms"}` : null, l.powderRooms ? `${l.powderRooms} ${fr ? "salle d'eau" : "powder room"}` : null, area ? `${num(area, lang)} m² (${num(l.areaSqft, lang)} pi²)` : null, ppm2 ? `${num(ppm2, lang)} $/m²` : null, lot ? `${fr ? "terrain" : "lot"} ${num(lot, lang)} m²` : null, l.yearBuilt ? `${fr ? "constr." : "built"} ${l.yearBuilt}` : null, ] .filter((x): x is string => x != null) .join(" · ")}

{fr ? "Faire mon évaluation" : "Build my valuation"} → {u && ( {fr ? "Rapport de valeur Vrai-Prix" : "Vrai-Prix value report"} )} {data.aiSummary ? (fr ? "Coût IA disponible" : "AI cost available") : fr ? "Analyser le coût avec l'IA" : "Analyse the cost with AI"} → {u && ( {fr ? "Voir la méthode du coût" : "See the cost approach"} → )} {l.url && ( {fr ? "Annonce originale" : "Original listing"} ↗ )}
{/* ================= 3. description ================= */} {(l.description || remarques) && (
{l.description || remarques}
{l.description && remarques && remarques !== l.description && (
{remarques}
)}
)} {/* ================= 4. inclusions / caractéristiques ================= */} {data.features.length > 0 && (
    {data.features.map((f, i) => (
  • ))}
)} {/* ================= 5. détails structurés ================= */} {(detailEntries.length > 0 || pieces.length > 0) && (
{detailEntries.length > 0 && (
{detailEntries.map(([k, v]) => ( ))}
)} {pieces.length > 0 && (
{pieces.map((p, i) => ( ))}
{fr ? "Pièce" : "Room"} {fr ? "Niveau" : "Level"} {fr ? "Dimensions" : "Dimensions"} {fr ? "Revêtement" : "Flooring"}
{p.nom ?? p.name ?? "—"} {p.niveau ?? p.level ?? "—"} {p.dimensions ?? "—"} {p.revetement ?? p.flooring ?? "—"}
)}
)} {/* ================= 6. analyses : mesure Vrai-Prix ================= */}
{e?.est ? (

{fr ? "Intervalle de confiance (P10-P90) et prix demandé" : "Confidence interval (P10-P90) and asking price"}

{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."}

{methods.map((m) => ( ))}
{fr ? "Méthode" : "Method"} {fr ? "Valeur" : "Value"} {fr ? "vs prix demandé" : "vs asking"}
{fr ? m.fr : m.en} {money(m.v, lang)} {m.v != null ? pct((m.v / l.price - 1) * 100, lang, 1) : "—"}
{fr ? "Prix demandé" : "Asking price"} {money(l.price, lang)} —

{fr ? "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." : "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."}

{fr ? "Historique du prix demandé" : "Asking price history"}

{fr ? "Jumelage au rôle" : "Match to the roll"}

{u ? fr ? `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 ?? "—"}.` : `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 ?? "—"}.` : fr ? "Aucune unité du rôle à moins de 250 m." : "No assessment unit within 250 m."}

{fr ? "Choisir mes comparables et comparer" : "Pick my comparables and compare"} →
) : (

{fr ? "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)." : "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)."}

{l.lat != null && ( {fr ? "Faire quand même mon évaluation par comparables" : "Build my valuation from comparables anyway"} → )}
)}
{/* ================= 6b. analyse technique IA (si déjà calculée) ================= */} {data.aiSummary && (

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)}` : ""}

{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."} {data.aiSummary.costValue != null ? ` ${fr ? "Indication par le coût" : "Cost approach indication"} : ${money(data.aiSummary.costValue, lang)}.` : ""}

{fr ? "Voir le profil complet" : "See the full profile"} →
)} {/* ================= 7. registre (unité jumelée) ================= */} {u && (
{area && u.aireEtagesM2 && Math.abs(area - u.aireEtagesM2) / u.aireEtagesM2 > 0.25 && (

{fr ? `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).` : `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).`}

)} {fr ? "Voir le rapport de valeur complet de cette unité" : "See this unit's full value report"} →
)} {/* ================= 8. voisines à vendre ================= */} {data.nearby.length > 0 && (
{data.nearby.map((c) => ( ))}
)}

{fr ? `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).` : `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).`} {e?.evaluated_at ? ` · ${fr ? "évaluée le" : "valued on"} ${dateFr(e.evaluated_at, lang)}` : ""}

); }