// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille // Filtres : ville, quartier, taille, loyer min/max, dispo, animaux, meublé, // superficie, gestionnaire, recherche — avec pastilles de filtres actifs. // ----------------------------------------------------------------------------- import { Suspense, lazy, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { Facets, Listing, ListingFilters, Stats, fetchFacets, fetchListings, fetchSources, fetchStats, isoInDays, registerSourceNames, sourceName, } from "../api"; import ListingCard from "../components/ListingCard"; // La carte (MapLibre, ~220 ko) n'est chargée que si l'utilisateur l'ouvre const MapView = lazy(() => import("../components/MapView")); const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"]; const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000]; const AREA_STEPS = [400, 600, 800, 1000, 1200]; /** Choix « Disponibilité » → paramètre API available_by (date ISO) */ const DISPO_CHOICES: { key: string; label: string; days: number | null }[] = [ { key: "", label: "Peu importe", days: null }, { key: "now", label: "Maintenant", days: 0 }, { key: "30", label: "D'ici 1 mois", days: 30 }, { key: "60", label: "D'ici 2 mois", days: 60 }, { key: "90", label: "D'ici 3 mois", days: 90 }, ]; export default function Home() { const [listings, setListings] = useState(null); const [total, setTotal] = useState(0); const [facets, setFacets] = useState(null); const [sectors, setSectors] = useState([]); const [stats, setStats] = useState(null); const [error, setError] = useState(null); // filtres (pré-remplis depuis l'URL, ex. /?city=Montréal — liens de la page Stats) const [params] = useSearchParams(); const [q, setQ] = useState(params.get("q") ?? ""); const [city, setCity] = useState(params.get("city") ?? ""); const [sector, setSector] = useState(params.get("sector") ?? ""); const [source, setSource] = useState(params.get("source") ?? ""); const [priceMin, setPriceMin] = useState(params.get("price_min") ?? ""); const [priceMax, setPriceMax] = useState(params.get("price_max") ?? ""); const [unitType, setUnitType] = useState(params.get("unit_type") ?? ""); const [dispo, setDispo] = useState(params.get("dispo") ?? ""); const [pets, setPets] = useState(params.get("pets") ?? ""); const [furnished, setFurnished] = useState(params.get("furnished") ?? ""); const [areaMin, setAreaMin] = useState(params.get("area_min") ?? ""); // feuille de filtres mobile (bottom sheet) + panneau avancé desktop const [sheetOpen, setSheetOpen] = useState(false); const [advOpen, setAdvOpen] = useState(false); const activeFilters = [q, city, sector, source, priceMin, priceMax, unitType, dispo, pets, furnished, areaMin].filter(Boolean).length; const advCount = [dispo, pets, furnished, areaMin, source].filter(Boolean).length; // vue liste ou carte (mémorisée dans l'URL : /?view=carte) const [view, setView] = useState<"liste" | "carte">( params.get("view") === "carte" ? "carte" : "liste"); useEffect(() => { // suivre l'URL quand on navigue via le menu (« Carte » -> /?view=carte) setView(params.get("view") === "carte" ? "carte" : "liste"); }, [params]); const filters: ListingFilters = useMemo(() => { const d = DISPO_CHOICES.find((c) => c.key === dispo); return { q, city, sector, source, unit_type: unitType, price_min: priceMin, price_max: priceMax, pets, furnished, area_min: areaMin, available_by: d?.days != null ? isoInDays(d.days) : "", }; }, [q, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo]); useEffect(() => { fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); fetchFacets().then(setFacets).catch(() => {}); fetchStats().then(setStats).catch(() => {}); }, []); // quartiers dépendants de la ville choisie useEffect(() => { fetchFacets(city || undefined) .then((f) => setSectors(f.sectors)) .catch(() => setSectors([])); }, [city]); useEffect(() => { let cancelled = false; setListings(null); setError(null); fetchListings(filters) .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } }) .catch((e) => !cancelled && setError(String(e))); return () => { cancelled = true; }; }, [filters]); const availableTypes = useMemo(() => { const set = new Set(facets?.unit_types ?? []); return UNIT_TYPES.filter((t) => set.size === 0 || set.has(t)); }, [facets]); const resetAll = () => { setQ(""); setCity(""); setSector(""); setSource(""); setPriceMin(""); setPriceMax(""); setUnitType(""); setDispo(""); setPets(""); setFurnished(""); setAreaMin(""); }; // pastilles « filtres actifs » — libellé + action de retrait const pills: { label: string; clear: () => void }[] = []; if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") }); if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } }); if (sector) pills.push({ label: sector, clear: () => setSector("") }); if (unitType) pills.push({ label: unitType, clear: () => setUnitType("") }); if (priceMin) pills.push({ label: `≥ ${priceMin} $`, clear: () => setPriceMin("") }); if (priceMax) pills.push({ label: `≤ ${priceMax} $`, clear: () => setPriceMax("") }); if (dispo) pills.push({ label: `Dispo : ${DISPO_CHOICES.find((c) => c.key === dispo)?.label ?? dispo}`, clear: () => setDispo(""), }); if (pets) pills.push({ label: "Animaux acceptés", clear: () => setPets("") }); if (furnished) pills.push({ label: furnished === "1" ? "Meublé" : "Non meublé", clear: () => setFurnished(""), }); if (areaMin) pills.push({ label: `≥ ${areaMin} pi²`, clear: () => setAreaMin("") }); if (source) pills.push({ label: sourceName(source), clear: () => setSource("") }); return (
Agrégateur — Québec · Lévis · Montréal

