1// -----------------------------------------------------------------------------2// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Listing.tsx : full listing page5// gallery + lightbox · specs · details (DDF fields) · rooms ·6// features · description · price history · agent · mini-map · financing7// -----------------------------------------------------------------------------8import { Suspense, lazy, useEffect, useRef, useState } from "react";9import { Link, useParams } from "react-router-dom";10import {11 Listing, Room, fetchListing, fetchSources, fmtArea, fmtDate, fmtPrice,12 registerSourceNames, sourceName,13} from "../api";1415const PropertyMap = lazy(() => import("../components/PropertyMap"));16import Financing from "../components/Financing";17import NearbyPlaces from "../components/NearbyPlaces";18import { Ico } from "../components/Icons";19import AmenityIco from "../components/AmenityIco";20import { TypeFallback } from "../components/PropertyImg";2122// --- Lightbox: pinch to zoom + pan + swipe between photos ---------------------23function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) {24 const [t, setT] = useState({ scale: 1, x: 0, y: 0 });25 const pointers = useRef(new Map<number, { x: number; y: number }>());26 const start = useRef({ scale: 1, x: 0, y: 0, dist: 0, cx: 0, cy: 0, t: 0 });27 const lastTap = useRef(0);2829 // reset when the photo changes30 useEffect(() => { setT({ scale: 1, x: 0, y: 0 }); }, [src]);3132 const dist = () => {33 const p = [...pointers.current.values()];34 return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);35 };36 const center = () => {37 const p = [...pointers.current.values()];38 return p.length < 239 ? p[0] ?? { x: 0, y: 0 }40 : { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 };41 };4243 const onDown = (e: React.PointerEvent) => {44 (e.target as HTMLElement).setPointerCapture(e.pointerId);45 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });46 const c = center();47 start.current = { scale: t.scale, x: t.x, y: t.y, dist: dist(), cx: c.x, cy: c.y, t: Date.now() };48 };49 const onMove = (e: React.PointerEvent) => {50 if (!pointers.current.has(e.pointerId)) return;51 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });52 const s = start.current;53 if (pointers.current.size >= 2 && s.dist > 0) {54 // pinch: zoom around the two-finger midpoint55 const scale = Math.min(4, Math.max(1, (dist() / s.dist) * s.scale));56 const c = center();57 setT({ scale, x: s.x + (c.x - s.cx), y: s.y + (c.y - s.cy) });58 } else if (pointers.current.size === 1 && t.scale > 1) {59 // pan once zoomed60 const p = pointers.current.get(e.pointerId)!;61 setT({ scale: t.scale, x: s.x + (p.x - s.cx), y: s.y + (p.y - s.cy) });62 }63 };64 const onUp = (e: React.PointerEvent) => {65 const p = pointers.current.get(e.pointerId);66 pointers.current.delete(e.pointerId);67 const s = start.current;68 if (pointers.current.size === 0 && p) {69 const dx = p.x - s.cx, dy = p.y - s.cy, dt = Date.now() - s.t;70 if (t.scale <= 1.05 && Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) {71 onSwipe(dx < 0 ? 1 : -1); // swipe → next photo72 } else if (dt < 260 && Math.abs(dx) < 8 && Math.abs(dy) < 8) {73 const now = Date.now();74 if (now - lastTap.current < 320) // double-tap: ×2.4 zoom75 setT(t.scale > 1 ? { scale: 1, x: 0, y: 0 } : { scale: 2.4, x: 0, y: 0 });76 lastTap.current = now;77 }78 if (t.scale <= 1.02) setT({ scale: 1, x: 0, y: 0 });79 }80 };8182 return (83 <img84 src={src} alt="" draggable={false}85 style={{86 transform: `translate(${t.x}px, ${t.y}px) scale(${t.scale})`,87 transition: pointers.current.size ? "none" : "transform 0.15s ease",88 touchAction: "none", cursor: t.scale > 1 ? "grab" : "zoom-out",89 }}90 onClick={(e) => e.stopPropagation()}91 onPointerDown={onDown} onPointerMove={onMove}92 onPointerUp={onUp} onPointerCancel={onUp}93 />94 );95}9697// --- Gallery: native swipe (scroll-snap) + thumbnails + fullscreen ------------98function Gallery({ images, captions, title, type }:99 { images: string[]; captions?: string[]; title: string; type?: string }) {100 const [idx, setIdx] = useState(0);101 const [zoom, setZoom] = useState(false);102 const [dead, setDead] = useState<Set<string>>(new Set());103 const track = useRef<HTMLDivElement>(null);104105 // images that fail to load: removed from the gallery on the fly (never a106 // broken-image icon); captions kept aligned107 const alive = images108 .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" }))109 .filter(({ u }) => !dead.has(u));110 const markDead = (u: string) => setDead((d) => new Set(d).add(u));111112 const onScroll = () => {113 const el = track.current;114 if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));115 };116 const goto = (i: number) =>117 track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });118119 useEffect(() => {120 if (!zoom) return;121 const onKey = (e: KeyboardEvent) => {122 if (e.key === "Escape") setZoom(false);123 if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));124 if (e.key === "ArrowRight") setIdx((i) => Math.min(alive.length - 1, i + 1));125 };126 window.addEventListener("keydown", onKey);127 // freeze the background while fullscreen (mobile)128 document.body.style.overflow = "hidden";129 return () => {130 window.removeEventListener("keydown", onKey);131 document.body.style.overflow = "";132 };133 }, [zoom, alive.length]);134135 if (alive.length === 0)136 return <div className="carousel"><div className="carousel-empty"><TypeFallback type={type} /></div></div>;137138 const cur = Math.min(idx, alive.length - 1);139 const swipe = (dir: 1 | -1) =>140 setIdx((i) => Math.min(alive.length - 1, Math.max(0, i + dir)));141142 return (143 <>144 <div className="carousel">145 <div className="carousel-track" ref={track} onScroll={onScroll}>146 {alive.map(({ u }, i) => (147 <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async"148 alt={`${title} — photo ${i + 1} of ${alive.length}`}149 onError={() => markDead(u)} onClick={() => setZoom(true)} />150 ))}151 </div>152 {alive[cur]?.cap && <span className="carousel-caption">{alive[cur].cap}</span>}153 <span className="carousel-count" aria-live="polite">{cur + 1}/{alive.length}</span>154 {cur > 0 && <button className="carousel-nav prev" aria-label="Previous photo" onClick={() => goto(cur - 1)}>‹</button>}155 {cur < alive.length - 1 && <button className="carousel-nav next" aria-label="Next photo" onClick={() => goto(cur + 1)}>›</button>}156 </div>157 {alive.length > 1 && (158 <div className="thumbs">159 {alive.map(({ u }, i) => (160 <button key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>161 <img src={u} alt="" loading="lazy" decoding="async" onError={() => markDead(u)} />162 </button>163 ))}164 </div>165 )}166 {zoom && (167 <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Enlarged photo">168 <button className="lb-close" aria-label="Close" onClick={() => setZoom(false)}>✕</button>169 {cur > 0 && <button className="lb-nav prev" aria-label="Previous" onClick={(e) => { e.stopPropagation(); setIdx(cur - 1); }}>‹</button>}170 <ZoomImg src={alive[cur].u} onSwipe={swipe} />171 {cur < alive.length - 1 && <button className="lb-nav next" aria-label="Next" onClick={(e) => { e.stopPropagation(); setIdx(cur + 1); }}>›</button>}172 <span className="lb-count">173 {alive[cur]?.cap ? `${alive[cur].cap} · ` : ""}{cur + 1} / {alive.length}174 </span>175 </div>176 )}177 </>178 );179}180181// technical `details` keys never shown in “Details”182const DETAIL_HIDDEN = new Set([183 "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited",184 "needs_image_review", "postal_code", "region", "transaction",185 "prix_pi2", "prix_m2", "listing_origin_url",186]);187188// icon for each “spec” tile (Icons.tsx)189const SPEC_ICONS: Record<string, string> = {190 "Type": "home", "Bedrooms": "bed", "Bathrooms": "bath",191 "Half baths": "drop", "Living area": "area", "Lot": "land",192 "Year built": "calendar", "MLS®": "tag",193};194195export default function ListingPage() {196 const { uid } = useParams<{ uid: string }>();197 const [l, setL] = useState<Listing | null>(null);198 const [error, setError] = useState<string | null>(null);199 // re-render when the source names arrive (Title Case fallback otherwise)200 const [, setSrcTick] = useState(0);201202 useEffect(() => {203 fetchSources().then((r) => { registerSourceNames(r.sources); setSrcTick(1); }).catch(() => {});204 if (!uid) return;205 setL(null); setError(null);206 fetchListing(uid).then(setL).catch((e) => setError(String(e)));207 window.scrollTo(0, 0);208 }, [uid]);209210 if (error)211 return (212 <div className="notice container">213 <div className="big"><Ico name="alert" size={44} /></div>214 <h2>Listing not found</h2>215 <p>{error}</p>216 <Link className="btn btn-primary" to="/">Back to homes</Link>217 </div>218 );219220 if (!l)221 return (222 <div className="container detail">223 <div className="fiche" aria-busy="true">224 <div className="skel"><div className="sk-img" /></div>225 <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>226 </div>227 </div>228 );229230 const specs: { k: string; v: string }[] = [];231 if (l.property_type) specs.push({ k: "Type", v: l.property_type });232 if (l.bedrooms != null) specs.push({ k: "Bedrooms", v: String(l.bedrooms) });233 if (l.bathrooms != null) specs.push({ k: "Bathrooms", v: String(l.bathrooms) });234 if (l.powder_rooms != null) specs.push({ k: "Half baths", v: String(l.powder_rooms) });235 if (l.area_sqft != null) specs.push({ k: "Living area", v: fmtArea(l.area_sqft)! });236 if (l.lot_sqft != null) specs.push({ k: "Lot", v: fmtArea(l.lot_sqft)! });237 if (l.year_built != null) specs.push({ k: "Year built", v: String(l.year_built) });238 if (l.mls) specs.push({ k: "MLS®", v: l.mls });239240 const rooms: Room[] = Array.isArray(l.details?.pieces) ? (l.details!.pieces as Room[]) : [];241 const detEntries = Object.entries(l.details ?? {})242 .filter(([k, v]) => !DETAIL_HIDDEN.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim());243244 const isSale = l.details?.transaction !== "location";245246 const hist = (l.price_history ?? []).filter((h) => h.price != null);247 const drop = hist.length >= 2 && hist[0].price !== hist[1].price248 ? { from: hist[1].price!, to: hist[0].price! } : null;249 const updated = l.updated_at ? fmtDate(l.updated_at) : null;250251 return (252 <div className="container detail">253 <nav className="crumbs" aria-label="Breadcrumb">254 <Link to="/">Homes</Link> ›255 {l.city && <span>{l.city}</span>} ›256 <span>{l.address || l.title}</span>257 </nav>258259 <div className="fiche">260 {/* -------- left column: gallery, price/summary, description, ---------261 -------- details, rooms — DOM order = visual order ---------------- */}262 <div className="f-col">263 <section className="f-bloc f-galerie" aria-label="Photos">264 <Gallery265 images={l.images ?? []}266 captions={Array.isArray(l.details?.photo_captions)267 ? (l.details!.photo_captions as string[]) : undefined}268 title={l.address || l.title}269 type={l.property_type}270 />271 </section>272273 <section className="f-bloc f-hero">274 <div className="price-kicker">275 {isSale ? "Asking price" : "Monthly rent"}276 </div>277 <div className="price-row">278 <div className="price">279 {fmtPrice(l.price, l.price_label)}280 {!isSale && <span className="per-month"> /month</span>}281 </div>282 {l.property_type && (283 <span className="type-chip">284 <Ico name={SPEC_ICONS["Type"]} size={13} /> {l.property_type}285 {!isSale ? " · for rent" : ""}286 {l.details?.price_from ? " · starting at" : ""}287 </span>288 )}289 </div>290 {l.price != null && l.area_sqft != null && l.area_sqft > 200 && (291 <div className="price-sub">${Math.round(l.price / l.area_sqft).toLocaleString("en-CA")} / sq ft of living area</div>292 )}293 <h1>{l.address || l.title}</h1>294 <div className="loc"><Ico name="pin" size={13} /> {[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div>295296 <div className="spec-list">297 {specs.map((s) => (298 <div className="spec-row" key={s.k}>299 <span className="spec-badge"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={15} /></span>300 <span className="spec-k">{s.k}</span>301 <b className="spec-v">{s.v}</b>302 </div>303 ))}304 </div>305306 {drop && (307 <div className={`prix-histo ${drop.to < drop.from ? "down" : ""}`}>308 <Ico name={drop.to < drop.from ? "trenddown" : "trendup"} size={16} /> Price changed from {fmtPrice(drop.from)} to <b>{fmtPrice(drop.to)}</b>309 </div>310 )}311312 {(l.broker_name || l.broker_phone) && (313 <div className="broker">314 <div className="broker-k">Listing agent</div>315 {l.broker_name && <div className="broker-name">{l.broker_name}</div>}316 {l.broker_phone && <a className="broker-tel" href={`tel:${l.broker_phone.replace(/\s/g, "")}`}><Ico name="phone" size={14} /> {l.broker_phone}</a>}317 </div>318 )}319320 <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">321 See the listing at {sourceName(l.source)} <Ico name="external" size={15} />322 </a>323 <div className="fine">324 Aggregated by House-Ka — {sourceName(l.source)}{updated ? ` · synced on ${updated}` : ""}.325 </div>326 </section>327328 {l.duplicates && l.duplicates.length > 0 && (329 <section className="f-bloc" id="publications">330 <h2>Also published on</h2>331 <p className="dups-note">332 This property was found on {l.duplicates.length}{" "}333 other site{l.duplicates.length > 1 ? "s" : ""} —334 House-Ka shows the most complete version.335 </p>336 <div className="dups-list">337 {l.duplicates.map((d) => (338 <a key={d.uid} className="dup-item" href={d.url} target="_blank" rel="noopener noreferrer">339 <span className="dup-src">{sourceName(d.source)}</span>340 {(d.broker_name || d.agency) && (341 <span className="dup-broker">{d.broker_name || d.agency}</span>342 )}343 <span className="dup-go">See the listing <Ico name="external" size={13} /></span>344 </a>345 ))}346 </div>347 </section>348 )}349350 {l.description && (351 <section className="f-bloc f-desc" id="description">352 <h2>Description</h2>353 <p className="desc-text">{l.description}</p>354 </section>355 )}356357 {detEntries.length > 0 && (358 <section className="f-bloc" id="details">359 <h2>Details</h2>360 <div className="dtable">361 {detEntries.map(([k, v]) => (362 <div className="drow" key={k}>363 <span>{k}</span>364 {/^https?:\/\//.test(String(v))365 ? <b><a href={String(v)} target="_blank" rel="noopener noreferrer">Open ↗</a></b>366 : <b>{String(v)}</b>}367 </div>368 ))}369 </div>370 </section>371 )}372373 {rooms.length > 0 && (374 <section className="f-bloc" id="rooms">375 <h2>Rooms</h2>376 <div className="rooms-wrap">377 <table className="rooms">378 <thead><tr><th>Room</th><th>Level</th><th>Dimensions</th><th>Flooring</th></tr></thead>379 <tbody>380 {rooms.map((r, i) => (381 <tr key={i}>382 <td>{r.nom || "—"}</td><td>{r.niveau || "—"}</td>383 <td>{r.dimensions || "—"}</td><td>{r.revetement || "—"}</td>384 </tr>385 ))}386 </tbody>387 </table>388 </div>389 </section>390 )}391392 </div>393394 {/* -------- right column (desktop): features, map --------------------- */}395 <div className="f-col">396 {l.features && l.features.length > 0 && (397 <section className="f-bloc" id="features">398 <h2>Features</h2>399 <div className="amenity-grid">400 {l.features.map((f, i) => (401 <span className="amenity-it" key={i}>402 <span className="am-ico"><AmenityIco label={f} /></span>403 <span className="am-txt">{f}</span>404 </span>405 ))}406 </div>407 </section>408 )}409410 {l.lat != null && l.lng != null && (411 <section className="f-bloc" id="map">412 <h2>Location</h2>413 <Suspense fallback={<div className="lmap3d lmap3d-skel map-loading">Loading the map…</div>}>414 <PropertyMap415 uid={l.uid} lat={l.lat} lng={l.lng} price={l.price}416 propertyType={l.property_type} address={l.address || l.title}417 city={l.city} image={l.images?.[0]}418 />419 </Suspense>420 </section>421 )}422 </div>423 </div>424425 {/* financing: real mortgage rates + Canadian calculator */}426 {isSale && <Financing price={l.price} />}427428 {/* nearby amenities: full width, AFTER the property info (correct mobile order) */}429 <NearbyPlaces lat={l.lat} lng={l.lng} />430431 <div className="fine f-foot">432 Prices and availability are those displayed by the source — every433 listing links back to the brokerage's original page.434 </div>435436 <div className="cta-sticky">437 <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}</span>438 <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">439 See at {sourceName(l.source)} ↗440 </a>441 </div>442 </div>443 );444}445