// ----------------------------------------------------------------------------- // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Project: Toit-Ka // pages/Listing.tsx : fiche complète d'une annonce — bi-univers. // galerie + lightbox · specs adaptées (loyer/animaux/meublé vs prix/MLS/ // courtier) · description · mini-carte · CTA vers l'annonce originale. // ----------------------------------------------------------------------------- import { Suspense, lazy, useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Listing, fetchListing, fmtArea, fmtDate, fmtPrice, setDocTitle, setMode, slugify, sourceName, } from "../api"; const PropertyMap = lazy(() => import("../components/PropertyMap")); import { useAccount } from "../account"; import { Ico, IcoHeart } from "../components/Icons"; // --- 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 SPEC_ICONS: Record = { "Type": "home", "Chambres": "bed", "Salles de bain": "bath", "Superficie": "area", "Terrain": "land", "Année": "calendar", "MLS / Centris": "tag", "Animaux": "paw", "Meublé": "sofa", "Disponibilité": "calendar", "Agence": "building", }; export default function ListingPage() { const { uid } = useParams<{ uid: string }>(); const [l, setL] = useState(null); const [error, setError] = useState(null); const { favs, toggleFav } = useAccount(); useEffect(() => { if (!uid) return; setL(null); setError(null); fetchListing(uid) .then((d) => { setL(d); setMode(d.transaction_type); setDocTitle(`${d.address || d.title} — ${d.type || "Annonce"} ${ d.transaction_type === "louer" ? "à louer" : "à vendre"}, ${d.city || "Québec"}`); }) .catch((e) => setError(String(e))); window.scrollTo(0, 0); }, [uid]); if (error) return (

Annonce introuvable

{error}

Retour aux annonces
); if (!l) return (
); const louer = l.transaction_type === "louer"; const tx = l.transaction_type; const specs: { k: string; v: string }[] = []; if (l.type) specs.push({ k: "Type", v: l.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.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 (louer && l.pets) specs.push({ k: "Animaux", v: l.pets }); if (louer && l.furnished != null) specs.push({ k: "Meublé", v: l.furnished === 1 ? "oui" : "non" }); if (louer && l.availability_date) specs.push({ k: "Disponibilité", v: l.availability_date === "now" ? "maintenant" : l.availability_date }); if (l.mls) specs.push({ k: "MLS / Centris", v: l.mls }); if (l.agency) specs.push({ k: "Agence", v: l.agency }); const updated = l.updated_at ? fmtDate(l.updated_at) : null; const cityHref = l.city ? `/${tx}/${slugify(l.city)}` : "/"; return (
{/* -------- colonne gauche : galerie, description ---------------------- */}
{l.description && (

Description

{l.description}

)} {l.lat != null && l.lng != null && (

Emplacement

Chargement de la carte…
}>
)}
{/* -------- colonne droite : synthèse, specs, CTA ----------------------- */}
{louer ? "Loyer demandé" : "Prix demandé"}
{l.price != null ? <>{l.price.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $ {louer && /mois} : (l.price_label || "Prix sur demande")}
{l.type && ( {l.type} )}
{!louer && l.price != null && l.area_sqft != null && l.area_sqft > 200 && (
{Math.round(l.price / l.area_sqft).toLocaleString("fr-CA")} $ / pi² habitable
)}

{l.address || l.title}

{[l.sector, l.city].filter(Boolean).join(" · ")}
{specs.map((s) => (
{s.k} {s.v}
))}
{(l.broker_name) && (
Courtier
{l.broker_name}
)} Voir l'annonce chez {sourceName(l.source)}
Agrégé par Toit-Ka — {sourceName(l.source)}{updated ? ` · synchronisé le ${updated}` : ""}.
Les {louer ? "loyers" : "prix"} et disponibilités sont ceux affichés par la source — chaque fiche renvoie à l'annonce originale.
{fmtPrice(l.price, tx, l.price_label)} Voir chez {sourceName(l.source)} ↗
); }