// ----------------------------------------------------------------------------- // Lou-Ka — Location court terme // pages/CourtTerme.tsx : agrégateur d'hébergements court terme (chalets, // séjours, hébergement insolite) — héro, filtres, grille + vue carte. // Section séparée du Lou-Ka standard (logements au mois) : API /api/ct/*. // ----------------------------------------------------------------------------- import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { CtFacets, CtFilters, CtListing, CtStats, ctSourceName, fetchCtFacets, fetchCtListings, fetchCtSources, fetchCtStats, registerCtSourceNames, } from "../ctapi"; import CtListingCard from "../components/CtListingCard"; import Pager from "../components/Pager"; import { useAgo, useCountUp } from "../live"; import { IcoAlert, IcoList, IcoMap, IcoPaw, IcoSearch, IcoSliders, } from "../components/Icons"; const CtMapView = lazy(() => import("../components/CtMapView")); const PAGE_SIZE = 12; const NIGHT_STEPS = [75, 100, 150, 200, 250, 300, 400, 500, 750, 1000]; const CAPACITY_STEPS = [2, 4, 6, 8, 10, 12]; const SORTS: { key: string; label: string }[] = [ { key: "recent", label: "Plus récents" }, { key: "prix", label: "Prix croissant" }, { key: "prix_desc", label: "Prix décroissant" }, { key: "note", label: "Mieux notés" }, ]; function useDebounced(value: T, delayMs = 300): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const t = setTimeout(() => setDebounced(value), delayMs); return () => clearTimeout(t); }, [value, delayMs]); return debounced; } export default function CourtTermePage() { const [listings, setListings] = useState(null); const [total, setTotal] = useState(0); const [facets, setFacets] = useState(null); const [stats, setStats] = useState(null); const [error, setError] = useState(null); const [params] = useSearchParams(); const [q, setQ] = useState(params.get("q") ?? ""); const [region, setRegion] = useState(params.get("region") ?? ""); const [type, setType] = useState(params.get("type") ?? ""); const [source, setSource] = useState(params.get("source") ?? ""); const [priceMin, setPriceMin] = useState(params.get("price_min") ?? ""); const [priceMax, setPriceMax] = useState(params.get("price_max") ?? ""); const [capMin, setCapMin] = useState(params.get("capacity_min") ?? ""); const [pets, setPets] = useState(params.get("pets") ?? ""); const [spa, setSpa] = useState(params.get("spa") ?? ""); const [waterfront, setWaterfront] = useState(params.get("waterfront") ?? ""); const [sort, setSort] = useState(params.get("sort") ?? "recent"); const [sheetOpen, setSheetOpen] = useState(false); const [advOpen, setAdvOpen] = useState(false); useEffect(() => { document.body.style.overflow = sheetOpen ? "hidden" : ""; if (!sheetOpen) return () => { document.body.style.overflow = ""; }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); }; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = ""; window.removeEventListener("keydown", onKey); }; }, [sheetOpen]); const activeFilters = [q, region, type, source, priceMin, priceMax, capMin, pets, spa, waterfront].filter(Boolean).length; const advCount = [capMin, pets, source].filter(Boolean).length; const [view, setView] = useState<"liste" | "carte">( params.get("view") === "carte" ? "carte" : "liste"); useEffect(() => { setView(params.get("view") === "carte" ? "carte" : "liste"); }, [params]); const qDebounced = useDebounced(q); const filters: CtFilters = useMemo(() => ({ q: qDebounced, region, type, source, price_min: priceMin, price_max: priceMax, capacity_min: capMin, pets, spa, waterfront, sort, }), [qDebounced, region, type, source, priceMin, priceMax, capMin, pets, spa, waterfront, sort]); // données vivantes du héro : dernière synchro réelle (max des connecteurs) const [lastSync, setLastSync] = useState(null); useEffect(() => { fetchCtSources().then((r) => { registerCtSourceNames(r.sources); const ts = Math.max(0, ...r.sources.map((s) => s.last_sync ?? 0)); if (ts > 0) setLastSync(ts > 1e12 ? Math.round(ts / 1000) : ts); }).catch(() => {}); fetchCtFacets().then(setFacets).catch(() => {}); fetchCtStats().then(setStats).catch(() => {}); }, []); const totalLive = useCountUp(stats && stats.total > 0 ? stats.total : null); const syncAgo = useAgo(lastSync); // pagination const [page, setPage] = useState(() => { const p = Number(params.get("page")); return Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1; }); const resultsRef = useRef(null); const filtersKey = useMemo(() => JSON.stringify(filters), [filters]); const firstRun = useRef(true); useEffect(() => { if (firstRun.current) { firstRun.current = false; return; } setPage(1); }, [filtersKey]); const goToPage = (p: number) => { setPage(p); resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); }; useEffect(() => { if (view === "carte") return; let cancelled = false; setListings(null); setError(null); fetchCtListings(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE) .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); const last = Math.max(1, Math.ceil(r.total / PAGE_SIZE)); if (page > last) setPage(last); } }) .catch((e) => !cancelled && setError(String(e))); return () => { cancelled = true; }; }, [filters, page, view]); const resetAll = () => { setQ(""); setRegion(""); setType(""); setSource(""); setPriceMin(""); setPriceMax(""); setCapMin(""); setPets(""); setSpa(""); setWaterfront(""); setSort("recent"); }; const pills: { label: string; clear: () => void }[] = []; if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") }); if (region) pills.push({ label: region, clear: () => setRegion("") }); if (type) pills.push({ label: type, clear: () => setType("") }); if (priceMin) pills.push({ label: `≥ ${priceMin} $/nuit`, clear: () => setPriceMin("") }); if (priceMax) pills.push({ label: `≤ ${priceMax} $/nuit`, clear: () => setPriceMax("") }); if (capMin) pills.push({ label: `${capMin}+ personnes`, clear: () => setCapMin("") }); if (pets) pills.push({ label: "Animaux acceptés", clear: () => setPets("") }); if (spa) pills.push({ label: "Spa", clear: () => setSpa("") }); if (waterfront) pills.push({ label: "Bord de l'eau", clear: () => setWaterfront("") }); if (source) pills.push({ label: ctSourceName(source), clear: () => setSource("") }); return (
Court terme — chalets et séjours, partout au Québec

