Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1// -----------------------------------------------------------------------------2// Lou-Ka — Location court terme3// pages/CourtTerme.tsx : agrégateur d'hébergements court terme (chalets,4// séjours, hébergement insolite) — héro, filtres, grille + vue carte.5// Section séparée du Lou-Ka standard (logements au mois) : API /api/ct/*.6// -----------------------------------------------------------------------------7import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";8import { useSearchParams } from "react-router-dom";9import {10 CtFacets, CtFilters, CtListing, CtStats,11 ctSourceName, fetchCtFacets, fetchCtListings, fetchCtSources, fetchCtStats,12 registerCtSourceNames,13} from "../ctapi";14import CtListingCard from "../components/CtListingCard";15import Pager from "../components/Pager";16import { useAgo, useCountUp } from "../live";17import {18 IcoAlert, IcoList, IcoMap, IcoPaw, IcoSearch, IcoSliders,19} from "../components/Icons";2021const CtMapView = lazy(() => import("../components/CtMapView"));2223const PAGE_SIZE = 12;24const NIGHT_STEPS = [75, 100, 150, 200, 250, 300, 400, 500, 750, 1000];25const CAPACITY_STEPS = [2, 4, 6, 8, 10, 12];26const SORTS: { key: string; label: string }[] = [27 { key: "recent", label: "Plus récents" },28 { key: "prix", label: "Prix croissant" },29 { key: "prix_desc", label: "Prix décroissant" },30 { key: "note", label: "Mieux notés" },31];3233function useDebounced<T>(value: T, delayMs = 300): T {34 const [debounced, setDebounced] = useState(value);35 useEffect(() => {36 const t = setTimeout(() => setDebounced(value), delayMs);37 return () => clearTimeout(t);38 }, [value, delayMs]);39 return debounced;40}4142export default function CourtTermePage() {43 const [listings, setListings] = useState<CtListing[] | null>(null);44 const [total, setTotal] = useState(0);45 const [facets, setFacets] = useState<CtFacets | null>(null);46 const [stats, setStats] = useState<CtStats | null>(null);47 const [error, setError] = useState<string | null>(null);4849 const [params] = useSearchParams();50 const [q, setQ] = useState(params.get("q") ?? "");51 const [region, setRegion] = useState(params.get("region") ?? "");52 const [type, setType] = useState(params.get("type") ?? "");53 const [source, setSource] = useState(params.get("source") ?? "");54 const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");55 const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");56 const [capMin, setCapMin] = useState(params.get("capacity_min") ?? "");57 const [pets, setPets] = useState(params.get("pets") ?? "");58 const [spa, setSpa] = useState(params.get("spa") ?? "");59 const [waterfront, setWaterfront] = useState(params.get("waterfront") ?? "");60 const [sort, setSort] = useState(params.get("sort") ?? "recent");6162 const [sheetOpen, setSheetOpen] = useState(false);63 const [advOpen, setAdvOpen] = useState(false);64 useEffect(() => {65 document.body.style.overflow = sheetOpen ? "hidden" : "";66 if (!sheetOpen) return () => { document.body.style.overflow = ""; };67 const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };68 window.addEventListener("keydown", onKey);69 return () => {70 document.body.style.overflow = "";71 window.removeEventListener("keydown", onKey);72 };73 }, [sheetOpen]);7475 const activeFilters = [q, region, type, source, priceMin, priceMax, capMin,76 pets, spa, waterfront].filter(Boolean).length;77 const advCount = [capMin, pets, source].filter(Boolean).length;7879 const [view, setView] = useState<"liste" | "carte">(80 params.get("view") === "carte" ? "carte" : "liste");81 useEffect(() => {82 setView(params.get("view") === "carte" ? "carte" : "liste");83 }, [params]);8485 const qDebounced = useDebounced(q);86 const filters: CtFilters = useMemo(() => ({87 q: qDebounced, region, type, source,88 price_min: priceMin, price_max: priceMax,89 capacity_min: capMin, pets, spa, waterfront, sort,90 }), [qDebounced, region, type, source, priceMin, priceMax, capMin, pets,91 spa, waterfront, sort]);9293 // données vivantes du héro : dernière synchro réelle (max des connecteurs)94 const [lastSync, setLastSync] = useState<number | null>(null);95 useEffect(() => {96 fetchCtSources().then((r) => {97 registerCtSourceNames(r.sources);98 const ts = Math.max(0, ...r.sources.map((s) => s.last_sync ?? 0));99 if (ts > 0) setLastSync(ts > 1e12 ? Math.round(ts / 1000) : ts);100 }).catch(() => {});101 fetchCtFacets().then(setFacets).catch(() => {});102 fetchCtStats().then(setStats).catch(() => {});103 }, []);104 const totalLive = useCountUp(stats && stats.total > 0 ? stats.total : null);105 const syncAgo = useAgo(lastSync);106107 // pagination108 const [page, setPage] = useState(() => {109 const p = Number(params.get("page"));110 return Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1;111 });112 const resultsRef = useRef<HTMLDivElement | null>(null);113 const filtersKey = useMemo(() => JSON.stringify(filters), [filters]);114 const firstRun = useRef(true);115 useEffect(() => {116 if (firstRun.current) { firstRun.current = false; return; }117 setPage(1);118 }, [filtersKey]);119120 const goToPage = (p: number) => {121 setPage(p);122 resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });123 };124125 useEffect(() => {126 if (view === "carte") return;127 let cancelled = false;128 setListings(null);129 setError(null);130 fetchCtListings(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE)131 .then((r) => {132 if (!cancelled) {133 setListings(r.listings);134 setTotal(r.total);135 const last = Math.max(1, Math.ceil(r.total / PAGE_SIZE));136 if (page > last) setPage(last);137 }138 })139 .catch((e) => !cancelled && setError(String(e)));140 return () => { cancelled = true; };141 }, [filters, page, view]);142143 const resetAll = () => {144 setQ(""); setRegion(""); setType(""); setSource("");145 setPriceMin(""); setPriceMax(""); setCapMin("");146 setPets(""); setSpa(""); setWaterfront(""); setSort("recent");147 };148149 const pills: { label: string; clear: () => void }[] = [];150 if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });151 if (region) pills.push({ label: region, clear: () => setRegion("") });152 if (type) pills.push({ label: type, clear: () => setType("") });153 if (priceMin) pills.push({ label: `≥ ${priceMin} $/nuit`, clear: () => setPriceMin("") });154 if (priceMax) pills.push({ label: `≤ ${priceMax} $/nuit`, clear: () => setPriceMax("") });155 if (capMin) pills.push({ label: `${capMin}+ personnes`, clear: () => setCapMin("") });156 if (pets) pills.push({ label: "Animaux acceptés", clear: () => setPets("") });157 if (spa) pills.push({ label: "Spa", clear: () => setSpa("") });158 if (waterfront) pills.push({ label: "Bord de l'eau", clear: () => setWaterfront("") });159 if (source) pills.push({ label: ctSourceName(source), clear: () => setSource("") });160161 return (162 <div className="container">163 <section className="hero">164 <span className="kicker">Court terme — chalets et séjours, partout au Québec</span>165 <h1 className="hero-display">166 Trouvez votre prochaine<br />167 <span className="brush">escapade.</span>168 </h1>169 <p className="lede">170 Chalets, condos et hébergements insolites en location court terme, agrégés171 depuis les plateformes québécoises et internationales — lien direct pour réserver.172 </p>173 <div className="live-line" aria-label="Données en direct">174 <span className="live-flag"><span className="live-dot" /> live</span>175 {totalLive && (176 <span className="live-item"><b>{totalLive}</b> hébergements indexés</span>177 )}178 {stats && stats.regions > 0 && (179 <span className="live-item"><b>{stats.regions}</b> régions</span>180 )}181 {syncAgo && <span className="live-item">synchro {syncAgo}</span>}182 {stats?.avg_night != null && (183 <span className="live-item">prix moyen <b>{Math.round(stats.avg_night)} $ / nuit</b></span>184 )}185 </div>186 </section>187188 {sheetOpen && (189 <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />190 )}191 <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Recherche et filtres">192 <div className="sheet-handle" aria-hidden="true" />193 <div className="sheet-head">194 <span>Affiner la recherche</span>195 <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">196 ✕197 </button>198 </div>199200 {/* — la recherche, au cœur du produit : un grand champ souligné — */}201 <div className="q-big">202 <IcoSearch size={22} strokeWidth={2} />203 <input204 id="ctf-q" placeholder="Où voulez-vous vous évader ?" value={q}205 onChange={(e) => setQ(e.target.value)}206 aria-label="Recherche — ville, région ou mot-clé"207 />208 {q && (209 <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>210 )}211 </div>212213 {/* — critères inline, séparés par filets (pas de boîtes) — */}214 <div className="crit-line">215 <label className="crit">216 <span>Région</span>217 <select value={region} onChange={(e) => setRegion(e.target.value)}>218 <option value="">Toutes</option>219 {(facets?.regions ?? []).map((r) => (220 <option key={r.region} value={r.region}>{r.region} ({r.n})</option>221 ))}222 </select>223 </label>224 <label className="crit">225 <span>Type</span>226 <select value={type} onChange={(e) => setType(e.target.value)}>227 <option value="">Tous</option>228 {(facets?.types ?? []).map((t) => (229 <option key={t.type} value={t.type}>{t.type} ({t.n})</option>230 ))}231 </select>232 </label>233 <div className="crit">234 <span>Prix / nuit</span>235 <div className="range-pair">236 <select aria-label="Prix minimum par nuit" value={priceMin}237 onChange={(e) => setPriceMin(e.target.value)}>238 <option value="">Min</option>239 {NIGHT_STEPS.map((p) => (240 <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>241 {p} $242 </option>243 ))}244 </select>245 <span className="range-sep">—</span>246 <select aria-label="Prix maximum par nuit" value={priceMax}247 onChange={(e) => setPriceMax(e.target.value)}>248 <option value="">Max</option>249 {NIGHT_STEPS.map((p) => (250 <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>251 {p} $252 </option>253 ))}254 </select>255 </div>256 </div>257 <button258 className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`}259 onClick={() => setAdvOpen(!advOpen)}260 aria-expanded={advOpen}261 >262 Tous les critères263 {advCount > 0 && <span className="crit-badge">{advCount}</span>}264 <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" />265 </button>266 </div>267268 {(advOpen || sheetOpen) && (269 <div className="f-adv">270 <div className="f-group">271 <label>Capacité minimale</label>272 <div className="seg" role="group">273 <button className={capMin === "" ? "on" : ""} onClick={() => setCapMin("")}>274 Peu importe275 </button>276 {CAPACITY_STEPS.map((c) => (277 <button key={c} className={capMin === String(c) ? "on" : ""}278 onClick={() => setCapMin(String(c))}>279 {c}+280 </button>281 ))}282 </div>283 </div>284 <div className="f-group">285 <label>Animaux</label>286 <div className="seg" role="group">287 {[["", "Peu importe"], ["oui", "Acceptés"]].map(([v, l]) => (288 <button key={v} className={pets === v ? "on" : ""}289 onClick={() => setPets(v)}>290 {v === "oui" && <IcoPaw size={13} />} {l}291 </button>292 ))}293 </div>294 </div>295 <div className="f-group">296 <label>Tri</label>297 <div className="seg" role="group">298 {SORTS.map((s) => (299 <button key={s.key} className={sort === s.key ? "on" : ""}300 onClick={() => setSort(s.key)}>301 {s.label}302 </button>303 ))}304 </div>305 </div>306 <div className="f-group">307 <label>Plateforme</label>308 <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>309 <option value="">Toutes</option>310 {(facets?.sources ?? []).map((s) => (311 <option key={s.source} value={s.source}>312 {ctSourceName(s.source)} ({s.n})313 </option>314 ))}315 </select>316 </div>317 <div className="f-group f-group-end">318 <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>319 Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}320 </button>321 </div>322 </div>323 )}324325 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>326 Voir {listings ? `les ${total.toLocaleString("fr-CA")} hébergements` : "les résultats"}327 </button>328 </section>329330 {/* mobile : résumé des critères — ouvre la feuille (recherche intégrée, pas de FAB) */}331 <button className="crit-summary" onClick={() => setSheetOpen(true)}>332 <IcoSliders size={14} />333 <span className="cs-txt">334 {pills.length > 0335 ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "")336 : "Région, type, prix, capacité…"}337 </span>338 <span className="f-chev" aria-hidden="true" />339 </button>340341 <div className="chips" role="group" aria-label="Filtres rapides">342 <button343 className={`chip chip-ico ${spa === "1" ? "on" : ""}`}344 onClick={() => setSpa(spa === "1" ? "" : "1")}345 >346 ♨ Spa347 </button>348 <button349 className={`chip chip-ico ${waterfront === "1" ? "on" : ""}`}350 onClick={() => setWaterfront(waterfront === "1" ? "" : "1")}351 >352 ≈ Bord de l'eau353 </button>354 <button355 className={`chip chip-ico ${pets === "oui" ? "on" : ""}`}356 onClick={() => setPets(pets === "oui" ? "" : "oui")}357 >358 <IcoPaw size={13} /> Animaux ok359 </button>360 <span className="chip-sep" aria-hidden="true" />361 {(facets?.types ?? []).slice(0, 5).map((t) => (362 <button363 key={t.type}364 className={`chip ${type === t.type ? "on" : ""}`}365 onClick={() => setType(type === t.type ? "" : t.type)}366 >367 {t.type}368 </button>369 ))}370 </div>371372 {pills.length > 0 && (373 <div className="pills" aria-label="Filtres actifs">374 {pills.map((p) => (375 <button key={p.label} className="pill" onClick={p.clear}376 aria-label={`Retirer le filtre ${p.label}`}>377 {p.label} <span className="pill-x">✕</span>378 </button>379 ))}380 <button className="pill pill-clear" onClick={resetAll}>381 Tout effacer382 </button>383 </div>384 )}385386 <div className="results-bar" ref={resultsRef}>387 <h2 className="rb-count">388 {view === "liste" && listings389 ? <><b>{total.toLocaleString("fr-CA")}</b> hébergement{total > 1 ? "s" : ""}</>390 : "Hébergements"}391 </h2>392 <div className="rb-tools">393 <div className="rb-tabs" role="tablist" aria-label="Mode d'affichage">394 <button395 role="tab" aria-selected={view === "liste"}396 className={`rb-tab ${view === "liste" ? "on" : ""}`}397 onClick={() => setView("liste")}398 >399 <IcoList size={13} /> Liste400 </button>401 <button402 role="tab" aria-selected={view === "carte"}403 className={`rb-tab ${view === "carte" ? "on" : ""}`}404 onClick={() => setView("carte")}405 >406 <IcoMap size={13} /> Carte407 </button>408 </div>409 <button className="rb-filters" onClick={() => setSheetOpen(true)}>410 <IcoSliders size={13} /> Filtres411 {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>}412 </button>413 </div>414 </div>415416 {error && (417 <div className="notice">418 <div className="big"><IcoAlert size={40} /></div>419 <h2>Impossible de charger les hébergements</h2>420 <p>{error}</p>421 <button className="btn btn-primary" onClick={() => window.location.reload()}>422 Réessayer423 </button>424 </div>425 )}426427 {!error && view === "liste" && listings === null && (428 <div className="grid grid-edito" aria-busy="true">429 {Array.from({ length: 8 }).map((_, i) => (430 <div className="skel" key={i}>431 <div className="sk-img" />432 <div className="sk-line" />433 <div className="sk-line short" />434 </div>435 ))}436 </div>437 )}438439 {!error && view === "liste" && listings !== null && listings.length === 0 && (440 <div className="notice">441 <div className="big"><IcoSearch size={40} /></div>442 <h2>Aucun hébergement ne correspond</h2>443 <p>444 {stats && stats.total === 0445 ? "La section court terme démarre : les premières synchronisations sont en cours."446 : "Essayez d'élargir vos critères"}447 {activeFilters > 0 && (448 <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>449 )}.450 </p>451 </div>452 )}453454 {!error && view === "carte" && (455 <div className="view-pane" key="carte">456 <Suspense457 fallback={458 <div className="map-split">459 <div className="mapview map-loading">Chargement de la carte…</div>460 </div>461 }462 >463 <CtMapView filters={filters} />464 </Suspense>465 </div>466 )}467468 {!error && view === "liste" && listings !== null && listings.length > 0 && (469 <div className="view-pane" key="liste">470 <div className="grid grid-edito">471 {listings.map((l) => (472 <CtListingCard key={l.uid} l={l} />473 ))}474 </div>475 <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />476 </div>477 )}478 </div>479 );480}481