Tous les logements à louer,
un seul endroit.

Lou-Ka rassemble les appartements affichés par les gestionnaires immobiliers du Québec — Québec, Lévis et le Grand Montréal — mis à jour automatiquement, avec toutes les photos et un lien direct vers l'annonce originale.

Données synchronisées en continu {stats && ( <> {stats.total} logements actifs {stats.quebec ?? 0} à Québec {stats.levis ?? 0} à Lévis {(stats.montreal ?? 0) > 0 && ( {stats.montreal} Grand Montréal )} {(stats.autres ?? 0) > 0 && ( {stats.autres} ailleurs au Québec )} {stats.avg_price != null && ( loyer moyen {Math.round(stats.avg_price)} $ )} )}
{sheetOpen && (
setSheetOpen(false)} aria-hidden="true" /> )}
Filtres
{/* — rangée principale : recherche, ville, quartier, loyer, + filtres — */}
setQ(e.target.value)} aria-label="Recherche" /> {q && ( )}
Loyer
{/* — panneau avancé : segments & sélecteurs — */} {(advOpen || sheetOpen) && (
{DISPO_CHOICES.map((c) => ( ))}
{[["", "Peu importe"], ["1", "Oui"], ["0", "Non"]].map(([v, l]) => ( ))}
{[["", "Peu importe"], ["oui", "🐾 Acceptés"]].map(([v, l]) => ( ))}
{AREA_STEPS.map((a) => ( ))}
)}
{availableTypes.map((t) => ( ))}
{pills.length > 0 && (
{pills.map((p) => ( ))}
)}

Logements disponibles

{listings && {total} résultat{total > 1 ? "s" : ""}}
{error && (
⚠️

Impossible de charger les annonces

{error}

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

Aucun logement ne correspond

Essayez d'élargir vos critères {activeFilters > 0 && ( <> — ou )}.

)} {!error && view === "carte" && (
{(listings ?? []).map((l) => ( ))}
Chargement de la carte…
}>
)} {!error && view === "liste" && listings !== null && listings.length > 0 && (
{listings.map((l) => ( ))}
)} {/* Bouton flottant mobile — ouvre la feuille de filtres */}
); }