Trouvez votre prochaine
escapade.

Chalets, condos et hébergements insolites en location court terme, agrégés depuis les plateformes québécoises et internationales — lien direct pour réserver.

live {totalLive && ( {totalLive} hébergements indexés )} {stats && stats.regions > 0 && ( {stats.regions} régions )} {syncAgo && synchro {syncAgo}} {stats?.avg_night != null && ( prix moyen {Math.round(stats.avg_night)} $ / nuit )}
{sheetOpen && (
setSheetOpen(false)} aria-hidden="true" /> )}
{/* mobile : résumé des critères — ouvre la feuille (recherche intégrée, pas de FAB) */}
{pills.length > 0 && (
{pills.map((p) => ( ))}
)}

{view === "liste" && listings ? <>{total.toLocaleString("fr-CA")} hébergement{total > 1 ? "s" : ""} : "Hébergements"}

{error && (

Impossible de charger les hébergements

{error}

)} {!error && view === "liste" && listings === null && (
{Array.from({ length: 8 }).map((_, i) => (
))}
)} {!error && view === "liste" && listings !== null && listings.length === 0 && (

Aucun hébergement ne correspond

{stats && stats.total === 0 ? "La section court terme démarre : les premières synchronisations sont en cours." : "Essayez d'élargir vos critères"} {activeFilters > 0 && ( <> — ou )}.

)} {!error && view === "carte" && (
Chargement de la carte…
} >
)} {!error && view === "liste" && listings !== null && listings.length > 0 && (
{listings.map((l) => ( ))}
)}
); }