SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
19.4 KB · 461 lines tsx
Raw Blame History
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