SPB Git forge

spb/home-ka

Public
10commits 1branches 0releases
793.0 KBsize
maindefault branch
20 days agolast push
Python 49.6% TypeScript 25.5% CSS 24.1%
21.5 KB · 502 lines tsx
Raw Blame History
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