| 13 | } from "../ctapi"; |
13 | } from "../ctapi"; |
| 14 | import CtListingCard from "../components/CtListingCard"; |
14 | import CtListingCard from "../components/CtListingCard"; |
| 15 | import Pager from "../components/Pager"; |
15 | import Pager from "../components/Pager"; |
| |
16 | +import { useAgo, useCountUp } from "../live"; |
| 16 | import { |
17 | import { |
| 17 | − IcoAlert, IcoHouse, IcoList, IcoMap, IcoPaw, IcoPin, IcoSearch, IcoSliders, |
18 | + IcoAlert, IcoList, IcoMap, IcoPaw, IcoSearch, IcoSliders, |
| 18 | } from "../components/Icons"; |
19 | } from "../components/Icons"; |
| 19 | |
20 | |
| 20 | const CtMapView = lazy(() => import("../components/CtMapView")); |
21 | const CtMapView = lazy(() => import("../components/CtMapView")); |
| 89 | }), [qDebounced, region, type, source, priceMin, priceMax, capMin, pets, |
90 | }), [qDebounced, region, type, source, priceMin, priceMax, capMin, pets, |
| 90 | spa, waterfront, sort]); |
91 | spa, waterfront, sort]); |
| 91 | |
92 | |
| |
93 | + // données vivantes du héro : dernière synchro réelle (max des connecteurs) |
| |
94 | + const [lastSync, setLastSync] = useState<number | null>(null); |
| 92 | useEffect(() => { |
95 | useEffect(() => { |
| 93 | − fetchCtSources().then((r) => registerCtSourceNames(r.sources)).catch(() => {}); |
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(() => {}); |
| 94 | fetchCtFacets().then(setFacets).catch(() => {}); |
101 | fetchCtFacets().then(setFacets).catch(() => {}); |
| 95 | fetchCtStats().then(setStats).catch(() => {}); |
102 | fetchCtStats().then(setStats).catch(() => {}); |
| 96 | }, []); |
103 | }, []); |
| |
104 | + const totalLive = useCountUp(stats && stats.total > 0 ? stats.total : null); |
| |
105 | + const syncAgo = useAgo(lastSync); |
| 97 | |
106 | |
| 98 | // pagination |
107 | // pagination |
| 99 | const [page, setPage] = useState(() => { |
108 | const [page, setPage] = useState(() => { |
| 152 | return ( |
161 | return ( |
| 153 | <div className="container"> |
162 | <div className="container"> |
| 154 | <section className="hero"> |
163 | <section className="hero"> |
| 155 | − <span className="kicker">Chalets, séjours et hébergements — partout au Québec.</span> |
164 | + <span className="kicker">Court terme — chalets et séjours, partout au Québec</span> |
| 156 | − <h1> |
165 | + <h1 className="hero-display"> |
| 157 | − Trouvez votre prochaine <span className="hl">escapade.</span> |
166 | + Trouvez votre prochaine<br /> |
| |
167 | + <span className="brush">escapade.</span> |
| 158 | </h1> |
168 | </h1> |
| 159 | <p className="lede"> |
169 | <p className="lede"> |
| 160 | − Chalets, condos et hébergements insolites en location court terme, |
170 | + Chalets, condos et hébergements insolites en location court terme, agrégés |
| 161 | − regroupés depuis les plateformes québécoises et internationales — avec |
171 | + depuis les plateformes québécoises et internationales — lien direct pour réserver. |
| 162 | − un lien direct vers l'annonce originale pour réserver. |
| |
| 163 | </p> |
172 | </p> |
| 164 | − <div className="benefits" aria-label="Avantages"> |
173 | + <div className="live-line" aria-label="Données en direct"> |
| 165 | − <div className="benefit"> |
174 | + <span className="live-flag"><span className="live-dot" /> live</span> |
| 166 | − <span className="benefit-ico"><IcoHouse size={20} /></span> |
175 | + {totalLive && ( |
| 167 | − <span className="benefit-label">Chalets et séjours</span> |
176 | + <span className="live-item"><b>{totalLive}</b> hébergements indexés</span> |
| 168 | − </div> |
177 | + )} |
| 169 | − <div className="benefit"> |
178 | + {stats && stats.regions > 0 && ( |
| 170 | − <span className="benefit-ico"><IcoSearch size={20} /></span> |
179 | + <span className="live-item"><b>{stats.regions}</b> régions</span> |
| 171 | − <span className="benefit-label">Toutes les plateformes</span> |
180 | + )} |
| 172 | − </div> |
181 | + {syncAgo && <span className="live-item">synchro {syncAgo}</span>} |
| 173 | − <div className="benefit"> |
182 | + {stats?.avg_night != null && ( |
| 174 | − <span className="benefit-ico blue"><IcoPin size={20} /></span> |
183 | + <span className="live-item">prix moyen <b>{Math.round(stats.avg_night)} $ / nuit</b></span> |
| 175 | − <span className="benefit-label">Toutes les régions</span> |
| |
| 176 | − </div> |
| |
| 177 | − </div> |
| |
| 178 | − <div className="stat-row"> |
| |
| 179 | − <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span> |
| |
| 180 | − {stats && stats.total > 0 && ( |
| |
| 181 | − <> |
| |
| 182 | − <span className="stat-chip"><b>{stats.total}</b> hébergements</span> |
| |
| 183 | − <span className="stat-chip"><b>{stats.regions}</b> régions</span> |
| |
| 184 | − <span className="stat-chip"><b>{stats.sources}</b> sources</span> |
| |
| 185 | − {stats.avg_night != null && ( |
| |
| 186 | − <span className="stat-chip"> |
| |
| 187 | − prix moyen <b>{Math.round(stats.avg_night)} $ / nuit</b> |
| |
| 188 | − </span> |
| |
| 189 | − )} |
| |
| 190 | − </> |
| |
| 191 | )} |
184 | )} |
| 192 | </div> |
185 | </div> |
| 193 | </section> |
186 | </section> |
| 195 | {sheetOpen && ( |
188 | {sheetOpen && ( |
| 196 | <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" /> |
189 | <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" /> |
| 197 | )} |
190 | )} |
| 198 | − <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres"> |
191 | + <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Recherche et filtres"> |
| |
192 | + <div className="sheet-handle" aria-hidden="true" /> |
| 199 | <div className="sheet-head"> |
193 | <div className="sheet-head"> |
| 200 | − <span>Filtres</span> |
194 | + <span>Affiner la recherche</span> |
| 201 | <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres"> |
195 | <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres"> |
| 202 | ✕ |
196 | ✕ |
| 203 | </button> |
197 | </button> |
| 204 | </div> |
198 | </div> |
| 205 | |
199 | |
| 206 | − <div className="f-primary"> |
200 | + {/* — la recherche, au cœur du produit : un grand champ souligné — */} |
| 207 | − <div className="f-search"> |
201 | + <div className="q-big"> |
| 208 | − <IcoSearch size={15} strokeWidth={2.4} /> |
202 | + <IcoSearch size={22} strokeWidth={2} /> |
| 209 | − <input |
203 | + <input |
| 210 | − id="ctf-q" placeholder="Ville, région ou mot-clé" value={q} |
204 | + id="ctf-q" placeholder="Où voulez-vous vous évader ?" value={q} |
| 211 | − onChange={(e) => setQ(e.target.value)} |
205 | + onChange={(e) => setQ(e.target.value)} |
| 212 | − aria-label="Recherche" |
206 | + aria-label="Recherche — ville, région ou mot-clé" |
| 213 | − /> |
207 | + /> |
| 214 | − {q && ( |
208 | + {q && ( |
| 215 | − <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button> |
209 | + <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button> |
| 216 | − )} |
210 | + )} |
| 217 | − </div> |
211 | + </div> |
| 218 | − <label className="f-ctl"> |
212 | + |
| |
213 | + {/* — critères inline, séparés par filets (pas de boîtes) — */} |
| |
214 | + <div className="crit-line"> |
| |
215 | + <label className="crit"> |
| 219 | <span>Région</span> |
216 | <span>Région</span> |
| 220 | <select value={region} onChange={(e) => setRegion(e.target.value)}> |
217 | <select value={region} onChange={(e) => setRegion(e.target.value)}> |
| 221 | <option value="">Toutes</option> |
218 | <option value="">Toutes</option> |
| 224 | ))} |
221 | ))} |
| 225 | </select> |
222 | </select> |
| 226 | </label> |
223 | </label> |
| 227 | − <label className="f-ctl"> |
224 | + <label className="crit"> |
| 228 | <span>Type</span> |
225 | <span>Type</span> |
| 229 | <select value={type} onChange={(e) => setType(e.target.value)}> |
226 | <select value={type} onChange={(e) => setType(e.target.value)}> |
| 230 | <option value="">Tous</option> |
227 | <option value="">Tous</option> |
| 233 | ))} |
230 | ))} |
| 234 | </select> |
231 | </select> |
| 235 | </label> |
232 | </label> |
| 236 | − <div className="f-ctl"> |
233 | + <div className="crit"> |
| 237 | <span>Prix / nuit</span> |
234 | <span>Prix / nuit</span> |
| 238 | <div className="range-pair"> |
235 | <div className="range-pair"> |
| 239 | <select aria-label="Prix minimum par nuit" value={priceMin} |
236 | <select aria-label="Prix minimum par nuit" value={priceMin} |
| 258 | </div> |
255 | </div> |
| 259 | </div> |
256 | </div> |
| 260 | <button |
257 | <button |
| 261 | − className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`} |
258 | + className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`} |
| 262 | onClick={() => setAdvOpen(!advOpen)} |
259 | onClick={() => setAdvOpen(!advOpen)} |
| 263 | aria-expanded={advOpen} |
260 | aria-expanded={advOpen} |
| 264 | > |
261 | > |
| 265 | − Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"} |
262 | + Tous les critères |
| |
263 | + {advCount > 0 && <span className="crit-badge">{advCount}</span>} |
| |
264 | + <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" /> |
| 266 | </button> |
265 | </button> |
| 267 | </div> |
266 | </div> |
| 268 | |
267 | |
| 324 | )} |
323 | )} |
| 325 | |
324 | |
| 326 | <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}> |
325 | <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}> |
| 327 | − Voir les résultats {listings ? `(${total})` : ""} |
326 | + Voir {listings ? `les ${total.toLocaleString("fr-CA")} hébergements` : "les résultats"} |
| 328 | </button> |
327 | </button> |
| 329 | </section> |
328 | </section> |
| 330 | |
329 | |
| |
330 | + {/* 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 > 0 |
| |
335 | + ? 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> |
| |
340 | + |
| 331 | <div className="chips" role="group" aria-label="Filtres rapides"> |
341 | <div className="chips" role="group" aria-label="Filtres rapides"> |
| 332 | <button |
342 | <button |
| 333 | className={`chip chip-ico ${spa === "1" ? "on" : ""}`} |
343 | className={`chip chip-ico ${spa === "1" ? "on" : ""}`} |
| 373 | </div> |
383 | </div> |
| 374 | )} |
384 | )} |
| 375 | |
385 | |
| 376 | − <div className="results-head" ref={resultsRef}> |
386 | + <div className="results-bar" ref={resultsRef}> |
| 377 | − <h2>Hébergements disponibles</h2> |
387 | + <h2 className="rb-count"> |
| 378 | − <div className="results-tools"> |
388 | + {view === "liste" && listings |
| 379 | − {view === "liste" && listings && ( |
389 | + ? <><b>{total.toLocaleString("fr-CA")}</b> hébergement{total > 1 ? "s" : ""}</> |
| 380 | − <span>{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}</span> |
390 | + : "Hébergements"} |
| 381 | − )} |
391 | + </h2> |
| 382 | − <div className="view-toggle" role="tablist" aria-label="Mode d'affichage"> |
392 | + <div className="rb-tools"> |
| |
393 | + <div className="rb-tabs" role="tablist" aria-label="Mode d'affichage"> |
| 383 | <button |
394 | <button |
| 384 | role="tab" aria-selected={view === "liste"} |
395 | role="tab" aria-selected={view === "liste"} |
| 385 | − className={view === "liste" ? "on" : ""} |
396 | + className={`rb-tab ${view === "liste" ? "on" : ""}`} |
| 386 | onClick={() => setView("liste")} |
397 | onClick={() => setView("liste")} |
| 387 | > |
398 | > |
| 388 | <IcoList size={13} /> Liste |
399 | <IcoList size={13} /> Liste |
| 389 | </button> |
400 | </button> |
| 390 | <button |
401 | <button |
| 391 | role="tab" aria-selected={view === "carte"} |
402 | role="tab" aria-selected={view === "carte"} |
| 392 | − className={view === "carte" ? "on" : ""} |
403 | + className={`rb-tab ${view === "carte" ? "on" : ""}`} |
| 393 | onClick={() => setView("carte")} |
404 | onClick={() => setView("carte")} |
| 394 | > |
405 | > |
| 395 | <IcoMap size={13} /> Carte |
406 | <IcoMap size={13} /> Carte |
| 396 | </button> |
407 | </button> |
| 397 | </div> |
408 | </div> |
| |
409 | + <button className="rb-filters" onClick={() => setSheetOpen(true)}> |
| |
410 | + <IcoSliders size={13} /> Filtres |
| |
411 | + {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>} |
| |
412 | + </button> |
| 398 | </div> |
413 | </div> |
| 399 | </div> |
414 | </div> |
| 400 | |
415 | |
| 410 | )} |
425 | )} |
| 411 | |
426 | |
| 412 | {!error && view === "liste" && listings === null && ( |
427 | {!error && view === "liste" && listings === null && ( |
| 413 | − <div className="grid" aria-busy="true"> |
428 | + <div className="grid grid-edito" aria-busy="true"> |
| 414 | {Array.from({ length: 8 }).map((_, i) => ( |
429 | {Array.from({ length: 8 }).map((_, i) => ( |
| 415 | <div className="skel" key={i}> |
430 | <div className="skel" key={i}> |
| 416 | <div className="sk-img" /> |
431 | <div className="sk-img" /> |
| 437 | )} |
452 | )} |
| 438 | |
453 | |
| 439 | {!error && view === "carte" && ( |
454 | {!error && view === "carte" && ( |
| 440 | − <Suspense |
455 | + <div className="view-pane" key="carte"> |
| 441 | − fallback={ |
456 | + <Suspense |
| 442 | − <div className="map-split"> |
457 | + fallback={ |
| 443 | − <div className="mapview map-loading">Chargement de la carte…</div> |
458 | + <div className="map-split"> |
| 444 | − </div> |
459 | + <div className="mapview map-loading">Chargement de la carte…</div> |
| 445 | − } |
460 | + </div> |
| 446 | − > |
461 | + } |
| 447 | − <CtMapView filters={filters} /> |
462 | + > |
| 448 | − </Suspense> |
463 | + <CtMapView filters={filters} /> |
| |
464 | + </Suspense> |
| |
465 | + </div> |
| 449 | )} |
466 | )} |
| 450 | |
467 | |
| 451 | {!error && view === "liste" && listings !== null && listings.length > 0 && ( |
468 | {!error && view === "liste" && listings !== null && listings.length > 0 && ( |
| 452 | − <> |
469 | + <div className="view-pane" key="liste"> |
| 453 | − <div className="grid"> |
470 | + <div className="grid grid-edito"> |
| 454 | {listings.map((l) => ( |
471 | {listings.map((l) => ( |
| 455 | <CtListingCard key={l.uid} l={l} /> |
472 | <CtListingCard key={l.uid} l={l} /> |
| 456 | ))} |
473 | ))} |
| 457 | </div> |
474 | </div> |
| 458 | <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} /> |
475 | <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} /> |
| 459 | − </> |
476 | + </div> |
| 460 | )} |
477 | )} |
| 461 | − |
| |
| 462 | − <button |
| |
| 463 | − className={`fab${view === "carte" ? " fab-carte" : ""}`} |
| |
| 464 | − onClick={() => setSheetOpen(true)} |
| |
| 465 | − aria-label="Ouvrir les filtres" |
| |
| 466 | − > |
| |
| 467 | − <IcoSliders size={15} /> Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""} |
| |
| 468 | − </button> |
| |
| 469 | </div> |
478 | </div> |
| 470 | ); |
479 | ); |
| 471 | } |
480 | } |