SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
13.6 KB · 314 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Listing.tsx : fiche complète d'une propriété5//   galerie + lightbox · specs · caractéristiques (details) · pièces ·6//   inclusions · description · historique de prix · courtier · mini-carte7// -----------------------------------------------------------------------------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 QuartierBlock from "../components/QuartierBlock";17import { Ico } from "../components/Icons";1819// --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran --------20function Galerie({ images, titre }: { images: string[]; titre: string }) {21  const [idx, setIdx] = useState(0);22  const [zoom, setZoom] = useState(false);23  const track = useRef<HTMLDivElement>(null);2425  const onScroll = () => {26    const el = track.current;27    if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));28  };29  const goto = (i: number) =>30    track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });3132  useEffect(() => {33    if (!zoom) return;34    const onKey = (e: KeyboardEvent) => {35      if (e.key === "Escape") setZoom(false);36      if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));37      if (e.key === "ArrowRight") setIdx((i) => Math.min(images.length - 1, i + 1));38    };39    window.addEventListener("keydown", onKey);40    return () => window.removeEventListener("keydown", onKey);41  }, [zoom, images.length]);4243  if (images.length === 0)44    return <div className="carousel"><div className="noimg carousel-empty"><Ico name="home" size={44} /></div></div>;4546  return (47    <>48      <div className="carousel">49        <div className="carousel-track" ref={track} onScroll={onScroll}>50          {images.map((u, i) => (51            <img key={u} src={u} loading={i === 0 ? "eager" : "lazy"}52              alt={`${titre} — photo ${i + 1} de ${images.length}`} onClick={() => setZoom(true)} />53          ))}54        </div>55        <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>56        {idx > 0 && <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button>}57        {idx < images.length - 1 && <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button>}58      </div>59      {images.length > 1 && (60        <div className="thumbs">61          {images.map((u, i) => (62            <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>63              <img src={u} alt="" loading="lazy" />64            </button>65          ))}66        </div>67      )}68      {zoom && (69        <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie">70          <button className="lb-close" aria-label="Fermer" onClick={() => setZoom(false)}>✕</button>71          {idx > 0 && <button className="lb-nav prev" aria-label="Précédente" onClick={(e) => { e.stopPropagation(); setIdx(idx - 1); }}>‹</button>}72          <img src={images[idx]} alt="" onClick={(e) => e.stopPropagation()} />73          {idx < images.length - 1 && <button className="lb-nav next" aria-label="Suivante" onClick={(e) => { e.stopPropagation(); setIdx(idx + 1); }}>›</button>}74          <span className="lb-count">{idx + 1} / {images.length}</span>75        </div>76      )}77    </>78  );79}8081const DETAIL_HIDDEN = new Set(["pieces", "price_from"]);8283// icône de chaque tuile « spec » (Icons.tsx)84const SPEC_ICONS: Record<string, string> = {85  "Type": "home", "Chambres": "bed", "Salles de bain": "bath",86  "Salles d'eau": "drop", "Superficie": "area", "Terrain": "land",87  "Année": "calendar", "MLS / Centris": "tag",88};8990export default function ListingPage() {91  const { uid } = useParams<{ uid: string }>();92  const [l, setL] = useState<Listing | null>(null);93  const [error, setError] = useState<string | null>(null);9495  useEffect(() => {96    fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});97    if (!uid) return;98    setL(null); setError(null);99    fetchListing(uid).then(setL).catch((e) => setError(String(e)));100    window.scrollTo(0, 0);101  }, [uid]);102103  if (error)104    return (105      <div className="notice container">106        <div className="big"><Ico name="alert" size={44} /></div>107        <h2>Propriété introuvable</h2>108        <p>{error}</p>109        <Link className="btn btn-primary" to="/">Retour aux propriétés</Link>110      </div>111    );112113  if (!l)114    return (115      <div className="container detail">116        <div className="fiche" aria-busy="true">117          <div className="skel"><div className="sk-img" /></div>118          <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>119        </div>120      </div>121    );122123  const specs: { k: string; v: string }[] = [];124  if (l.property_type) specs.push({ k: "Type", v: l.property_type });125  if (l.bedrooms != null) specs.push({ k: "Chambres", v: String(l.bedrooms) });126  if (l.bathrooms != null) specs.push({ k: "Salles de bain", v: String(l.bathrooms) });127  if (l.powder_rooms != null) specs.push({ k: "Salles d'eau", v: String(l.powder_rooms) });128  if (l.area_sqft != null) specs.push({ k: "Superficie", v: fmtArea(l.area_sqft)! });129  if (l.lot_sqft != null) specs.push({ k: "Terrain", v: fmtArea(l.lot_sqft)! });130  if (l.year_built != null) specs.push({ k: "Année", v: String(l.year_built) });131  if (l.mls) specs.push({ k: "MLS / Centris", v: l.mls });132133  const rooms: Room[] = Array.isArray(l.details?.pieces) ? (l.details!.pieces as Room[]) : [];134  const detEntries = Object.entries(l.details ?? {})135    .filter(([k, v]) => !DETAIL_HIDDEN.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim());136137  const hist = (l.price_history ?? []).filter((h) => h.price != null);138  const baisse = hist.length >= 2 && hist[0].price !== hist[1].price139    ? { de: hist[1].price!, a: hist[0].price! } : null;140  const updated = l.updated_at ? fmtDate(l.updated_at) : null;141142  return (143    <div className="container detail">144      <nav className="crumbs" aria-label="Fil d'Ariane">145        <Link to="/">Propriétés</Link> ›146        {l.city && <span>{l.city}</span>} ›147        <span>{l.address || l.title}</span>148      </nav>149150      <div className="fiche">151        {/* -------- colonne gauche : galerie, description, caractéristiques ---- */}152        <div className="f-col">153          <section className="f-bloc f-galerie" aria-label="Photos">154            <Galerie images={l.images ?? []} titre={l.address || l.title} />155          </section>156157          {l.description && (158            <section className="f-bloc f-desc" id="description">159              <h2>Description</h2>160              <p className="desc-text">{l.description}</p>161            </section>162          )}163164          {detEntries.length > 0 && (165            <section className="f-bloc" id="caracteristiques">166              <h2>Caractéristiques</h2>167              <div className="dtable">168                {detEntries.map(([k, v]) => (169                  <div className="drow" key={k}><span>{k}</span><b>{String(v)}</b></div>170                ))}171              </div>172            </section>173          )}174175          {rooms.length > 0 && (176            <section className="f-bloc" id="pieces">177              <h2>Pièces</h2>178              <div className="rooms-wrap">179                <table className="rooms">180                  <thead><tr><th>Pièce</th><th>Niveau</th><th>Dimensions</th><th>Revêtement</th></tr></thead>181                  <tbody>182                    {rooms.map((r, i) => (183                      <tr key={i}>184                        <td>{r.nom || "—"}</td><td>{r.niveau || "—"}</td>185                        <td>{r.dimensions || "—"}</td><td>{r.revetement || "—"}</td>186                      </tr>187                    ))}188                  </tbody>189                </table>190              </div>191            </section>192          )}193194          {l.features && l.features.length > 0 && (195            <section className="f-bloc" id="inclusions">196              <h2>Inclusions</h2>197              <div className="amenity-row">198                {l.features.map((f, i) => <span className="amenity" key={i}><Ico name="check" size={13} /> {f}</span>)}199              </div>200            </section>201          )}202203          {l.lat != null && l.lng != null && (204            <section className="f-bloc" id="carte">205              <h2>Emplacement</h2>206              <div className="mini-map">207                <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>208                  <PropertyMap209                    lat={l.lat} lng={l.lng} price={l.price}210                    deal={l.price != null && l.vraiprix?.value != null211                          && l.price <= l.vraiprix.value * 0.95}212                  />213                </Suspense>214              </div>215            </section>216          )}217218        </div>219220        {/* -------- colonne droite : synthèse, specs, courtier ---------------- */}221        <div className="f-col">222          <section className="f-bloc f-hero">223            <div className="price">{fmtPrice(l.price, l.price_label)}</div>224            {l.property_type && <div className="deal-badge deal-ok">{l.property_type}{l.details?.price_from ? " · à partir de" : ""}</div>}225            <h1>{l.address || l.title}</h1>226            <div className="loc">{[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div>227228            <div className="specs-grid">229              {specs.map((s) => (230                <div className="spec" key={s.k}>231                  <span className="spec-ico"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={17} /></span>232                  <b>{s.v}</b><span>{s.k}</span>233                </div>234              ))}235            </div>236237            {baisse && (238              <div className={`prix-histo ${baisse.a < baisse.de ? "down" : ""}`}>239                <Ico name={baisse.a < baisse.de ? "trenddown" : "trendup"} size={16} /> Prix passé de {fmtPrice(baisse.de)} à <b>{fmtPrice(baisse.a)}</b>240              </div>241            )}242243            {l.vraiprix && l.vraiprix.value != null && (244              <div className="vraiprix">245                <div className="vp-head">246                  <span className="vp-logo">Vrai‑Prix</span>247                  {l.vraiprix.confidence && (248                    <span className={`vp-conf vp-conf-${l.vraiprix.confidence}`}>249                      confiance {l.vraiprix.confidence}250                    </span>251                  )}252                </div>253                <div className="vp-k">Valeur marchande estimée</div>254                <div className="vp-value">{fmtPrice(l.vraiprix.value)}</div>255                {l.vraiprix.low != null && l.vraiprix.high != null && (256                  <div className="vp-range">257                    fourchette {fmtPrice(l.vraiprix.low)} – {fmtPrice(l.vraiprix.high)}258                  </div>259                )}260                {l.price != null && (261                  (() => {262                    const diff = l.price - (l.vraiprix!.value as number);263                    const pct = Math.round((diff / (l.vraiprix!.value as number)) * 100);264                    const cls = diff > 0 ? "over" : "under";265                    const txt = diff > 0266                      ? `Prix demandé ${pct}% au-dessus de l'estimation`267                      : `Prix demandé ${Math.abs(pct)}% sous l'estimation`;268                    return Math.abs(pct) >= 1269                      ? <div className={`vp-delta vp-${cls}`}>{diff > 0 ? "▲" : "▼"} {txt}</div>270                      : <div className="vp-delta vp-fair">≈ Prix aligné sur l'estimation</div>;271                  })()272                )}273                <a className="vp-link" href={l.vraiprix.url} target="_blank" rel="noopener noreferrer">274                  Voir l'analyse détaillée ↗275                </a>276              </div>277            )}278279            {(l.broker_name || l.broker_phone) && (280              <div className="broker">281                <div className="broker-k">Courtier</div>282                {l.broker_name && <div className="broker-name">{l.broker_name}</div>}283                {l.broker_phone && <a className="broker-tel" href={`tel:${l.broker_phone.replace(/\s/g, "")}`}><Ico name="phone" size={14} /> {l.broker_phone}</a>}284              </div>285            )}286287            <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">288              Voir l'annonce chez {sourceName(l.source)} <Ico name="external" size={15} />289            </a>290            <div className="fine">291              Agrégé par Immo-Ka — {sourceName(l.source)}{updated ? ` · synchronisé le ${updated}` : ""}.292            </div>293          </section>294        </div>295      </div>296297      {/* quartier : pleine largeur, APRÈS les infos de propriété (ordre mobile correct) */}298      {l.quartier && <QuartierBlock q={l.quartier} />}299300      <div className="fine f-foot">301        Les prix et disponibilités sont ceux affichés par la source — chaque fiche renvoie à302        l'annonce originale de l'agence.303      </div>304305      <div className="cta-sticky">306        <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}</span>307        <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">308          Voir chez {sourceName(l.source)} ↗309        </a>310      </div>311    </div>312  );313}314