// ----------------------------------------------------------------------------- // Rent-Ka — Rental listings aggregator (Canada, outside Québec) // Author: Simon-Pierre Boucher — contact@spboucher.ai // pages/Listing.tsx: listing detail page — mobile-first // DOM order = visual order, identical on mobile AND desktop (Groupe KA // "detail page section order" standard): gallery → price + market badge + // address + chips → CTA/PDF → description → amenities → practical details → // price analysis → location → KA Scores → nearby. // Mobile: stacked columns; desktop: 2-column grid (col A | col B). // Forbidden: reordering via `order` / `column-reverse` (v2 bug where blocks // without `order` jumped ahead of the gallery on mobile). // ----------------------------------------------------------------------------- import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api"; import SmartImg from "../components/SmartImg"; import FairValueBadge from "../components/FairValueBadge"; import PriceAnalysis from "../components/PriceAnalysis"; import HistoriqueLouka from "../components/HistoriqueLouka"; import ImmeubleBloc from "../components/ImmeubleBloc"; import GestionnaireBloc from "../components/GestionnaireBloc"; import HiverScore from "../components/HiverScore"; import { IcoAlert, IcoDoc } from "../components/Icons"; import AmenityIco from "../components/AmenityIco"; import KaScoresBlock from "../components/KaScoresBlock"; import { markSeen } from "../search/seen"; // 3D mini-map (Mapbox) — lazily loaded, like the big map. const ListingMap3D = lazy(() => import("../components/ListingMap3D")); // Icons and labels of nearby amenities (rentka/poi.py category keys) const POI_META: Record = { epicerie: { icon: "🛒", label: "Grocery store" }, depanneur: { icon: "🏪", label: "Convenience store" }, pharmacie: { icon: "💊", label: "Pharmacy" }, ecole: { icon: "🏫", label: "School" }, garderie: { icon: "🧸", label: "Daycare" }, parc: { icon: "🌳", label: "Park" }, bus: { icon: "🚌", label: "Bus" }, metro: { icon: "🚇", label: "Subway" }, gym: { icon: "🏋️", label: "Gym" }, cafe: { icon: "☕", label: "Café" }, clinique: { icon: "🩺", label: "Clinic" }, hopital: { icon: "🏥", label: "Hospital" }, bibliotheque: { icon: "📚", label: "Library" }, }; // POI grouped into collapsible categories const POI_GROUPES: { titre: string; icone: string; cats: string[] }[] = [ { titre: "Groceries", icone: "🛒", cats: ["epicerie", "depanneur"] }, { titre: "Transit", icone: "🚌", cats: ["bus", "metro"] }, { titre: "School and family", icone: "🎓", cats: ["ecole", "garderie", "bibliotheque"] }, { titre: "Health", icone: "🏥", cats: ["pharmacie", "clinique", "hopital"] }, { titre: "Neighbourhood life", icone: "☕", cats: ["cafe", "parc", "gym"] }, ]; const PETS_LABEL: Record = { oui: "Pets allowed", non: "No pets", conditions: "Pets with conditions", }; const NBSP = " "; /** ≈ walking minutes (straight-line × detour factor 1.3, 4.8 km/h) */ const fmtMarche = (m: number): string => `≈${NBSP}${Math.max(1, Math.round((m * 1.3) / 80))}${NBSP}min walk`; /** Badges derived from structured details (confirmed inclusions ✓). */ function badgesConfirmes(l: Listing): string[] { const d = l.details ?? {}; const out: string[] = []; const inc = d.inclusions ?? {}; if (inc.heating) out.push("Heat included"); if (inc.electricity) out.push("Electricity included"); if (inc.hot_water) out.push("Hot water included"); if (inc.internet) out.push("Internet included"); const app = d.appliances ?? {}; if (app.dishwasher) out.push("Dishwasher"); if (app.washer_dryer) out.push("Washer-dryer"); if (app.fridge && app.stove) out.push("Appliances"); if (d.ac) out.push("Air conditioning"); if (d.elevator) out.push("Elevator"); if (d.balcony) out.push("Balcony"); if (d.pool) out.push("Pool"); if (d.gym) out.push("Gym"); if (d.laundry) out.push("Laundry"); if (d.storage) out.push("Storage"); if (d.parking?.available) out.push(`Parking${d.parking.type ? ` ${d.parking.type}` : ""}${d.parking.included ? " included" : ""}`); if (l.furnished) out.push("Furnished"); if (d.smoking === false) out.push("Non-smoking"); return out; } // --- Full-screen lightbox: swipe between photos + pinch to zoom ------------- function Lightbox({ images, start, titre, onClose }: { images: string[]; start: number; titre: string; onClose: () => void }) { const [idx, setIdx] = useState(start); const [scale, setScale] = useState(1); const [tx, setTx] = useState(0); const [ty, setTy] = useState(0); const track = useRef(null); const pointers = useRef(new Map()); const pinch = useRef<{ d: number; scale: number } | null>(null); const lastTap = useRef(0); useEffect(() => { // initial position + page scroll lock track.current?.scrollTo({ left: start * track.current.clientWidth }); document.body.style.overflow = "hidden"; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = ""; window.removeEventListener("keydown", onKey); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const resetZoom = () => { setScale(1); setTx(0); setTy(0); }; const onScroll = () => { const el = track.current; if (el && scale === 1) { const i = Math.round(el.scrollLeft / el.clientWidth); if (i !== idx) { setIdx(i); resetZoom(); } } }; const dist = () => { const [a, b] = [...pointers.current.values()]; return Math.hypot(a.x - b.x, a.y - b.y); }; const onPointerDown = (e: React.PointerEvent) => { pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pointers.current.size === 2) pinch.current = { d: dist(), scale }; if (pointers.current.size === 1) { // double-tap = ×2.5 zoom / back to ×1 const now = Date.now(); if (now - lastTap.current < 300) { if (scale > 1) resetZoom(); else setScale(2.5); } lastTap.current = now; } }; const onPointerMove = (e: React.PointerEvent) => { const prev = pointers.current.get(e.pointerId); if (!prev) return; pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pointers.current.size === 2 && pinch.current) { const s = Math.min(4, Math.max(1, pinch.current.scale * (dist() / pinch.current.d))); setScale(s); if (s === 1) { setTx(0); setTy(0); } } else if (pointers.current.size === 1 && scale > 1) { setTx((v) => v + (e.clientX - prev.x)); // pan while zoomed setTy((v) => v + (e.clientY - prev.y)); } }; const onPointerUp = (e: React.PointerEvent) => { pointers.current.delete(e.pointerId); if (pointers.current.size < 2) pinch.current = null; }; return (
{idx + 1}/{images.length}
1 ? { overflow: "hidden", touchAction: "none" } : undefined} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} > {images.map((u, i) => (
1 ? { transform: `translate(${tx}px, ${ty}px) scale(${scale})` } : undefined} />
))}
{scale === 1 && idx > 0 && ( )} {scale === 1 && idx < images.length - 1 && ( )}
); } // --- Gallery with native swipe (scroll-snap) + counter + full screen -------- function Galerie({ images, titre, unitType }: { images: string[]; titre: string; unitType?: 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" }); if (images.length === 0) return (
); return ( <>
{images.map((u, i) => ( setZoom(true)} /> ))}
{idx + 1}/{images.length} {idx > 0 && ( )} {idx < images.length - 1 && ( )}
{images.length > 1 && (
{images.map((u, i) => ( ))}
)} {zoom && ( setZoom(false)} /> )} ); } 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; fetchListing(uid).then(setL).catch((e) => setError(String(e))); markSeen(uid); // dimmed "already seen" marker on the search map window.scrollTo(0, 0); }, [uid]); if (error) return (

