SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
22.8 KB · 538 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Rent-Ka — Rental listings aggregator (Canada, outside Québec)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Listing.tsx: listing detail page — mobile-first5//   DOM order = visual order, identical on mobile AND desktop (Groupe KA6//   "detail page section order" standard): gallery → price + market badge +7//   address + chips → CTA/PDF → description → amenities → practical details →8//   price analysis → location → KA Scores → nearby.9//   Mobile: stacked columns; desktop: 2-column grid (col A | col B).10//   Forbidden: reordering via `order` / `column-reverse` (v2 bug where blocks11//   without `order` jumped ahead of the gallery on mobile).12// -----------------------------------------------------------------------------13import { lazy, Suspense, useEffect, useRef, useState } from "react";14import { Link, useParams } from "react-router-dom";15import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api";16import SmartImg from "../components/SmartImg";17import FairValueBadge from "../components/FairValueBadge";18import PriceAnalysis from "../components/PriceAnalysis";19import HistoriqueLouka from "../components/HistoriqueLouka";20import ImmeubleBloc from "../components/ImmeubleBloc";21import GestionnaireBloc from "../components/GestionnaireBloc";22import HiverScore from "../components/HiverScore";23import { IcoAlert, IcoDoc } from "../components/Icons";24import AmenityIco from "../components/AmenityIco";25import KaScoresBlock from "../components/KaScoresBlock";26import { markSeen } from "../search/seen";2728// 3D mini-map (Mapbox) — lazily loaded, like the big map.29const ListingMap3D = lazy(() => import("../components/ListingMap3D"));3031// Icons and labels of nearby amenities (rentka/poi.py category keys)32const POI_META: Record<string, { icon: string; label: string }> = {33  epicerie: { icon: "🛒", label: "Grocery store" },34  depanneur: { icon: "🏪", label: "Convenience store" },35  pharmacie: { icon: "💊", label: "Pharmacy" },36  ecole: { icon: "🏫", label: "School" },37  garderie: { icon: "🧸", label: "Daycare" },38  parc: { icon: "🌳", label: "Park" },39  bus: { icon: "🚌", label: "Bus" },40  metro: { icon: "🚇", label: "Subway" },41  gym: { icon: "🏋️", label: "Gym" },42  cafe: { icon: "☕", label: "Café" },43  clinique: { icon: "🩺", label: "Clinic" },44  hopital: { icon: "🏥", label: "Hospital" },45  bibliotheque: { icon: "📚", label: "Library" },46};4748// POI grouped into collapsible categories49const POI_GROUPES: { titre: string; icone: string; cats: string[] }[] = [50  { titre: "Groceries", icone: "🛒", cats: ["epicerie", "depanneur"] },51  { titre: "Transit", icone: "🚌", cats: ["bus", "metro"] },52  { titre: "School and family", icone: "🎓", cats: ["ecole", "garderie", "bibliotheque"] },53  { titre: "Health", icone: "🏥", cats: ["pharmacie", "clinique", "hopital"] },54  { titre: "Neighbourhood life", icone: "☕", cats: ["cafe", "parc", "gym"] },55];5657const PETS_LABEL: Record<string, string> = {58  oui: "Pets allowed", non: "No pets", conditions: "Pets with conditions",59};6061const NBSP = " ";6263/** ≈ walking minutes (straight-line × detour factor 1.3, 4.8 km/h) */64const fmtMarche = (m: number): string =>65  `≈${NBSP}${Math.max(1, Math.round((m * 1.3) / 80))}${NBSP}min walk`;6667/** Badges derived from structured details (confirmed inclusions ✓). */68function badgesConfirmes(l: Listing): string[] {69  const d = l.details ?? {};70  const out: string[] = [];71  const inc = d.inclusions ?? {};72  if (inc.heating) out.push("Heat included");73  if (inc.electricity) out.push("Electricity included");74  if (inc.hot_water) out.push("Hot water included");75  if (inc.internet) out.push("Internet included");76  const app = d.appliances ?? {};77  if (app.dishwasher) out.push("Dishwasher");78  if (app.washer_dryer) out.push("Washer-dryer");79  if (app.fridge && app.stove) out.push("Appliances");80  if (d.ac) out.push("Air conditioning");81  if (d.elevator) out.push("Elevator");82  if (d.balcony) out.push("Balcony");83  if (d.pool) out.push("Pool");84  if (d.gym) out.push("Gym");85  if (d.laundry) out.push("Laundry");86  if (d.storage) out.push("Storage");87  if (d.parking?.available)88    out.push(`Parking${d.parking.type ? ` ${d.parking.type}` : ""}${d.parking.included ? " included" : ""}`);89  if (l.furnished) out.push("Furnished");90  if (d.smoking === false) out.push("Non-smoking");91  return out;92}9394// --- Full-screen lightbox: swipe between photos + pinch to zoom -------------95function Lightbox({ images, start, titre, onClose }:96                  { images: string[]; start: number; titre: string; onClose: () => void }) {97  const [idx, setIdx] = useState(start);98  const [scale, setScale] = useState(1);99  const [tx, setTx] = useState(0);100  const [ty, setTy] = useState(0);101  const track = useRef<HTMLDivElement>(null);102  const pointers = useRef(new Map<number, { x: number; y: number }>());103  const pinch = useRef<{ d: number; scale: number } | null>(null);104  const lastTap = useRef(0);105106  useEffect(() => {   // initial position + page scroll lock107    track.current?.scrollTo({ left: start * track.current.clientWidth });108    document.body.style.overflow = "hidden";109    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };110    window.addEventListener("keydown", onKey);111    return () => {112      document.body.style.overflow = "";113      window.removeEventListener("keydown", onKey);114    };115    // eslint-disable-next-line react-hooks/exhaustive-deps116  }, []);117118  const resetZoom = () => { setScale(1); setTx(0); setTy(0); };119  const onScroll = () => {120    const el = track.current;121    if (el && scale === 1) {122      const i = Math.round(el.scrollLeft / el.clientWidth);123      if (i !== idx) { setIdx(i); resetZoom(); }124    }125  };126  const dist = () => {127    const [a, b] = [...pointers.current.values()];128    return Math.hypot(a.x - b.x, a.y - b.y);129  };130  const onPointerDown = (e: React.PointerEvent) => {131    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });132    if (pointers.current.size === 2)133      pinch.current = { d: dist(), scale };134    if (pointers.current.size === 1) {   // double-tap = ×2.5 zoom / back to ×1135      const now = Date.now();136      if (now - lastTap.current < 300) {137        if (scale > 1) resetZoom(); else setScale(2.5);138      }139      lastTap.current = now;140    }141  };142  const onPointerMove = (e: React.PointerEvent) => {143    const prev = pointers.current.get(e.pointerId);144    if (!prev) return;145    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });146    if (pointers.current.size === 2 && pinch.current) {147      const s = Math.min(4, Math.max(1, pinch.current.scale * (dist() / pinch.current.d)));148      setScale(s);149      if (s === 1) { setTx(0); setTy(0); }150    } else if (pointers.current.size === 1 && scale > 1) {151      setTx((v) => v + (e.clientX - prev.x));   // pan while zoomed152      setTy((v) => v + (e.clientY - prev.y));153    }154  };155  const onPointerUp = (e: React.PointerEvent) => {156    pointers.current.delete(e.pointerId);157    if (pointers.current.size < 2) pinch.current = null;158  };159160  return (161    <div className="lightbox lightbox-v2" role="dialog" aria-modal="true"162         aria-label={`Photos — ${titre}`}>163      <button className="lightbox-close" aria-label="Close" onClick={onClose}>✕</button>164      <span className="carousel-count lightbox-count" aria-live="polite">165        {idx + 1}/{images.length}166      </span>167      <div168        className="lightbox-track" ref={track} onScroll={onScroll}169        style={scale > 1 ? { overflow: "hidden", touchAction: "none" } : undefined}170        onPointerDown={onPointerDown} onPointerMove={onPointerMove}171        onPointerUp={onPointerUp} onPointerCancel={onPointerUp}172      >173        {images.map((u, i) => (174          <div className="lightbox-cell" key={u}>175            <SmartImg176              src={u} original alt={`${titre} — photo ${i + 1} of ${images.length}`}177              draggable={false}178              style={i === idx && scale > 1179                ? { transform: `translate(${tx}px, ${ty}px) scale(${scale})` }180                : undefined}181            />182          </div>183        ))}184      </div>185      {scale === 1 && idx > 0 && (186        <button className="carousel-nav prev" aria-label="Previous photo"187                onClick={() => track.current?.scrollTo({188                  left: (idx - 1) * track.current.clientWidth, behavior: "smooth" })}>‹</button>189      )}190      {scale === 1 && idx < images.length - 1 && (191        <button className="carousel-nav next" aria-label="Next photo"192                onClick={() => track.current?.scrollTo({193                  left: (idx + 1) * track.current.clientWidth, behavior: "smooth" })}>›</button>194      )}195    </div>196  );197}198199// --- Gallery with native swipe (scroll-snap) + counter + full screen --------200function Galerie({ images, titre, unitType }:201                 { images: string[]; titre: string; unitType?: string }) {202  const [idx, setIdx] = useState(0);203  const [zoom, setZoom] = useState(false);204  const track = useRef<HTMLDivElement>(null);205206  const onScroll = () => {207    const el = track.current;208    if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));209  };210  const goto = (i: number) =>211    track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });212213  if (images.length === 0)214    return (215      <div className="carousel">216        <SmartImg src={null} fallbackLabel={unitType}217                  alt="No photo provided by the source" />218      </div>219    );220221  return (222    <>223      <div className="carousel">224        <div className="carousel-track" ref={track} onScroll={onScroll}>225          {images.map((u, i) => (226            <SmartImg227              key={u} src={u} width_={800} fallbackLabel={unitType}228              loading={i === 0 ? "eager" : "lazy"} decoding="async"229              alt={`${titre} — photo ${i + 1} of ${images.length}`}230              onClick={() => setZoom(true)}231            />232          ))}233        </div>234        <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>235        {idx > 0 && (236          <button className="carousel-nav prev" aria-label="Previous photo" onClick={() => goto(idx - 1)}>‹</button>237        )}238        {idx < images.length - 1 && (239          <button className="carousel-nav next" aria-label="Next photo" onClick={() => goto(idx + 1)}>›</button>240        )}241      </div>242      {images.length > 1 && (243        <div className="thumbs">244          {images.map((u, i) => (245            <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}246                    aria-label={`Photo ${i + 1}`}>247              <SmartImg src={u} width_={160} alt="" loading="lazy" decoding="async" />248            </button>249          ))}250        </div>251      )}252      {zoom && (253        <Lightbox images={images} start={idx} titre={titre}254                  onClose={() => setZoom(false)} />255      )}256    </>257  );258}259260export default function ListingPage() {261  const { uid } = useParams<{ uid: string }>();262  const [l, setL] = useState<Listing | null>(null);263  const [error, setError] = useState<string | null>(null);264265  useEffect(() => {266    fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});267    if (!uid) return;268    fetchListing(uid).then(setL).catch((e) => setError(String(e)));269    markSeen(uid);   // dimmed "already seen" marker on the search map270    window.scrollTo(0, 0);271  }, [uid]);272273  if (error)274    return (275      <div className="notice container">276        <div className="big"><IcoAlert size={40} /></div>277        <h2>Listing not found</h2>278        <p>{error}</p>279        <Link className="btn btn-primary" to="/">Back to rentals</Link>280      </div>281    );282283  if (!l)284    return (285      <div className="container detail">286        <div className="fiche" aria-busy="true">287          <div className="skel"><div className="sk-img" /></div>288          <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>289        </div>290      </div>291    );292293  const dg = l.digest ?? null;294  const f = dg?.faits;295  const conf = dg?.confiance ?? {};296  const confirmes = badgesConfirmes(l);297  const autres = l.amenities.filter(298    (a) => !confirmes.some((b) => b.toLowerCase().includes(a.toLowerCase())));299  const inc = l.details?.inclusions ?? {};300  const zeroFrais = inc.heating && inc.electricity && inc.hot_water;301302  const enLigneDepuis = l.first_seen303    ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null;304  const hist = (l.price_history ?? []).filter((h) => h.price != null);305  const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price306    ? { de: hist[1].price!, a: hist[0].price! } : null;307308  const updated = l.updated_at309    ? new Date(l.updated_at * 1000).toLocaleDateString("en-CA", {310        day: "numeric", month: "long", year: "numeric" }) : null;311312  // key chips (only high-confidence facts extracted from text)313  const chips: string[] = [];314  if (l.unit_type) chips.push(l.unit_type);315  const dispo = fmtAvailability(l.availability_date);316  if (dispo) chips.push(dispo === "Now" ? "Available now" : `Available ${dispo}`);317  if (l.furnished) chips.push("Furnished");318  if (l.pets) chips.push(PETS_LABEL[l.pets] ?? l.pets);319  if (l.area_sqft) chips.push(`${Math.round(l.area_sqft).toLocaleString("en-CA")}${NBSP}sq ft`);320  if (f?.nb_occupants_total && conf.nb_occupants_total !== "faible")321    chips.push(`${f.nb_occupants_total} occupants`);322  if (f?.salle_de_bain && conf.salle_de_bain !== "faible")323    chips.push(`${f.salle_de_bain === "commune" ? "Shared" : "Private"} bathroom`);324  if (l.details?.floor != null) chips.push(`Floor ${l.details.floor}`);325326  const pois = l.poi ?? [];327328  return (329    <div className="container detail">330      <nav className="crumbs" aria-label="Breadcrumb">331        <Link to="/">Rentals</Link> ›332        {l.city && <span>{l.city}</span>} ›333        <span>{l.title || l.address}</span>334      </nav>335336      <div className="fiche">337        {/* ------- left column (desktop): gallery, price, description, -------338             ------- amenities, practical — DOM order IS the visual order ------ */}339        <div className="f-col">340          <section className="f-bloc f-galerie" aria-label="Photos">341            <Galerie images={l.images ?? []} titre={l.title || l.address}342                     unitType={l.unit_type || undefined} />343          </section>344345          <section className="f-bloc f-hero">346            <div className="price">347              {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/{NBSP}month</small>}348            </div>349            {l.fv_verdict && (350              <div><FairValueBadge verdict={l.fv_verdict} deviation={l.fv_deviation} /></div>351            )}352            <h1>{l.title || l.address}</h1>353            <div className="loc">354              {[l.address !== l.title ? l.address : "", l.sector, l.city].filter(Boolean).join(" · ")}355            </div>356            <div className="chips-scroll" role="list" aria-label="Key features">357              {chips.map((c) => (358                <span className="chip-key" role="listitem" key={c}>359                  <AmenityIco label={c} size={14} fallback="spark" /> {c}360                </span>361              ))}362            </div>363            <nav className="ancres" aria-label="Page sections">364              <a href="#description">Description</a>365              <a href="#analyse-prix">Price</a>366              <a href="#inclusions">Amenities</a>367              {l.lat != null && l.lng != null && <a href="#emplacement">Map</a>}368              <a href="#proximite">Nearby</a>369            </nav>370            <a className="cta cta-desktop" href={`/gateway/${encodeURIComponent(l.uid)}`}371               target="_blank" rel="noopener noreferrer">372              See the listing at {sourceName(l.source)} ↗373            </a>374            <a className="btn btn-ghost btn-pdf"375               href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download>376              <IcoDoc size={14} /> Download the listing sheet (PDF)377            </a>378          </section>379380          <section className="f-bloc f-desc" id="description">381            <h2>Description</h2>382            {dg ? (383              <>384                {dg.en_bref && <p className="enbref">{dg.en_bref}</p>}385                {dg.sections.map((s) => (386                  <div key={s.titre} className="desc-section">387                    <h4>{s.titre}</h4>388                    <p>{s.texte}</p>389                  </div>390                ))}391                <details className="texte-original">392                  <summary>See the source's original text</summary>393                  <p>{l.description}</p>394                </details>395              </>396            ) : (397              l.description398                ? <p style={{ color: "var(--ink-2)" }}>{l.description}</p>399                : <p className="fine">The source does not provide a description for this listing.</p>400            )}401          </section>402403          <section className="f-bloc f-incl" id="inclusions">404            <h2>Amenities and inclusions</h2>405            {zeroFrais && <div className="deal-badge deal-good">💡 Heat, electricity and hot water included — $0 hidden costs</div>}406            <div className="amenity-grid">407              {confirmes.map((b) => (408                <span className="amenity-it confirmed" key={`c-${b}`}>409                  <span className="am-ico"><AmenityIco label={b} /></span>410                  <span className="am-txt">{b}</span>411                  <span className="am-conf" title="Confirmed by the source's structured data">✓</span>412                </span>413              ))}414              {autres.map((a) => (415                <span className="amenity-it unconfirmed" key={a} title="Mentioned by the source, without structured confirmation">416                  <span className="am-ico"><AmenityIco label={a} fallback="spark" /></span>417                  <span className="am-txt">{a}</span>418                </span>419              ))}420            </div>421            {confirmes.length === 0 && autres.length === 0 && (422              <p className="fine">The source does not specify inclusions.</p>423            )}424          </section>425426          <section className="f-bloc f-pratique">427            <h2>Practical details</h2>428            <div className="kv">429              <div className="cell"><div className="k">Manager</div><div className="v">{sourceName(l.source)}</div></div>430              {l.price_label && (431                <div className="cell"><div className="k">Advertised price</div><div className="v">{l.price_label}</div></div>432              )}433              {f?.duree_bail_minimale_mois && (434                <div className="cell"><div className="k">Minimum lease</div><div className="v">{f.duree_bail_minimale_mois} months</div></div>435              )}436              {enLigneDepuis != null && (437                <div className="cell"><div className="k">Online for</div>438                  <div className="v">{enLigneDepuis === 0 ? "today" : `${enLigneDepuis}${NBSP}day${enLigneDepuis > 1 ? "s" : ""}`}</div></div>439              )}440              {updated && (441                <div className="cell"><div className="k">Synced</div><div className="v">{updated}</div></div>442              )}443            </div>444            {baissePrix && (445              <div className={`prix-histo ${baissePrix.a < baissePrix.de ? "down" : "up"}`}>446                {baissePrix.a < baissePrix.de ? "📉" : "📈"} Price went from{" "}447                {fmtPrice(baissePrix.de)} to <b>{fmtPrice(baissePrix.a)}</b>448                {baissePrix.a < baissePrix.de && " — negotiation leverage"}449              </div>450            )}451          </section>452        </div>453454        {/* ------- right column (desktop): price analysis, history, ----------455             ------- building, manager, map, KA Scores, nearby ----------------- */}456        <div className="f-col">457          <PriceAnalysis uid={l.uid} price={l.price} />458459          <HistoriqueLouka uid={l.uid} />460461          {l.immeuble && <ImmeubleBloc im={l.immeuble} />}462463          <GestionnaireBloc source={l.source} />464465          {l.lat != null && l.lng != null && (466            <section className="f-bloc f-carte" id="emplacement">467              <h2>Location</h2>468              <Suspense fallback={<div className="lmap3d lmap3d-skel" aria-busy="true" />}>469                <ListingMap3D l={l} />470              </Suspense>471              <p className="fine">472                3D view of the area — the listing's building is highlighted.473                Position from the geocoded address (OpenStreetMap).474              </p>475            </section>476          )}477478          {l.kascores && <KaScoresBlock ks={l.kascores} />}479480          {l.hiver && <HiverScore h={l.hiver} />}481482          <section className="f-bloc f-poi" id="proximite">483            {pois.length > 0 && (484              <>485                <h2>Nearby</h2>486                {POI_GROUPES.map((g, gi) => {487                  const items = pois.filter((p) => g.cats.includes(p.cat));488                  if (items.length === 0) return null;489                  return (490                    <details className="poi-groupe" key={g.titre} open={gi === 0}>491                      <summary>492                        <span>{g.icone} {g.titre}</span>493                        <span className="poi-resume">494                          {items.length} · nearest at {fmtDist(items[0].dist_m)}495                        </span>496                      </summary>497                      <ul className="poi-list">498                        {items.map((p) => {499                          const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat };500                          return (501                            <li key={p.cat} title={meta.label}>502                              <span className="poi-ico" aria-hidden="true">{meta.icon}</span>503                              <span className="poi-name">{p.name}</span>504                              <span className="poi-dist">{fmtDist(p.dist_m)} · {fmtMarche(p.dist_m)}</span>505                            </li>506                          );507                        })}508                      </ul>509                    </details>510                  );511                })}512                <div className="fine">513                  Estimated walking times (straight-line distance ×{NBSP}1.3, 4.8{NBSP}km/h) — OpenStreetMap data.514                </div>515              </>516            )}517          </section>518        </div>519      </div>520521      <div className="fine f-foot">522        {updated && <>Last synced: {updated}. </>}523        Prices and availability are as displayed by the source — every listing524        links back to the original ad.525      </div>526527      {/* sticky mobile CTA — always visible */}528      <div className="cta-sticky">529        <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}{l.price != null && <small>/month</small>}</span>530        <a className="cta" href={`/gateway/${encodeURIComponent(l.uid)}`}531           target="_blank" rel="noopener noreferrer">532          See at {sourceName(l.source)} ↗533        </a>534      </div>535    </div>536  );537}538