SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
19.2 KB · 481 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Lou-Ka — Location court terme3// pages/CourtTerme.tsx : agrégateur d'hébergements court terme (chalets,4//   séjours, hébergement insolite) — héro, filtres, grille + vue carte.5//   Section séparée du Lou-Ka standard (logements au mois) : API /api/ct/*.6// -----------------------------------------------------------------------------7import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";8import { useSearchParams } from "react-router-dom";9import {10  CtFacets, CtFilters, CtListing, CtStats,11  ctSourceName, fetchCtFacets, fetchCtListings, fetchCtSources, fetchCtStats,12  registerCtSourceNames,13} from "../ctapi";14import CtListingCard from "../components/CtListingCard";15import Pager from "../components/Pager";16import { useAgo, useCountUp } from "../live";17import {18  IcoAlert, IcoList, IcoMap, IcoPaw, IcoSearch, IcoSliders,19} from "../components/Icons";2021const CtMapView = lazy(() => import("../components/CtMapView"));2223const PAGE_SIZE = 12;24const NIGHT_STEPS = [75, 100, 150, 200, 250, 300, 400, 500, 750, 1000];25const CAPACITY_STEPS = [2, 4, 6, 8, 10, 12];26const SORTS: { key: string; label: string }[] = [27  { key: "recent", label: "Plus récents" },28  { key: "prix", label: "Prix croissant" },29  { key: "prix_desc", label: "Prix décroissant" },30  { key: "note", label: "Mieux notés" },31];3233function useDebounced<T>(value: T, delayMs = 300): T {34  const [debounced, setDebounced] = useState(value);35  useEffect(() => {36    const t = setTimeout(() => setDebounced(value), delayMs);37    return () => clearTimeout(t);38  }, [value, delayMs]);39  return debounced;40}4142export default function CourtTermePage() {43  const [listings, setListings] = useState<CtListing[] | null>(null);44  const [total, setTotal] = useState(0);45  const [facets, setFacets] = useState<CtFacets | null>(null);46  const [stats, setStats] = useState<CtStats | null>(null);47  const [error, setError] = useState<string | null>(null);4849  const [params] = useSearchParams();50  const [q, setQ] = useState(params.get("q") ?? "");51  const [region, setRegion] = useState(params.get("region") ??  "");52  const [type, setType] = useState(params.get("type") ?? "");53  const [source, setSource] = useState(params.get("source") ?? "");54  const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");55  const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");56  const [capMin, setCapMin] = useState(params.get("capacity_min") ?? "");57  const [pets, setPets] = useState(params.get("pets") ?? "");58  const [spa, setSpa] = useState(params.get("spa") ?? "");59  const [waterfront, setWaterfront] = useState(params.get("waterfront") ?? "");60  const [sort, setSort] = useState(params.get("sort") ?? "recent");6162  const [sheetOpen, setSheetOpen] = useState(false);63  const [advOpen, setAdvOpen] = useState(false);64  useEffect(() => {65    document.body.style.overflow = sheetOpen ? "hidden" : "";66    if (!sheetOpen) return () => { document.body.style.overflow = ""; };67    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };68    window.addEventListener("keydown", onKey);69    return () => {70      document.body.style.overflow = "";71      window.removeEventListener("keydown", onKey);72    };73  }, [sheetOpen]);7475  const activeFilters = [q, region, type, source, priceMin, priceMax, capMin,76    pets, spa, waterfront].filter(Boolean).length;77  const advCount = [capMin, pets, source].filter(Boolean).length;7879  const [view, setView] = useState<"liste" | "carte">(80    params.get("view") === "carte" ? "carte" : "liste");81  useEffect(() => {82    setView(params.get("view") === "carte" ? "carte" : "liste");83  }, [params]);8485  const qDebounced = useDebounced(q);86  const filters: CtFilters = useMemo(() => ({87    q: qDebounced, region, type, source,88    price_min: priceMin, price_max: priceMax,89    capacity_min: capMin, pets, spa, waterfront, sort,90  }), [qDebounced, region, type, source, priceMin, priceMax, capMin, pets,91       spa, waterfront, sort]);9293  // données vivantes du héro : dernière synchro réelle (max des connecteurs)94  const [lastSync, setLastSync] = useState<number | null>(null);95  useEffect(() => {96    fetchCtSources().then((r) => {97      registerCtSourceNames(r.sources);98      const ts = Math.max(0, ...r.sources.map((s) => s.last_sync ?? 0));99      if (ts > 0) setLastSync(ts > 1e12 ? Math.round(ts / 1000) : ts);100    }).catch(() => {});101    fetchCtFacets().then(setFacets).catch(() => {});102    fetchCtStats().then(setStats).catch(() => {});103  }, []);104  const totalLive = useCountUp(stats && stats.total > 0 ? stats.total : null);105  const syncAgo = useAgo(lastSync);106107  // pagination108  const [page, setPage] = useState(() => {109    const p = Number(params.get("page"));110    return Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1;111  });112  const resultsRef = useRef<HTMLDivElement | null>(null);113  const filtersKey = useMemo(() => JSON.stringify(filters), [filters]);114  const firstRun = useRef(true);115  useEffect(() => {116    if (firstRun.current) { firstRun.current = false; return; }117    setPage(1);118  }, [filtersKey]);119120  const goToPage = (p: number) => {121    setPage(p);122    resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });123  };124125  useEffect(() => {126    if (view === "carte") return;127    let cancelled = false;128    setListings(null);129    setError(null);130    fetchCtListings(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE)131      .then((r) => {132        if (!cancelled) {133          setListings(r.listings);134          setTotal(r.total);135          const last = Math.max(1, Math.ceil(r.total / PAGE_SIZE));136          if (page > last) setPage(last);137        }138      })139      .catch((e) => !cancelled && setError(String(e)));140    return () => { cancelled = true; };141  }, [filters, page, view]);142143  const resetAll = () => {144    setQ(""); setRegion(""); setType(""); setSource("");145    setPriceMin(""); setPriceMax(""); setCapMin("");146    setPets(""); setSpa(""); setWaterfront(""); setSort("recent");147  };148149  const pills: { label: string; clear: () => void }[] = [];150  if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });151  if (region) pills.push({ label: region, clear: () => setRegion("") });152  if (type) pills.push({ label: type, clear: () => setType("") });153  if (priceMin) pills.push({ label: `≥ ${priceMin} $/nuit`, clear: () => setPriceMin("") });154  if (priceMax) pills.push({ label: `≤ ${priceMax} $/nuit`, clear: () => setPriceMax("") });155  if (capMin) pills.push({ label: `${capMin}+ personnes`, clear: () => setCapMin("") });156  if (pets) pills.push({ label: "Animaux acceptés", clear: () => setPets("") });157  if (spa) pills.push({ label: "Spa", clear: () => setSpa("") });158  if (waterfront) pills.push({ label: "Bord de l'eau", clear: () => setWaterfront("") });159  if (source) pills.push({ label: ctSourceName(source), clear: () => setSource("") });160161  return (162    <div className="container">163      <section className="hero">164        <span className="kicker">Court terme — chalets et séjours, partout au Québec</span>165        <h1 className="hero-display">166          Trouvez votre prochaine<br />167          <span className="brush">escapade.</span>168        </h1>169        <p className="lede">170          Chalets, condos et hébergements insolites en location court terme, agrégés171          depuis les plateformes québécoises et internationales — lien direct pour réserver.172        </p>173        <div className="live-line" aria-label="Données en direct">174          <span className="live-flag"><span className="live-dot" /> live</span>175          {totalLive && (176            <span className="live-item"><b>{totalLive}</b> hébergements indexés</span>177          )}178          {stats && stats.regions > 0 && (179            <span className="live-item"><b>{stats.regions}</b> régions</span>180          )}181          {syncAgo && <span className="live-item">synchro {syncAgo}</span>}182          {stats?.avg_night != null && (183            <span className="live-item">prix moyen <b>{Math.round(stats.avg_night)} $ / nuit</b></span>184          )}185        </div>186      </section>187188      {sheetOpen && (189        <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />190      )}191      <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Recherche et filtres">192        <div className="sheet-handle" aria-hidden="true" />193        <div className="sheet-head">194          <span>Affiner la recherche</span>195          <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">196            ✕197          </button>198        </div>199200        {/* — la recherche, au cœur du produit : un grand champ souligné — */}201        <div className="q-big">202          <IcoSearch size={22} strokeWidth={2} />203          <input204            id="ctf-q" placeholder="Où voulez-vous vous évader ?" value={q}205            onChange={(e) => setQ(e.target.value)}206            aria-label="Recherche — ville, région ou mot-clé"207          />208          {q && (209            <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>210          )}211        </div>212213        {/* — critères inline, séparés par filets (pas de boîtes) — */}214        <div className="crit-line">215          <label className="crit">216            <span>Région</span>217            <select value={region} onChange={(e) => setRegion(e.target.value)}>218              <option value="">Toutes</option>219              {(facets?.regions ?? []).map((r) => (220                <option key={r.region} value={r.region}>{r.region} ({r.n})</option>221              ))}222            </select>223          </label>224          <label className="crit">225            <span>Type</span>226            <select value={type} onChange={(e) => setType(e.target.value)}>227              <option value="">Tous</option>228              {(facets?.types ?? []).map((t) => (229                <option key={t.type} value={t.type}>{t.type} ({t.n})</option>230              ))}231            </select>232          </label>233          <div className="crit">234            <span>Prix / nuit</span>235            <div className="range-pair">236              <select aria-label="Prix minimum par nuit" value={priceMin}237                onChange={(e) => setPriceMin(e.target.value)}>238                <option value="">Min</option>239                {NIGHT_STEPS.map((p) => (240                  <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>241                    {p} $242                  </option>243                ))}244              </select>245              <span className="range-sep">—</span>246              <select aria-label="Prix maximum par nuit" value={priceMax}247                onChange={(e) => setPriceMax(e.target.value)}>248                <option value="">Max</option>249                {NIGHT_STEPS.map((p) => (250                  <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>251                    {p} $252                  </option>253                ))}254              </select>255            </div>256          </div>257          <button258            className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`}259            onClick={() => setAdvOpen(!advOpen)}260            aria-expanded={advOpen}261          >262            Tous les critères263            {advCount > 0 && <span className="crit-badge">{advCount}</span>}264            <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" />265          </button>266        </div>267268        {(advOpen || sheetOpen) && (269          <div className="f-adv">270            <div className="f-group">271              <label>Capacité minimale</label>272              <div className="seg" role="group">273                <button className={capMin === "" ? "on" : ""} onClick={() => setCapMin("")}>274                  Peu importe275                </button>276                {CAPACITY_STEPS.map((c) => (277                  <button key={c} className={capMin === String(c) ? "on" : ""}278                    onClick={() => setCapMin(String(c))}>279                    {c}+280                  </button>281                ))}282              </div>283            </div>284            <div className="f-group">285              <label>Animaux</label>286              <div className="seg" role="group">287                {[["", "Peu importe"], ["oui", "Acceptés"]].map(([v, l]) => (288                  <button key={v} className={pets === v ? "on" : ""}289                    onClick={() => setPets(v)}>290                    {v === "oui" && <IcoPaw size={13} />} {l}291                  </button>292                ))}293              </div>294            </div>295            <div className="f-group">296              <label>Tri</label>297              <div className="seg" role="group">298                {SORTS.map((s) => (299                  <button key={s.key} className={sort === s.key ? "on" : ""}300                    onClick={() => setSort(s.key)}>301                    {s.label}302                  </button>303                ))}304              </div>305            </div>306            <div className="f-group">307              <label>Plateforme</label>308              <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>309                <option value="">Toutes</option>310                {(facets?.sources ?? []).map((s) => (311                  <option key={s.source} value={s.source}>312                    {ctSourceName(s.source)} ({s.n})313                  </option>314                ))}315              </select>316            </div>317            <div className="f-group f-group-end">318              <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>319                Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}320              </button>321            </div>322          </div>323        )}324325        <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>326          Voir {listings ? `les ${total.toLocaleString("fr-CA")} hébergements` : "les résultats"}327        </button>328      </section>329330      {/* mobile : résumé des critères — ouvre la feuille (recherche intégrée, pas de FAB) */}331      <button className="crit-summary" onClick={() => setSheetOpen(true)}>332        <IcoSliders size={14} />333        <span className="cs-txt">334          {pills.length > 0335            ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "")336            : "Région, type, prix, capacité…"}337        </span>338        <span className="f-chev" aria-hidden="true" />339      </button>340341      <div className="chips" role="group" aria-label="Filtres rapides">342        <button343          className={`chip chip-ico ${spa === "1" ? "on" : ""}`}344          onClick={() => setSpa(spa === "1" ? "" : "1")}345        >346          ♨ Spa347        </button>348        <button349          className={`chip chip-ico ${waterfront === "1" ? "on" : ""}`}350          onClick={() => setWaterfront(waterfront === "1" ? "" : "1")}351        >352          ≈ Bord de l'eau353        </button>354        <button355          className={`chip chip-ico ${pets === "oui" ? "on" : ""}`}356          onClick={() => setPets(pets === "oui" ? "" : "oui")}357        >358          <IcoPaw size={13} /> Animaux ok359        </button>360        <span className="chip-sep" aria-hidden="true" />361        {(facets?.types ?? []).slice(0, 5).map((t) => (362          <button363            key={t.type}364            className={`chip ${type === t.type ? "on" : ""}`}365            onClick={() => setType(type === t.type ? "" : t.type)}366          >367            {t.type}368          </button>369        ))}370      </div>371372      {pills.length > 0 && (373        <div className="pills" aria-label="Filtres actifs">374          {pills.map((p) => (375            <button key={p.label} className="pill" onClick={p.clear}376              aria-label={`Retirer le filtre ${p.label}`}>377              {p.label} <span className="pill-x">✕</span>378            </button>379          ))}380          <button className="pill pill-clear" onClick={resetAll}>381            Tout effacer382          </button>383        </div>384      )}385386      <div className="results-bar" ref={resultsRef}>387        <h2 className="rb-count">388          {view === "liste" && listings389            ? <><b>{total.toLocaleString("fr-CA")}</b> hébergement{total > 1 ? "s" : ""}</>390            : "Hébergements"}391        </h2>392        <div className="rb-tools">393          <div className="rb-tabs" role="tablist" aria-label="Mode d'affichage">394            <button395              role="tab" aria-selected={view === "liste"}396              className={`rb-tab ${view === "liste" ? "on" : ""}`}397              onClick={() => setView("liste")}398            >399              <IcoList size={13} /> Liste400            </button>401            <button402              role="tab" aria-selected={view === "carte"}403              className={`rb-tab ${view === "carte" ? "on" : ""}`}404              onClick={() => setView("carte")}405            >406              <IcoMap size={13} /> Carte407            </button>408          </div>409          <button className="rb-filters" onClick={() => setSheetOpen(true)}>410            <IcoSliders size={13} /> Filtres411            {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>}412          </button>413        </div>414      </div>415416      {error && (417        <div className="notice">418          <div className="big"><IcoAlert size={40} /></div>419          <h2>Impossible de charger les hébergements</h2>420          <p>{error}</p>421          <button className="btn btn-primary" onClick={() => window.location.reload()}>422            Réessayer423          </button>424        </div>425      )}426427      {!error && view === "liste" && listings === null && (428        <div className="grid grid-edito" aria-busy="true">429          {Array.from({ length: 8 }).map((_, i) => (430            <div className="skel" key={i}>431              <div className="sk-img" />432              <div className="sk-line" />433              <div className="sk-line short" />434            </div>435          ))}436        </div>437      )}438439      {!error && view === "liste" && listings !== null && listings.length === 0 && (440        <div className="notice">441          <div className="big"><IcoSearch size={40} /></div>442          <h2>Aucun hébergement ne correspond</h2>443          <p>444            {stats && stats.total === 0445              ? "La section court terme démarre : les premières synchronisations sont en cours."446              : "Essayez d'élargir vos critères"}447            {activeFilters > 0 && (448              <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>449            )}.450          </p>451        </div>452      )}453454      {!error && view === "carte" && (455        <div className="view-pane" key="carte">456          <Suspense457            fallback={458              <div className="map-split">459                <div className="mapview map-loading">Chargement de la carte…</div>460              </div>461            }462          >463            <CtMapView filters={filters} />464          </Suspense>465        </div>466      )}467468      {!error && view === "liste" && listings !== null && listings.length > 0 && (469        <div className="view-pane" key="liste">470          <div className="grid grid-edito">471            {listings.map((l) => (472              <CtListingCard key={l.uid} l={l} />473            ))}474          </div>475          <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />476        </div>477      )}478    </div>479  );480}481