Listing not found

{error}

Back to rentals
); if (!l) return (
); const dg = l.digest ?? null; const f = dg?.faits; const conf = dg?.confiance ?? {}; const confirmes = badgesConfirmes(l); const autres = l.amenities.filter( (a) => !confirmes.some((b) => b.toLowerCase().includes(a.toLowerCase()))); const inc = l.details?.inclusions ?? {}; const zeroFrais = inc.heating && inc.electricity && inc.hot_water; const enLigneDepuis = l.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null; const hist = (l.price_history ?? []).filter((h) => h.price != null); const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price ? { de: hist[1].price!, a: hist[0].price! } : null; const updated = l.updated_at ? new Date(l.updated_at * 1000).toLocaleDateString("en-CA", { day: "numeric", month: "long", year: "numeric" }) : null; // key chips (only high-confidence facts extracted from text) const chips: string[] = []; if (l.unit_type) chips.push(l.unit_type); const dispo = fmtAvailability(l.availability_date); if (dispo) chips.push(dispo === "Now" ? "Available now" : `Available ${dispo}`); if (l.furnished) chips.push("Furnished"); if (l.pets) chips.push(PETS_LABEL[l.pets] ?? l.pets); if (l.area_sqft) chips.push(`${Math.round(l.area_sqft).toLocaleString("en-CA")}${NBSP}sq ft`); if (f?.nb_occupants_total && conf.nb_occupants_total !== "faible") chips.push(`${f.nb_occupants_total} occupants`); if (f?.salle_de_bain && conf.salle_de_bain !== "faible") chips.push(`${f.salle_de_bain === "commune" ? "Shared" : "Private"} bathroom`); if (l.details?.floor != null) chips.push(`Floor ${l.details.floor}`); const pois = l.poi ?? []; return (
{/* ------- left column (desktop): gallery, price, description, ------- ------- amenities, practical — DOM order IS the visual order ------ */}
{fmtPrice(l.price, l.price_label)} {l.price != null && /{NBSP}month}
{l.fv_verdict && (
)}

