spb/immo-ka Public
Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 66.4%
TypeScript 19.9%
CSS 13.2%
HTML 0.5%
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 MiniMap = lazy(() => import("../components/MapView"));16import QuartierBlock from "../components/QuartierBlock";1718// --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran --------19function Galerie({ images, titre }: { images: string[]; titre: string }) {20 const [idx, setIdx] = useState(0);21 const [zoom, setZoom] = useState(false);22 const track = useRef<HTMLDivElement>(null);2324 const onScroll = () => {25 const el = track.current;26 if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));27 };28 const goto = (i: number) =>29 track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });3031 useEffect(() => {32 if (!zoom) return;33 const onKey = (e: KeyboardEvent) => {34 if (e.key === "Escape") setZoom(false);35 if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));36 if (e.key === "ArrowRight") setIdx((i) => Math.min(images.length - 1, i + 1));37 };38 window.addEventListener("keydown", onKey);39 return () => window.removeEventListener("keydown", onKey);40 }, [zoom, images.length]);4142 if (images.length === 0)43 return <div className="carousel"><div className="noimg carousel-empty">🏠</div></div>;4445 return (46 <>47 <div className="carousel">48 <div className="carousel-track" ref={track} onScroll={onScroll}>49 {images.map((u, i) => (50 <img key={u} src={u} loading={i === 0 ? "eager" : "lazy"}51 alt={`${titre} — photo ${i + 1} de ${images.length}`} onClick={() => setZoom(true)} />52 ))}53 </div>54 <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>55 {idx > 0 && <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button>}56 {idx < images.length - 1 && <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button>}57 </div>58 {images.length > 1 && (59 <div className="thumbs">60 {images.map((u, i) => (61 <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>62 <img src={u} alt="" loading="lazy" />63 </button>64 ))}65 </div>66 )}67 {zoom && (68 <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie">69 <button className="lb-close" aria-label="Fermer" onClick={() => setZoom(false)}>✕</button>70 {idx > 0 && <button className="lb-nav prev" aria-label="Précédente" onClick={(e) => { e.stopPropagation(); setIdx(idx - 1); }}>‹</button>}71 <img src={images[idx]} alt="" onClick={(e) => e.stopPropagation()} />72 {idx < images.length - 1 && <button className="lb-nav next" aria-label="Suivante" onClick={(e) => { e.stopPropagation(); setIdx(idx + 1); }}>›</button>}73 <span className="lb-count">{idx + 1} / {images.length}</span>74 </div>75 )}76 </>77 );78}7980const DETAIL_HIDDEN = new Set(["pieces", "price_from"]);8182export default function ListingPage() {83 const { uid } = useParams<{ uid: string }>();84 const [l, setL] = useState<Listing | null>(null);85 const [error, setError] = useState<string | null>(null);8687 useEffect(() => {88 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});89 if (!uid) return;90 setL(null); setError(null);91 fetchListing(uid).then(setL).catch((e) => setError(String(e)));92 window.scrollTo(0, 0);93 }, [uid]);9495 if (error)96 return (97 <div className="notice container">98 <div className="big">⚠️</div>99 <h2>Propriété introuvable</h2>100 <p>{error}</p>101 <Link className="btn btn-primary" to="/">Retour aux propriétés</Link>102 </div>103 );104105 if (!l)106 return (107 <div className="container detail">108 <div className="fiche" aria-busy="true">109 <div className="skel"><div className="sk-img" /></div>110 <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>111 </div>112 </div>113 );114115 const specs: { k: string; v: string }[] = [];116 if (l.property_type) specs.push({ k: "Type", v: l.property_type });117 if (l.bedrooms != null) specs.push({ k: "Chambres", v: String(l.bedrooms) });118 if (l.bathrooms != null) specs.push({ k: "Salles de bain", v: String(l.bathrooms) });119 if (l.powder_rooms != null) specs.push({ k: "Salles d'eau", v: String(l.powder_rooms) });120 if (l.area_sqft != null) specs.push({ k: "Superficie", v: fmtArea(l.area_sqft)! });121 if (l.lot_sqft != null) specs.push({ k: "Terrain", v: fmtArea(l.lot_sqft)! });122 if (l.year_built != null) specs.push({ k: "Année", v: String(l.year_built) });123 if (l.mls) specs.push({ k: "MLS / Centris", v: l.mls });124125 const rooms: Room[] = Array.isArray(l.details?.pieces) ? (l.details!.pieces as Room[]) : [];126 const detEntries = Object.entries(l.details ?? {})127 .filter(([k, v]) => !DETAIL_HIDDEN.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim());128129 const hist = (l.price_history ?? []).filter((h) => h.price != null);130 const baisse = hist.length >= 2 && hist[0].price !== hist[1].price131 ? { de: hist[1].price!, a: hist[0].price! } : null;132 const updated = l.updated_at ? fmtDate(l.updated_at) : null;133134 return (135 <div className="container detail">136 <nav className="crumbs" aria-label="Fil d'Ariane">137 <Link to="/">Propriétés</Link> ›138 {l.city && <span>{l.city}</span>} ›139 <span>{l.address || l.title}</span>140 </nav>141142 <div className="fiche">143 {/* -------- colonne gauche : galerie, description, caractéristiques ---- */}144 <div className="f-col">145 <section className="f-bloc f-galerie" aria-label="Photos">146 <Galerie images={l.images ?? []} titre={l.address || l.title} />147 </section>148149 {l.description && (150 <section className="f-bloc f-desc" id="description">151 <h2>Description</h2>152 <p className="desc-text">{l.description}</p>153 </section>154 )}155156 {detEntries.length > 0 && (157 <section className="f-bloc" id="caracteristiques">158 <h2>Caractéristiques</h2>159 <div className="dtable">160 {detEntries.map(([k, v]) => (161 <div className="drow" key={k}><span>{k}</span><b>{String(v)}</b></div>162 ))}163 </div>164 </section>165 )}166167 {rooms.length > 0 && (168 <section className="f-bloc" id="pieces">169 <h2>Pièces</h2>170 <div className="rooms-wrap">171 <table className="rooms">172 <thead><tr><th>Pièce</th><th>Niveau</th><th>Dimensions</th><th>Revêtement</th></tr></thead>173 <tbody>174 {rooms.map((r, i) => (175 <tr key={i}>176 <td>{r.nom || "—"}</td><td>{r.niveau || "—"}</td>177 <td>{r.dimensions || "—"}</td><td>{r.revetement || "—"}</td>178 </tr>179 ))}180 </tbody>181 </table>182 </div>183 </section>184 )}185186 {l.features && l.features.length > 0 && (187 <section className="f-bloc" id="inclusions">188 <h2>Inclusions</h2>189 <div className="amenity-row">190 {l.features.map((f, i) => <span className="amenity" key={i}>✓ {f}</span>)}191 </div>192 </section>193 )}194195 {l.lat != null && l.lng != null && (196 <section className="f-bloc" id="carte">197 <h2>Emplacement</h2>198 <div className="mini-map">199 <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>200 <MiniMap filters={{ q: l.mls || l.address }} />201 </Suspense>202 </div>203 </section>204 )}205206 {l.quartier && (207 <section className="f-bloc" id="quartier">208 <h2>Le quartier</h2>209 <QuartierBlock q={l.quartier} />210 </section>211 )}212 </div>213214 {/* -------- colonne droite : synthèse, specs, courtier ---------------- */}215 <div className="f-col">216 <section className="f-bloc f-hero">217 <div className="price">{fmtPrice(l.price, l.price_label)}</div>218 {l.property_type && <div className="deal-badge deal-ok">{l.property_type}{l.details?.price_from ? " · à partir de" : ""}</div>}219 <h1>{l.address || l.title}</h1>220 <div className="loc">{[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div>221222 <div className="specs-grid">223 {specs.map((s) => (224 <div className="spec" key={s.k}><b>{s.v}</b><span>{s.k}</span></div>225 ))}226 </div>227228 {baisse && (229 <div className={`prix-histo ${baisse.a < baisse.de ? "down" : ""}`}>230 {baisse.a < baisse.de ? "📉" : "📈"} Prix passé de {fmtPrice(baisse.de)} à <b>{fmtPrice(baisse.a)}</b>231 </div>232 )}233234 {l.vraiprix && l.vraiprix.value != null && (235 <div className="vraiprix">236 <div className="vp-head">237 <span className="vp-logo">Vrai‑Prix</span>238 {l.vraiprix.confidence && (239 <span className={`vp-conf vp-conf-${l.vraiprix.confidence}`}>240 confiance {l.vraiprix.confidence}241 </span>242 )}243 </div>244 <div className="vp-k">Valeur marchande estimée</div>245 <div className="vp-value">{fmtPrice(l.vraiprix.value)}</div>246 {l.vraiprix.low != null && l.vraiprix.high != null && (247 <div className="vp-range">248 fourchette {fmtPrice(l.vraiprix.low)} – {fmtPrice(l.vraiprix.high)}249 </div>250 )}251 {l.price != null && (252 (() => {253 const diff = l.price - (l.vraiprix!.value as number);254 const pct = Math.round((diff / (l.vraiprix!.value as number)) * 100);255 const cls = diff > 0 ? "over" : "under";256 const txt = diff > 0257 ? `Prix demandé ${pct}% au-dessus de l'estimation`258 : `Prix demandé ${Math.abs(pct)}% sous l'estimation`;259 return Math.abs(pct) >= 1260 ? <div className={`vp-delta vp-${cls}`}>{diff > 0 ? "▲" : "▼"} {txt}</div>261 : <div className="vp-delta vp-fair">≈ Prix aligné sur l'estimation</div>;262 })()263 )}264 <a className="vp-link" href={l.vraiprix.url} target="_blank" rel="noopener noreferrer">265 Voir l'analyse détaillée ↗266 </a>267 </div>268 )}269270 {(l.broker_name || l.broker_phone) && (271 <div className="broker">272 <div className="broker-k">Courtier</div>273 {l.broker_name && <div className="broker-name">{l.broker_name}</div>}274 {l.broker_phone && <a className="broker-tel" href={`tel:${l.broker_phone.replace(/\s/g, "")}`}>📞 {l.broker_phone}</a>}275 </div>276 )}277278 <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">279 Voir l'annonce chez {sourceName(l.source)} ↗280 </a>281 <div className="fine">282 Agrégé par Immo-Ka — {sourceName(l.source)}{updated ? ` · synchronisé le ${updated}` : ""}.283 </div>284 </section>285 </div>286 </div>287288 <div className="fine f-foot">289 Les prix et disponibilités sont ceux affichés par la source — chaque fiche renvoie à290 l'annonce originale de l'agence.291 </div>292293 <div className="cta-sticky">294 <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}</span>295 <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">296 Voir chez {sourceName(l.source)} ↗297 </a>298 </div>299 </div>300 );301}302