Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1// -----------------------------------------------------------------------------2// Rent-Ka — Rental listings aggregator (Canada, outside Québec)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Listing.tsx: listing detail page — mobile-first5// DOM order = visual order, identical on mobile AND desktop (Groupe KA6// "detail page section order" standard): gallery → price + market badge +7// address + chips → CTA/PDF → description → amenities → practical details →8// price analysis → location → KA Scores → nearby.9// Mobile: stacked columns; desktop: 2-column grid (col A | col B).10// Forbidden: reordering via `order` / `column-reverse` (v2 bug where blocks11// without `order` jumped ahead of the gallery on mobile).12// -----------------------------------------------------------------------------13import { lazy, Suspense, useEffect, useRef, useState } from "react";14import { Link, useParams } from "react-router-dom";15import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api";16import SmartImg from "../components/SmartImg";17import FairValueBadge from "../components/FairValueBadge";18import PriceAnalysis from "../components/PriceAnalysis";19import HistoriqueLouka from "../components/HistoriqueLouka";20import ImmeubleBloc from "../components/ImmeubleBloc";21import GestionnaireBloc from "../components/GestionnaireBloc";22import HiverScore from "../components/HiverScore";23import { IcoAlert, IcoDoc } from "../components/Icons";24import AmenityIco from "../components/AmenityIco";25import KaScoresBlock from "../components/KaScoresBlock";26import { markSeen } from "../search/seen";2728// 3D mini-map (Mapbox) — lazily loaded, like the big map.29const ListingMap3D = lazy(() => import("../components/ListingMap3D"));3031// Icons and labels of nearby amenities (rentka/poi.py category keys)32const POI_META: Record<string, { icon: string; label: string }> = {33 epicerie: { icon: "🛒", label: "Grocery store" },34 depanneur: { icon: "🏪", label: "Convenience store" },35 pharmacie: { icon: "💊", label: "Pharmacy" },36 ecole: { icon: "🏫", label: "School" },37 garderie: { icon: "🧸", label: "Daycare" },38 parc: { icon: "🌳", label: "Park" },39 bus: { icon: "🚌", label: "Bus" },40 metro: { icon: "🚇", label: "Subway" },41 gym: { icon: "🏋️", label: "Gym" },42 cafe: { icon: "☕", label: "Café" },43 clinique: { icon: "🩺", label: "Clinic" },44 hopital: { icon: "🏥", label: "Hospital" },45 bibliotheque: { icon: "📚", label: "Library" },46};4748// POI grouped into collapsible categories49const POI_GROUPES: { titre: string; icone: string; cats: string[] }[] = [50 { titre: "Groceries", icone: "🛒", cats: ["epicerie", "depanneur"] },51 { titre: "Transit", icone: "🚌", cats: ["bus", "metro"] },52 { titre: "School and family", icone: "🎓", cats: ["ecole", "garderie", "bibliotheque"] },53 { titre: "Health", icone: "🏥", cats: ["pharmacie", "clinique", "hopital"] },54 { titre: "Neighbourhood life", icone: "☕", cats: ["cafe", "parc", "gym"] },55];5657const PETS_LABEL: Record<string, string> = {58 oui: "Pets allowed", non: "No pets", conditions: "Pets with conditions",59};6061const NBSP = " ";6263/** ≈ walking minutes (straight-line × detour factor 1.3, 4.8 km/h) */64const fmtMarche = (m: number): string =>65 `≈${NBSP}${Math.max(1, Math.round((m * 1.3) / 80))}${NBSP}min walk`;6667/** Badges derived from structured details (confirmed inclusions ✓). */68function badgesConfirmes(l: Listing): string[] {69 const d = l.details ?? {};70 const out: string[] = [];71 const inc = d.inclusions ?? {};72 if (inc.heating) out.push("Heat included");73 if (inc.electricity) out.push("Electricity included");74 if (inc.hot_water) out.push("Hot water included");75 if (inc.internet) out.push("Internet included");76 const app = d.appliances ?? {};77 if (app.dishwasher) out.push("Dishwasher");78 if (app.washer_dryer) out.push("Washer-dryer");79 if (app.fridge && app.stove) out.push("Appliances");80 if (d.ac) out.push("Air conditioning");81 if (d.elevator) out.push("Elevator");82 if (d.balcony) out.push("Balcony");83 if (d.pool) out.push("Pool");84 if (d.gym) out.push("Gym");85 if (d.laundry) out.push("Laundry");86 if (d.storage) out.push("Storage");87 if (d.parking?.available)88 out.push(`Parking${d.parking.type ? ` ${d.parking.type}` : ""}${d.parking.included ? " included" : ""}`);89 if (l.furnished) out.push("Furnished");90 if (d.smoking === false) out.push("Non-smoking");91 return out;92}9394// --- Full-screen lightbox: swipe between photos + pinch to zoom -------------95function Lightbox({ images, start, titre, onClose }:96 { images: string[]; start: number; titre: string; onClose: () => void }) {97 const [idx, setIdx] = useState(start);98 const [scale, setScale] = useState(1);99 const [tx, setTx] = useState(0);100 const [ty, setTy] = useState(0);101 const track = useRef<HTMLDivElement>(null);102 const pointers = useRef(new Map<number, { x: number; y: number }>());103 const pinch = useRef<{ d: number; scale: number } | null>(null);104 const lastTap = useRef(0);105106 useEffect(() => { // initial position + page scroll lock107 track.current?.scrollTo({ left: start * track.current.clientWidth });108 document.body.style.overflow = "hidden";109 const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };110 window.addEventListener("keydown", onKey);111 return () => {112 document.body.style.overflow = "";113 window.removeEventListener("keydown", onKey);114 };115 // eslint-disable-next-line react-hooks/exhaustive-deps116 }, []);117118 const resetZoom = () => { setScale(1); setTx(0); setTy(0); };119 const onScroll = () => {120 const el = track.current;121 if (el && scale === 1) {122 const i = Math.round(el.scrollLeft / el.clientWidth);123 if (i !== idx) { setIdx(i); resetZoom(); }124 }125 };126 const dist = () => {127 const [a, b] = [...pointers.current.values()];128 return Math.hypot(a.x - b.x, a.y - b.y);129 };130 const onPointerDown = (e: React.PointerEvent) => {131 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });132 if (pointers.current.size === 2)133 pinch.current = { d: dist(), scale };134 if (pointers.current.size === 1) { // double-tap = ×2.5 zoom / back to ×1135 const now = Date.now();136 if (now - lastTap.current < 300) {137 if (scale > 1) resetZoom(); else setScale(2.5);138 }139 lastTap.current = now;140 }141 };142 const onPointerMove = (e: React.PointerEvent) => {143 const prev = pointers.current.get(e.pointerId);144 if (!prev) return;145 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });146 if (pointers.current.size === 2 && pinch.current) {147 const s = Math.min(4, Math.max(1, pinch.current.scale * (dist() / pinch.current.d)));148 setScale(s);149 if (s === 1) { setTx(0); setTy(0); }150 } else if (pointers.current.size === 1 && scale > 1) {151 setTx((v) => v + (e.clientX - prev.x)); // pan while zoomed152 setTy((v) => v + (e.clientY - prev.y));153 }154 };155 const onPointerUp = (e: React.PointerEvent) => {156 pointers.current.delete(e.pointerId);157 if (pointers.current.size < 2) pinch.current = null;158 };159160 return (161 <div className="lightbox lightbox-v2" role="dialog" aria-modal="true"162 aria-label={`Photos — ${titre}`}>163 <button className="lightbox-close" aria-label="Close" onClick={onClose}>✕</button>164 <span className="carousel-count lightbox-count" aria-live="polite">165 {idx + 1}/{images.length}166 </span>167 <div168 className="lightbox-track" ref={track} onScroll={onScroll}169 style={scale > 1 ? { overflow: "hidden", touchAction: "none" } : undefined}170 onPointerDown={onPointerDown} onPointerMove={onPointerMove}171 onPointerUp={onPointerUp} onPointerCancel={onPointerUp}172 >173 {images.map((u, i) => (174 <div className="lightbox-cell" key={u}>175 <SmartImg176 src={u} original alt={`${titre} — photo ${i + 1} of ${images.length}`}177 draggable={false}178 style={i === idx && scale > 1179 ? { transform: `translate(${tx}px, ${ty}px) scale(${scale})` }180 : undefined}181 />182 </div>183 ))}184 </div>185 {scale === 1 && idx > 0 && (186 <button className="carousel-nav prev" aria-label="Previous photo"187 onClick={() => track.current?.scrollTo({188 left: (idx - 1) * track.current.clientWidth, behavior: "smooth" })}>‹</button>189 )}190 {scale === 1 && idx < images.length - 1 && (191 <button className="carousel-nav next" aria-label="Next photo"192 onClick={() => track.current?.scrollTo({193 left: (idx + 1) * track.current.clientWidth, behavior: "smooth" })}>›</button>194 )}195 </div>196 );197}198199// --- Gallery with native swipe (scroll-snap) + counter + full screen --------200function Galerie({ images, titre, unitType }:201 { images: string[]; titre: string; unitType?: string }) {202 const [idx, setIdx] = useState(0);203 const [zoom, setZoom] = useState(false);204 const track = useRef<HTMLDivElement>(null);205206 const onScroll = () => {207 const el = track.current;208 if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));209 };210 const goto = (i: number) =>211 track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });212213 if (images.length === 0)214 return (215 <div className="carousel">216 <SmartImg src={null} fallbackLabel={unitType}217 alt="No photo provided by the source" />218 </div>219 );220221 return (222 <>223 <div className="carousel">224 <div className="carousel-track" ref={track} onScroll={onScroll}>225 {images.map((u, i) => (226 <SmartImg227 key={u} src={u} width_={800} fallbackLabel={unitType}228 loading={i === 0 ? "eager" : "lazy"} decoding="async"229 alt={`${titre} — photo ${i + 1} of ${images.length}`}230 onClick={() => setZoom(true)}231 />232 ))}233 </div>234 <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>235 {idx > 0 && (236 <button className="carousel-nav prev" aria-label="Previous photo" onClick={() => goto(idx - 1)}>‹</button>237 )}238 {idx < images.length - 1 && (239 <button className="carousel-nav next" aria-label="Next photo" onClick={() => goto(idx + 1)}>›</button>240 )}241 </div>242 {images.length > 1 && (243 <div className="thumbs">244 {images.map((u, i) => (245 <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}246 aria-label={`Photo ${i + 1}`}>247 <SmartImg src={u} width_={160} alt="" loading="lazy" decoding="async" />248 </button>249 ))}250 </div>251 )}252 {zoom && (253 <Lightbox images={images} start={idx} titre={titre}254 onClose={() => setZoom(false)} />255 )}256 </>257 );258}259260export default function ListingPage() {261 const { uid } = useParams<{ uid: string }>();262 const [l, setL] = useState<Listing | null>(null);263 const [error, setError] = useState<string | null>(null);264265 useEffect(() => {266 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});267 if (!uid) return;268 fetchListing(uid).then(setL).catch((e) => setError(String(e)));269 markSeen(uid); // dimmed "already seen" marker on the search map270 window.scrollTo(0, 0);271 }, [uid]);272273 if (error)274 return (275 <div className="notice container">276 <div className="big"><IcoAlert size={40} /></div>277 <h2>Listing not found</h2>278 <p>{error}</p>279 <Link className="btn btn-primary" to="/">Back to rentals</Link>280 </div>281 );282283 if (!l)284 return (285 <div className="container detail">286 <div className="fiche" aria-busy="true">287 <div className="skel"><div className="sk-img" /></div>288 <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>289 </div>290 </div>291 );292293 const dg = l.digest ?? null;294 const f = dg?.faits;295 const conf = dg?.confiance ?? {};296 const confirmes = badgesConfirmes(l);297 const autres = l.amenities.filter(298 (a) => !confirmes.some((b) => b.toLowerCase().includes(a.toLowerCase())));299 const inc = l.details?.inclusions ?? {};300 const zeroFrais = inc.heating && inc.electricity && inc.hot_water;301302 const enLigneDepuis = l.first_seen303 ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null;304 const hist = (l.price_history ?? []).filter((h) => h.price != null);305 const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price306 ? { de: hist[1].price!, a: hist[0].price! } : null;307308 const updated = l.updated_at309 ? new Date(l.updated_at * 1000).toLocaleDateString("en-CA", {310 day: "numeric", month: "long", year: "numeric" }) : null;311312 // key chips (only high-confidence facts extracted from text)313 const chips: string[] = [];314 if (l.unit_type) chips.push(l.unit_type);315 const dispo = fmtAvailability(l.availability_date);316 if (dispo) chips.push(dispo === "Now" ? "Available now" : `Available ${dispo}`);317 if (l.furnished) chips.push("Furnished");318 if (l.pets) chips.push(PETS_LABEL[l.pets] ?? l.pets);319 if (l.area_sqft) chips.push(`${Math.round(l.area_sqft).toLocaleString("en-CA")}${NBSP}sq ft`);320 if (f?.nb_occupants_total && conf.nb_occupants_total !== "faible")321 chips.push(`${f.nb_occupants_total} occupants`);322 if (f?.salle_de_bain && conf.salle_de_bain !== "faible")323 chips.push(`${f.salle_de_bain === "commune" ? "Shared" : "Private"} bathroom`);324 if (l.details?.floor != null) chips.push(`Floor ${l.details.floor}`);325326 const pois = l.poi ?? [];327328 return (329 <div className="container detail">330 <nav className="crumbs" aria-label="Breadcrumb">331 <Link to="/">Rentals</Link> ›332 {l.city && <span>{l.city}</span>} ›333 <span>{l.title || l.address}</span>334 </nav>335336 <div className="fiche">337 {/* ------- left column (desktop): gallery, price, description, -------338 ------- amenities, practical — DOM order IS the visual order ------ */}339 <div className="f-col">340 <section className="f-bloc f-galerie" aria-label="Photos">341 <Galerie images={l.images ?? []} titre={l.title || l.address}342 unitType={l.unit_type || undefined} />343 </section>344345 <section className="f-bloc f-hero">346 <div className="price">347 {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/{NBSP}month</small>}348 </div>349 {l.fv_verdict && (350 <div><FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} /></div>351 )}352 <h1>{l.title || l.address}</h1>353 <div className="loc">354 {[l.address !== l.title ? l.address : "", l.sector, l.city].filter(Boolean).join(" · ")}355 </div>356 <div className="chips-scroll" role="list" aria-label="Key features">357 {chips.map((c) => (358 <span className="chip-key" role="listitem" key={c}>359 <AmenityIco label={c} size={14} fallback="spark" /> {c}360 </span>361 ))}362 </div>363 <nav className="ancres" aria-label="Page sections">364 <a href="#description">Description</a>365 <a href="#analyse-prix">Price</a>366 <a href="#inclusions">Amenities</a>367 {l.lat != null && l.lng != null && <a href="#emplacement">Map</a>}368 <a href="#proximite">Nearby</a>369 </nav>370 <a className="cta cta-desktop" href={`/gateway/${encodeURIComponent(l.uid)}`}371 target="_blank" rel="noopener noreferrer">372 See the listing at {sourceName(l.source)} ↗373 </a>374 <a className="btn btn-ghost btn-pdf"375 href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download>376 <IcoDoc size={14} /> Download the listing sheet (PDF)377 </a>378 </section>379380 <section className="f-bloc f-desc" id="description">381 <h2>Description</h2>382 {dg ? (383 <>384 {dg.en_bref && <p className="enbref">{dg.en_bref}</p>}385 {dg.sections.map((s) => (386 <div key={s.titre} className="desc-section">387 <h4>{s.titre}</h4>388 <p>{s.texte}</p>389 </div>390 ))}391 <details className="texte-original">392 <summary>See the source's original text</summary>393 <p>{l.description}</p>394 </details>395 </>396 ) : (397 l.description398 ? <p style={{ color: "var(--ink-2)" }}>{l.description}</p>399 : <p className="fine">The source does not provide a description for this listing.</p>400 )}401 </section>402403 <section className="f-bloc f-incl" id="inclusions">404 <h2>Amenities and inclusions</h2>405 {zeroFrais && <div className="deal-badge deal-good">💡 Heat, electricity and hot water included — $0 hidden costs</div>}406 <div className="amenity-grid">407 {confirmes.map((b) => (408 <span className="amenity-it confirmed" key={`c-${b}`}>409 <span className="am-ico"><AmenityIco label={b} /></span>410 <span className="am-txt">{b}</span>411 <span className="am-conf" title="Confirmed by the source's structured data">✓</span>412 </span>413 ))}414 {autres.map((a) => (415 <span className="amenity-it unconfirmed" key={a} title="Mentioned by the source, without structured confirmation">416 <span className="am-ico"><AmenityIco label={a} fallback="spark" /></span>417 <span className="am-txt">{a}</span>418 </span>419 ))}420 </div>421 {confirmes.length === 0 && autres.length === 0 && (422 <p className="fine">The source does not specify inclusions.</p>423 )}424 </section>425426 <section className="f-bloc f-pratique">427 <h2>Practical details</h2>428 <div className="kv">429 <div className="cell"><div className="k">Manager</div><div className="v">{sourceName(l.source)}</div></div>430 {l.price_label && (431 <div className="cell"><div className="k">Advertised price</div><div className="v">{l.price_label}</div></div>432 )}433 {f?.duree_bail_minimale_mois && (434 <div className="cell"><div className="k">Minimum lease</div><div className="v">{f.duree_bail_minimale_mois} months</div></div>435 )}436 {enLigneDepuis != null && (437 <div className="cell"><div className="k">Online for</div>438 <div className="v">{enLigneDepuis === 0 ? "today" : `${enLigneDepuis}${NBSP}day${enLigneDepuis > 1 ? "s" : ""}`}</div></div>439 )}440 {updated && (441 <div className="cell"><div className="k">Synced</div><div className="v">{updated}</div></div>442 )}443 </div>444 {baissePrix && (445 <div className={`prix-histo ${baissePrix.a < baissePrix.de ? "down" : "up"}`}>446 {baissePrix.a < baissePrix.de ? "📉" : "📈"} Price went from{" "}447 {fmtPrice(baissePrix.de)} to <b>{fmtPrice(baissePrix.a)}</b>448 {baissePrix.a < baissePrix.de && " — negotiation leverage"}449 </div>450 )}451 </section>452 </div>453454 {/* ------- right column (desktop): price analysis, history, ----------455 ------- building, manager, map, KA Scores, nearby ----------------- */}456 <div className="f-col">457 <PriceAnalysis uid={l.uid} price={l.price} />458459 <HistoriqueLouka uid={l.uid} />460461 {l.immeuble && <ImmeubleBloc im={l.immeuble} />}462463 <GestionnaireBloc source={l.source} />464465 {l.lat != null && l.lng != null && (466 <section className="f-bloc f-carte" id="emplacement">467 <h2>Location</h2>468 <Suspense fallback={<div className="lmap3d lmap3d-skel" aria-busy="true" />}>469 <ListingMap3D l={l} />470 </Suspense>471 <p className="fine">472 3D view of the area — the listing's building is highlighted.473 Position from the geocoded address (OpenStreetMap).474 </p>475 </section>476 )}477478 {l.kascores && <KaScoresBlock ks={l.kascores} />}479480 {l.hiver && <HiverScore h={l.hiver} />}481482 <section className="f-bloc f-poi" id="proximite">483 {pois.length > 0 && (484 <>485 <h2>Nearby</h2>486 {POI_GROUPES.map((g, gi) => {487 const items = pois.filter((p) => g.cats.includes(p.cat));488 if (items.length === 0) return null;489 return (490 <details className="poi-groupe" key={g.titre} open={gi === 0}>491 <summary>492 <span>{g.icone} {g.titre}</span>493 <span className="poi-resume">494 {items.length} · nearest at {fmtDist(items[0].dist_m)}495 </span>496 </summary>497 <ul className="poi-list">498 {items.map((p) => {499 const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat };500 return (501 <li key={p.cat} title={meta.label}>502 <span className="poi-ico" aria-hidden="true">{meta.icon}</span>503 <span className="poi-name">{p.name}</span>504 <span className="poi-dist">{fmtDist(p.dist_m)} · {fmtMarche(p.dist_m)}</span>505 </li>506 );507 })}508 </ul>509 </details>510 );511 })}512 <div className="fine">513 Estimated walking times (straight-line distance ×{NBSP}1.3, 4.8{NBSP}km/h) — OpenStreetMap data.514 </div>515 </>516 )}517 </section>518 </div>519 </div>520521 <div className="fine f-foot">522 {updated && <>Last synced: {updated}. </>}523 Prices and availability are as displayed by the source — every listing524 links back to the original ad.525 </div>526527 {/* sticky mobile CTA — always visible */}528 <div className="cta-sticky">529 <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}{l.price != null && <small>/month</small>}</span>530 <a className="cta" href={`/gateway/${encodeURIComponent(l.uid)}`}531 target="_blank" rel="noopener noreferrer">532 See at {sourceName(l.source)} ↗533 </a>534 </div>535 </div>536 );537}538