{l.title || l.address}

{[l.address !== l.title ? l.address : "", l.sector, l.city].filter(Boolean).join(" · ")}
{chips.map((c) => ( {c} ))}
See the listing at {sourceName(l.source)} ↗ Download the listing sheet (PDF)

Description

{dg ? ( <> {dg.en_bref &&

{dg.en_bref}

} {dg.sections.map((s) => (

{s.titre}

{s.texte}

))}
See the source's original text

{l.description}

) : ( l.description ?

{l.description}

:

The source does not provide a description for this listing.

)}

Amenities and inclusions

{zeroFrais &&
💡 Heat, electricity and hot water included — $0 hidden costs
}
{confirmes.map((b) => ( {b} ✓ ))} {autres.map((a) => ( {a} ))}
{confirmes.length === 0 && autres.length === 0 && (

The source does not specify inclusions.

)}

Practical details

Manager
{sourceName(l.source)}
{l.price_label && (
Advertised price
{l.price_label}
)} {f?.duree_bail_minimale_mois && (
Minimum lease
{f.duree_bail_minimale_mois} months
)} {enLigneDepuis != null && (
Online for
{enLigneDepuis === 0 ? "today" : `${enLigneDepuis}${NBSP}day${enLigneDepuis > 1 ? "s" : ""}`}
)} {updated && (
Synced
{updated}
)}
{baissePrix && (
{baissePrix.a < baissePrix.de ? "📉" : "📈"} Price went from{" "} {fmtPrice(baissePrix.de)} to {fmtPrice(baissePrix.a)} {baissePrix.a < baissePrix.de && " — negotiation leverage"}
)}
{/* ------- right column (desktop): price analysis, history, ---------- ------- building, manager, map, KA Scores, nearby ----------------- */}
{l.immeuble && } {l.lat != null && l.lng != null && (

Location

}>

3D view of the area — the listing's building is highlighted. Position from the geocoded address (OpenStreetMap).

)} {l.kascores && } {l.hiver && }
{pois.length > 0 && ( <>

Nearby

{POI_GROUPES.map((g, gi) => { const items = pois.filter((p) => g.cats.includes(p.cat)); if (items.length === 0) return null; return (
{g.icone} {g.titre} {items.length} · nearest at {fmtDist(items[0].dist_m)}
    {items.map((p) => { const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat }; return (
  • {p.name} {fmtDist(p.dist_m)} · {fmtMarche(p.dist_m)}
  • ); })}
); })}
Estimated walking times (straight-line distance ×{NBSP}1.3, 4.8{NBSP}km/h) — OpenStreetMap data.
)}
{updated && <>Last synced: {updated}. } Prices and availability are as displayed by the source — every listing links back to the original ad.
{/* sticky mobile CTA — always visible */}
{fmtPrice(l.price, l.price_label)}{l.price != null && /month} See at {sourceName(l.source)} ↗
); }