// -----------------------------------------------------------------------------
// 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
setSheetOpen(false)} aria-label="Fermer les filtres">
✕
{/* — rangée principale : recherche, ville, quartier, loyer, + filtres — */}
Ville
{ setCity(e.target.value); setSector(""); }}>
Toutes
{(facets?.cities ?? ["Québec", "Lévis"]).map((c) => (
{c}
))}
Quartier
setSector(e.target.value)}>
Tous
{sectors.map((s) => (
{s}
))}
Loyer
setPriceMin(e.target.value)}>
Min
{PRICE_STEPS.map((p) => (
= Number(priceMax)}>
{p} $
))}
—
setPriceMax(e.target.value)}>
Max
{PRICE_STEPS.map((p) => (
{p} $
))}
0 ? "on" : ""}`}
onClick={() => setAdvOpen(!advOpen)}
aria-expanded={advOpen}
>
Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}
{/* — panneau avancé : segments & sélecteurs — */}
{(advOpen || sheetOpen) && (
Disponibilité
{DISPO_CHOICES.map((c) => (
setDispo(c.key)}>
{c.label}
))}
Meublé
{[["", "Peu importe"], ["1", "Oui"], ["0", "Non"]].map(([v, l]) => (
setFurnished(v)}>
{l}
))}
Animaux
{[["", "Peu importe"], ["oui", "🐾 Acceptés"]].map(([v, l]) => (
setPets(v)}>
{l}
))}
Superficie minimale
setAreaMin("")}>
Peu importe
{AREA_STEPS.map((a) => (
setAreaMin(String(a))}>
{a}+
))}
Gestionnaire
setSource(e.target.value)}>
Tous
{(facets?.sources ?? []).map((s) => (
{sourceName(s.source)} ({s.n})
))}
Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}
)}
setSheetOpen(false)}>
Voir les résultats {listings ? `(${total})` : ""}
{availableTypes.map((t) => (
setUnitType(unitType === t ? "" : t)}
>
{t}
))}
setDispo(dispo === "now" ? "" : "now")}
>
⚡ Dispo maintenant
setPets(pets === "oui" ? "" : "oui")}
>
🐾 Animaux ok
setFurnished(furnished === "1" ? "" : "1")}
>
🛋 Meublé
{pills.length > 0 && (
{pills.map((p) => (
{p.label} ✕
))}
Tout effacer
)}
Logements disponibles
{listings &&
{total} résultat{total > 1 ? "s" : ""} }
setView("liste")}
>
☰ Liste
setView("carte")}
>
◈ Carte
{error && (
⚠️
Impossible de charger les annonces
{error}
window.location.reload()}>
Réessayer
)}
{!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 retirez les {activeFilters} filtres actifs >
)}.
)}
{!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 */}
setSheetOpen(true)}
aria-label="Ouvrir les filtres"
>
⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}
);
}