SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
18.3 KB · 462 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille5//   Filtres : ville, quartier, taille, loyer min/max, dispo, animaux, meublé,6//   superficie, gestionnaire, recherche — avec pastilles de filtres actifs.7// -----------------------------------------------------------------------------8import { Suspense, lazy, useEffect, useMemo, useState } from "react";9import { useSearchParams } from "react-router-dom";10import {11  Facets, Listing, ListingFilters, Stats,12  fetchFacets, fetchListings, fetchSources, fetchStats, isoInDays,13  registerSourceNames, sourceName,14} from "../api";15import ListingCard from "../components/ListingCard";1617// La carte (MapLibre, ~220 ko) n'est chargée que si l'utilisateur l'ouvre18const MapView = lazy(() => import("../components/MapView"));1920const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"];21const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000];22const AREA_STEPS = [400, 600, 800, 1000, 1200];2324/** Choix « Disponibilité » → paramètre API available_by (date ISO) */25const DISPO_CHOICES: { key: string; label: string; days: number | null }[] = [26  { key: "", label: "Peu importe", days: null },27  { key: "now", label: "Maintenant", days: 0 },28  { key: "30", label: "D'ici 1 mois", days: 30 },29  { key: "60", label: "D'ici 2 mois", days: 60 },30  { key: "90", label: "D'ici 3 mois", days: 90 },31];3233export default function Home() {34  const [listings, setListings] = useState<Listing[] | null>(null);35  const [total, setTotal] = useState(0);36  const [facets, setFacets] = useState<Facets | null>(null);37  const [sectors, setSectors] = useState<string[]>([]);38  const [stats, setStats] = useState<Stats | null>(null);39  const [error, setError] = useState<string | null>(null);4041  // filtres (pré-remplis depuis l'URL, ex. /?city=Montréal — liens de la page Stats)42  const [params] = useSearchParams();43  const [q, setQ] = useState(params.get("q") ?? "");44  const [city, setCity] = useState(params.get("city") ?? "");45  const [sector, setSector] = useState(params.get("sector") ?? "");46  const [source, setSource] = useState(params.get("source") ?? "");47  const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");48  const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");49  const [unitType, setUnitType] = useState(params.get("unit_type") ?? "");50  const [dispo, setDispo] = useState(params.get("dispo") ?? "");51  const [pets, setPets] = useState(params.get("pets") ?? "");52  const [furnished, setFurnished] = useState(params.get("furnished") ?? "");53  const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");54  // feuille de filtres mobile (bottom sheet) + panneau avancé desktop55  const [sheetOpen, setSheetOpen] = useState(false);56  const [advOpen, setAdvOpen] = useState(false);57  const activeFilters = [q, city, sector, source, priceMin, priceMax, unitType,58    dispo, pets, furnished, areaMin].filter(Boolean).length;59  const advCount = [dispo, pets, furnished, areaMin, source].filter(Boolean).length;60  // vue liste ou carte (mémorisée dans l'URL : /?view=carte)61  const [view, setView] = useState<"liste" | "carte">(62    params.get("view") === "carte" ? "carte" : "liste");63  useEffect(() => {64    // suivre l'URL quand on navigue via le menu (« Carte » -> /?view=carte)65    setView(params.get("view") === "carte" ? "carte" : "liste");66  }, [params]);6768  const filters: ListingFilters = useMemo(() => {69    const d = DISPO_CHOICES.find((c) => c.key === dispo);70    return {71      q, city, sector, source, unit_type: unitType,72      price_min: priceMin, price_max: priceMax,73      pets, furnished, area_min: areaMin,74      available_by: d?.days != null ? isoInDays(d.days) : "",75    };76  }, [q, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo]);7778  useEffect(() => {79    fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});80    fetchFacets().then(setFacets).catch(() => {});81    fetchStats().then(setStats).catch(() => {});82  }, []);8384  // quartiers dépendants de la ville choisie85  useEffect(() => {86    fetchFacets(city || undefined)87      .then((f) => setSectors(f.sectors))88      .catch(() => setSectors([]));89  }, [city]);9091  useEffect(() => {92    let cancelled = false;93    setListings(null);94    setError(null);95    fetchListings(filters)96      .then((r) => {97        if (!cancelled) {98          setListings(r.listings);99          setTotal(r.total);100        }101      })102      .catch((e) => !cancelled && setError(String(e)));103    return () => {104      cancelled = true;105    };106  }, [filters]);107108  const availableTypes = useMemo(() => {109    const set = new Set(facets?.unit_types ?? []);110    return UNIT_TYPES.filter((t) => set.size === 0 || set.has(t));111  }, [facets]);112113  const resetAll = () => {114    setQ(""); setCity(""); setSector(""); setSource("");115    setPriceMin(""); setPriceMax(""); setUnitType("");116    setDispo(""); setPets(""); setFurnished(""); setAreaMin("");117  };118119  // pastilles « filtres actifs » — libellé + action de retrait120  const pills: { label: string; clear: () => void }[] = [];121  if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });122  if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });123  if (sector) pills.push({ label: sector, clear: () => setSector("") });124  if (unitType) pills.push({ label: unitType, clear: () => setUnitType("") });125  if (priceMin) pills.push({ label: `≥ ${priceMin} $`, clear: () => setPriceMin("") });126  if (priceMax) pills.push({ label: `≤ ${priceMax} $`, clear: () => setPriceMax("") });127  if (dispo) pills.push({128    label: `Dispo : ${DISPO_CHOICES.find((c) => c.key === dispo)?.label ?? dispo}`,129    clear: () => setDispo(""),130  });131  if (pets) pills.push({ label: "Animaux acceptés", clear: () => setPets("") });132  if (furnished) pills.push({133    label: furnished === "1" ? "Meublé" : "Non meublé",134    clear: () => setFurnished(""),135  });136  if (areaMin) pills.push({ label: `≥ ${areaMin} pi²`, clear: () => setAreaMin("") });137  if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });138139  return (140    <div className="container">141      <section className="hero">142        <span className="kicker">Agrégateur — Québec · Lévis · Montréal</span>143        <h1>144          Tous les logements <span className="outline">à louer</span>,<br />145          <span className="hl">un seul</span> endroit.146        </h1>147        <p className="lede">148          Lou-Ka rassemble les appartements affichés par les gestionnaires immobiliers du149          Québec — Québec, Lévis et le Grand Montréal — mis à jour automatiquement, avec150          toutes les photos et un lien direct vers l'annonce originale.151        </p>152        <div className="stat-row">153          <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>154          {stats && (155            <>156              <span className="stat-chip"><b>{stats.total}</b> logements actifs</span>157              <span className="stat-chip"><b>{stats.quebec ?? 0}</b> à Québec</span>158              <span className="stat-chip"><b>{stats.levis ?? 0}</b> à Lévis</span>159              {(stats.montreal ?? 0) > 0 && (160                <span className="stat-chip"><b>{stats.montreal}</b> Grand Montréal</span>161              )}162              {(stats.autres ?? 0) > 0 && (163                <span className="stat-chip"><b>{stats.autres}</b> ailleurs au Québec</span>164              )}165              {stats.avg_price != null && (166                <span className="stat-chip">167                  loyer moyen <b>{Math.round(stats.avg_price)} $</b>168                </span>169              )}170            </>171          )}172        </div>173      </section>174175      {sheetOpen && (176        <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />177      )}178      <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">179        <div className="sheet-head">180          <span>Filtres</span>181          <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">182183          </button>184        </div>185186        {/* — rangée principale : recherche, ville, quartier, loyer, + filtres — */}187        <div className="f-primary">188          <div className="f-search">189            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">190              <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />191            </svg>192            <input193              id="f-q" placeholder="Adresse, quartier, rue…" value={q}194              onChange={(e) => setQ(e.target.value)}195              aria-label="Recherche"196            />197            {q && (198              <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>199            )}200          </div>201          <label className="f-ctl">202            <span>Ville</span>203            <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>204              <option value="">Toutes</option>205              {(facets?.cities ?? ["Québec", "Lévis"]).map((c) => (206                <option key={c} value={c}>{c}</option>207              ))}208            </select>209          </label>210          <label className="f-ctl">211            <span>Quartier</span>212            <select value={sector} onChange={(e) => setSector(e.target.value)}>213              <option value="">Tous</option>214              {sectors.map((s) => (215                <option key={s} value={s}>{s}</option>216              ))}217            </select>218          </label>219          <div className="f-ctl">220            <span>Loyer</span>221            <div className="range-pair">222              <select aria-label="Loyer minimum" value={priceMin}223                onChange={(e) => setPriceMin(e.target.value)}>224                <option value="">Min</option>225                {PRICE_STEPS.map((p) => (226                  <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>227                    {p} $228                  </option>229                ))}230              </select>231              <span className="range-sep">—</span>232              <select aria-label="Loyer maximum" value={priceMax}233                onChange={(e) => setPriceMax(e.target.value)}>234                <option value="">Max</option>235                {PRICE_STEPS.map((p) => (236                  <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>237                    {p} $238                  </option>239                ))}240              </select>241            </div>242          </div>243          <button244            className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`}245            onClick={() => setAdvOpen(!advOpen)}246            aria-expanded={advOpen}247          >248            Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}249          </button>250        </div>251252        {/* — panneau avancé : segments & sélecteurs — */}253        {(advOpen || sheetOpen) && (254          <div className="f-adv">255            <div className="f-group">256              <label>Disponibilité</label>257              <div className="seg" role="group">258                {DISPO_CHOICES.map((c) => (259                  <button key={c.key} className={dispo === c.key ? "on" : ""}260                    onClick={() => setDispo(c.key)}>261                    {c.label}262                  </button>263                ))}264              </div>265            </div>266            <div className="f-group">267              <label>Meublé</label>268              <div className="seg" role="group">269                {[["", "Peu importe"], ["1", "Oui"], ["0", "Non"]].map(([v, l]) => (270                  <button key={v} className={furnished === v ? "on" : ""}271                    onClick={() => setFurnished(v)}>272                    {l}273                  </button>274                ))}275              </div>276            </div>277            <div className="f-group">278              <label>Animaux</label>279              <div className="seg" role="group">280                {[["", "Peu importe"], ["oui", "🐾 Acceptés"]].map(([v, l]) => (281                  <button key={v} className={pets === v ? "on" : ""}282                    onClick={() => setPets(v)}>283                    {l}284                  </button>285                ))}286              </div>287            </div>288            <div className="f-group">289              <label>Superficie minimale</label>290              <div className="seg" role="group">291                <button className={areaMin === "" ? "on" : ""} onClick={() => setAreaMin("")}>292                  Peu importe293                </button>294                {AREA_STEPS.map((a) => (295                  <button key={a} className={areaMin === String(a) ? "on" : ""}296                    onClick={() => setAreaMin(String(a))}>297                    {a}+298                  </button>299                ))}300              </div>301            </div>302            <div className="f-group">303              <label>Gestionnaire</label>304              <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>305                <option value="">Tous</option>306                {(facets?.sources ?? []).map((s) => (307                  <option key={s.source} value={s.source}>308                    {sourceName(s.source)} ({s.n})309                  </option>310                ))}311              </select>312            </div>313            <div className="f-group f-group-end">314              <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>315                Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}316              </button>317            </div>318          </div>319        )}320321        <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>322          Voir les résultats {listings ? `(${total})` : ""}323        </button>324      </section>325326      <div className="chips" role="group" aria-label="Filtres rapides">327        {availableTypes.map((t) => (328          <button329            key={t}330            className={`chip ${unitType === t ? "on" : ""}`}331            onClick={() => setUnitType(unitType === t ? "" : t)}332          >333            {t}334          </button>335        ))}336        <span className="chip-sep" aria-hidden="true" />337        <button338          className={`chip ${dispo === "now" ? "on" : ""}`}339          onClick={() => setDispo(dispo === "now" ? "" : "now")}340        >341          ⚡ Dispo maintenant342        </button>343        <button344          className={`chip ${pets === "oui" ? "on" : ""}`}345          onClick={() => setPets(pets === "oui" ? "" : "oui")}346        >347          🐾 Animaux ok348        </button>349        <button350          className={`chip ${furnished === "1" ? "on" : ""}`}351          onClick={() => setFurnished(furnished === "1" ? "" : "1")}352        >353          🛋 Meublé354        </button>355      </div>356357      {pills.length > 0 && (358        <div className="pills" aria-label="Filtres actifs">359          {pills.map((p) => (360            <button key={p.label} className="pill" onClick={p.clear}361              aria-label={`Retirer le filtre ${p.label}`}>362              {p.label} <span className="pill-x">✕</span>363            </button>364          ))}365          <button className="pill pill-clear" onClick={resetAll}>366            Tout effacer367          </button>368        </div>369      )}370371      <div className="results-head">372        <h2>Logements disponibles</h2>373        <div className="results-tools">374          {listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>}375          <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">376            <button377              role="tab" aria-selected={view === "liste"}378              className={view === "liste" ? "on" : ""}379              onClick={() => setView("liste")}380            >381              ☰ Liste382            </button>383            <button384              role="tab" aria-selected={view === "carte"}385              className={view === "carte" ? "on" : ""}386              onClick={() => setView("carte")}387            >388              ◈ Carte389            </button>390          </div>391        </div>392      </div>393394      {error && (395        <div className="notice">396          <div className="big">⚠️</div>397          <h2>Impossible de charger les annonces</h2>398          <p>{error}</p>399          <button className="btn btn-primary" onClick={() => window.location.reload()}>400            Réessayer401          </button>402        </div>403      )}404405      {!error && view === "liste" && listings === null && (406        <div className="grid" aria-busy="true">407          {Array.from({ length: 8 }).map((_, i) => (408            <div className="skel" key={i}>409              <div className="sk-img" />410              <div className="sk-line" />411              <div className="sk-line short" />412            </div>413          ))}414        </div>415      )}416417      {!error && view === "liste" && listings !== null && listings.length === 0 && (418        <div className="notice">419          <div className="big">🔍</div>420          <h2>Aucun logement ne correspond</h2>421          <p>422            Essayez d'élargir vos critères423            {activeFilters > 0 && (424              <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>425            )}.426          </p>427        </div>428      )}429430      {!error && view === "carte" && (431        <div className="map-split">432          <div className="map-list" aria-label="Résultats en liste">433            {(listings ?? []).map((l) => (434              <ListingCard key={l.uid} l={l} />435            ))}436          </div>437          <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>438            <MapView filters={filters} />439          </Suspense>440        </div>441      )}442443      {!error && view === "liste" && listings !== null && listings.length > 0 && (444        <div className="grid">445          {listings.map((l) => (446            <ListingCard key={l.uid} l={l} />447          ))}448        </div>449      )}450451      {/* Bouton flottant mobile — ouvre la feuille de filtres */}452      <button453        className="fab"454        onClick={() => setSheetOpen(true)}455        aria-label="Ouvrir les filtres"456      >457        ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}458      </button>459    </div>460  );461}462