// ----------------------------------------------------------------------------- // Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // pages/Listing.tsx : fiche complète d'une propriété // galerie + lightbox · specs · caractéristiques (details) · pièces · // inclusions · description · historique de prix · courtier · mini-carte // ----------------------------------------------------------------------------- import { Suspense, lazy, useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Listing, Room, fetchListing, fetchSources, fmtArea, fmtDate, fmtPrice, registerSourceNames, sourceName, } from "../api"; const MiniMap = lazy(() => import("../components/MapView")); import QuartierBlock from "../components/QuartierBlock"; // --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran -------- function Galerie({ images, titre }: { images: string[]; titre: string }) { const [idx, setIdx] = useState(0); const [zoom, setZoom] = useState(false); const track = useRef(null); const onScroll = () => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)); }; const goto = (i: number) => track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" }); useEffect(() => { if (!zoom) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setZoom(false); if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1)); if (e.key === "ArrowRight") setIdx((i) => Math.min(images.length - 1, i + 1)); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [zoom, images.length]); if (images.length === 0) return
🏠
; return ( <>
{images.map((u, i) => ( {`${titre} setZoom(true)} /> ))}
{idx + 1}/{images.length} {idx > 0 && } {idx < images.length - 1 && }
{images.length > 1 && (
{images.map((u, i) => ( ))}
)} {zoom && (
setZoom(false)} role="dialog" aria-label="Photo agrandie"> {idx > 0 && } e.stopPropagation()} /> {idx < images.length - 1 && } {idx + 1} / {images.length}
)} ); } const DETAIL_HIDDEN = new Set(["pieces", "price_from"]); export default function ListingPage() { const { uid } = useParams<{ uid: string }>(); const [l, setL] = useState(null); const [error, setError] = useState(null); useEffect(() => { fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); if (!uid) return; setL(null); setError(null); fetchListing(uid).then(setL).catch((e) => setError(String(e))); window.scrollTo(0, 0); }, [uid]); if (error) return (
⚠️

Propriété introuvable

{error}

Retour aux propriétés
); if (!l) return (
); const specs: { k: string; v: string }[] = []; if (l.property_type) specs.push({ k: "Type", v: l.property_type }); if (l.bedrooms != null) specs.push({ k: "Chambres", v: String(l.bedrooms) }); if (l.bathrooms != null) specs.push({ k: "Salles de bain", v: String(l.bathrooms) }); if (l.powder_rooms != null) specs.push({ k: "Salles d'eau", v: String(l.powder_rooms) }); if (l.area_sqft != null) specs.push({ k: "Superficie", v: fmtArea(l.area_sqft)! }); if (l.lot_sqft != null) specs.push({ k: "Terrain", v: fmtArea(l.lot_sqft)! }); if (l.year_built != null) specs.push({ k: "Année", v: String(l.year_built) }); if (l.mls) specs.push({ k: "MLS / Centris", v: l.mls }); const rooms: Room[] = Array.isArray(l.details?.pieces) ? (l.details!.pieces as Room[]) : []; const detEntries = Object.entries(l.details ?? {}) .filter(([k, v]) => !DETAIL_HIDDEN.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim()); const hist = (l.price_history ?? []).filter((h) => h.price != null); const baisse = hist.length >= 2 && hist[0].price !== hist[1].price ? { de: hist[1].price!, a: hist[0].price! } : null; const updated = l.updated_at ? fmtDate(l.updated_at) : null; return (
{/* -------- colonne gauche : galerie, description, caractéristiques ---- */}
{l.description && (

Description

{l.description}

)} {detEntries.length > 0 && (

Caractéristiques

{detEntries.map(([k, v]) => (
{k}{String(v)}
))}
)} {rooms.length > 0 && (

Pièces

{rooms.map((r, i) => ( ))}
PièceNiveauDimensionsRevêtement
{r.nom || "—"}{r.niveau || "—"} {r.dimensions || "—"}{r.revetement || "—"}
)} {l.features && l.features.length > 0 && (

Inclusions

{l.features.map((f, i) => ✓ {f})}
)} {l.lat != null && l.lng != null && (

Emplacement

Chargement de la carte…
}>
)} {l.quartier && (

Le quartier

)}
{/* -------- colonne droite : synthèse, specs, courtier ---------------- */}
{fmtPrice(l.price, l.price_label)}
{l.property_type &&
{l.property_type}{l.details?.price_from ? " · à partir de" : ""}
}

{l.address || l.title}

{[l.sector, l.city, l.region].filter(Boolean).join(" · ")}
{specs.map((s) => (
{s.v}{s.k}
))}
{baisse && (
{baisse.a < baisse.de ? "📉" : "📈"} Prix passé de {fmtPrice(baisse.de)} à {fmtPrice(baisse.a)}
)} {l.vraiprix && l.vraiprix.value != null && (
Vrai‑Prix {l.vraiprix.confidence && ( confiance {l.vraiprix.confidence} )}
Valeur marchande estimée
{fmtPrice(l.vraiprix.value)}
{l.vraiprix.low != null && l.vraiprix.high != null && (
fourchette {fmtPrice(l.vraiprix.low)} – {fmtPrice(l.vraiprix.high)}
)} {l.price != null && ( (() => { const diff = l.price - (l.vraiprix!.value as number); const pct = Math.round((diff / (l.vraiprix!.value as number)) * 100); const cls = diff > 0 ? "over" : "under"; const txt = diff > 0 ? `Prix demandé ${pct}% au-dessus de l'estimation` : `Prix demandé ${Math.abs(pct)}% sous l'estimation`; return Math.abs(pct) >= 1 ?
{diff > 0 ? "▲" : "▼"} {txt}
:
≈ Prix aligné sur l'estimation
; })() )} Voir l'analyse détaillée ↗
)} {(l.broker_name || l.broker_phone) && (
Courtier
{l.broker_name &&
{l.broker_name}
} {l.broker_phone && 📞 {l.broker_phone}}
)} Voir l'annonce chez {sourceName(l.source)} ↗
Agrégé par Immo-Ka — {sourceName(l.source)}{updated ? ` · synchronisé le ${updated}` : ""}.
Les prix et disponibilités sont ceux affichés par la source — chaque fiche renvoie à l'annonce originale de l'agence.
{fmtPrice(l.price, l.price_label)} Voir chez {sourceName(l.source)} ↗
); }