SPB Git

spb/toit-ka Public

Toit-Ka — louer ou acheter un toit au Québec, un seul endroit (fusion Lou-Ka × Immo-Ka) — www.toit-ka.com

Python 40.2% TypeScript 39% CSS 20.2% HTML 0.7%
22.9 KB · 495 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Author: Simon-Pierre Boucher3// Contact: contact@spboucher.ai4// Project: Toit-Ka5// pages/Home.tsx : accueil — LE commutateur Louer/Acheter, héros, filtres6//   adaptés à l'univers, grille + carte. L'accent du site suit le toggle.7// -----------------------------------------------------------------------------8import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";9import { useSearchParams } from "react-router-dom";10import {11  Facets, Listing, ListingFilters, Stats, Tx,12  fetchFacets, fetchListings, fetchStats, setDocTitle, setMode, sourceName,13} from "../api";14import ListingCard from "../components/ListingCard";15import { Ico } from "../components/Icons";1617const MapView = lazy(() => import("../components/MapView"));1819const PRICE_STEPS: Record<Tx, number[]> = {20  louer: [500, 700, 900, 1100, 1300, 1500, 1800, 2200, 2600, 3000, 4000],21  acheter: [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000],22};23const AREA_STEPS = [500, 800, 1000, 1500, 2000, 3000];24const PAGE = 12;2526const fmtStep = (n: number, tx: Tx) =>27  tx === "acheter"28    ? (n >= 1_000_000 ? `${n / 1_000_000} M$` : `${Math.round(n / 1000)} k$`)29    : `${n.toLocaleString("fr-CA")} $`;3031function pageNumbers(p: number, n: number): (number | "…")[] {32  if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);33  const out: (number | "…")[] = [1];34  const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1);35  if (lo > 2) out.push("…");36  for (let i = lo; i <= hi; i++) out.push(i);37  if (hi < n - 1) out.push("…");38  out.push(n);39  return out;40}4142/** Valeur retardée : évite une requête API à chaque frappe de recherche. */43function useDebounced<T>(value: T, delayMs = 300): T {44  const [debounced, setDebounced] = useState(value);45  useEffect(() => {46    const t = setTimeout(() => setDebounced(value), delayMs);47    return () => clearTimeout(t);48  }, [value, delayMs]);49  return debounced;50}5152export default function Home() {53  const [params, setParams] = useSearchParams();54  const [tx, setTx] = useState<Tx>(params.get("tx") === "acheter" ? "acheter" : "louer");55  // le toggle est LE geste central : il bascule aussi l'identité du site56  useEffect(() => { setMode(tx); setDocTitle(57    tx === "louer" ? "Logements à louer au Québec" : "Propriétés à vendre au Québec");58  }, [tx]);59  useEffect(() => {60    const urlTx = params.get("tx") === "acheter" ? "acheter" : "louer";61    setTx(urlTx);62  }, [params]);6364  const [listings, setListings] = useState<Listing[] | null>(null);65  const [total, setTotal] = useState(0);66  const [page, setPage] = useState(Number(params.get("page")) || 1);67  const [facets, setFacets] = useState<Facets | null>(null);68  const [sectors, setSectors] = useState<string[]>([]);69  const [stats, setStats] = useState<Stats | null>(null);70  const [error, setError] = useState<string | null>(null);71  const [q, setQ] = useState(params.get("q") ?? "");72  const [city, setCity] = useState(params.get("city") ?? "");73  const [sector, setSector] = useState(params.get("sector") ?? "");74  const [type, setType] = useState(params.get("type") ?? "");75  const [source, setSource] = useState(params.get("source") ?? "");76  const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");77  const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");78  const [bedsMin, setBedsMin] = useState(params.get("bedrooms_min") ?? "");79  const [bathsMin, setBathsMin] = useState(params.get("bathrooms_min") ?? "");80  const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");81  const [pets, setPets] = useState(params.get("pets") ?? "");82  const [furnished, setFurnished] = useState(params.get("furnished") ?? "");83  const [sort, setSort] = useState(params.get("sort") ?? "recent");8485  const [sheetOpen, setSheetOpen] = useState(false);86  const [advOpen, setAdvOpen] = useState(false);87  const activeFilters = [q, city, sector, type, source, priceMin, priceMax,88    bedsMin, bathsMin, areaMin, pets, furnished].filter(Boolean).length;89  const advCount = [bedsMin, bathsMin, areaMin, source, sector, pets, furnished].filter(Boolean).length;9091  const [view, setView] = useState<"liste" | "carte">(params.get("view") === "carte" ? "carte" : "liste");92  useEffect(() => { setView(params.get("view") === "carte" ? "carte" : "liste"); }, [params]);9394  const qDebounced = useDebounced(q);95  const filters: ListingFilters = useMemo(() => ({96    tx, q: qDebounced, city, sector, type, source,97    price_min: priceMin, price_max: priceMax,98    bedrooms_min: bedsMin, bathrooms_min: bathsMin, area_min: areaMin,99    pets, furnished, sort,100  }), [tx, qDebounced, city, sector, type, source, priceMin, priceMax,101       bedsMin, bathsMin, areaMin, pets, furnished, sort]);102103  // sélection carte <-> liste104  const [mapSelectedUid, setMapSelectedUid] = useState<string | null>(null);105  const [mapHoveredUid, setMapHoveredUid] = useState<string | null>(null);106  const mapListRef = useRef<HTMLDivElement | null>(null);107  useEffect(() => {108    if (!mapSelectedUid) return;109    mapListRef.current110      ?.querySelector(`[data-uid="${CSS.escape(mapSelectedUid)}"]`)111      ?.scrollIntoView({ behavior: "smooth", block: "nearest" });112  }, [mapSelectedUid]);113114  useEffect(() => {115    fetchStats().then(setStats).catch(() => {});116  }, []);117118  // les facettes (villes, types, sources) dépendent de l'univers119  useEffect(() => {120    fetchFacets(tx).then(setFacets).catch(() => {});121  }, [tx]);122123  useEffect(() => {124    fetchFacets(tx, city || undefined).then((f) => setSectors(f.sectors)).catch(() => setSectors([]));125  }, [tx, city]);126127  // changer d'univers : purge les filtres propres à l'autre univers128  const switchTx = (next: Tx) => {129    if (next === tx) return;130    setTx(next);131    setType(""); setSource(""); setSector("");132    setPriceMin(""); setPriceMax("");133    setPets(""); setFurnished(""); setBedsMin(""); setBathsMin("");134    setPage(1);135  };136137  const firstRender = useRef(true);138  useEffect(() => {139    if (firstRender.current) { firstRender.current = false; return; }140    setPage(1);141  }, [filters]);142143  // synchroniser filtres + page + tri + vue dans l'URL144  useEffect(() => {145    const p = new URLSearchParams();146    const set = (k: string, v: string) => { if (v) p.set(k, v); };147    p.set("tx", tx);148    set("q", q); set("city", city); set("sector", sector);149    set("type", type); set("source", source);150    set("price_min", priceMin); set("price_max", priceMax);151    set("bedrooms_min", bedsMin); set("bathrooms_min", bathsMin);152    set("area_min", areaMin); set("pets", pets); set("furnished", furnished);153    if (sort && sort !== "recent") p.set("sort", sort);154    if (view === "carte") p.set("view", "carte");155    if (page > 1) p.set("page", String(page));156    setParams(p, { replace: true });157  }, [filters, page, view, tx, q, city, sector, type, source, priceMin, priceMax,158      bedsMin, bathsMin, areaMin, pets, furnished, sort, setParams]);159160  useEffect(() => {161    let cancelled = false;162    setListings(null); setError(null);163    fetchListings(filters, PAGE, (page - 1) * PAGE)164      .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } })165      .catch((e) => !cancelled && setError(String(e)));166    return () => { cancelled = true; };167  }, [filters, page]);168169  const totalPages = Math.max(1, Math.ceil(total / PAGE));170  const gotoPage = (p: number) => {171    setPage(Math.min(Math.max(1, p), totalPages));172    document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });173  };174175  const resetAll = () => {176    setQ(""); setCity(""); setSector(""); setType(""); setSource("");177    setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin("");178    setAreaMin(""); setPets(""); setFurnished("");179  };180181  const louer = tx === "louer";182  const txStats = stats ? stats[tx] : null;183184  const pills: { label: string; clear: () => void }[] = [];185  if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });186  if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });187  if (sector) pills.push({ label: sector, clear: () => setSector("") });188  if (type) pills.push({ label: type, clear: () => setType("") });189  if (priceMin) pills.push({ label: `≥ ${fmtStep(Number(priceMin), tx)}`, clear: () => setPriceMin("") });190  if (priceMax) pills.push({ label: `≤ ${fmtStep(Number(priceMax), tx)}`, clear: () => setPriceMax("") });191  if (bedsMin) pills.push({ label: `${bedsMin}+ ch.`, clear: () => setBedsMin("") });192  if (bathsMin) pills.push({ label: `${bathsMin}+ sdb`, clear: () => setBathsMin("") });193  if (areaMin) pills.push({ label: `≥ ${areaMin} pi²`, clear: () => setAreaMin("") });194  if (pets) pills.push({ label: "animaux acceptés", clear: () => setPets("") });195  if (furnished) pills.push({ label: furnished === "1" ? "meublé" : "non meublé", clear: () => setFurnished("") });196  if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });197198  const topTypes = (facets?.types ?? []).slice(0, 8).map((t) => t.type);199  const priceSteps = PRICE_STEPS[tx];200201  return (202    <div className="container">203      <section className="hero">204        <span className="kicker">Lou-Ka × Immo-Ka — la fusion</span>205        <h1>206          Un <span className="outline">toit</span> au Québec.<br />207          <span className="hl">{louer ? "Louer" : "Acheter"}</span>, un seul endroit.208        </h1>209        <p className="lede">210          {louer211            ? "Toit-Ka rassemble les logements à louer affichés par les gestionnaires immobiliers, courtiers et plateformes du Québec — mis à jour automatiquement, avec un lien direct vers l'annonce originale."212            : "Toit-Ka rassemble les propriétés à vendre affichées par les agences de courtage et plateformes du Québec — RE/MAX, Sutton, Via Capitale, Proprio Direct et plus — avec un lien direct vers l'annonce originale."}213        </p>214215        <div style={{ marginTop: 24 }}>216          <div className="tx-toggle" role="tablist" aria-label="Louer ou acheter">217            <button role="tab" aria-selected={louer} className={louer ? "on" : ""}218                    onClick={() => switchTx("louer")}>219              <span className="tt-dot louer" aria-hidden="true" /> Louer220            </button>221            <button role="tab" aria-selected={!louer} className={!louer ? "on" : ""}222                    onClick={() => switchTx("acheter")}>223              <span className="tt-dot acheter" aria-hidden="true" /> Acheter224            </button>225          </div>226        </div>227228        <div className="stat-row">229          <span className="stat-chip"><span className="pulse" /> Synchronisé en continu</span>230          {txStats && (231            <>232              <span className="stat-chip"><b>{txStats.total.toLocaleString("fr-CA")}</b> annonces</span>233              <span className="stat-chip"><b>{txStats.cities.toLocaleString("fr-CA")}</b> villes</span>234              <span className="stat-chip"><b>{txStats.sources}</b> sources</span>235              {txStats.avg_price != null && (236                <span className="stat-chip">{louer ? "loyer moyen" : "prix moyen"}{" "}237                  <b>{Math.round(txStats.avg_price).toLocaleString("fr-CA")} $</b></span>238              )}239            </>240          )}241        </div>242      </section>243244      {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}245      <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">246        <div className="sheet-head">247          <span>Filtres</span>248          <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">✕</button>249        </div>250251        <div className="f-primary">252          <div className="f-search">253            <Ico name="search" size={15} />254            <input id="f-q" placeholder={louer ? "Adresse, ville, quartier…" : "Adresse, ville, n° MLS…"}255              value={q} onChange={(e) => setQ(e.target.value)} aria-label="Recherche" />256            {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>}257          </div>258          <label className="f-ctl">259            <span>Ville</span>260            <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>261              <option value="">Toutes</option>262              {(facets?.cities ?? []).map((c) => (263                <option key={c.city} value={c.city}>{c.city} ({c.n})</option>264              ))}265            </select>266          </label>267          <label className="f-ctl">268            <span>Type</span>269            <select value={type} onChange={(e) => setType(e.target.value)}>270              <option value="">Tous</option>271              {(facets?.types ?? []).map((t) => (272                <option key={t.type} value={t.type}>{t.type} ({t.n})</option>273              ))}274            </select>275          </label>276          <div className="f-ctl">277            <span>{louer ? "Loyer" : "Prix"}</span>278            <div className="range-pair">279              <select aria-label="Prix minimum" value={priceMin} onChange={(e) => setPriceMin(e.target.value)}>280                <option value="">Min</option>281                {priceSteps.map((p) => (282                  <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>{fmtStep(p, tx)}</option>283                ))}284              </select>285              <span className="range-sep">—</span>286              <select aria-label="Prix maximum" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}>287                <option value="">Max</option>288                {priceSteps.map((p) => (289                  <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>{fmtStep(p, tx)}</option>290                ))}291              </select>292            </div>293          </div>294          <button className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>295            Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}296          </button>297        </div>298299        {(advOpen || sheetOpen) && (300          <div className="f-adv">301            <div className="f-group">302              <label>Quartier / secteur</label>303              <select className="f-native" value={sector} onChange={(e) => setSector(e.target.value)}>304                <option value="">Tous</option>305                {sectors.map((s) => <option key={s} value={s}>{s}</option>)}306              </select>307            </div>308            {louer ? (309              <>310                <div className="f-group">311                  <label>Animaux</label>312                  <div className="seg" role="group">313                    <button className={pets === "" ? "on" : ""} onClick={() => setPets("")}>Peu importe</button>314                    <button className={pets === "oui" ? "on" : ""} onClick={() => setPets(pets === "oui" ? "" : "oui")}>Acceptés</button>315                  </div>316                </div>317                <div className="f-group">318                  <label>Meublé</label>319                  <div className="seg" role="group">320                    <button className={furnished === "" ? "on" : ""} onClick={() => setFurnished("")}>Peu importe</button>321                    <button className={furnished === "1" ? "on" : ""} onClick={() => setFurnished(furnished === "1" ? "" : "1")}>Meublé</button>322                    <button className={furnished === "0" ? "on" : ""} onClick={() => setFurnished(furnished === "0" ? "" : "0")}>Non meublé</button>323                  </div>324                </div>325              </>326            ) : (327              <>328                <div className="f-group">329                  <label>Chambres (min.)</label>330                  <div className="seg" role="group">331                    <button className={bedsMin === "" ? "on" : ""} onClick={() => setBedsMin("")}>Toutes</button>332                    {["1", "2", "3", "4", "5"].map((n) => (333                      <button key={n} className={bedsMin === n ? "on" : ""} onClick={() => setBedsMin(n)}>{n}+</button>334                    ))}335                  </div>336                </div>337                <div className="f-group">338                  <label>Salles de bain (min.)</label>339                  <div className="seg" role="group">340                    <button className={bathsMin === "" ? "on" : ""} onClick={() => setBathsMin("")}>Toutes</button>341                    {["1", "2", "3"].map((n) => (342                      <button key={n} className={bathsMin === n ? "on" : ""} onClick={() => setBathsMin(n)}>{n}+</button>343                    ))}344                  </div>345                </div>346              </>347            )}348            <div className="f-group">349              <label>Superficie minimale</label>350              <div className="seg" role="group">351                <button className={areaMin === "" ? "on" : ""} onClick={() => setAreaMin("")}>Peu importe</button>352                {AREA_STEPS.map((a) => (353                  <button key={a} className={areaMin === String(a) ? "on" : ""} onClick={() => setAreaMin(String(a))}>{a}+</button>354                ))}355              </div>356            </div>357            <div className="f-group">358              <label>Source</label>359              <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>360                <option value="">Toutes</option>361                {(facets?.sources ?? []).map((s) => (362                  <option key={s.source} value={s.source}>{sourceName(s.source)} ({s.n})</option>363                ))}364              </select>365            </div>366            <div className="f-group f-group-end">367              <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>368                Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}369              </button>370            </div>371          </div>372        )}373374        <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>375          Voir les résultats {listings ? `(${total})` : ""}376        </button>377      </section>378379      <div className="chips" role="group" aria-label="Filtres rapides">380        {topTypes.map((t) => (381          <button key={t} className={`chip ${type === t ? "on" : ""}`} onClick={() => setType(type === t ? "" : t)}>382            {t}383          </button>384        ))}385      </div>386387      {pills.length > 0 && (388        <div className="pills" aria-label="Filtres actifs">389          {pills.map((p) => (390            <button key={p.label} className="pill" onClick={p.clear} aria-label={`Retirer le filtre ${p.label}`}>391              {p.label} <span className="pill-x">✕</span>392            </button>393          ))}394          <button className="pill pill-clear" onClick={resetAll}>Tout effacer</button>395        </div>396      )}397398      <div className="results-head" id="results-top">399        <h2>{louer ? "Logements à louer" : "Propriétés à vendre"}</h2>400        <div className="results-tools">401          {listings && <span>{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}</span>}402          <label className="sort-ctl">403            <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Trier">404              <option value="recent">Plus récents</option>405              <option value="price_asc">{louer ? "Loyer" : "Prix"} ↑</option>406              <option value="price_desc">{louer ? "Loyer" : "Prix"} ↓</option>407            </select>408          </label>409          <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">410            <button role="tab" aria-selected={view === "liste"} className={view === "liste" ? "on" : ""} onClick={() => setView("liste")}>☰ Liste</button>411            <button role="tab" aria-selected={view === "carte"} className={view === "carte" ? "on" : ""} onClick={() => setView("carte")}>◈ Carte</button>412          </div>413        </div>414      </div>415416      {error && (417        <div className="notice">418          <div className="big"><Ico name="alert" size={40} /></div>419          <h2>Impossible de charger les annonces</h2>420          <p>{error}</p>421          <button className="btn btn-primary" onClick={() => window.location.reload()}>Réessayer</button>422        </div>423      )}424425      {!error && view === "liste" && listings === null && (426        <div className="grid" aria-busy="true">427          {Array.from({ length: 8 }).map((_, i) => (428            <div className="skel" key={i}><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>429          ))}430        </div>431      )}432433      {!error && view === "liste" && listings !== null && listings.length === 0 && (434        <div className="notice">435          <div className="big"><Ico name="search" size={40} /></div>436          <h2>Aucune annonce ne correspond</h2>437          <p>Essayez d'élargir vos critères438            {activeFilters > 0 && <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>}.</p>439        </div>440      )}441442      {!error && view === "carte" && (443        <div className="map-split">444          <div className="map-list" aria-label="Résultats en liste" ref={mapListRef}>445            {(listings ?? []).map((l) => (446              <div447                key={l.uid}448                data-uid={l.uid}449                className={`map-card${mapSelectedUid === l.uid ? " map-card-sel" : ""}`}450                onMouseEnter={() => setMapHoveredUid(l.uid)}451                onMouseLeave={() => setMapHoveredUid((h) => (h === l.uid ? null : h))}452              >453                <ListingCard l={l} />454              </div>455            ))}456          </div>457          <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>458            <MapView459              filters={filters}460              selectedUid={mapSelectedUid}461              hoveredUid={mapHoveredUid}462              onSelect={setMapSelectedUid}463            />464          </Suspense>465        </div>466      )}467468      {!error && view === "liste" && listings !== null && listings.length > 0 && (469        <>470          <div className="grid">471            {listings.map((l) => <ListingCard key={l.uid} l={l} />)}472          </div>473          {totalPages > 1 && (474            <nav className="pager" aria-label="Pagination">475              <button className="pager-btn" onClick={() => gotoPage(page - 1)} disabled={page <= 1}>‹ Préc.</button>476              {pageNumbers(page, totalPages).map((p, i) =>477                p === "…"478                  ? <span key={`e${i}`} className="pager-gap">…</span>479                  : <button key={p} className={`pager-btn ${p === page ? "on" : ""}`}480                            onClick={() => gotoPage(p as number)}>{p}</button>481              )}482              <button className="pager-btn" onClick={() => gotoPage(page + 1)} disabled={page >= totalPages}>Suiv. ›</button>483              <span className="pager-info">Page {page} / {totalPages}</span>484            </nav>485          )}486        </>487      )}488489      <button className="fab" onClick={() => setSheetOpen(true)} aria-label="Ouvrir les filtres">490        ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}491      </button>492    </div>493  );494}495