SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
17.0 KB · 389 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Listing.tsx : fiche d'un logement — refonte mobile-first5//   Ordre mobile : galerie → prix + badge marché → chips clés → ancres →6//   description restructurée → inclusions → détails pratiques → quartier →7//   à proximité → pied de fiche. CTA source sticky en bas d'écran (mobile).8//   Desktop : deux colonnes (logement à gauche, quartier/synthèse à droite)9//   via wrappers `display:contents` + `order` (voir styles.css « fiche v2 »).10// -----------------------------------------------------------------------------11import { useEffect, useRef, useState } from "react";12import { Link, useParams } from "react-router-dom";13import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api";14import QuartierBlock from "../components/QuartierBlock";1516// Icônes et libellés des commodités de proximité (louka/poi.py)17const POI_META: Record<string, { icon: string; label: string }> = {18  epicerie: { icon: "🛒", label: "Épicerie" },19  depanneur: { icon: "🏪", label: "Dépanneur" },20  pharmacie: { icon: "💊", label: "Pharmacie" },21  ecole: { icon: "🏫", label: "École" },22  garderie: { icon: "🧸", label: "Garderie" },23  parc: { icon: "🌳", label: "Parc" },24  bus: { icon: "🚌", label: "Bus" },25  metro: { icon: "🚇", label: "Métro" },26  gym: { icon: "🏋️", label: "Gym" },27  cafe: { icon: "☕", label: "Café" },28  clinique: { icon: "🩺", label: "Clinique" },29  hopital: { icon: "🏥", label: "Hôpital" },30  bibliotheque: { icon: "📚", label: "Bibliothèque" },31};3233// Regroupement des POI en catégories repliables34const POI_GROUPES: { titre: string; icone: string; cats: string[] }[] = [35  { titre: "Courses", icone: "🛒", cats: ["epicerie", "depanneur"] },36  { titre: "Transport", icone: "🚌", cats: ["bus", "metro"] },37  { titre: "Études et famille", icone: "🎓", cats: ["ecole", "garderie", "bibliotheque"] },38  { titre: "Santé", icone: "🏥", cats: ["pharmacie", "clinique", "hopital"] },39  { titre: "Vie de quartier", icone: "☕", cats: ["cafe", "parc", "gym"] },40];4142// Badge « prix vs marché » — seuils configurables43const SEUILS_MARCHE = { bonDeal: -0.15, dansLeMarche: 0.10 };4445const PETS_LABEL: Record<string, string> = {46  oui: "Animaux acceptés", non: "Animaux refusés", conditions: "Animaux sous conditions",47};4849const NBSP = " ";5051/** ≈ minutes de marche (vol d'oiseau × facteur de détour 1,3, 4,8 km/h) */52const fmtMarche = (m: number): string =>53  `≈${NBSP}${Math.max(1, Math.round((m * 1.3) / 80))}${NBSP}min à pied`;5455function badgeMarche(price: number | null | undefined,56                     loyerSecteur: number | null | undefined) {57  if (price == null || loyerSecteur == null || loyerSecteur <= 0) return null;58  const delta = (price - loyerSecteur) / loyerSecteur;59  const pct = `${delta > 0 ? "+" : "−"}${Math.abs(Math.round(delta * 100))}${NBSP}%`;60  if (delta <= SEUILS_MARCHE.bonDeal)61    return { cls: "deal-good", txt: `${pct} vs le secteur · Bon deal 🔥` };62  if (delta <= SEUILS_MARCHE.dansLeMarche)63    return { cls: "deal-ok", txt: `${pct} vs le secteur · Dans le marché` };64  return { cls: "deal-high", txt: `${pct} vs le secteur · Au-dessus du marché` };65}6667/** Badges dérivés des détails structurés (inclusions confirmées ✓). */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("Chauffage inclus");73  if (inc.electricity) out.push("Électricité incluse");74  if (inc.hot_water) out.push("Eau chaude incluse");75  if (inc.internet) out.push("Internet inclus");76  const app = d.appliances ?? {};77  if (app.dishwasher) out.push("Lave-vaisselle");78  if (app.washer_dryer) out.push("Laveuse-sécheuse");79  if (app.fridge && app.stove) out.push("Électroménagers");80  if (d.ac) out.push("Climatisation");81  if (d.elevator) out.push("Ascenseur");82  if (d.balcony) out.push("Balcon");83  if (d.pool) out.push("Piscine");84  if (d.gym) out.push("Gym");85  if (d.laundry) out.push("Buanderie");86  if (d.storage) out.push("Rangement");87  if (d.parking?.available)88    out.push(`Stationnement${d.parking.type ? ` ${d.parking.type}` : ""}${d.parking.included ? " inclus" : ""}`);89  if (l.furnished) out.push("Meublé");90  if (d.smoking === false) out.push("Non-fumeur");91  return out;92}9394// --- Galerie avec balayage natif (scroll-snap) + compteur + plein écran -----95function Galerie({ images, titre }: { images: string[]; titre: string }) {96  const [idx, setIdx] = useState(0);97  const [zoom, setZoom] = useState(false);98  const track = useRef<HTMLDivElement>(null);99100  const onScroll = () => {101    const el = track.current;102    if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));103  };104  const goto = (i: number) =>105    track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });106107  if (images.length === 0)108    return <div className="carousel"><div className="noimg carousel-empty">🏠</div></div>;109110  return (111    <>112      <div className="carousel">113        <div className="carousel-track" ref={track} onScroll={onScroll}>114          {images.map((u, i) => (115            <img116              key={u} src={u} loading={i === 0 ? "eager" : "lazy"}117              alt={`${titre} — photo ${i + 1} de ${images.length}`}118              onClick={() => setZoom(true)}119            />120          ))}121        </div>122        <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>123        {idx > 0 && (124          <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button>125        )}126        {idx < images.length - 1 && (127          <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button>128        )}129      </div>130      {images.length > 1 && (131        <div className="thumbs">132          {images.map((u, i) => (133            <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}134                    aria-label={`Photo ${i + 1}`}>135              <img src={u} alt="" loading="lazy" />136            </button>137          ))}138        </div>139      )}140      {zoom && (141        <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie">142          <img src={images[idx]} alt="" />143        </div>144      )}145    </>146  );147}148149export default function ListingPage() {150  const { uid } = useParams<{ uid: string }>();151  const [l, setL] = useState<Listing | null>(null);152  const [error, setError] = useState<string | null>(null);153154  useEffect(() => {155    fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});156    if (!uid) return;157    fetchListing(uid).then(setL).catch((e) => setError(String(e)));158    window.scrollTo(0, 0);159  }, [uid]);160161  if (error)162    return (163      <div className="notice container">164        <div className="big">⚠️</div>165        <h2>Annonce introuvable</h2>166        <p>{error}</p>167        <Link className="btn btn-primary" to="/">Retour aux logements</Link>168      </div>169    );170171  if (!l)172    return (173      <div className="container detail">174        <div className="fiche" aria-busy="true">175          <div className="skel"><div className="sk-img" /></div>176          <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>177        </div>178      </div>179    );180181  const dg = l.digest ?? null;182  const f = dg?.faits;183  const conf = dg?.confiance ?? {};184  const deal = badgeMarche(l.price, l.quartier?.demographie?.loyer_moyen);185  const confirmes = badgesConfirmes(l);186  const autres = l.amenities.filter(187    (a) => !confirmes.some((b) => b.toLowerCase().includes(a.toLowerCase())));188  const inc = l.details?.inclusions ?? {};189  const zeroFrais = inc.heating && inc.electricity && inc.hot_water;190191  const enLigneDepuis = l.first_seen192    ? Math.max(0, Math.round((Date.now() / 1000 - l.first_seen) / 86400)) : null;193  const hist = (l.price_history ?? []).filter((h) => h.price != null);194  const baissePrix = hist.length >= 2 && hist[0].price !== hist[1].price195    ? { de: hist[1].price!, a: hist[0].price! } : null;196197  const updated = l.updated_at198    ? new Date(l.updated_at * 1000).toLocaleDateString("fr-CA", {199        day: "numeric", month: "long", year: "numeric" }) : null;200201  // chips clés (haute confiance seulement pour les faits extraits du texte)202  const chips: string[] = [];203  if (l.unit_type) chips.push(l.unit_type);204  const dispo = fmtAvailability(l.availability_date);205  if (dispo) chips.push(dispo === "Maintenant" ? "Libre maintenant" : `Dispo ${dispo}`);206  if (l.furnished) chips.push("Meublé");207  if (l.pets) chips.push(PETS_LABEL[l.pets] ?? l.pets);208  if (l.area_sqft) chips.push(`${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`);209  if (f?.nb_occupants_total && conf.nb_occupants_total !== "faible")210    chips.push(`${f.nb_occupants_total} occupants`);211  if (f?.salle_de_bain && conf.salle_de_bain !== "faible")212    chips.push(`Salle de bain ${f.salle_de_bain === "commune" ? "partagée" : "privée"}`);213  if (l.details?.floor != null) chips.push(`${l.details.floor}ᵉ étage`);214215  const pois = l.poi ?? [];216217  return (218    <div className="container detail">219      <nav className="crumbs" aria-label="Fil d'Ariane">220        <Link to="/">Logements</Link> ›221        {l.city && <span>{l.city}</span>} ›222        <span>{l.title || l.address}</span>223      </nav>224225      <div className="fiche">226        {/* ------- colonne gauche (desktop) : galerie, description, pratique -- */}227        <div className="f-col">228          <section className="f-bloc f-galerie" aria-label="Photos">229            <Galerie images={l.images ?? []} titre={l.title || l.address} />230          </section>231232          <section className="f-bloc f-desc" id="description">233            <h2>Description</h2>234            {dg ? (235              <>236                {dg.en_bref && <p className="enbref">{dg.en_bref}</p>}237                {dg.sections.map((s) => (238                  <div key={s.titre} className="desc-section">239                    <h4>{s.titre}</h4>240                    <p>{s.texte}</p>241                  </div>242                ))}243                <details className="texte-original">244                  <summary>Voir le texte original de la source</summary>245                  <p>{l.description}</p>246                </details>247              </>248            ) : (249              l.description250                ? <p style={{ color: "var(--ink-2)" }}>{l.description}</p>251                : <p className="fine">La source ne fournit pas de description pour cette annonce.</p>252            )}253          </section>254255          <section className="f-bloc f-pratique">256            <h2>Détails pratiques</h2>257            <div className="kv">258              <div className="cell"><div className="k">Gestionnaire</div><div className="v">{sourceName(l.source)}</div></div>259              {l.price_label && (260                <div className="cell"><div className="k">Prix affiché</div><div className="v">{l.price_label}</div></div>261              )}262              {f?.duree_bail_minimale_mois && (263                <div className="cell"><div className="k">Bail minimum</div><div className="v">{f.duree_bail_minimale_mois} mois</div></div>264              )}265              {enLigneDepuis != null && (266                <div className="cell"><div className="k">En ligne depuis</div>267                  <div className="v">{enLigneDepuis === 0 ? "aujourd'hui" : `${enLigneDepuis}${NBSP}jour${enLigneDepuis > 1 ? "s" : ""}`}</div></div>268              )}269              {updated && (270                <div className="cell"><div className="k">Synchronisé</div><div className="v">{updated}</div></div>271              )}272            </div>273            {baissePrix && (274              <div className={`prix-histo ${baissePrix.a < baissePrix.de ? "down" : "up"}`}>275                {baissePrix.a < baissePrix.de ? "📉" : "📈"} Prix passé de{" "}276                {fmtPrice(baissePrix.de)} à <b>{fmtPrice(baissePrix.a)}</b>277                {baissePrix.a < baissePrix.de && " — levier de négociation"}278              </div>279            )}280          </section>281        </div>282283        {/* ------- colonne droite (desktop) : synthèse, inclusions, quartier -- */}284        <div className="f-col">285          <section className="f-bloc f-hero">286            <div className="price">287              {fmtPrice(l.price, l.price_label)} {l.price != null && <small>/{NBSP}mois</small>}288            </div>289            {deal && <div className={`deal-badge ${deal.cls}`}>{deal.txt}</div>}290            <h1>{l.title || l.address}</h1>291            <div className="loc">292              {[l.address !== l.title ? l.address : "", l.sector, l.city].filter(Boolean).join(" · ")}293            </div>294            <div className="chips-scroll" role="list" aria-label="Caractéristiques clés">295              {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)}296            </div>297            <nav className="ancres" aria-label="Sections de la fiche">298              <a href="#description">Description</a>299              <a href="#inclusions">Inclusions</a>300              <a href="#quartier">Quartier</a>301              <a href="#proximite">À proximité</a>302            </nav>303            <a className="cta cta-desktop" href={`/passerelle/${encodeURIComponent(l.uid)}`}304               target="_blank" rel="noopener noreferrer">305              Voir l'annonce chez {sourceName(l.source)} ↗306            </a>307            <a className="btn btn-ghost btn-pdf"308               href={`/api/listings/${encodeURIComponent(l.uid)}/pdf`} download>309              📄 Télécharger la fiche (PDF)310            </a>311          </section>312313          <section className="f-bloc f-incl" id="inclusions">314            <h2>Inclusions et commodités</h2>315            {zeroFrais && <div className="deal-badge deal-good">💡 Chauffage, électricité et eau chaude inclus — 0{NBSP}$ de frais cachés</div>}316            <div className="amenity-row">317              {confirmes.map((b) => (318                <span className="amenity confirmed" key={`c-${b}`}>✓ {b}</span>319              ))}320              {autres.map((a) => (321                <span className="amenity unconfirmed" key={a} title="Mentionné par la source, sans confirmation structurée">{a}</span>322              ))}323            </div>324            {confirmes.length === 0 && autres.length === 0 && (325              <p className="fine">La source ne précise pas les inclusions.</p>326            )}327          </section>328329          <section className="f-bloc f-quartier" id="quartier">330            {l.quartier ? <QuartierBlock q={l.quartier} /> : null}331          </section>332333          <section className="f-bloc f-poi" id="proximite">334            {pois.length > 0 && (335              <>336                <h2>À proximité</h2>337                {POI_GROUPES.map((g, gi) => {338                  const items = pois.filter((p) => g.cats.includes(p.cat));339                  if (items.length === 0) return null;340                  return (341                    <details className="poi-groupe" key={g.titre} open={gi === 0}>342                      <summary>343                        <span>{g.icone} {g.titre}</span>344                        <span className="poi-resume">345                          {items.length} · le + proche à {fmtDist(items[0].dist_m)}346                        </span>347                      </summary>348                      <ul className="poi-list">349                        {items.map((p) => {350                          const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat };351                          return (352                            <li key={p.cat} title={meta.label}>353                              <span className="poi-ico" aria-hidden="true">{meta.icon}</span>354                              <span className="poi-name">{p.name}</span>355                              <span className="poi-dist">{fmtDist(p.dist_m)} · {fmtMarche(p.dist_m)}</span>356                            </li>357                          );358                        })}359                      </ul>360                    </details>361                  );362                })}363                <div className="fine">364                  Temps de marche estimés (distance à vol d'oiseau ×{NBSP}1,3, 4,8{NBSP}km/h) — données OpenStreetMap.365                </div>366              </>367            )}368          </section>369        </div>370      </div>371372      <div className="fine f-foot">373        {updated && <>Dernière synchronisation : {updated}. </>}374        Les prix et disponibilités sont ceux affichés par la source — chaque fiche375        renvoie à l'annonce originale.376      </div>377378      {/* CTA sticky mobile — toujours visible */}379      <div className="cta-sticky">380        <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}{l.price != null && <small>/mois</small>}</span>381        <a className="cta" href={`/passerelle/${encodeURIComponent(l.uid)}`}382           target="_blank" rel="noopener noreferrer">383          Voir chez {sourceName(l.source)} ↗384        </a>385      </div>386    </div>387  );388}389