Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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