spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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