Python 49.6%
TypeScript 25.5%
CSS 24.1%
1// -----------------------------------------------------------------------------2// Home-Ka — US real-estate aggregator (Groupe KA)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Listing.tsx : full property detail page5// Groupe KA standard section order (DOM order = visual order, mobile AND6// desktop — no CSS `order:` / `column-reverse` reordering):7// 1. photo gallery (hero) → 2. price + status badge + address + key facts →8// 3. description → 4. features → 5. practical details → 6. price history →9// 7. map → 8. "Also listed on" + PROPERTY history → footer.10// -----------------------------------------------------------------------------11import { Suspense, lazy, useEffect, useRef, useState } from "react";12import { Link, useParams } from "react-router-dom";13import {14 Listing, PropertyListingRef, STATUS_LABELS, fetchListing, fetchSources,15 fmtArea, fmtBaths, fmtCityLine, fmtDate, fmtPrice,16 registerSourceNames, sourceName,17} from "../api";1819const PropertyMap = lazy(() => import("../components/PropertyMap"));20import { Ico } from "../components/Icons";21import AmenityIco from "../components/AmenityIco";22import { TypeFallback } from "../components/PropertyImg";2324// --- Lightbox: pinch to zoom + pan + swipe between photos ---------------------25function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) {26 const [t, setT] = useState({ scale: 1, x: 0, y: 0 });27 const pointers = useRef(new Map<number, { x: number; y: number }>());28 const start = useRef({ scale: 1, x: 0, y: 0, dist: 0, cx: 0, cy: 0, t: 0 });29 const lastTap = useRef(0);3031 // reset when switching photos32 useEffect(() => { setT({ scale: 1, x: 0, y: 0 }); }, [src]);3334 const dist = () => {35 const p = [...pointers.current.values()];36 return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);37 };38 const center = () => {39 const p = [...pointers.current.values()];40 return p.length < 241 ? p[0] ?? { x: 0, y: 0 }42 : { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 };43 };4445 const onDown = (e: React.PointerEvent) => {46 (e.target as HTMLElement).setPointerCapture(e.pointerId);47 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });48 const c = center();49 start.current = { scale: t.scale, x: t.x, y: t.y, dist: dist(), cx: c.x, cy: c.y, t: Date.now() };50 };51 const onMove = (e: React.PointerEvent) => {52 if (!pointers.current.has(e.pointerId)) return;53 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });54 const s = start.current;55 if (pointers.current.size >= 2 && s.dist > 0) {56 // pinch: zoom around the two-finger midpoint57 const scale = Math.min(4, Math.max(1, (dist() / s.dist) * s.scale));58 const c = center();59 setT({ scale, x: s.x + (c.x - s.cx), y: s.y + (c.y - s.cy) });60 } else if (pointers.current.size === 1 && t.scale > 1) {61 // pan once zoomed62 const p = pointers.current.get(e.pointerId)!;63 setT({ scale: t.scale, x: s.x + (p.x - s.cx), y: s.y + (p.y - s.cy) });64 }65 };66 const onUp = (e: React.PointerEvent) => {67 const p = pointers.current.get(e.pointerId);68 pointers.current.delete(e.pointerId);69 const s = start.current;70 if (pointers.current.size === 0 && p) {71 const dx = p.x - s.cx, dy = p.y - s.cy, dt = Date.now() - s.t;72 if (t.scale <= 1.05 && Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) {73 onSwipe(dx < 0 ? 1 : -1); // swipe → next photo74 } else if (dt < 260 && Math.abs(dx) < 8 && Math.abs(dy) < 8) {75 const now = Date.now();76 if (now - lastTap.current < 320) // double-tap: ×2.4 zoom77 setT(t.scale > 1 ? { scale: 1, x: 0, y: 0 } : { scale: 2.4, x: 0, y: 0 });78 lastTap.current = now;79 }80 if (t.scale <= 1.02) setT({ scale: 1, x: 0, y: 0 });81 }82 };8384 return (85 <img86 src={src} alt="" draggable={false}87 style={{88 transform: `translate(${t.x}px, ${t.y}px) scale(${t.scale})`,89 transition: pointers.current.size ? "none" : "transform 0.15s ease",90 touchAction: "none", cursor: t.scale > 1 ? "grab" : "zoom-out",91 }}92 onClick={(e) => e.stopPropagation()}93 onPointerDown={onDown} onPointerMove={onMove}94 onPointerUp={onUp} onPointerCancel={onUp}95 />96 );97}9899// --- Gallery: native swipe (scroll-snap) + thumbnails + fullscreen -------------100function Gallery({ images, captions, title, type }:101 { images: string[]; captions?: string[]; title: string; type?: string }) {102 const [idx, setIdx] = useState(0);103 const [zoom, setZoom] = useState(false);104 const [dead, setDead] = useState<Set<string>>(new Set());105 const track = useRef<HTMLDivElement>(null);106107 // images that fail to load are removed on the fly (never a broken-image108 // icon); captions kept aligned109 const alive = images110 .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" }))111 .filter(({ u }) => !dead.has(u));112 const markDead = (u: string) => setDead((d) => new Set(d).add(u));113114 const onScroll = () => {115 const el = track.current;116 if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));117 };118 const goto = (i: number) =>119 track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });120121 useEffect(() => {122 if (!zoom) return;123 const onKey = (e: KeyboardEvent) => {124 if (e.key === "Escape") setZoom(false);125 if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));126 if (e.key === "ArrowRight") setIdx((i) => Math.min(alive.length - 1, i + 1));127 };128 window.addEventListener("keydown", onKey);129 // freeze the background during fullscreen (mobile)130 document.body.style.overflow = "hidden";131 return () => {132 window.removeEventListener("keydown", onKey);133 document.body.style.overflow = "";134 };135 }, [zoom, alive.length]);136137 if (alive.length === 0)138 return <div className="carousel"><div className="carousel-empty"><TypeFallback type={type} /></div></div>;139140 const cur = Math.min(idx, alive.length - 1);141 const swipe = (dir: 1 | -1) =>142 setIdx((i) => Math.min(alive.length - 1, Math.max(0, i + dir)));143144 return (145 <>146 <div className="carousel">147 <div className="carousel-track" ref={track} onScroll={onScroll}>148 {alive.map(({ u }, i) => (149 <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async"150 alt={`${title} — photo ${i + 1} of ${alive.length}`}151 onError={() => markDead(u)} onClick={() => setZoom(true)} />152 ))}153 </div>154 {alive[cur]?.cap && <span className="carousel-caption">{alive[cur].cap}</span>}155 <span className="carousel-count" aria-live="polite">{cur + 1}/{alive.length}</span>156 {cur > 0 && <button className="carousel-nav prev" aria-label="Previous photo" onClick={() => goto(cur - 1)}>‹</button>}157 {cur < alive.length - 1 && <button className="carousel-nav next" aria-label="Next photo" onClick={() => goto(cur + 1)}>›</button>}158 </div>159 {alive.length > 1 && (160 <div className="thumbs">161 {alive.map(({ u }, i) => (162 <button key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>163 <img src={u} alt="" loading="lazy" decoding="async" onError={() => markDead(u)} />164 </button>165 ))}166 </div>167 )}168 {zoom && (169 <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Enlarged photo">170 <button className="lb-close" aria-label="Close" onClick={() => setZoom(false)}>✕</button>171 {cur > 0 && <button className="lb-nav prev" aria-label="Previous" onClick={(e) => { e.stopPropagation(); setIdx(cur - 1); }}>‹</button>}172 <ZoomImg src={alive[cur].u} onSwipe={swipe} />173 {cur < alive.length - 1 && <button className="lb-nav next" aria-label="Next" onClick={(e) => { e.stopPropagation(); setIdx(cur + 1); }}>›</button>}174 <span className="lb-count">175 {alive[cur]?.cap ? `${alive[cur].cap} · ` : ""}{cur + 1} / {alive.length}176 </span>177 </div>178 )}179 </>180 );181}182183// technical `details` keys never shown in "Practical details"184const DETAIL_HIDDEN = new Set([185 "price_from", "cover_thumb", "photo_captions", "img_audited",186 "needs_image_review", "listing_origin_url",187]);188189// icon for each key-fact row (Icons.tsx)190const SPEC_ICONS: Record<string, string> = {191 "Type": "home", "Bedrooms": "bed", "Bathrooms": "bath",192 "Living area": "area", "Lot size": "land",193 "Year built": "calendar", "MLS #": "tag",194};195196const STATUS_CLASS: Record<string, string> = {197 "active": "st-active", "pending": "st-pending", "sold": "st-sold",198 "withdrawn": "st-withdrawn", "coming-soon": "st-coming",199};200201function historyDate(ts: number | null): string {202 if (ts == null) return "—";203 return fmtDate(ts > 1e12 ? ts / 1000 : ts);204}205206export default function ListingPage() {207 const { uid } = useParams<{ uid: string }>();208 const [l, setL] = useState<Listing | null>(null);209 const [error, setError] = useState<string | null>(null);210 // re-render when source names arrive (otherwise Title Case fallback)211 const [, setSrcTick] = useState(0);212213 useEffect(() => {214 fetchSources().then((r) => { registerSourceNames(r.sources); setSrcTick(1); }).catch(() => {});215 if (!uid) return;216 setL(null); setError(null);217 fetchListing(uid).then(setL).catch((e) => setError(String(e)));218 window.scrollTo(0, 0);219 }, [uid]);220221 if (error)222 return (223 <div className="notice container">224 <div className="big"><Ico name="alert" size={44} /></div>225 <h2>Property not found</h2>226 <p>{error}</p>227 <Link className="btn btn-primary" to="/">Back to homes</Link>228 </div>229 );230231 if (!l)232 return (233 <div className="container detail">234 <div className="fiche" aria-busy="true">235 <div className="skel"><div className="sk-img" /></div>236 <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>237 </div>238 </div>239 );240241 // 2. key facts242 const specs: { k: string; v: string }[] = [];243 if (l.property_type) specs.push({ k: "Type", v: l.property_type });244 if (l.bedrooms != null) specs.push({ k: "Bedrooms", v: String(l.bedrooms) });245 if (l.bathrooms != null) specs.push({ k: "Bathrooms", v: fmtBaths(l.bathrooms) });246 if (l.living_area_sqft != null) specs.push({ k: "Living area", v: fmtArea(l.living_area_sqft)! });247 if (l.lot_size_sqft != null) specs.push({ k: "Lot size", v: fmtArea(l.lot_size_sqft)! });248 if (l.year_built != null) specs.push({ k: "Year built", v: String(l.year_built) });249 if (l.mls_id) specs.push({ k: "MLS #", v: l.mls_id });250251 // 5. practical details: structured fields first, then source `details`252 const practical: { k: string; v: string }[] = [];253 if (l.property_subtype) practical.push({ k: "Subtype", v: l.property_subtype });254 if (l.status) practical.push({ k: "Status", v: STATUS_LABELS[l.status] ?? l.status });255 if (l.county) practical.push({ k: "County", v: l.county });256 if (l.zip_code) practical.push({ k: "ZIP code", v: l.zip_code });257 if (l.apn) practical.push({ k: "APN (parcel #)", v: l.apn });258 if (l.mls_name) practical.push({ k: "MLS", v: l.mls_name });259 if (l.listed_at) practical.push({ k: "Listed on", v: l.listed_at });260 if (l.days_on_market != null) practical.push({ k: "Days on market", v: String(l.days_on_market) });261 if (l.brokerage_name) practical.push({ k: "Brokerage", v: l.brokerage_name });262 if (l.office_name) practical.push({ k: "Office", v: l.office_name });263 if (l.agent_name) practical.push({ k: "Listing agent", v: l.agent_name });264 const detEntries: [string, string][] = Object.entries(l.details ?? {})265 .filter(([k, v]) => !DETAIL_HIDDEN.has(k)266 && (typeof v === "string" || typeof v === "number") && String(v).trim())267 .map(([k, v]) => [k, String(v)]);268269 // 6. price history270 const hist = (l.price_history ?? []).filter((h) => h.price != null);271 const drop = hist.length >= 2 && hist[0].price !== hist[1].price272 ? { from: hist[1].price!, to: hist[0].price! } : null;273 const updated = l.updated_at ? fmtDate(l.updated_at) : null;274275 const propHistory: PropertyListingRef[] = l.property?.listing_history ?? [];276 const statusLabel = STATUS_LABELS[l.status] ?? l.status;277 const addr = l.street_address || l.title;278 const cityLine = fmtCityLine(l);279280 return (281 <div className="container detail">282 <nav className="crumbs" aria-label="Breadcrumb">283 <Link to="/">Homes</Link> ›284 {l.state && <Link to={`/?state=${encodeURIComponent(l.state)}`}>{l.state}</Link>} ›285 {l.city && <span>{l.city}</span>} ›286 <span>{addr}</span>287 </nav>288289 <div className="fiche">290 {/* -------- main column: gallery, price/summary, description, ----------291 -------- features, details, price history — DOM order = visual ---- */}292 <div className="f-col">293 {/* 1. photo gallery (hero) */}294 <section className="f-bloc f-galerie" aria-label="Photos">295 <Gallery296 images={l.images ?? []}297 captions={Array.isArray(l.details?.photo_captions)298 ? (l.details!.photo_captions as string[]) : undefined}299 title={addr}300 type={l.property_type}301 />302 </section>303304 {/* 2. price + status badge + address + key facts */}305 <section className="f-bloc f-hero">306 <div className="price-kicker">List price</div>307 <div className="price-row">308 <div className="price">{fmtPrice(l.list_price, l.price_label)}</div>309 {l.status && (310 <span className={`status-chip ${STATUS_CLASS[l.status] ?? ""}`}>{statusLabel}</span>311 )}312 {l.property_type && (313 <span className="type-chip">314 <Ico name={SPEC_ICONS["Type"]} size={13} /> {l.property_type}315 {l.details?.price_from ? " · from" : ""}316 </span>317 )}318 </div>319 {l.list_price != null && l.living_area_sqft != null && l.living_area_sqft > 200 && (320 <div className="price-sub">${Math.round(l.list_price / l.living_area_sqft).toLocaleString("en-US")} / sq ft</div>321 )}322 <h1>{addr}</h1>323 <div className="loc"><Ico name="pin" size={13} /> {[cityLine, l.county ? `${l.county} County` : ""].filter(Boolean).join(" · ")}</div>324325 <div className="spec-list">326 {specs.map((s) => (327 <div className="spec-row" key={s.k}>328 <span className="spec-badge"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={15} /></span>329 <span className="spec-k">{s.k}</span>330 <b className="spec-v">{s.v}</b>331 </div>332 ))}333 </div>334335 {drop && (336 <div className={`prix-histo ${drop.to < drop.from ? "down" : ""}`}>337 <Ico name={drop.to < drop.from ? "trenddown" : "trendup"} size={16} /> Price changed from {fmtPrice(drop.from)} to <b>{fmtPrice(drop.to)}</b>338 </div>339 )}340341 {(l.agent_name || l.agent_phone || l.brokerage_name) && (342 <div className="broker">343 <div className="broker-k">Listed by</div>344 {l.agent_name && <div className="broker-name">{l.agent_name}</div>}345 {l.brokerage_name && <div className="broker-agency">{l.brokerage_name}</div>}346 {l.agent_phone && <a className="broker-tel" href={`tel:${l.agent_phone.replace(/\s/g, "")}`}><Ico name="phone" size={14} /> {l.agent_phone}</a>}347 </div>348 )}349350 <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">351 View the original listing at {sourceName(l.source)} <Ico name="external" size={15} />352 </a>353 <div className="fine">354 Aggregated by Home-Ka — {sourceName(l.source)}{updated ? ` · synced ${updated}` : ""}.355 </div>356 </section>357358 {/* 3. description */}359 {l.description && (360 <section className="f-bloc f-desc" id="description">361 <h2>Description</h2>362 <p className="desc-text">{l.description}</p>363 </section>364 )}365366 {/* 4. features */}367 {l.features && l.features.length > 0 && (368 <section className="f-bloc" id="features">369 <h2>Features</h2>370 <div className="amenity-grid">371 {l.features.map((f, i) => (372 <span className="amenity-it" key={i}>373 <span className="am-ico"><AmenityIco label={f} /></span>374 <span className="am-txt">{f}</span>375 </span>376 ))}377 </div>378 </section>379 )}380381 {/* 5. practical details */}382 {(practical.length > 0 || detEntries.length > 0) && (383 <section className="f-bloc" id="details">384 <h2>Practical details</h2>385 <div className="dtable">386 {practical.map(({ k, v }) => (387 <div className="drow" key={`p-${k}`}>388 <span>{k}</span><b>{v}</b>389 </div>390 ))}391 {detEntries.map(([k, v]) => (392 <div className="drow" key={k}>393 <span>{k}</span>394 {/^https?:\/\//.test(v)395 ? <b><a href={v} target="_blank" rel="noopener noreferrer">Open ↗</a></b>396 : <b>{v}</b>}397 </div>398 ))}399 </div>400 </section>401 )}402403 {/* 6. price history */}404 {hist.length > 0 && (405 <section className="f-bloc" id="price-history">406 <h2>Price history</h2>407 <div className="dtable">408 {hist.map((h, i) => (409 <div className="drow" key={`${h.ts}-${i}`}>410 <span>{historyDate(h.ts)}</span>411 <b>{fmtPrice(h.price)}</b>412 </div>413 ))}414 </div>415 </section>416 )}417 </div>418419 {/* -------- second column (desktop): map, other publications ---------- */}420 <div className="f-col">421 {/* 7. map */}422 {l.lat != null && l.lng != null && (423 <section className="f-bloc" id="map">424 <h2>Location</h2>425 <Suspense fallback={<div className="lmap3d lmap3d-skel map-loading">Loading the map…</div>}>426 <PropertyMap427 uid={l.uid} lat={l.lat} lng={l.lng} price={l.list_price}428 propertyType={l.property_type} address={addr}429 city={l.city} image={l.images?.[0]}430 />431 </Suspense>432 </section>433 )}434435 {/* 8a. also listed on (duplicates) */}436 {l.duplicates && l.duplicates.length > 0 && (437 <section className="f-bloc" id="also-listed">438 <h2>Also listed on</h2>439 <p className="dups-note">440 This property was found on {l.duplicates.length}{" "}441 other platform{l.duplicates.length > 1 ? "s" : ""} —442 Home-Ka shows the most complete version.443 </p>444 <div className="dups-list">445 {l.duplicates.map((d) => (446 <a key={d.uid} className="dup-item" href={d.url} target="_blank" rel="noopener noreferrer">447 <span className="dup-src">{sourceName(d.source)}</span>448 {(d.agent_name || d.brokerage_name) && (449 <span className="dup-broker">{d.agent_name || d.brokerage_name}</span>450 )}451 <span className="dup-go">View listing <Ico name="external" size={13} /></span>452 </a>453 ))}454 </div>455 </section>456 )}457458 {/* 8b. PROPERTY history — every listing ever seen for this home */}459 {propHistory.length > 0 && (460 <section className="f-bloc" id="property-history">461 <h2>Property history</h2>462 <p className="dups-note">463 Home-Ka tracks the physical property behind each listing — every464 publication of this home across sources, past and present.465 </p>466 <div className="dtable">467 {propHistory.map((h) => (468 <div className="drow" key={h.uid}>469 <span>470 {h.uid === l.uid471 ? sourceName(h.source)472 : <Link to={`/property/${encodeURIComponent(h.uid)}`}>{sourceName(h.source)}</Link>}473 {" · "}{historyDate(h.first_seen)}474 {h.active ? "" : " (inactive)"}475 </span>476 <b>477 {fmtPrice(h.list_price, "—")}478 {h.status ? ` · ${STATUS_LABELS[h.status] ?? h.status}` : ""}479 </b>480 </div>481 ))}482 </div>483 </section>484 )}485 </div>486 </div>487488 <div className="fine f-foot">489 Prices and availability are those displayed by the source — every listing490 links back to the original announcement.491 </div>492493 <div className="cta-sticky">494 <span className="cta-sticky-prix">{fmtPrice(l.list_price, l.price_label)}</span>495 <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">496 View at {sourceName(l.source)} ↗497 </a>498 </div>499 </div>500 );501}502