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%

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 1 mo ago (Aug 25, 2026) parent 608e351

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 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,11 +90,19 @@ export default function CourtTermePage() {
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,42 +161,26 @@ export default function CourtTermePage() {
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,27 +188,31 @@ export default function CourtTermePage() {
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,7 +221,7 @@ export default function CourtTermePage() {
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,7 +230,7 @@ export default function CourtTermePage() {
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,11 +255,13 @@ export default function CourtTermePage() {
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,10 +323,21 @@ export default function CourtTermePage() {
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,28 +383,33 @@ export default function CourtTermePage() {
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,7 +425,7 @@ export default function CourtTermePage() {
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,35 +452,29 @@ export default function CourtTermePage() {
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 }
modified frontend/src/pages/Home.tsx +2 −34
@@ -14,6 +14,7 @@ import {
14 14 } from "../api";
15 15 import ListingCard from "../components/ListingCard";
16 16 import Pager from "../components/Pager";
17 +import { useAgo, useCountUp } from "../live";
17 18 import {
18 19 IcoAlert, IcoBolt, IcoList, IcoMap, IcoPaw,
19 20 IcoSearch, IcoSliders, IcoSofa,
@@ -35,40 +36,7 @@ function useDebounced<T>(value: T, delayMs = 300): T {
35 36 return debounced;
36 37 }
37 38
38 −/** Compteur animé du héro (~0,9 s, easing cubique) — l'impression d'un vrai
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 −}
39 +// useCountUp / useAgo : hooks « données vivantes » partagés — voir ../live.ts
72 40 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"];
73 41 const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000];
74 42 const AREA_STEPS = [400, 600, 800, 1000, 1200];
75 43