Python 67%
TypeScript 18.2%
CSS 14.4%
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";21import { useAccount } from "../account";2223// --- Lightbox: pinch to zoom + pan + swipe between photos ---------------------24function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) {25 const [t, setT] = useState({ scale: 1, x: 0, y: 0 });26 const pointers = useRef(new Map<number, { x: number; y: number }>());27 const start = useRef({ scale: 1, x: 0, y: 0, dist: 0, cx: 0, cy: 0, t: 0 });28 const lastTap = useRef(0);2930 // reset when the photo changes31 useEffect(() => { setT({ scale: 1, x: 0, y: 0 }); }, [src]);3233 const dist = () => {34 const p = [...pointers.current.values()];35 return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);36 };37 const center = () => {38 const p = [...pointers.current.values()];39 return p.length < 240 ? p[0] ?? { x: 0, y: 0 }41 : { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 };42 };4344 const onDown = (e: React.PointerEvent) => {45 (e.target as HTMLElement).setPointerCapture(e.pointerId);46 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });47 const c = center();48 start.current = { scale: t.scale, x: t.x, y: t.y, dist: dist(), cx: c.x, cy: c.y, t: Date.now() };49 };50 const onMove = (e: React.PointerEvent) => {51 if (!pointers.current.has(e.pointerId)) return;52 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });53 const s = start.current;54 if (pointers.current.size >= 2 && s.dist > 0) {55 // pinch: zoom around the two-finger midpoint56 const scale = Math.min(4, Math.max(1, (dist() / s.dist) * s.scale));57 const c = center();58 setT({ scale, x: s.x + (c.x - s.cx), y: s.y + (c.y - s.cy) });59 } else if (pointers.current.size === 1 && t.scale > 1) {60 // pan once zoomed61 const p = pointers.current.get(e.pointerId)!;62 setT({ scale: t.scale, x: s.x + (p.x - s.cx), y: s.y + (p.y - s.cy) });63 }64 };65 const onUp = (e: React.PointerEvent) => {66 const p = pointers.current.get(e.pointerId);67 pointers.current.delete(e.pointerId);68 const s = start.current;69 if (pointers.current.size === 0 && p) {70 const dx = p.x - s.cx, dy = p.y - s.cy, dt = Date.now() - s.t;71 if (t.scale <= 1.05 && Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) {72 onSwipe(dx < 0 ? 1 : -1); // swipe → next photo73 } else if (dt < 260 && Math.abs(dx) < 8 && Math.abs(dy) < 8) {74 const now = Date.now();75 if (now - lastTap.current < 320) // double-tap: ×2.4 zoom76 setT(t.scale > 1 ? { scale: 1, x: 0, y: 0 } : { scale: 2.4, x: 0, y: 0 });77 lastTap.current = now;78 }79 if (t.scale <= 1.02) setT({ scale: 1, x: 0, y: 0 });80 }81 };8283 return (84 <img85 src={src} alt="" draggable={false}86 style={{87 transform: `translate(${t.x}px, ${t.y}px) scale(${t.scale})`,88 transition: pointers.current.size ? "none" : "transform 0.15s ease",89 touchAction: "none", cursor: t.scale > 1 ? "grab" : "zoom-out",90 }}91 onClick={(e) => e.stopPropagation()}92 onPointerDown={onDown} onPointerMove={onMove}93 onPointerUp={onUp} onPointerCancel={onUp}94 />95 );96}9798// --- Gallery: native swipe (scroll-snap) + thumbnails + fullscreen ------------99function Gallery({ images, captions, title, type }:100 { images: string[]; captions?: string[]; title: string; type?: string }) {101 const [idx, setIdx] = useState(0);102 const [zoom, setZoom] = useState(false);103 const [dead, setDead] = useState<Set<string>>(new Set());104 const track = useRef<HTMLDivElement>(null);105106 // images that fail to load: removed from the gallery on the fly (never a107 // broken-image icon); captions kept aligned108 const alive = images109 .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" }))110 .filter(({ u }) => !dead.has(u));111 const markDead = (u: string) => setDead((d) => new Set(d).add(u));112113 const onScroll = () => {114 const el = track.current;115 if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));116 };117 const goto = (i: number) =>118 track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });119120 useEffect(() => {121 if (!zoom) return;122 const onKey = (e: KeyboardEvent) => {123 if (e.key === "Escape") setZoom(false);124 if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));125 if (e.key === "ArrowRight") setIdx((i) => Math.min(alive.length - 1, i + 1));126 };127 window.addEventListener("keydown", onKey);128 // freeze the background while fullscreen (mobile)129 document.body.style.overflow = "hidden";130 return () => {131 window.removeEventListener("keydown", onKey);132 document.body.style.overflow = "";133 };134 }, [zoom, alive.length]);135136 if (alive.length === 0)137 return <div className="carousel"><div className="carousel-empty"><TypeFallback type={type} /></div></div>;138139 const cur = Math.min(idx, alive.length - 1);140 const swipe = (dir: 1 | -1) =>141 setIdx((i) => Math.min(alive.length - 1, Math.max(0, i + dir)));142143 return (144 <>145 <div className="carousel">146 <div className="carousel-track" ref={track} onScroll={onScroll}>147 {alive.map(({ u }, i) => (148 <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async"149 alt={`${title} — photo ${i + 1} of ${alive.length}`}150 onError={() => markDead(u)} onClick={() => setZoom(true)} />151 ))}152 </div>153 {alive[cur]?.cap && <span className="carousel-caption">{alive[cur].cap}</span>}154 <span className="carousel-count" aria-live="polite">{cur + 1}/{alive.length}</span>155 {cur > 0 && <button className="carousel-nav prev" aria-label="Previous photo" onClick={() => goto(cur - 1)}>‹</button>}156 {cur < alive.length - 1 && <button className="carousel-nav next" aria-label="Next photo" onClick={() => goto(cur + 1)}>›</button>}157 </div>158 {alive.length > 1 && (159 <div className="thumbs">160 {alive.map(({ u }, i) => (161 <button key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>162 <img src={u} alt="" loading="lazy" decoding="async" onError={() => markDead(u)} />163 </button>164 ))}165 </div>166 )}167 {zoom && (168 <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Enlarged photo">169 <button className="lb-close" aria-label="Close" onClick={() => setZoom(false)}>✕</button>170 {cur > 0 && <button className="lb-nav prev" aria-label="Previous" onClick={(e) => { e.stopPropagation(); setIdx(cur - 1); }}>‹</button>}171 <ZoomImg src={alive[cur].u} onSwipe={swipe} />172 {cur < alive.length - 1 && <button className="lb-nav next" aria-label="Next" onClick={(e) => { e.stopPropagation(); setIdx(cur + 1); }}>›</button>}173 <span className="lb-count">174 {alive[cur]?.cap ? `${alive[cur].cap} · ` : ""}{cur + 1} / {alive.length}175 </span>176 </div>177 )}178 </>179 );180}181182// technical `details` keys never shown in “Details”183const DETAIL_HIDDEN = new Set([184 "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited",185 "needs_image_review", "postal_code", "region", "transaction",186 "prix_pi2", "prix_m2", "listing_origin_url",187]);188189// icon for each “spec” tile (Icons.tsx)190const SPEC_ICONS: Record<string, string> = {191 "Type": "home", "Bedrooms": "bed", "Bathrooms": "bath",192 "Half baths": "drop", "Living area": "area", "Lot": "land",193 "Year built": "calendar", "MLS®": "tag",194};195196export default function ListingPage() {197 const { uid } = useParams<{ uid: string }>();198 const [l, setL] = useState<Listing | null>(null);199 const [error, setError] = useState<string | null>(null);200 // re-render when the source names arrive (Title Case fallback otherwise)201 const [, setSrcTick] = useState(0);202203 useEffect(() => {204 fetchSources().then((r) => { registerSourceNames(r.sources); setSrcTick(1); }).catch(() => {});205 if (!uid) return;206 setL(null); setError(null);207 fetchListing(uid).then(setL).catch((e) => setError(String(e)));208 window.scrollTo(0, 0);209 }, [uid]);210211 const { favs, toggleFav, enabled: accountEnabled } = useAccount();212213 if (error)214 return (215 <div className="notice container">216 <div className="big"><Ico name="alert" size={44} /></div>217 <h2>Listing not found</h2>218 <p>{error}</p>219 <Link className="btn btn-primary" to="/">Back to homes</Link>220 </div>221 );222223 if (!l)224 return (225 <div className="container detail">226 <div className="fiche" aria-busy="true">227 <div className="skel"><div className="sk-img" /></div>228 <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>229 </div>230 </div>231 );232233 const specs: { k: string; v: string }[] = [];234 if (l.property_type) specs.push({ k: "Type", v: l.property_type });235 if (l.bedrooms != null) specs.push({ k: "Bedrooms", v: String(l.bedrooms) });236 if (l.bathrooms != null) specs.push({ k: "Bathrooms", v: String(l.bathrooms) });237 if (l.powder_rooms != null) specs.push({ k: "Half baths", v: String(l.powder_rooms) });238 if (l.area_sqft != null) specs.push({ k: "Living area", v: fmtArea(l.area_sqft)! });239 if (l.lot_sqft != null) specs.push({ k: "Lot", v: fmtArea(l.lot_sqft)! });240 if (l.year_built != null) specs.push({ k: "Year built", v: String(l.year_built) });241 if (l.mls) specs.push({ k: "MLS®", v: l.mls });242243 const rooms: Room[] = Array.isArray(l.details?.pieces) ? (l.details!.pieces as Room[]) : [];244 const detEntries = Object.entries(l.details ?? {})245 .filter(([k, v]) => !DETAIL_HIDDEN.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim());246247 const isSale = l.details?.transaction !== "location";248249 const hist = (l.price_history ?? []).filter((h) => h.price != null);250 const drop = hist.length >= 2 && hist[0].price !== hist[1].price251 ? { from: hist[1].price!, to: hist[0].price! } : null;252 const updated = l.updated_at ? fmtDate(l.updated_at) : null;253254 return (255 <div className="container detail">256 <nav className="crumbs" aria-label="Breadcrumb">257 <Link to="/">Homes</Link> ›258 {l.city && <span>{l.city}</span>} ›259 <span>{l.address || l.title}</span>260 </nav>261262 <div className="fiche">263 {/* -------- left column: gallery, price/summary, description, ---------264 -------- details, rooms — DOM order = visual order ---------------- */}265 <div className="f-col">266 <section className="f-bloc f-galerie" aria-label="Photos">267 <Gallery268 images={l.images ?? []}269 captions={Array.isArray(l.details?.photo_captions)270 ? (l.details!.photo_captions as string[]) : undefined}271 title={l.address || l.title}272 type={l.property_type}273 />274 </section>275276 <section className="f-bloc f-hero">277 <div className="price-kicker">278 {isSale ? "Asking price" : "Monthly rent"}279 </div>280 <div className="price-row">281 <div className="price">282 {fmtPrice(l.price, l.price_label)}283 {!isSale && <span className="per-month"> /month</span>}284 </div>285 {l.property_type && (286 <span className="type-chip">287 <Ico name={SPEC_ICONS["Type"]} size={13} /> {l.property_type}288 {!isSale ? " · for rent" : ""}289 {l.details?.price_from ? " · starting at" : ""}290 </span>291 )}292 </div>293 {l.price != null && l.area_sqft != null && l.area_sqft > 200 && (294 <div className="price-sub">${Math.round(l.price / l.area_sqft).toLocaleString("en-CA")} / sq ft of living area</div>295 )}296 <h1>297 {l.address || l.title}298 {accountEnabled && (299 <button300 className={`fav-heart ${favs.has(l.uid) ? "on" : ""}`}301 aria-label={favs.has(l.uid) ? "Remove from favourites" : "Add to favourites"}302 onClick={() => toggleFav(l)}303 >304 {favs.has(l.uid) ? "♥" : "♡"}305 </button>306 )}307 </h1>308 <div className="loc"><Ico name="pin" size={13} /> {[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div>309310 <div className="spec-list">311 {specs.map((s) => (312 <div className="spec-row" key={s.k}>313 <span className="spec-badge"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={15} /></span>314 <span className="spec-k">{s.k}</span>315 <b className="spec-v">{s.v}</b>316 </div>317 ))}318 </div>319320 {drop && (321 <div className={`prix-histo ${drop.to < drop.from ? "down" : ""}`}>322 <Ico name={drop.to < drop.from ? "trenddown" : "trendup"} size={16} /> Price changed from {fmtPrice(drop.from)} to <b>{fmtPrice(drop.to)}</b>323 </div>324 )}325326 {(l.broker_name || l.broker_phone) && (327 <div className="broker">328 <div className="broker-k">Listing agent</div>329 {l.broker_name && <div className="broker-name">{l.broker_name}</div>}330 {l.broker_phone && <a className="broker-tel" href={`tel:${l.broker_phone.replace(/\s/g, "")}`}><Ico name="phone" size={14} /> {l.broker_phone}</a>}331 </div>332 )}333334 <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">335 See the listing at {sourceName(l.source)} <Ico name="external" size={15} />336 </a>337 <div className="fine">338 Aggregated by House-Ka — {sourceName(l.source)}{updated ? ` · synced on ${updated}` : ""}.339 </div>340 </section>341342 {l.duplicates && l.duplicates.length > 0 && (343 <section className="f-bloc" id="publications">344 <h2>Also published on</h2>345 <p className="dups-note">346 This property was found on {l.duplicates.length}{" "}347 other site{l.duplicates.length > 1 ? "s" : ""} —348 House-Ka shows the most complete version.349 </p>350 <div className="dups-list">351 {l.duplicates.map((d) => (352 <a key={d.uid} className="dup-item" href={d.url} target="_blank" rel="noopener noreferrer">353 <span className="dup-src">{sourceName(d.source)}</span>354 {(d.broker_name || d.agency) && (355 <span className="dup-broker">{d.broker_name || d.agency}</span>356 )}357 <span className="dup-go">See the listing <Ico name="external" size={13} /></span>358 </a>359 ))}360 </div>361 </section>362 )}363364 {l.description && (365 <section className="f-bloc f-desc" id="description">366 <h2>Description</h2>367 <p className="desc-text">{l.description}</p>368 </section>369 )}370371 {detEntries.length > 0 && (372 <section className="f-bloc" id="details">373 <h2>Details</h2>374 <div className="dtable">375 {detEntries.map(([k, v]) => (376 <div className="drow" key={k}>377 <span>{k}</span>378 {/^https?:\/\//.test(String(v))379 ? <b><a href={String(v)} target="_blank" rel="noopener noreferrer">Open ↗</a></b>380 : <b>{String(v)}</b>}381 </div>382 ))}383 </div>384 </section>385 )}386387 {rooms.length > 0 && (388 <section className="f-bloc" id="rooms">389 <h2>Rooms</h2>390 <div className="rooms-wrap">391 <table className="rooms">392 <thead><tr><th>Room</th><th>Level</th><th>Dimensions</th><th>Flooring</th></tr></thead>393 <tbody>394 {rooms.map((r, i) => (395 <tr key={i}>396 <td>{r.nom || "—"}</td><td>{r.niveau || "—"}</td>397 <td>{r.dimensions || "—"}</td><td>{r.revetement || "—"}</td>398 </tr>399 ))}400 </tbody>401 </table>402 </div>403 </section>404 )}405406 </div>407408 {/* -------- right column (desktop): features, map --------------------- */}409 <div className="f-col">410 {l.features && l.features.length > 0 && (411 <section className="f-bloc" id="features">412 <h2>Features</h2>413 <div className="amenity-grid">414 {l.features.map((f, i) => (415 <span className="amenity-it" key={i}>416 <span className="am-ico"><AmenityIco label={f} /></span>417 <span className="am-txt">{f}</span>418 </span>419 ))}420 </div>421 </section>422 )}423424 {l.lat != null && l.lng != null && (425 <section className="f-bloc" id="map">426 <h2>Location</h2>427 <Suspense fallback={<div className="lmap3d lmap3d-skel map-loading">Loading the map…</div>}>428 <PropertyMap429 uid={l.uid} lat={l.lat} lng={l.lng} price={l.price}430 propertyType={l.property_type} address={l.address || l.title}431 city={l.city} image={l.images?.[0]}432 />433 </Suspense>434 </section>435 )}436 </div>437 </div>438439 {/* financing: real mortgage rates + Canadian calculator */}440 {isSale && <Financing price={l.price} />}441442 {/* nearby amenities: full width, AFTER the property info (correct mobile order) —443 the banner basket is per-province (Save-On-Foods in BC, Sobeys in the444 Atlantic, Co-op in SK, LCBO in ON…) */}445 <NearbyPlaces lat={l.lat} lng={l.lng} region={l.region} />446447 <div className="fine f-foot">448 Prices and availability are those displayed by the source — every449 listing links back to the brokerage's original page.450 </div>451452 <div className="cta-sticky">453 <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}</span>454 <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">455 See at {sourceName(l.source)} ↗456 </a>457 </div>458 </div>459 );460}461