| 13 |
13 |
} from "../ctapi"; |
| 14 |
14 |
import CtListingCard from "../components/CtListingCard"; |
| 15 |
15 |
import Pager from "../components/Pager"; |
|
16 |
+import { useAgo, useCountUp } from "../live"; |
| 16 |
17 |
import { |
| 17 |
|
− IcoAlert, IcoHouse, IcoList, IcoMap, IcoPaw, IcoPin, IcoSearch, IcoSliders, |
|
18 |
+ IcoAlert, IcoList, IcoMap, IcoPaw, IcoSearch, IcoSliders, |
| 18 |
19 |
} from "../components/Icons"; |
| 19 |
20 |
|
| 20 |
21 |
const CtMapView = lazy(() => import("../components/CtMapView")); |
| 89 |
90 |
}), [qDebounced, region, type, source, priceMin, priceMax, capMin, pets, |
| 90 |
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 |
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 |
101 |
fetchCtFacets().then(setFacets).catch(() => {}); |
| 95 |
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 |
107 |
// pagination |
| 99 |
108 |
const [page, setPage] = useState(() => { |
| 152 |
161 |
return ( |
| 153 |
162 |
<div className="container"> |
| 154 |
163 |
<section className="hero"> |
| 155 |
|
− <span className="kicker">Chalets, séjours et hébergements — partout au Québec.</span> |
| 156 |
|
− <h1> |
| 157 |
|
− Trouvez votre prochaine <span className="hl">escapade.</span> |
|
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> |
| 158 |
168 |
</h1> |
| 159 |
169 |
<p className="lede"> |
| 160 |
|
− Chalets, condos et hébergements insolites en location court terme, |
| 161 |
|
− regroupés depuis les plateformes québécoises et internationales — avec |
| 162 |
|
− un lien direct vers l'annonce originale pour réserver. |
|
170 |
+ Chalets, condos et hébergements insolites en location court terme, agrégés |
|
171 |
+ depuis les plateformes québécoises et internationales — lien direct pour réserver. |
| 163 |
172 |
</p> |
| 164 |
|
− <div className="benefits" aria-label="Avantages"> |
| 165 |
|
− <div className="benefit"> |
| 166 |
|
− <span className="benefit-ico"><IcoHouse size={20} /></span> |
| 167 |
|
− <span className="benefit-label">Chalets et séjours</span> |
| 168 |
|
− </div> |
| 169 |
|
− <div className="benefit"> |
| 170 |
|
− <span className="benefit-ico"><IcoSearch size={20} /></span> |
| 171 |
|
− <span className="benefit-label">Toutes les plateformes</span> |
| 172 |
|
− </div> |
| 173 |
|
− <div className="benefit"> |
| 174 |
|
− <span className="benefit-ico blue"><IcoPin size={20} /></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 |
|
− </> |
|
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> |
| 191 |
184 |
)} |
| 192 |
185 |
</div> |
| 193 |
186 |
</section> |
| 195 |
188 |
{sheetOpen && ( |
| 196 |
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 |
193 |
<div className="sheet-head"> |
| 200 |
|
− <span>Filtres</span> |
|
194 |
+ <span>Affiner la recherche</span> |
| 201 |
195 |
<button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres"> |
| 202 |
196 |
✕ |
| 203 |
197 |
</button> |
| 204 |
198 |
</div> |
| 205 |
199 |
|
| 206 |
|
− <div className="f-primary"> |
| 207 |
|
− <div className="f-search"> |
| 208 |
|
− <IcoSearch size={15} strokeWidth={2.4} /> |
| 209 |
|
− <input |
| 210 |
|
− id="ctf-q" placeholder="Ville, région ou mot-clé" value={q} |
| 211 |
|
− onChange={(e) => setQ(e.target.value)} |
| 212 |
|
− aria-label="Recherche" |
| 213 |
|
− /> |
| 214 |
|
− {q && ( |
| 215 |
|
− <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button> |
| 216 |
|
− )} |
| 217 |
|
− </div> |
| 218 |
|
− <label className="f-ctl"> |
|
200 |
+ {/* — la recherche, au cœur du produit : un grand champ souligné — */} |
|
201 |
+ <div className="q-big"> |
|
202 |
+ <IcoSearch size={22} strokeWidth={2} /> |
|
203 |
+ <input |
|
204 |
+ 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> |
|
212 |
+ |
|
213 |
+ {/* — critères inline, séparés par filets (pas de boîtes) — */} |
|
214 |
+ <div className="crit-line"> |
|
215 |
+ <label className="crit"> |
| 219 |
216 |
<span>Région</span> |
| 220 |
217 |
<select value={region} onChange={(e) => setRegion(e.target.value)}> |
| 221 |
218 |
<option value="">Toutes</option> |
| 224 |
221 |
))} |
| 225 |
222 |
</select> |
| 226 |
223 |
</label> |
| 227 |
|
− <label className="f-ctl"> |
|
224 |
+ <label className="crit"> |
| 228 |
225 |
<span>Type</span> |
| 229 |
226 |
<select value={type} onChange={(e) => setType(e.target.value)}> |
| 230 |
227 |
<option value="">Tous</option> |
| 233 |
230 |
))} |
| 234 |
231 |
</select> |
| 235 |
232 |
</label> |
| 236 |
|
− <div className="f-ctl"> |
|
233 |
+ <div className="crit"> |
| 237 |
234 |
<span>Prix / nuit</span> |
| 238 |
235 |
<div className="range-pair"> |
| 239 |
236 |
<select aria-label="Prix minimum par nuit" value={priceMin} |
| 258 |
255 |
</div> |
| 259 |
256 |
</div> |
| 260 |
257 |
<button |
| 261 |
|
− className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`} |
|
258 |
+ className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`} |
| 262 |
259 |
onClick={() => setAdvOpen(!advOpen)} |
| 263 |
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 |
265 |
</button> |
| 267 |
266 |
</div> |
| 268 |
267 |
|
| 324 |
323 |
)} |
| 325 |
324 |
|
| 326 |
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 |
327 |
</button> |
| 329 |
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 |
341 |
<div className="chips" role="group" aria-label="Filtres rapides"> |
| 332 |
342 |
<button |
| 333 |
343 |
className={`chip chip-ico ${spa === "1" ? "on" : ""}`} |
| 373 |
383 |
</div> |
| 374 |
384 |
)} |
| 375 |
385 |
|
| 376 |
|
− <div className="results-head" ref={resultsRef}> |
| 377 |
|
− <h2>Hébergements disponibles</h2> |
| 378 |
|
− <div className="results-tools"> |
| 379 |
|
− {view === "liste" && listings && ( |
| 380 |
|
− <span>{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}</span> |
| 381 |
|
− )} |
| 382 |
|
− <div className="view-toggle" role="tablist" aria-label="Mode d'affichage"> |
|
386 |
+ <div className="results-bar" ref={resultsRef}> |
|
387 |
+ <h2 className="rb-count"> |
|
388 |
+ {view === "liste" && listings |
|
389 |
+ ? <><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"> |
| 383 |
394 |
<button |
| 384 |
395 |
role="tab" aria-selected={view === "liste"} |
| 385 |
|
− className={view === "liste" ? "on" : ""} |
|
396 |
+ className={`rb-tab ${view === "liste" ? "on" : ""}`} |
| 386 |
397 |
onClick={() => setView("liste")} |
| 387 |
398 |
> |
| 388 |
399 |
<IcoList size={13} /> Liste |
| 389 |
400 |
</button> |
| 390 |
401 |
<button |
| 391 |
402 |
role="tab" aria-selected={view === "carte"} |
| 392 |
|
− className={view === "carte" ? "on" : ""} |
|
403 |
+ className={`rb-tab ${view === "carte" ? "on" : ""}`} |
| 393 |
404 |
onClick={() => setView("carte")} |
| 394 |
405 |
> |
| 395 |
406 |
<IcoMap size={13} /> Carte |
| 396 |
407 |
</button> |
| 397 |
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 |
413 |
</div> |
| 399 |
414 |
</div> |
| 400 |
415 |
|
| 410 |
425 |
)} |
| 411 |
426 |
|
| 412 |
427 |
{!error && view === "liste" && listings === null && ( |
| 413 |
|
− <div className="grid" aria-busy="true"> |
|
428 |
+ <div className="grid grid-edito" aria-busy="true"> |
| 414 |
429 |
{Array.from({ length: 8 }).map((_, i) => ( |
| 415 |
430 |
<div className="skel" key={i}> |
| 416 |
431 |
<div className="sk-img" /> |
| 437 |
452 |
)} |
| 438 |
453 |
|
| 439 |
454 |
{!error && view === "carte" && ( |
| 440 |
|
− <Suspense |
| 441 |
|
− fallback={ |
| 442 |
|
− <div className="map-split"> |
| 443 |
|
− <div className="mapview map-loading">Chargement de la carte…</div> |
| 444 |
|
− </div> |
| 445 |
|
− } |
| 446 |
|
− > |
| 447 |
|
− <CtMapView filters={filters} /> |
| 448 |
|
− </Suspense> |
|
455 |
+ <div className="view-pane" key="carte"> |
|
456 |
+ <Suspense |
|
457 |
+ 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> |
| 449 |
466 |
)} |
| 450 |
467 |
|
| 451 |
468 |
{!error && view === "liste" && listings !== null && listings.length > 0 && ( |
| 452 |
|
− <> |
| 453 |
|
− <div className="grid"> |
|
469 |
+ <div className="view-pane" key="liste"> |
|
470 |
+ <div className="grid grid-edito"> |
| 454 |
471 |
{listings.map((l) => ( |
| 455 |
472 |
<CtListingCard key={l.uid} l={l} /> |
| 456 |
473 |
))} |
| 457 |
474 |
</div> |
| 458 |
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 |
478 |
</div> |
| 470 |
479 |
); |
| 471 |
480 |
} |