// ----------------------------------------------------------------------------- // House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first) // Author: Simon-Pierre Boucher — contact@spboucher.ai // pages/Listing.tsx : full listing page // gallery + lightbox · specs · details (DDF fields) · rooms · // features · description · price history · agent · mini-map · financing // ----------------------------------------------------------------------------- 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 PropertyMap = lazy(() => import("../components/PropertyMap")); import Financing from "../components/Financing"; import NearbyPlaces from "../components/NearbyPlaces"; import { Ico } from "../components/Icons"; import AmenityIco from "../components/AmenityIco"; import { TypeFallback } from "../components/PropertyImg"; import { useAccount } from "../account"; // --- Lightbox: pinch to zoom + pan + swipe between photos --------------------- function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) { const [t, setT] = useState({ scale: 1, x: 0, y: 0 }); const pointers = useRef(new Map()); const start = useRef({ scale: 1, x: 0, y: 0, dist: 0, cx: 0, cy: 0, t: 0 }); const lastTap = useRef(0); // reset when the photo changes useEffect(() => { setT({ scale: 1, x: 0, y: 0 }); }, [src]); const dist = () => { const p = [...pointers.current.values()]; return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); }; const center = () => { const p = [...pointers.current.values()]; return p.length < 2 ? p[0] ?? { x: 0, y: 0 } : { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 }; }; const onDown = (e: React.PointerEvent) => { (e.target as HTMLElement).setPointerCapture(e.pointerId); pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); const c = center(); start.current = { scale: t.scale, x: t.x, y: t.y, dist: dist(), cx: c.x, cy: c.y, t: Date.now() }; }; const onMove = (e: React.PointerEvent) => { if (!pointers.current.has(e.pointerId)) return; pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); const s = start.current; if (pointers.current.size >= 2 && s.dist > 0) { // pinch: zoom around the two-finger midpoint const scale = Math.min(4, Math.max(1, (dist() / s.dist) * s.scale)); const c = center(); setT({ scale, x: s.x + (c.x - s.cx), y: s.y + (c.y - s.cy) }); } else if (pointers.current.size === 1 && t.scale > 1) { // pan once zoomed const p = pointers.current.get(e.pointerId)!; setT({ scale: t.scale, x: s.x + (p.x - s.cx), y: s.y + (p.y - s.cy) }); } }; const onUp = (e: React.PointerEvent) => { const p = pointers.current.get(e.pointerId); pointers.current.delete(e.pointerId); const s = start.current; if (pointers.current.size === 0 && p) { const dx = p.x - s.cx, dy = p.y - s.cy, dt = Date.now() - s.t; if (t.scale <= 1.05 && Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) { onSwipe(dx < 0 ? 1 : -1); // swipe → next photo } else if (dt < 260 && Math.abs(dx) < 8 && Math.abs(dy) < 8) { const now = Date.now(); if (now - lastTap.current < 320) // double-tap: ×2.4 zoom setT(t.scale > 1 ? { scale: 1, x: 0, y: 0 } : { scale: 2.4, x: 0, y: 0 }); lastTap.current = now; } if (t.scale <= 1.02) setT({ scale: 1, x: 0, y: 0 }); } }; return ( 1 ? "grab" : "zoom-out", }} onClick={(e) => e.stopPropagation()} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp} /> ); } // --- Gallery: native swipe (scroll-snap) + thumbnails + fullscreen ------------ function Gallery({ images, captions, title, type }: { images: string[]; captions?: string[]; title: string; type?: string }) { const [idx, setIdx] = useState(0); const [zoom, setZoom] = useState(false); const [dead, setDead] = useState>(new Set()); const track = useRef(null); // images that fail to load: removed from the gallery on the fly (never a // broken-image icon); captions kept aligned const alive = images .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" })) .filter(({ u }) => !dead.has(u)); const markDead = (u: string) => setDead((d) => new Set(d).add(u)); 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(alive.length - 1, i + 1)); }; window.addEventListener("keydown", onKey); // freeze the background while fullscreen (mobile) document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = ""; }; }, [zoom, alive.length]); if (alive.length === 0) return
; const cur = Math.min(idx, alive.length - 1); const swipe = (dir: 1 | -1) => setIdx((i) => Math.min(alive.length - 1, Math.max(0, i + dir))); return ( <>
{alive.map(({ u }, i) => ( {`${title} markDead(u)} onClick={() => setZoom(true)} /> ))}
{alive[cur]?.cap && {alive[cur].cap}} {cur + 1}/{alive.length} {cur > 0 && } {cur < alive.length - 1 && }
{alive.length > 1 && (
{alive.map(({ u }, i) => ( ))}
)} {zoom && (
setZoom(false)} role="dialog" aria-label="Enlarged photo"> {cur > 0 && } {cur < alive.length - 1 && } {alive[cur]?.cap ? `${alive[cur].cap} · ` : ""}{cur + 1} / {alive.length}
)} ); } // technical `details` keys never shown in “Details” const DETAIL_HIDDEN = new Set([ "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited", "needs_image_review", "postal_code", "region", "transaction", "prix_pi2", "prix_m2", "listing_origin_url", ]); // icon for each “spec” tile (Icons.tsx) const SPEC_ICONS: Record = { "Type": "home", "Bedrooms": "bed", "Bathrooms": "bath", "Half baths": "drop", "Living area": "area", "Lot": "land", "Year built": "calendar", "MLS®": "tag", }; export default function ListingPage() { const { uid } = useParams<{ uid: string }>(); const [l, setL] = useState(null); const [error, setError] = useState(null); // re-render when the source names arrive (Title Case fallback otherwise) const [, setSrcTick] = useState(0); useEffect(() => { fetchSources().then((r) => { registerSourceNames(r.sources); setSrcTick(1); }).catch(() => {}); if (!uid) return; setL(null); setError(null); fetchListing(uid).then(setL).catch((e) => setError(String(e))); window.scrollTo(0, 0); }, [uid]); const { favs, toggleFav, enabled: accountEnabled } = useAccount(); if (error) return (

Listing not found

{error}

Back to homes
); 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: "Bedrooms", v: String(l.bedrooms) }); if (l.bathrooms != null) specs.push({ k: "Bathrooms", v: String(l.bathrooms) }); if (l.powder_rooms != null) specs.push({ k: "Half baths", v: String(l.powder_rooms) }); if (l.area_sqft != null) specs.push({ k: "Living area", v: fmtArea(l.area_sqft)! }); if (l.lot_sqft != null) specs.push({ k: "Lot", v: fmtArea(l.lot_sqft)! }); if (l.year_built != null) specs.push({ k: "Year built", v: String(l.year_built) }); if (l.mls) specs.push({ k: "MLS®", 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 isSale = l.details?.transaction !== "location"; const hist = (l.price_history ?? []).filter((h) => h.price != null); const drop = hist.length >= 2 && hist[0].price !== hist[1].price ? { from: hist[1].price!, to: hist[0].price! } : null; const updated = l.updated_at ? fmtDate(l.updated_at) : null; return (
{/* -------- left column: gallery, price/summary, description, --------- -------- details, rooms — DOM order = visual order ---------------- */}
{isSale ? "Asking price" : "Monthly rent"}
{fmtPrice(l.price, l.price_label)} {!isSale && /month}
{l.property_type && ( {l.property_type} {!isSale ? " · for rent" : ""} {l.details?.price_from ? " · starting at" : ""} )}
{l.price != null && l.area_sqft != null && l.area_sqft > 200 && (
${Math.round(l.price / l.area_sqft).toLocaleString("en-CA")} / sq ft of living area
)}

{l.address || l.title} {accountEnabled && ( )}

{[l.sector, l.city, l.region].filter(Boolean).join(" · ")}
{specs.map((s) => (
{s.k} {s.v}
))}
{drop && (
Price changed from {fmtPrice(drop.from)} to {fmtPrice(drop.to)}
)} {(l.broker_name || l.broker_phone) && (
Listing agent
{l.broker_name &&
{l.broker_name}
} {l.broker_phone && {l.broker_phone}}
)} See the listing at {sourceName(l.source)}
Aggregated by House-Ka — {sourceName(l.source)}{updated ? ` · synced on ${updated}` : ""}.
{l.duplicates && l.duplicates.length > 0 && (

Also published on

This property was found on {l.duplicates.length}{" "} other site{l.duplicates.length > 1 ? "s" : ""} — House-Ka shows the most complete version.

)} {l.description && (

Description

{l.description}

)} {detEntries.length > 0 && (

Details

{detEntries.map(([k, v]) => (
{k} {/^https?:\/\//.test(String(v)) ? Open ↗ : {String(v)}}
))}
)} {rooms.length > 0 && (

Rooms

{rooms.map((r, i) => ( ))}
RoomLevelDimensionsFlooring
{r.nom || "—"}{r.niveau || "—"} {r.dimensions || "—"}{r.revetement || "—"}
)}
{/* -------- right column (desktop): features, map --------------------- */}
{l.features && l.features.length > 0 && (

Features

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

Location

Loading the map…
}> )}
{/* financing: real mortgage rates + Canadian calculator */} {isSale && } {/* nearby amenities: full width, AFTER the property info (correct mobile order) — the banner basket is per-province (Save-On-Foods in BC, Sobeys in the Atlantic, Co-op in SK, LCBO in ON…) */}
Prices and availability are those displayed by the source — every listing links back to the brokerage's original page.
{fmtPrice(l.price, l.price_label)} See at {sourceName(l.source)} ↗
); }