// ----------------------------------------------------------------------------- // Home-Ka — US real-estate aggregator (Groupe KA) // Author: Simon-Pierre Boucher — contact@spboucher.ai // pages/Listing.tsx : full property detail page // Groupe KA standard section order (DOM order = visual order, mobile AND // desktop — no CSS `order:` / `column-reverse` reordering): // 1. photo gallery (hero) → 2. price + status badge + address + key facts → // 3. description → 4. features → 5. practical details → 6. price history → // 7. map → 8. "Also listed on" + PROPERTY history → footer. // ----------------------------------------------------------------------------- import { Suspense, lazy, useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Listing, PropertyListingRef, STATUS_LABELS, fetchListing, fetchSources, fmtArea, fmtBaths, fmtCityLine, fmtDate, fmtPrice, registerSourceNames, sourceName, } from "../api"; const PropertyMap = lazy(() => import("../components/PropertyMap")); import { Ico } from "../components/Icons"; import AmenityIco from "../components/AmenityIco"; import { TypeFallback } from "../components/PropertyImg"; // --- 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 switching photos 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 are removed 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 during 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 "Practical details" const DETAIL_HIDDEN = new Set([ "price_from", "cover_thumb", "photo_captions", "img_audited", "needs_image_review", "listing_origin_url", ]); // icon for each key-fact row (Icons.tsx) const SPEC_ICONS: Record = { "Type": "home", "Bedrooms": "bed", "Bathrooms": "bath", "Living area": "area", "Lot size": "land", "Year built": "calendar", "MLS #": "tag", }; const STATUS_CLASS: Record = { "active": "st-active", "pending": "st-pending", "sold": "st-sold", "withdrawn": "st-withdrawn", "coming-soon": "st-coming", }; function historyDate(ts: number | null): string { if (ts == null) return "—"; return fmtDate(ts > 1e12 ? ts / 1000 : ts); } export default function ListingPage() { const { uid } = useParams<{ uid: string }>(); const [l, setL] = useState(null); const [error, setError] = useState(null); // re-render when source names arrive (otherwise Title Case fallback) 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]); if (error) return (

Property not found

{error}

Back to homes
); if (!l) return (
); // 2. key facts 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: fmtBaths(l.bathrooms) }); if (l.living_area_sqft != null) specs.push({ k: "Living area", v: fmtArea(l.living_area_sqft)! }); if (l.lot_size_sqft != null) specs.push({ k: "Lot size", v: fmtArea(l.lot_size_sqft)! }); if (l.year_built != null) specs.push({ k: "Year built", v: String(l.year_built) }); if (l.mls_id) specs.push({ k: "MLS #", v: l.mls_id }); // 5. practical details: structured fields first, then source `details` const practical: { k: string; v: string }[] = []; if (l.property_subtype) practical.push({ k: "Subtype", v: l.property_subtype }); if (l.status) practical.push({ k: "Status", v: STATUS_LABELS[l.status] ?? l.status }); if (l.county) practical.push({ k: "County", v: l.county }); if (l.zip_code) practical.push({ k: "ZIP code", v: l.zip_code }); if (l.apn) practical.push({ k: "APN (parcel #)", v: l.apn }); if (l.mls_name) practical.push({ k: "MLS", v: l.mls_name }); if (l.listed_at) practical.push({ k: "Listed on", v: l.listed_at }); if (l.days_on_market != null) practical.push({ k: "Days on market", v: String(l.days_on_market) }); if (l.brokerage_name) practical.push({ k: "Brokerage", v: l.brokerage_name }); if (l.office_name) practical.push({ k: "Office", v: l.office_name }); if (l.agent_name) practical.push({ k: "Listing agent", v: l.agent_name }); const detEntries: [string, string][] = Object.entries(l.details ?? {}) .filter(([k, v]) => !DETAIL_HIDDEN.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim()) .map(([k, v]) => [k, String(v)]); // 6. price history 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; const propHistory: PropertyListingRef[] = l.property?.listing_history ?? []; const statusLabel = STATUS_LABELS[l.status] ?? l.status; const addr = l.street_address || l.title; const cityLine = fmtCityLine(l); return (
{/* -------- main column: gallery, price/summary, description, ---------- -------- features, details, price history — DOM order = visual ---- */}
{/* 1. photo gallery (hero) */}
{/* 2. price + status badge + address + key facts */}
List price
{fmtPrice(l.list_price, l.price_label)}
{l.status && ( {statusLabel} )} {l.property_type && ( {l.property_type} {l.details?.price_from ? " · from" : ""} )}
{l.list_price != null && l.living_area_sqft != null && l.living_area_sqft > 200 && (
${Math.round(l.list_price / l.living_area_sqft).toLocaleString("en-US")} / sq ft
)}

{addr}

{[cityLine, l.county ? `${l.county} County` : ""].filter(Boolean).join(" · ")}
{specs.map((s) => (
{s.k} {s.v}
))}
{drop && (
Price changed from {fmtPrice(drop.from)} to {fmtPrice(drop.to)}
)} {(l.agent_name || l.agent_phone || l.brokerage_name) && (
Listed by
{l.agent_name &&
{l.agent_name}
} {l.brokerage_name &&
{l.brokerage_name}
} {l.agent_phone && {l.agent_phone}}
)} View the original listing at {sourceName(l.source)}
Aggregated by Home-Ka — {sourceName(l.source)}{updated ? ` · synced ${updated}` : ""}.
{/* 3. description */} {l.description && (

Description

{l.description}

)} {/* 4. features */} {l.features && l.features.length > 0 && (

Features

{l.features.map((f, i) => ( {f} ))}
)} {/* 5. practical details */} {(practical.length > 0 || detEntries.length > 0) && (

Practical details

{practical.map(({ k, v }) => (
{k}{v}
))} {detEntries.map(([k, v]) => (
{k} {/^https?:\/\//.test(v) ? Open ↗ : {v}}
))}
)} {/* 6. price history */} {hist.length > 0 && (

Price history

{hist.map((h, i) => (
{historyDate(h.ts)} {fmtPrice(h.price)}
))}
)}
{/* -------- second column (desktop): map, other publications ---------- */}
{/* 7. map */} {l.lat != null && l.lng != null && (

Location

Loading the map…
}> )} {/* 8a. also listed on (duplicates) */} {l.duplicates && l.duplicates.length > 0 && (

Also listed on

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

)} {/* 8b. PROPERTY history — every listing ever seen for this home */} {propHistory.length > 0 && (

Property history

Home-Ka tracks the physical property behind each listing — every publication of this home across sources, past and present.

{propHistory.map((h) => (
{h.uid === l.uid ? sourceName(h.source) : {sourceName(h.source)}} {" · "}{historyDate(h.first_seen)} {h.active ? "" : " (inactive)"} {fmtPrice(h.list_price, "—")} {h.status ? ` · ${STATUS_LABELS[h.status] ?? h.status}` : ""}
))}
)}
Prices and availability are those displayed by the source — every listing links back to the original announcement.
{fmtPrice(l.list_price, l.price_label)} View at {sourceName(l.source)} ↗
); }