SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
27.9 KB · 676 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Rent-Ka — Rental listings aggregator (Canada, outside Québec)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// pages/Home.tsx: home — hero, browse by province, advanced filters, grid5//   Filters: province, city, neighbourhood, size, rent min/max, availability,6//   pets, furnished, area, manager, search — with active-filter pills.7// -----------------------------------------------------------------------------8import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";9import { useSearchParams } from "react-router-dom";10import {11  Facets, Listing, ListingFilters, PROVINCE_NAMES, Stats,12  fetchFacets, fetchListings, fetchSources, fetchStats, isoInDays,13  registerSourceNames, sourceName,14} from "../api";15import ListingCard from "../components/ListingCard";16import Pager from "../components/Pager";17import { useAgo, useCountUp } from "../live";18import {19  IcoAlert, IcoBolt, IcoList, IcoMap, IcoPaw,20  IcoSearch, IcoSliders, IcoSofa,21} from "../components/Icons";2223// The synchronized list+map search (Mapbox, ~220 kB) is only loaded when the24// user opens the Map view — see search/MapSearch.tsx25const MapSearch = lazy(() => import("../search/MapSearch"));2627const PAGE_SIZE = 12;                 // listings per page ("list" grid)2829/** Debounced value: avoids one API request per keystroke in the search box. */30function useDebounced<T>(value: T, delayMs = 300): T {31  const [debounced, setDebounced] = useState(value);32  useEffect(() => {33    const t = setTimeout(() => setDebounced(value), delayMs);34    return () => clearTimeout(t);35  }, [value, delayMs]);36  return debounced;37}3839// useCountUp / useAgo: shared "live data" hooks — see ../live.ts40const UNIT_TYPES = ["Studio", "1 bedroom", "2 bedrooms", "3 bedrooms", "4 bedrooms", "Loft", "Condo", "House", "Room"];41const PRICE_STEPS = [800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000, 3500, 4000];42const AREA_STEPS = [400, 600, 800, 1000, 1200];4344/** "Availability" choice → API parameter available_by (ISO date) */45const DISPO_CHOICES: { key: string; label: string; days: number | null }[] = [46  { key: "", label: "Any", days: null },47  { key: "now", label: "Now", days: 0 },48  { key: "30", label: "Within 1 month", days: 30 },49  { key: "60", label: "Within 2 months", days: 60 },50  { key: "90", label: "Within 3 months", days: 90 },51];5253export default function Home() {54  const [listings, setListings] = useState<Listing[] | null>(null);55  const [total, setTotal] = useState(0);56  const [facets, setFacets] = useState<Facets | null>(null);57  const [sectors, setSectors] = useState<string[]>([]);58  const [stats, setStats] = useState<Stats | null>(null);59  const [error, setError] = useState<string | null>(null);6061  // filters (pre-filled from the URL, e.g. /?city=Toronto — Stats page links)62  const [params] = useSearchParams();63  const [q, setQ] = useState(params.get("q") ?? "");64  const [province, setProvince] = useState(params.get("province") ?? "");65  const [city, setCity] = useState(params.get("city") ?? "");66  const [sector, setSector] = useState(params.get("sector") ?? "");67  const [source, setSource] = useState(params.get("source") ?? "");68  const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");69  const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");70  const [unitType, setUnitType] = useState(params.get("unit_type") ?? "");71  const [dispo, setDispo] = useState(params.get("dispo") ?? "");72  const [pets, setPets] = useState(params.get("pets") ?? "");73  const [furnished, setFurnished] = useState(params.get("furnished") ?? "");74  const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");75  const [deal, setDeal] = useState(params.get("deal") ?? "");   // fair value76  const [kaMin, setKaMin] = useState(params.get("kascore_min") ?? "");  // KA Score77  // mobile filter sheet (bottom sheet) + desktop advanced panel78  const [sheetOpen, setSheetOpen] = useState(false);79  const [advOpen, setAdvOpen] = useState(false);80  // sheet open: freeze background scroll + close on Escape81  // (same pattern as the App.tsx hamburger menu)82  useEffect(() => {83    document.body.style.overflow = sheetOpen ? "hidden" : "";84    if (!sheetOpen) return () => { document.body.style.overflow = ""; };85    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };86    window.addEventListener("keydown", onKey);87    return () => {88      document.body.style.overflow = "";89      window.removeEventListener("keydown", onKey);90    };91  }, [sheetOpen]);92  const activeFilters = [q, province, city, sector, source, priceMin, priceMax, unitType,93    dispo, pets, furnished, areaMin, deal, kaMin].filter(Boolean).length;94  const advCount = [dispo, pets, furnished, areaMin, source, kaMin].filter(Boolean).length;95  // list or map view (remembered in the URL: /?view=map)96  const [view, setView] = useState<"liste" | "carte">(97    params.get("view") === "map" ? "carte" : "liste");98  useEffect(() => {99    // follow the URL when navigating via the menu ("Map" -> /?view=map)100    setView(params.get("view") === "map" ? "carte" : "liste");101  }, [params]);102  // map mode (Ka Map System v2): body class — the filter sheet becomes a modal103  // ABOVE the shell — and view remembered in the URL (reload, back from a104  // listing: we land back on the map, camera included).105  useEffect(() => {106    document.body.classList.toggle("ka-map-mode", view === "carte");107    const url = new URL(window.location.href);108    if (view === "carte") url.searchParams.set("view", "map");109    else url.searchParams.delete("view");110    window.history.replaceState(null, "", url);111    return () => document.body.classList.remove("ka-map-mode");112  }, [view]);113114  const qDebounced = useDebounced(q);115  const filters: ListingFilters = useMemo(() => {116    const d = DISPO_CHOICES.find((c) => c.key === dispo);117    return {118      q: qDebounced, province, city, sector, source, unit_type: unitType,119      price_min: priceMin, price_max: priceMax,120      pets, furnished, area_min: areaMin,121      available_by: d?.days != null ? isoInDays(d.days) : "",122      deal, kascore_min: kaMin,123      sort: deal === "sous" ? "deal" : "",   // best deals first124    };125  }, [qDebounced, province, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo, deal, kaMin]);126127  // shared counter of the map view (lifted by MapSearch: list = map)128  const [mapTotal, setMapTotal] = useState<number | null>(null);129130  // hero live data: last real sync (max across connectors)131  const [lastSync, setLastSync] = useState<number | null>(null);132  useEffect(() => {133    fetchSources().then((r) => {134      registerSourceNames(r.sources);135      const ts = Math.max(0, ...r.sources.map((s) => s.last_sync ?? 0));136      if (ts > 0) setLastSync(ts > 1e12 ? Math.round(ts / 1000) : ts);137    }).catch(() => {});138    fetchFacets().then(setFacets).catch(() => {});139    fetchStats().then(setStats).catch(() => {});140  }, []);141  const totalLive = useCountUp(stats?.total);142  const syncAgo = useAgo(lastSync);143144  // Home geolocation: on arrival (bare URL, once per session), ask for the145  // position to directly show rentals around the user — Map view centred on146  // them. Shared link (filters/camera in the URL): respect the link, no147  // prompt. Denied: silent, the classic home stays as is.148  useEffect(() => {149    const url = new URL(window.location.href);150    const hasState = [...url.searchParams.keys()].length > 0;151    if (hasState) return;152    if (!("geolocation" in navigator)) return;153    if (sessionStorage.getItem("rentka_geo_session")) return;154    sessionStorage.setItem("rentka_geo_session", "1");155    navigator.geolocation.getCurrentPosition(156      (pos) => {157        const { latitude, longitude } = pos.coords;158        // outside Canada (travel, VPN): don't centre on the ocean159        if (latitude < 41.6 || latitude > 83.2 || longitude < -141 || longitude > -52.5) return;160        const u = new URL(window.location.href);161        u.searchParams.set("lat", latitude.toFixed(5));162        u.searchParams.set("lng", longitude.toFixed(5));163        u.searchParams.set("zoom", "13.5");164        u.searchParams.set("view", "map");165        window.history.replaceState(null, "", u);166        // MapSearch already mounted (map view): recentre + list via event167        window.dispatchEvent(new CustomEvent("rentka:geolocate", {168          detail: { lat: latitude, lng: longitude },169        }));170        setView("carte");171      },172      () => { /* denied or unavailable: home unchanged */ },173      { enableHighAccuracy: false, timeout: 8000, maximumAge: 600000 },174    );175  }, []);176177  // neighbourhoods depend on the chosen city178  useEffect(() => {179    fetchFacets(city || undefined)180      .then((f) => setSectors(f.sectors))181      .catch(() => setSectors([]));182  }, [city]);183184  // pagination — 12 listings/page, page remembered in the URL (?page=N)185  const [page, setPage] = useState(() => {186    const p = Number(params.get("page"));187    return Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1;188  });189  const resultsRef = useRef<HTMLDivElement | null>(null);190  const filtersKey = useMemo(() => JSON.stringify(filters), [filters]);191  const firstRun = useRef(true);192  useEffect(() => {193    // any filter change goes back to page 1 (except on initial mount,194    // to honour a shared link /?city=…&page=3)195    if (firstRun.current) { firstRun.current = false; return; }196    setPage(1);197  }, [filtersKey]);198  useEffect(() => {199    // reflect the page in the URL without a router re-render200    // (in map view, pagination belongs to MapSearch)201    if (view === "carte") return;202    const url = new URL(window.location.href);203    if (page > 1) url.searchParams.set("page", String(page));204    else url.searchParams.delete("page");205    window.history.replaceState(null, "", url);206  }, [page, view]);207208  const goToPage = (p: number) => {209    setPage(p);210    resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });211  };212213  useEffect(() => {214    if (view === "carte") return;   // the map view has its own unified request215    let cancelled = false;216    setListings(null);217    setError(null);218    fetchListings(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE)219      .then((r) => {220        if (!cancelled) {221          setListings(r.listings);222          setTotal(r.total);223          // page now out of bounds (narrowed filter): go to the last one224          const last = Math.max(1, Math.ceil(r.total / PAGE_SIZE));225          if (page > last) setPage(last);226        }227      })228      .catch((e) => !cancelled && setError(String(e)));229    return () => {230      cancelled = true;231    };232  }, [filters, page, view]);233234  const availableTypes = useMemo(() => {235    const set = new Set(facets?.unit_types ?? []);236    return UNIT_TYPES.filter((t) => set.size === 0 || set.has(t));237  }, [facets]);238239  // "Browse by province" cards — provinces with at least one listing240  const provinceCards = useMemo(() => {241    const provs = stats?.provinces ?? {};242    return Object.entries(provs)243      .filter(([, n]) => (n as number) > 0)244      .sort((a, b) => (b[1] as number) - (a[1] as number))245      .map(([code, n]) => ({ code, name: PROVINCE_NAMES[code] || code, n: n as number }));246  }, [stats]);247248  const resetAll = () => {249    setQ(""); setProvince(""); setCity(""); setSector(""); setSource("");250    setPriceMin(""); setPriceMax(""); setUnitType("");251    setDispo(""); setPets(""); setFurnished(""); setAreaMin(""); setDeal("");252    setKaMin("");253  };254255  // "active filters" pills — label + removal action256  const pills: { label: string; clear: () => void }[] = [];257  if (q) pills.push({ label: `“${q}”`, clear: () => setQ("") });258  if (province) pills.push({259    label: PROVINCE_NAMES[province] || province,260    clear: () => setProvince(""),261  });262  if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });263  if (sector) pills.push({ label: sector, clear: () => setSector("") });264  if (unitType) pills.push({ label: unitType, clear: () => setUnitType("") });265  if (priceMin) pills.push({ label: `≥ $${priceMin}`, clear: () => setPriceMin("") });266  if (priceMax) pills.push({ label: `≤ $${priceMax}`, clear: () => setPriceMax("") });267  if (dispo) pills.push({268    label: `Available: ${DISPO_CHOICES.find((c) => c.key === dispo)?.label ?? dispo}`,269    clear: () => setDispo(""),270  });271  if (pets) pills.push({ label: "Pets allowed", clear: () => setPets("") });272  if (furnished) pills.push({273    label: furnished === "1" ? "Furnished" : "Unfurnished",274    clear: () => setFurnished(""),275  });276  if (areaMin) pills.push({ label: `≥ ${areaMin} sq ft`, clear: () => setAreaMin("") });277  if (deal) pills.push({ label: "Below market", clear: () => setDeal("") });278  if (kaMin) pills.push({ label: `KA Score ${kaMin}+`, clear: () => setKaMin("") });279  if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });280281  return (282    <div className="container">283      <section className="hero">284        <span className="kicker">Rentals — all of Canada, one place</span>285        <h1 className="hero-display">286          Find your next<br />287          <span className="brush">home.</span>288        </h1>289        <p className="lede">290          Apartments and homes for rent across Canada, aggregated continuously —291          full photos, estimated fair value, direct link to the original listing.292        </p>293        <div className="live-line" aria-label="Live data">294          <span className="live-flag"><span className="live-dot" /> live</span>295          {totalLive && (296            <span className="live-item"><b>{totalLive}</b> rentals indexed</span>297          )}298          {stats && stats.sources > 0 && (299            <span className="live-item"><b>{stats.sources}</b> sources</span>300          )}301          {syncAgo && <span className="live-item">synced {syncAgo}</span>}302          {stats?.avg_price != null && (303            <span className="live-item">average rent <b>${Math.round(stats.avg_price).toLocaleString("en-CA")}</b></span>304          )}305        </div>306      </section>307308      {/* — Browse by province: signature Rent-Ka section — */}309      {provinceCards.length > 0 && activeFilters === 0 && (310        <section className="prov-zone" aria-label="Browse by province">311          <div className="prov-head">312            <h2>Browse by province</h2>313            <span className="prov-sub">Every listing links back to the original ad.</span>314          </div>315          <div className="prov-grid">316            {provinceCards.map((p) => (317              <button318                key={p.code}319                className="prov-card"320                onClick={() => {321                  setProvince(p.code);322                  resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });323                }}324              >325                <span className="prov-code">{p.code}</span>326                <span className="prov-name">{p.name}</span>327                <span className="prov-n">{p.n.toLocaleString("en-CA")} rentals</span>328              </button>329            ))}330          </div>331        </section>332      )}333334      {sheetOpen && (335        <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />336      )}337      <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Search and filters">338        <div className="sheet-handle" aria-hidden="true" />339        <div className="sheet-head">340          <span>Refine your search</span>341          <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Close filters">342            ✕343          </button>344        </div>345346        {/* — search, the heart of the product: one big underlined field — */}347        <div className="q-big">348          <IcoSearch size={22} strokeWidth={2} />349          <input350            id="f-q" placeholder="Where do you want to live?" value={q}351            onChange={(e) => setQ(e.target.value)}352            aria-label="Search — city, neighbourhood or postal code"353          />354          {q && (355            <button className="f-clear" onClick={() => setQ("")} aria-label="Clear search">✕</button>356          )}357        </div>358359        {/* — inline criteria, separated by rules (no boxes) — */}360        <div className="crit-line">361          <label className="crit">362            <span>Province</span>363            <select value={province} onChange={(e) => { setProvince(e.target.value); setCity(""); setSector(""); }}>364              <option value="">All</option>365              {(provinceCards.length > 0 ? provinceCards.map((p) => p.code) : Object.keys(PROVINCE_NAMES)).map((c) => (366                <option key={c} value={c}>{PROVINCE_NAMES[c] || c}</option>367              ))}368            </select>369          </label>370          <label className="crit">371            <span>City</span>372            <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>373              <option value="">All</option>374              {(facets?.cities ?? ["Toronto", "Ottawa"]).map((c) => (375                <option key={c} value={c}>{c}</option>376              ))}377            </select>378          </label>379          <label className="crit">380            <span>Neighbourhood</span>381            <select value={sector} onChange={(e) => setSector(e.target.value)}>382              <option value="">All</option>383              {sectors.map((s) => (384                <option key={s} value={s}>{s}</option>385              ))}386            </select>387          </label>388          <div className="crit">389            <span>Rent</span>390            <div className="range-pair">391              <select aria-label="Minimum rent" value={priceMin}392                onChange={(e) => setPriceMin(e.target.value)}>393                <option value="">Min</option>394                {PRICE_STEPS.map((p) => (395                  <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>396                    ${p}397                  </option>398                ))}399              </select>400              <span className="range-sep">—</span>401              <select aria-label="Maximum rent" value={priceMax}402                onChange={(e) => setPriceMax(e.target.value)}>403                <option value="">Max</option>404                {PRICE_STEPS.map((p) => (405                  <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>406                    ${p}407                  </option>408                ))}409              </select>410            </div>411          </div>412          <button413            className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`}414            onClick={() => setAdvOpen(!advOpen)}415            aria-expanded={advOpen}416          >417            All criteria418            {advCount > 0 && <span className="crit-badge">{advCount}</span>}419            <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" />420          </button>421        </div>422423        {/* — advanced panel: segments & selectors — */}424        {(advOpen || sheetOpen) && (425          <div className="f-adv">426            <div className="f-group">427              <label>Availability</label>428              <div className="seg" role="group">429                {DISPO_CHOICES.map((c) => (430                  <button key={c.key} className={dispo === c.key ? "on" : ""}431                    onClick={() => setDispo(c.key)}>432                    {c.label}433                  </button>434                ))}435              </div>436            </div>437            <div className="f-group">438              <label>Furnished</label>439              <div className="seg" role="group">440                {[["", "Any"], ["1", "Yes"], ["0", "No"]].map(([v, l]) => (441                  <button key={v} className={furnished === v ? "on" : ""}442                    onClick={() => setFurnished(v)}>443                    {l}444                  </button>445                ))}446              </div>447            </div>448            <div className="f-group">449              <label>Pets</label>450              <div className="seg" role="group">451                {[["", "Any"], ["oui", "Allowed"]].map(([v, l]) => (452                  <button key={v} className={pets === v ? "on" : ""}453                    onClick={() => setPets(v)}>454                    {v === "oui" && <IcoPaw size={13} />} {l}455                  </button>456                ))}457              </div>458            </div>459            <div className="f-group">460              <label>Minimum area</label>461              <div className="seg" role="group">462                <button className={areaMin === "" ? "on" : ""} onClick={() => setAreaMin("")}>463                  Any464                </button>465                {AREA_STEPS.map((a) => (466                  <button key={a} className={areaMin === String(a) ? "on" : ""}467                    onClick={() => setAreaMin(String(a))}>468                    {a}+469                  </button>470                ))}471              </div>472            </div>473            <div className="f-group">474              <label>Minimum KA Score</label>475              <div className="seg" role="group">476                <button className={kaMin === "" ? "on" : ""} onClick={() => setKaMin("")}>477                  Any478                </button>479                {["60", "70", "80"].map((v) => (480                  <button key={v} className={kaMin === v ? "on" : ""}481                    onClick={() => setKaMin(v)}482                    title="Rent-Ka location score (walking, transit, biking, quiet, services) — methodology at /ka-scores">483                    {v}+484                  </button>485                ))}486              </div>487            </div>488            <div className="f-group">489              <label>Manager</label>490              <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>491                <option value="">All</option>492                {(facets?.sources ?? []).map((s) => (493                  <option key={s.source} value={s.source}>494                    {sourceName(s.source)} ({s.n})495                  </option>496                ))}497              </select>498            </div>499            <div className="f-group f-group-end">500              <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>501                Reset all{activeFilters > 0 ? ` (${activeFilters})` : ""}502              </button>503            </div>504          </div>505        )}506507        <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>508          {listings ? `See ${total.toLocaleString("en-CA")} rentals` : "See results"}509        </button>510      </section>511512      {/* mobile: criteria summary — opens the sheet (search included, no FAB) */}513      <button className="crit-summary" onClick={() => setSheetOpen(true)}>514        <IcoSliders size={14} />515        <span className="cs-txt">516          {pills.length > 0517            ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "")518            : "City, neighbourhood, budget, criteria…"}519        </span>520        <span className="f-chev" aria-hidden="true" />521      </button>522523      <div className="chips" role="group" aria-label="Quick filters">524        {availableTypes.map((t) => (525          <button526            key={t}527            className={`chip ${unitType === t ? "on" : ""}`}528            onClick={() => setUnitType(unitType === t ? "" : t)}529          >530            {t}531          </button>532        ))}533        <span className="chip-sep" aria-hidden="true" />534        <button535          className={`chip chip-ico chip-deal ${deal === "sous" ? "on" : ""}`}536          onClick={() => setDeal(deal === "sous" ? "" : "sous")}537          title="Listings below estimated fair value, best deals first"538        >539          ▼ Below market540        </button>541        <button542          className={`chip chip-ico ${dispo === "now" ? "on" : ""}`}543          onClick={() => setDispo(dispo === "now" ? "" : "now")}544        >545          <IcoBolt size={13} /> Available now546        </button>547        <button548          className={`chip chip-ico ${pets === "oui" ? "on" : ""}`}549          onClick={() => setPets(pets === "oui" ? "" : "oui")}550        >551          <IcoPaw size={13} /> Pets ok552        </button>553        <button554          className={`chip chip-ico ${furnished === "1" ? "on" : ""}`}555          onClick={() => setFurnished(furnished === "1" ? "" : "1")}556        >557          <IcoSofa size={13} /> Furnished558        </button>559      </div>560561      {pills.length > 0 && (562        <div className="pills" aria-label="Active filters">563          {pills.map((p) => (564            <button key={p.label} className="pill" onClick={p.clear}565              aria-label={`Remove filter ${p.label}`}>566              {p.label} <span className="pill-x">✕</span>567            </button>568          ))}569          <button className="pill pill-clear" onClick={resetAll}>570            Clear all571          </button>572        </div>573      )}574575      <div className="results-bar" ref={resultsRef}>576        <h2 className="rb-count">577          {view === "carte"578            ? (mapTotal != null579                ? <><b>{mapTotal.toLocaleString("en-CA")}</b> rental{mapTotal > 1 ? "s" : ""}</>580                : "Rentals")581            : (listings582                ? <><b>{total.toLocaleString("en-CA")}</b> rental{total > 1 ? "s" : ""}</>583                : "Rentals")}584        </h2>585        <div className="rb-tools">586          <div className="rb-tabs" role="tablist" aria-label="Display mode">587            <button588              role="tab" aria-selected={view === "liste"}589              className={`rb-tab ${view === "liste" ? "on" : ""}`}590              onClick={() => setView("liste")}591            >592              <IcoList size={13} /> List593            </button>594            <button595              role="tab" aria-selected={view === "carte"}596              className={`rb-tab ${view === "carte" ? "on" : ""}`}597              onClick={() => setView("carte")}598            >599              <IcoMap size={13} /> Map600            </button>601          </div>602          <button className="rb-filters" onClick={() => setSheetOpen(true)}>603            <IcoSliders size={13} /> Filters604            {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>}605          </button>606        </div>607      </div>608609      {error && (610        <div className="notice">611          <div className="big"><IcoAlert size={40} /></div>612          <h2>Could not load listings</h2>613          <p>{error}</p>614          <button className="btn btn-primary" onClick={() => window.location.reload()}>615            Try again616          </button>617        </div>618      )}619620      {!error && view === "liste" && listings === null && (621        <div className="grid grid-edito" aria-busy="true">622          {Array.from({ length: 8 }).map((_, i) => (623            <div className="skel" key={i}>624              <div className="sk-img" />625              <div className="sk-line" />626              <div className="sk-line short" />627            </div>628          ))}629        </div>630      )}631632      {!error && view === "liste" && listings !== null && listings.length === 0 && (633        <div className="notice">634          <div className="big"><IcoSearch size={40} /></div>635          <h2>No rentals match</h2>636          <p>637            Try widening your criteria638            {activeFilters > 0 && (639              <> — or <button className="link-btn" onClick={resetAll}>remove the {activeFilters} active filters</button></>640            )}.641          </p>642        </div>643      )}644645      {!error && view === "carte" && (646        <div className="view-pane" key="carte">647          <Suspense648            fallback={649              <div className="ka-shell-fallback">Loading the map…</div>650            }651          >652            <MapSearch653              filters={filters}654              onTotal={setMapTotal}655              onExit={() => setView("liste")}656              onOpenFilters={() => setSheetOpen(true)}657              filtersCount={activeFilters}658            />659          </Suspense>660        </div>661      )}662663      {!error && view === "liste" && listings !== null && listings.length > 0 && (664        <div className="view-pane" key="liste">665          <div className="grid grid-edito">666            {listings.map((l) => (667              <ListingCard key={l.uid} l={l} />668            ))}669          </div>670          <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />671        </div>672      )}673    </div>674  );675}676