SPB Git

spb/lou-ka

Public

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

Court terme : DA editoriale v2 (meme langage que l accueil)

- Hero typo geant Trouvez votre prochaine escapade. avec surlignage orange
  irregulier ; benefits-cards et stat-chips remplacees par la ligne LIVE
  (hebergements indexes en count-up, regions, derniere synchro reelle des
  connecteurs ct qui tictaque, prix moyen/nuit)
- Recherche au coeur : grand champ Ou voulez-vous vous evader ? souligne,
  criteres Region/Type/Prix par nuit en filets, Tous les criteres avec badge
- Barre de resultats sticky N hebergements · Liste|Carte · Filtres (mobile),
  FAB supprime ; resume des criteres actifs sous la recherche sur mobile
- Grille magazine grid-edito (grand format 16/9 toutes les 7 fiches) et
  transitions de vue ; reutilise integralement la couche CSS v2 de l accueil
- Hooks useCountUp/useAgo factorises dans src/live.ts (partages Home/CT)
Simon-Pierre Boucher committed (Aug 25, 2026) parent 608e351 Browse files

3 changed files +137 −119

added frontend/src/live.ts +41 −0
@@ -0,0 +1,41 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+// live.ts : hooks « données vivantes » partagés (accueil, court terme)
5+// -----------------------------------------------------------------------------
6+import { useEffect, useState } from "react";
7+
8+/** Compteur animé du héro (~0,9 s, easing cubique) — l'impression d'un vrai
9+ moteur qui indexe. Respecte prefers-reduced-motion (valeur directe). */
10+export function useCountUp(target: number | null | undefined, ms = 900): string | null {
11+ const [v, setV] = useState<number | null>(null);
12+ useEffect(() => {
13+ if (target == null) return;
14+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setV(target); return; }
15+ let raf = 0;
16+ const t0 = performance.now();
17+ const step = (t: number) => {
18+ const p = Math.min(1, (t - t0) / ms);
19+ setV(Math.round(target * (1 - Math.pow(1 - p, 3))));
20+ if (p < 1) raf = requestAnimationFrame(step);
21+ };
22+ raf = requestAnimationFrame(step);
23+ return () => cancelAnimationFrame(raf);
24+ }, [target, ms]);
25+ return v == null ? null : v.toLocaleString("fr-CA");
26+}
27+
28+/** « il y a X s » vivant — re-rendu chaque seconde tant que le ts existe. */
29+export function useAgo(ts: number | null): string | null {
30+ const [, tick] = useState(0);
31+ useEffect(() => {
32+ if (ts == null) return;
33+ const id = setInterval(() => tick((x) => x + 1), 1000);
34+ return () => clearInterval(id);
35+ }, [ts]);
36+ if (ts == null) return null;
37+ const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
38+ if (s < 90) return `il y a ${s} s`;
39+ if (s < 5400) return `il y a ${Math.round(s / 60)} min`;
40+ return `il y a ${Math.round(s / 3600)} h`;
41+}
modified frontend/src/pages/CourtTerme.tsx +94 −85
@@ -13,8 +13,9 @@ import {
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,11 +90,19 @@ export default function CourtTermePage() {
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,42 +161,26 @@ export default function CourtTermePage() {
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,27 +188,31 @@ export default function CourtTermePage() {
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,7 +221,7 @@ export default function CourtTermePage() {
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,7 +230,7 @@ export default function CourtTermePage() {
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,11 +255,13 @@ export default function CourtTermePage() {
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,10 +323,21 @@ export default function CourtTermePage() {
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,28 +383,33 @@ export default function CourtTermePage() {
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,7 +425,7 @@ export default function CourtTermePage() {
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,35 +452,29 @@ export default function CourtTermePage() {
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 }
modified frontend/src/pages/Home.tsx +2 −34
@@ -14,6 +14,7 @@ import {
14 } from "../api"; 14 } from "../api";
15 import ListingCard from "../components/ListingCard"; 15 import ListingCard from "../components/ListingCard";
16 import Pager from "../components/Pager"; 16 import Pager from "../components/Pager";
17+import { useAgo, useCountUp } from "../live";
17 import { 18 import {
18 IcoAlert, IcoBolt, IcoList, IcoMap, IcoPaw, 19 IcoAlert, IcoBolt, IcoList, IcoMap, IcoPaw,
19 IcoSearch, IcoSliders, IcoSofa, 20 IcoSearch, IcoSliders, IcoSofa,
@@ -35,40 +36,7 @@ function useDebounced<T>(value: T, delayMs = 300): T {
35 return debounced; 36 return debounced;
36 } 37 }
37 38
38−/** Compteur animé du héro (~0,9 s, easing cubique) — l'impression d'un vrai 39+// useCountUp / useAgo : hooks « données vivantes » partagés — voir ../live.ts
39− moteur qui indexe. Respecte prefers-reduced-motion (valeur directe). */
40−function useCountUp(target: number | null | undefined, ms = 900): string | null {
41− const [v, setV] = useState<number | null>(null);
42− useEffect(() => {
43− if (target == null) return;
44− if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setV(target); return; }
45− let raf = 0;
46− const t0 = performance.now();
47− const step = (t: number) => {
48− const p = Math.min(1, (t - t0) / ms);
49− setV(Math.round(target * (1 - Math.pow(1 - p, 3))));
50− if (p < 1) raf = requestAnimationFrame(step);
51− };
52− raf = requestAnimationFrame(step);
53− return () => cancelAnimationFrame(raf);
54− }, [target, ms]);
55− return v == null ? null : v.toLocaleString("fr-CA");
56−}
57−
58−/** « il y a X s » vivant — re-rendu chaque seconde tant que le ts existe. */
59−function useAgo(ts: number | null): string | null {
60− const [, tick] = useState(0);
61− useEffect(() => {
62− if (ts == null) return;
63− const id = setInterval(() => tick((x) => x + 1), 1000);
64− return () => clearInterval(id);
65− }, [ts]);
66− if (ts == null) return null;
67− const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
68− if (s < 90) return `il y a ${s} s`;
69− if (s < 5400) return `il y a ${Math.round(s / 60)} min`;
70− return `il y a ${Math.round(s / 3600)} h`;
71−}
72 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"]; 40 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"];
73 const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000]; 41 const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000];
74 const AREA_STEPS = [400, 600, 800, 1000, 1200]; 42 const AREA_STEPS = [400, 600, 800, 1000, 1200];
75 43
Menu

Theme