SPB Git

spb/house-ka

Public
18.8 KB · 445 lines tsx
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
Files · 852c914b66dca5351aaa5df6ece6070a6e3d64c5
Menu

Theme