SPB Git

spb/lou-ka

Public

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

Refonte editoriale v2 de l accueil — direction artistique premium

Moins de boites, plus de typographie : le contenu structure la page.

- Hero : composition typographique geante (clamp 44-88px), surlignage orange
  irregulier en trait SVG sous chez-vous., plus de gros boutons rectangulaires
- Donnees vivantes : ligne mono LIVE avec compteur anime (count-up 0,9s),
  derniere synchro reelle qui tictaque a la seconde (max last_sync des
  connecteurs), sources et loyer moyen — separes par filets, zero chip
- Recherche au coeur : grand champ Ou voulez-vous habiter ? souligne 2px
  (focus accent), criteres inline Ville/Quartier/Loyer separes par filets
  verticaux, bouton texte Tous les criteres avec badge — la boite filterbar
  disparait de l accueil
- Barre de resultats sticky : N logements · Liste|Carte (onglets textes a
  soulignement anime scaleX) · Filtres (mobile) — remplace le FAB flottant
- Fiches editoriales : plus de carte-boite — l image domine (radius 10,
  zoom 1,025 au survol), prix 22px niveau 1, adresse niveau 2, quartier/ville
  en mono uppercase niveau 3, source en micro-mono niveau 4
- Grille a rythme magazine : 3 colonnes, 1 grand format panoramique 16/9
  toutes les 7 fiches, apparition en decale par colonne, align-items start
- Pagination nue : chiffres sans boites, page courante soulignee accent
- Nav mobile : tabbar flottante detachee des bords (radius 18, blur, ombre
  portee), etat actif = trait accent sous l icone ; widget KA Agent remonte
- Primitives locales v2 : rayons 0/6/10/18, durees 150/240/350ms, hairlines
- Feuille mobile : resume des criteres actifs sous la recherche (remplace le
  FAB), poignee, application sticky
- Classes partagees avec CourtTerme (.hero, .filterbar, .stat-chip…)
  intactes : la refonte vit dans de nouvelles classes + une couche finale
Simon-Pierre Boucher committed (Aug 25, 2026) parent 0c49ec4 Browse files

2 changed files +439 −131

modified frontend/src/pages/Home.tsx +129 −119
@@ -15,7 +15,7 @@ import {
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 { 17 import {
18− IcoAlert, IcoBolt, IcoHeart, IcoHouse, IcoList, IcoMap, IcoPaw, IcoPin, 18+ IcoAlert, IcoBolt, IcoList, IcoMap, IcoPaw,
19 IcoSearch, IcoSliders, IcoSofa, 19 IcoSearch, IcoSliders, IcoSofa,
20 } from "../components/Icons"; 20 } from "../components/Icons";
21 21
@@ -34,6 +34,41 @@ function useDebounced<T>(value: T, delayMs = 300): T {
34 }, [value, delayMs]); 34 }, [value, delayMs]);
35 return debounced; 35 return debounced;
36 } 36 }
37+
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+}
37 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"]; 72 const UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Loft", "Studio"];
38 const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000]; 73 const PRICE_STEPS = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000];
39 const AREA_STEPS = [400, 600, 800, 1000, 1200]; 74 const AREA_STEPS = [400, 600, 800, 1000, 1200];
@@ -112,11 +147,19 @@ export default function Home() {
112 // compteur partagé de la vue carte (remonté par MapSearch : liste = carte) 147 // compteur partagé de la vue carte (remonté par MapSearch : liste = carte)
113 const [mapTotal, setMapTotal] = useState<number | null>(null); 148 const [mapTotal, setMapTotal] = useState<number | null>(null);
114 149
150+ // données vivantes du héro : dernière synchro réelle (max des connecteurs)
151+ const [lastSync, setLastSync] = useState<number | null>(null);
115 useEffect(() => { 152 useEffect(() => {
116− fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); 153+ fetchSources().then((r) => {
154+ registerSourceNames(r.sources);
155+ const ts = Math.max(0, ...r.sources.map((s) => s.last_sync ?? 0));
156+ if (ts > 0) setLastSync(ts > 1e12 ? Math.round(ts / 1000) : ts);
157+ }).catch(() => {});
117 fetchFacets().then(setFacets).catch(() => {}); 158 fetchFacets().then(setFacets).catch(() => {});
118 fetchStats().then(setStats).catch(() => {}); 159 fetchStats().then(setStats).catch(() => {});
119 }, []); 160 }, []);
161+ const totalLive = useCountUp(stats?.total);
162+ const syncAgo = useAgo(lastSync);
120 163
121 // Géolocalisation d'accueil : à l'arrivée sur le site (URL nue, une fois 164 // Géolocalisation d'accueil : à l'arrivée sur le site (URL nue, une fois
122 // par session), demander la position pour afficher directement les 165 // par session), demander la position pour afficher directement les
@@ -246,71 +289,26 @@ export default function Home() {
246 return ( 289 return (
247 <div className="container"> 290 <div className="container">
248 <section className="hero"> 291 <section className="hero">
249− <span className="kicker">La porte d'entrée vers votre prochain chez-vous.</span> 292+ <span className="kicker">Location — tout le Québec, un seul endroit</span>
250− <h1> 293+ <h1 className="hero-display">
251− Trouvez votre prochain <span className="hl">chez-vous.</span> 294+ Trouvez votre prochain<br />
295+ <span className="brush">chez-vous.</span>
252 </h1> 296 </h1>
253 <p className="lede"> 297 <p className="lede">
254− Tous les logements à louer au Québec, regroupés au même endroit — mis à jour 298+ Tous les logements à louer au Québec, agrégés en continu — photos complètes,
255− automatiquement, avec toutes les photos et un lien direct vers l'annonce originale. 299+ juste valeur estimée, lien direct vers l'annonce originale.
256 </p> 300 </p>
257− <div className="hero-cta"> 301+ <div className="live-line" aria-label="Données en direct">
258− <button 302+ <span className="live-flag"><span className="live-dot" /> live</span>
259− className="btn btn-primary btn-lg" 303+ {totalLive && (
260− onClick={() => { 304+ <span className="live-item"><b>{totalLive}</b> logements indexés</span>
261− setView("liste"); 305+ )}
262− resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); 306+ {stats && stats.sources > 0 && (
263− }} 307+ <span className="live-item"><b>{stats.sources}</b> sources</span>
264− > 308+ )}
265− <IcoSearch size={16} /> Explorer les logements 309+ {syncAgo && <span className="live-item">synchro {syncAgo}</span>}
266− </button> 310+ {stats?.avg_price != null && (
267− <button 311+ <span className="live-item">loyer moyen <b>{Math.round(stats.avg_price)} $</b></span>
268− className="btn btn-lg"
269− onClick={() => {
270− setView("carte");
271− resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
272− }}
273− >
274− <IcoMap size={16} /> Voir la carte
275− </button>
276− </div>
277− <div className="benefits" aria-label="Avantages">
278− <div className="benefit">
279− <span className="benefit-ico"><IcoHouse size={20} /></span>
280− <span className="benefit-label">Nouveaux logements</span>
281− </div>
282− <div className="benefit">
283− <span className="benefit-ico"><IcoSearch size={20} /></span>
284− <span className="benefit-label">Recherche facile</span>
285− </div>
286− <div className="benefit">
287− <span className="benefit-ico"><IcoHeart size={20} /></span>
288− <span className="benefit-label">Favoris et alertes</span>
289− </div>
290− <div className="benefit">
291− <span className="benefit-ico blue"><IcoPin size={20} /></span>
292− <span className="benefit-label">Partout au Québec</span>
293− </div>
294− </div>
295− <div className="stat-row">
296− <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>
297− {stats && (
298− <>
299− <span className="stat-chip"><b>{stats.total}</b> logements actifs</span>
300− <span className="stat-chip"><b>{stats.quebec ?? 0}</b> à Québec</span>
301− <span className="stat-chip"><b>{stats.levis ?? 0}</b> à Lévis</span>
302− {(stats.montreal ?? 0) > 0 && (
303− <span className="stat-chip"><b>{stats.montreal}</b> Grand Montréal</span>
304− )}
305− {(stats.autres ?? 0) > 0 && (
306− <span className="stat-chip"><b>{stats.autres}</b> ailleurs au Québec</span>
307− )}
308− {stats.avg_price != null && (
309− <span className="stat-chip">
310− loyer moyen <b>{Math.round(stats.avg_price)} $</b>
311− </span>
312− )}
313− </>
314 )} 312 )}
315 </div> 313 </div>
316 </section> 314 </section>
@@ -318,29 +316,31 @@ export default function Home() {
318 {sheetOpen && ( 316 {sheetOpen && (
319 <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" /> 317 <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />
320 )} 318 )}
321− <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres"> 319+ <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Recherche et filtres">
322 <div className="sheet-handle" aria-hidden="true" /> 320 <div className="sheet-handle" aria-hidden="true" />
323 <div className="sheet-head"> 321 <div className="sheet-head">
324− <span>Filtres</span> 322+ <span>Affiner la recherche</span>
325 <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres"> 323 <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">
326 ✕ 324 ✕
327 </button> 325 </button>
328 </div> 326 </div>
329 327
330− {/* — rangée principale : recherche, ville, quartier, loyer, + filtres — */} 328+ {/* — la recherche, au cœur du produit : un grand champ souligné — */}
331− <div className="f-primary"> 329+ <div className="q-big">
332− <div className="f-search"> 330+ <IcoSearch size={22} strokeWidth={2} />
333− <IcoSearch size={15} strokeWidth={2.4} /> 331+ <input
334− <input 332+ id="f-q" placeholder="Où voulez-vous habiter ?" value={q}
335− id="f-q" placeholder="Ville, quartier ou code postal" value={q} 333+ onChange={(e) => setQ(e.target.value)}
336− onChange={(e) => setQ(e.target.value)} 334+ aria-label="Recherche — ville, quartier ou code postal"
337− aria-label="Recherche" 335+ />
338− /> 336+ {q && (
339− {q && ( 337+ <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>
340− <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button> 338+ )}
341− )} 339+ </div>
342− </div> 340+
343− <label className="f-ctl"> 341+ {/* — critères inline, séparés par filets (pas de boîtes) — */}
342+ <div className="crit-line">
343+ <label className="crit">
344 <span>Ville</span> 344 <span>Ville</span>
345 <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}> 345 <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>
346 <option value="">Toutes</option> 346 <option value="">Toutes</option>
@@ -349,7 +349,7 @@ export default function Home() {
349 ))} 349 ))}
350 </select> 350 </select>
351 </label> 351 </label>
352− <label className="f-ctl"> 352+ <label className="crit">
353 <span>Quartier</span> 353 <span>Quartier</span>
354 <select value={sector} onChange={(e) => setSector(e.target.value)}> 354 <select value={sector} onChange={(e) => setSector(e.target.value)}>
355 <option value="">Tous</option> 355 <option value="">Tous</option>
@@ -358,7 +358,7 @@ export default function Home() {
358 ))} 358 ))}
359 </select> 359 </select>
360 </label> 360 </label>
361− <div className="f-ctl"> 361+ <div className="crit">
362 <span>Loyer</span> 362 <span>Loyer</span>
363 <div className="range-pair"> 363 <div className="range-pair">
364 <select aria-label="Loyer minimum" value={priceMin} 364 <select aria-label="Loyer minimum" value={priceMin}
@@ -383,12 +383,12 @@ export default function Home() {
383 </div> 383 </div>
384 </div> 384 </div>
385 <button 385 <button
386− className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`} 386+ className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`}
387 onClick={() => setAdvOpen(!advOpen)} 387 onClick={() => setAdvOpen(!advOpen)}
388 aria-expanded={advOpen} 388 aria-expanded={advOpen}
389 > 389 >
390− <IcoSliders size={14} /> Plus de filtres 390+ Tous les critères
391− {advCount > 0 && <span className="f-more-badge">{advCount}</span>} 391+ {advCount > 0 && <span className="crit-badge">{advCount}</span>}
392 <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" /> 392 <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" />
393 </button> 393 </button>
394 </div> 394 </div>
@@ -478,10 +478,21 @@ export default function Home() {
478 )} 478 )}
479 479
480 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}> 480 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
481− Voir les résultats {listings ? `(${total})` : ""} 481+ Voir {listings ? `les ${total.toLocaleString("fr-CA")} logements` : "les résultats"}
482 </button> 482 </button>
483 </section> 483 </section>
484 484
485+ {/* mobile : résumé des critères — ouvre la feuille (recherche intégrée, pas de FAB) */}
486+ <button className="crit-summary" onClick={() => setSheetOpen(true)}>
487+ <IcoSliders size={14} />
488+ <span className="cs-txt">
489+ {pills.length > 0
490+ ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "")
491+ : "Ville, quartier, budget, critères…"}
492+ </span>
493+ <span className="f-chev" aria-hidden="true" />
494+ </button>
495+
485 <div className="chips" role="group" aria-label="Filtres rapides"> 496 <div className="chips" role="group" aria-label="Filtres rapides">
486 {availableTypes.map((t) => ( 497 {availableTypes.map((t) => (
487 <button 498 <button
@@ -534,30 +545,37 @@ export default function Home() {
534 </div> 545 </div>
535 )} 546 )}
536 547
537− <div className="results-head" ref={resultsRef}> 548+ <div className="results-bar" ref={resultsRef}>
538− <h2>Logements disponibles</h2> 549+ <h2 className="rb-count">
539− <div className="results-tools">
540 {view === "carte" 550 {view === "carte"
541− ? mapTotal != null && ( 551+ ? (mapTotal != null
542− <span>{mapTotal.toLocaleString("fr-CA")} résultat{mapTotal > 1 ? "s" : ""}</span> 552+ ? <><b>{mapTotal.toLocaleString("fr-CA")}</b> logement{mapTotal > 1 ? "s" : ""}</>
543− ) 553+ : "Logements")
544− : listings && <span>{total} résultat{total > 1 ? "s" : ""}</span>} 554+ : (listings
545− <div className="view-toggle" role="tablist" aria-label="Mode d'affichage"> 555+ ? <><b>{total.toLocaleString("fr-CA")}</b> logement{total > 1 ? "s" : ""}</>
556+ : "Logements")}
557+ </h2>
558+ <div className="rb-tools">
559+ <div className="rb-tabs" role="tablist" aria-label="Mode d'affichage">
546 <button 560 <button
547 role="tab" aria-selected={view === "liste"} 561 role="tab" aria-selected={view === "liste"}
548− className={view === "liste" ? "on" : ""} 562+ className={`rb-tab ${view === "liste" ? "on" : ""}`}
549 onClick={() => setView("liste")} 563 onClick={() => setView("liste")}
550 > 564 >
551 <IcoList size={13} /> Liste 565 <IcoList size={13} /> Liste
552 </button> 566 </button>
553 <button 567 <button
554 role="tab" aria-selected={view === "carte"} 568 role="tab" aria-selected={view === "carte"}
555− className={view === "carte" ? "on" : ""} 569+ className={`rb-tab ${view === "carte" ? "on" : ""}`}
556 onClick={() => setView("carte")} 570 onClick={() => setView("carte")}
557 > 571 >
558 <IcoMap size={13} /> Carte 572 <IcoMap size={13} /> Carte
559 </button> 573 </button>
560 </div> 574 </div>
575+ <button className="rb-filters" onClick={() => setSheetOpen(true)}>
576+ <IcoSliders size={13} /> Filtres
577+ {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>}
578+ </button>
561 </div> 579 </div>
562 </div> 580 </div>
563 581
@@ -573,7 +591,7 @@ export default function Home() {
573 )} 591 )}
574 592
575 {!error && view === "liste" && listings === null && ( 593 {!error && view === "liste" && listings === null && (
576− <div className="grid" aria-busy="true"> 594+ <div className="grid grid-edito" aria-busy="true">
577 {Array.from({ length: 8 }).map((_, i) => ( 595 {Array.from({ length: 8 }).map((_, i) => (
578 <div className="skel" key={i}> 596 <div className="skel" key={i}>
579 <div className="sk-img" /> 597 <div className="sk-img" />
@@ -598,37 +616,29 @@ export default function Home() {
598 )} 616 )}
599 617
600 {!error && view === "carte" && ( 618 {!error && view === "carte" && (
601− <Suspense 619+ <div className="view-pane" key="carte">
602− fallback={ 620+ <Suspense
603− <div className="map-split"> 621+ fallback={
604− <div className="mapview map-loading">Chargement de la carte…</div> 622+ <div className="map-split">
605− </div> 623+ <div className="mapview map-loading">Chargement de la carte…</div>
606− } 624+ </div>
607− > 625+ }
608− <MapSearch filters={filters} onTotal={setMapTotal} /> 626+ >
609− </Suspense> 627+ <MapSearch filters={filters} onTotal={setMapTotal} />
628+ </Suspense>
629+ </div>
610 )} 630 )}
611 631
612 {!error && view === "liste" && listings !== null && listings.length > 0 && ( 632 {!error && view === "liste" && listings !== null && listings.length > 0 && (
613− <> 633+ <div className="view-pane" key="liste">
614− <div className="grid"> 634+ <div className="grid grid-edito">
615 {listings.map((l) => ( 635 {listings.map((l) => (
616 <ListingCard key={l.uid} l={l} /> 636 <ListingCard key={l.uid} l={l} />
617 ))} 637 ))}
618 </div> 638 </div>
619 <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} /> 639 <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />
620− </> 640+ </div>
621 )} 641 )}
622−
623− {/* Bouton flottant mobile — ouvre la feuille de filtres */}
624− <button
625− className={`fab${view === "carte" ? " fab-carte" : ""}`}
626− onClick={() => setSheetOpen(true)}
627− aria-label="Ouvrir les filtres"
628− >
629− <IcoSliders size={15} /> Filtres
630− {activeFilters > 0 && <span className="fab-badge">{activeFilters}</span>}
631− </button>
632 </div> 642 </div>
633 ); 643 );
634 } 644 }
modified frontend/src/styles.css +310 −12
@@ -110,18 +110,6 @@ html, body { overflow-x: clip; }
110 .hero h1 .outline { color: var(--navy); } 110 .hero h1 .outline { color: var(--navy); }
111 .hero p.lede { color: var(--ink-2); font-size: 17px; max-width: 640px; margin: 18px 0 0; } 111 .hero p.lede { color: var(--ink-2); font-size: 17px; max-width: 640px; margin: 18px 0 0; }
112 112
113−/* — CTA du héro : accès direct aux résultats / à la carte — */
114−.hero-cta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
115−.btn-lg { min-height: 52px; padding: 14px 26px; font-size: 15px; border-radius: 12px; }
116−.btn-lg svg { flex: none; }
117−.hero-cta .btn-primary { box-shadow: 5px 5px 0 var(--accent); }
118−.hero-cta .btn-primary:hover { transform: translate(-2px, -2px); box-shadow: 7px 7px 0 var(--accent); }
119−.hero-cta .btn-primary:active { transform: none; box-shadow: 3px 3px 0 var(--accent); }
120−@media (max-width: 640px) {
121− .hero-cta { gap: 10px; }
122− .hero-cta .btn { flex: 1 1 45%; }
123−}
124−
125 .stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; } 113 .stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
126 .stat-chip { 114 .stat-chip {
127 background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-pill); 115 background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-pill);
@@ -2407,3 +2395,313 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2407 .hd-crit { min-width: 86px; font-size: 10px; } 2395 .hd-crit { min-width: 86px; font-size: 10px; }
2408 .hiver-detail li { font-size: 12.5px; } 2396 .hiver-detail li { font-size: 12.5px; }
2409 } 2397 }
2398+
2399+/* =============================================================================
2400+ REFONTE ÉDITORIALE v2 (2026-08-25) — accueil « produit premium »
2401+ Direction : moins de boîtes, hiérarchie typographique, hairlines, données
2402+ vivantes, image dominante. Primitives locales : rayons 0/6/10/18,
2403+ durées 150/240/350 ms. Couche posée en FIN de fichier : elle a le dernier
2404+ mot sur la cascade (y compris sur la couche « harmonisation » ci-dessus).
2405+============================================================================= */
2406+:root {
2407+ --r-0: 0px; --r-1: 6px; --r-2: 10px; --r-3: 18px;
2408+ --dur-1: 150ms; --dur-2: 240ms; --dur-3: 350ms;
2409+ --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
2410+ --hairline: rgba(20, 24, 20, 0.14);
2411+ --hairline-strong: rgba(20, 24, 20, 0.6);
2412+}
2413+
2414+/* ---- Héro : composition typographique, zéro boîte ---- */
2415+.hero h1.hero-display {
2416+ font-size: clamp(44px, 7.2vw, 88px);
2417+ line-height: 1.0; letter-spacing: -0.045em; max-width: 1020px; margin-top: 18px;
2418+}
2419+.brush { position: relative; display: inline-block; padding: 0 0.06em; }
2420+.brush::after { /* surlignage orange irrégulier (trait SVG) — pas un rectangle */
2421+ content: ""; position: absolute; left: -3%; right: -3%; bottom: 0.03em; height: 0.34em;
2422+ z-index: -1; transform: rotate(-0.6deg);
2423+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 26' preserveAspectRatio='none'%3E%3Cpath d='M5 17 C 60 8, 118 21, 168 13 S 258 10, 295 15' fill='none' stroke='%23ff6a00' stroke-width='11' stroke-linecap='round' opacity='0.92'/%3E%3C/svg%3E") no-repeat center / 100% 100%;
2424+}
2425+.hero p.lede { font-size: 16.5px; max-width: 580px; }
2426+.hero::before { opacity: 0.8; }
2427+.hero::after { opacity: 0.55; }
2428+
2429+/* ---- Ligne de données vivantes (mono + filets — plus de chips) ---- */
2430+.live-line {
2431+ display: flex; flex-wrap: wrap; align-items: baseline;
2432+ margin-top: 34px; font-family: var(--font-mono); font-size: 12px;
2433+ color: var(--ink-2); letter-spacing: 0.02em; row-gap: 10px;
2434+}
2435+.live-flag {
2436+ display: inline-flex; align-items: center; gap: 7px;
2437+ font-weight: 700; font-size: 10.5px; letter-spacing: 0.18em;
2438+ text-transform: uppercase; color: var(--accent-deep);
2439+ padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline-strong);
2440+}
2441+.live-dot {
2442+ width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
2443+ animation: pulse 2.4s ease infinite;
2444+}
2445+.live-item { padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline); }
2446+.live-item:last-child { border-right: 0; padding-right: 0; margin-right: 0; }
2447+.live-item b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }
2448+@media (max-width: 640px) {
2449+ .live-line { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
2450+ white-space: nowrap; margin-right: -16px; padding-right: 16px; }
2451+ .live-line::-webkit-scrollbar { display: none; }
2452+}
2453+
2454+/* ---- Recherche au cœur : grand champ souligné + critères en filets ---- */
2455+.search-zone { margin: 42px 0 0; }
2456+.q-big {
2457+ display: flex; align-items: center; gap: 14px;
2458+ border: 0; border-bottom: 2px solid var(--ink); border-radius: 0;
2459+ background: transparent; padding: 4px 2px 14px; color: var(--ink-3);
2460+ transition: border-color var(--dur-2) var(--ease-out), color var(--dur-2) ease;
2461+}
2462+.q-big:focus-within { border-color: var(--accent); color: var(--accent-deep); }
2463+.q-big svg { flex: none; }
2464+.q-big input {
2465+ flex: 1; min-width: 0; border: 0; background: none; outline: none; padding: 0;
2466+ font-family: var(--font-display); font-weight: 600;
2467+ font-size: clamp(19px, 2.8vw, 28px); letter-spacing: -0.02em; color: var(--ink);
2468+}
2469+.q-big input::placeholder { color: var(--ink-3); font-weight: 500; }
2470+.crit-line { display: flex; align-items: stretch; flex-wrap: wrap; }
2471+.crit {
2472+ display: flex; flex-direction: column; justify-content: center; gap: 1px;
2473+ padding: 12px 26px 12px 0; margin-right: 26px;
2474+ border-right: 1px solid var(--hairline); min-width: 0; cursor: pointer;
2475+}
2476+.crit > span {
2477+ font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
2478+ text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3);
2479+}
2480+.crit select {
2481+ border: 0; background: transparent; outline: none; cursor: pointer;
2482+ font-family: var(--font-display); font-weight: 700; font-size: 15.5px;
2483+ color: var(--ink); appearance: none; -webkit-appearance: none;
2484+ padding: 2px 18px 2px 0; min-width: 0; max-width: 190px; text-overflow: ellipsis;
2485+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%238b928c'/%3E%3C/svg%3E");
2486+ background-repeat: no-repeat; background-position: right center;
2487+}
2488+.crit:hover select { color: var(--accent-deep); }
2489+.crit .range-pair select { max-width: 88px; }
2490+.crit-more {
2491+ display: inline-flex; align-items: center; gap: 9px; align-self: center;
2492+ border: 0; background: none; padding: 12px 0; cursor: pointer;
2493+ font-family: var(--font-display); font-weight: 700; font-size: 14.5px; color: var(--ink);
2494+ transition: color var(--dur-1) ease;
2495+}
2496+.crit-more:hover, .crit-more.on { color: var(--accent-deep); }
2497+.crit-badge, .rb-badge {
2498+ display: inline-grid; place-items: center; min-width: 19px; height: 19px; padding: 0 5px;
2499+ border-radius: var(--r-pill); background: var(--accent); color: var(--on-accent);
2500+ font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
2501+}
2502+/* panneau avancé : filet, pas de boîte (réutilise .f-adv/.f-group/.seg) */
2503+.search-zone .f-adv { border-top: 1px solid var(--hairline); margin-top: 2px; padding-top: 18px; }
2504+.search-zone .seg button {
2505+ border: 1px solid var(--hairline-strong); background: transparent; color: var(--ink-2);
2506+ min-height: 38px; font-weight: 600; margin-left: -1px;
2507+}
2508+.search-zone .seg button:first-child { border-radius: var(--r-1) 0 0 var(--r-1); }
2509+.search-zone .seg button:last-child { border-radius: 0 var(--r-1) var(--r-1) 0; }
2510+.search-zone .seg button:hover { background: var(--accent-soft); color: var(--accent-deep); }
2511+.search-zone .seg button.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; }
2512+.search-zone .f-native { border: 1px solid var(--hairline-strong); background: transparent; }
2513+
2514+/* résumé mobile des critères — remplace le FAB (voir media 640 plus bas) */
2515+.crit-summary { display: none; }
2516+
2517+/* ---- Barre de résultats sticky : N logements · Liste|Carte · Filtres ---- */
2518+.results-bar {
2519+ position: sticky; top: 64px; z-index: var(--z-sticky, 300);
2520+ display: flex; align-items: center; gap: 16px;
2521+ margin: 30px 0 22px; padding: 12px 0 11px;
2522+ border-bottom: 1px solid var(--hairline-strong);
2523+ background: color-mix(in srgb, var(--paper) 86%, transparent);
2524+ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
2525+}
2526+@supports not (background: color-mix(in srgb, red 50%, blue)) {
2527+ .results-bar { background: rgba(245, 243, 238, 0.92); }
2528+}
2529+.rb-count { margin: 0; font-size: clamp(16px, 2vw, 20px); letter-spacing: -0.02em; color: var(--ink); }
2530+.rb-count b { font-variant-numeric: tabular-nums; }
2531+.rb-tools { margin-left: auto; display: flex; align-items: center; gap: 20px; }
2532+.rb-tabs { display: flex; gap: 18px; }
2533+.rb-tab {
2534+ position: relative; display: inline-flex; align-items: center; gap: 6px;
2535+ border: 0; background: none; padding: 8px 0; cursor: pointer;
2536+ font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink-3);
2537+ transition: color var(--dur-1) ease;
2538+}
2539+.rb-tab:hover, .rb-tab.on { color: var(--ink); }
2540+.rb-tab::after {
2541+ content: ""; position: absolute; left: 0; right: 0; bottom: -12px; height: 2.5px;
2542+ background: var(--accent); transform: scaleX(0); transform-origin: left;
2543+ transition: transform var(--dur-2) var(--ease-out);
2544+}
2545+.rb-tab.on::after { transform: scaleX(1); }
2546+.rb-filters {
2547+ display: inline-flex; align-items: center; gap: 7px;
2548+ border: 0; background: none; padding: 8px 0; cursor: pointer;
2549+ font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink);
2550+}
2551+.rb-filters:hover { color: var(--accent-deep); }
2552+@media (min-width: 641px) { .rb-filters { display: none; } }
2553+
2554+/* ---- Transition douce liste ↔ carte ---- */
2555+.view-pane { animation: pane-in var(--dur-2) var(--ease-out); }
2556+@keyframes pane-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
2557+
2558+/* ---- Chips rapides : plus légères (hairline, texte d'abord) ---- */
2559+.chip {
2560+ border: 1px solid var(--hairline-strong); background: transparent;
2561+ min-height: 38px; padding: 7px 15px; font-size: 12.5px; box-shadow: none;
2562+}
2563+.chip:hover { transform: none; box-shadow: none; background: var(--accent-soft); color: var(--accent-deep); }
2564+.chip.on, .chip.chip-deal.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; }
2565+.chips { margin: 20px 0 0; }
2566+
2567+/* ---- Pastilles actives : discrètes, survol = retrait ---- */
2568+.pills { margin: 14px 0 0; }
2569+.pills .pill { border: 1px solid var(--hairline-strong); background: transparent; box-shadow: none; padding: 5px 12px; font-size: 12px; }
2570+.pills .pill:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
2571+
2572+/* ---- Cartes éditoriales : l'image domine, plus de boîte ---- */
2573+.card, .card:hover { background: transparent; border: 0; border-radius: 0; box-shadow: none; transform: none; }
2574+.card-img { border-radius: var(--r-2); aspect-ratio: 4/3; }
2575+.card-img img { transition: transform var(--dur-3) var(--ease-out); }
2576+@media (hover: hover) {
2577+ .card:hover .card-img img { transform: scale(1.025); }
2578+ .card:hover .card-price { color: var(--accent-deep); }
2579+}
2580+.card:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: var(--r-2); }
2581+.card-body { padding: 13px 2px 0; gap: 3px; }
2582+.card-price { font-size: 22px; letter-spacing: -0.03em; color: var(--ink); transition: color var(--dur-1) ease; }
2583+.card-title { font-size: 14px; font-weight: 500; color: var(--ink-2); }
2584+.card-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); }
2585+.card-meta .sep { width: 3px; height: 3px; border-radius: 50%; background: var(--accent); }
2586+.card-foot { border-top: 0; padding-top: 8px; }
2587+.source-tag { background: none; border: 0; border-radius: 0; padding: 0; color: var(--ink-3); font-size: 10px; letter-spacing: 0.08em; max-width: 62%; }
2588+.avail { font-size: 11px; color: var(--ink-3); }
2589+.badge { border: 0; border-radius: 4px; background: rgba(20, 24, 20, 0.78); color: var(--paper);
2590+ backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
2591+ font-size: 9.5px; padding: 4px 9px; letter-spacing: 0.08em; }
2592+.badge.type { background: var(--accent); color: var(--on-accent); }
2593+.badge.right { background: rgba(20, 24, 20, 0.55); }
2594+
2595+/* ---- Grille à rythme éditorial (accueil) : grand / petits / panoramique ---- */
2596+.grid.grid-edito { grid-template-columns: repeat(3, 1fr); gap: 36px 26px; }
2597+.grid-edito > *:nth-child(7n+1) { grid-column: span 2; }
2598+.grid-edito > *:nth-child(7n+1) .card-img,
2599+.grid-edito > .skel:nth-child(7n+1) .sk-img { aspect-ratio: 16/9; }
2600+.grid-edito > *:nth-child(7n+1) .card-price { font-size: 27px; }
2601+.grid-edito > *:nth-child(7n+1) .card-title { font-size: 15px; }
2602+@media (max-width: 980px) {
2603+ .grid.grid-edito { grid-template-columns: 1fr 1fr; gap: 30px 20px; }
2604+}
2605+@media (max-width: 640px) {
2606+ .grid.grid-edito { grid-template-columns: 1fr; gap: 32px; }
2607+ .grid-edito > *:nth-child(7n+1) { grid-column: auto; }
2608+}
2609+@media (prefers-reduced-motion: no-preference) {
2610+ .grid-edito > * { animation: rise 0.5s var(--ease-out) backwards; }
2611+ .grid-edito > *:nth-child(3n+2) { animation-delay: 60ms; }
2612+ .grid-edito > *:nth-child(3n) { animation-delay: 120ms; }
2613+}
2614+@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
2615+
2616+/* ---- Squelettes sans boîte ---- */
2617+.skel { border: 0; border-radius: 0; background: transparent; }
2618+.skel .sk-img { aspect-ratio: 4/3; border-radius: var(--r-2); }
2619+.skel .sk-line { margin: 12px 2px; }
2620+
2621+/* ---- Pagination éditoriale : chiffres nus, page courante soulignée ---- */
2622+.pager { border-top: 1px solid var(--hairline); padding-top: 16px; }
2623+.pager-info { font-family: var(--font-mono); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); }
2624+.pager-btn {
2625+ min-width: 34px; height: 38px; padding: 0 8px; border: 0; border-radius: var(--r-1);
2626+ background: none; color: var(--ink-2);
2627+ font-family: var(--font-display); font-size: 13.5px; font-weight: 600; position: relative;
2628+}
2629+.pager-btn:hover:not(:disabled):not(.on) { background: var(--accent-soft); color: var(--accent-deep); border: 0; }
2630+.pager-btn.on { background: none; border: 0; color: var(--ink); font-weight: 700; }
2631+.pager-btn.on::after { content: ""; position: absolute; left: 9px; right: 9px; bottom: 3px; height: 2.5px; background: var(--accent); }
2632+.pager-arrow { border: 1px solid var(--hairline-strong); }
2633+
2634+/* ---- Navigation mobile flottante (pilule détachée du bord) ---- */
2635+@media (max-width: 760px) {
2636+ .tabbar {
2637+ left: 12px; right: 12px; bottom: calc(10px + env(safe-area-inset-bottom));
2638+ border: 1px solid var(--hairline-strong); border-radius: var(--r-3);
2639+ background: rgba(250, 249, 245, 0.88);
2640+ box-shadow: 0 14px 34px rgba(20, 24, 20, 0.16);
2641+ padding: 6px;
2642+ }
2643+ .tabbar a { border-radius: 12px; font-size: 10px; color: var(--ink-3); gap: 2px; }
2644+ .tabbar a.active { color: var(--ink); }
2645+ .tabbar a.active::after { content: ""; width: 16px; height: 2.5px; border-radius: 2px; background: var(--accent); }
2646+ body { padding-bottom: calc(80px + env(safe-area-inset-bottom)); }
2647+ .footer { padding-bottom: calc(86px + env(safe-area-inset-bottom)); }
2648+}
2649+
2650+/* ---- Mobile : recherche condensée + feuille de critères premium ---- */
2651+@media (max-width: 640px) {
2652+ .results-bar { top: 56px; gap: 10px; }
2653+ .search-zone { margin: 30px 0 0; }
2654+ .crit-line, .search-zone .f-adv, .search-zone .sheet-apply { display: none; }
2655+ .crit-summary {
2656+ display: flex; align-items: center; gap: 10px; width: 100%;
2657+ border: 0; border-bottom: 1px solid var(--hairline); background: none;
2658+ padding: 14px 2px; margin: 0; cursor: pointer; text-align: left;
2659+ font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
2660+ }
2661+ .crit-summary svg { flex: none; color: var(--ink); }
2662+ .crit-summary .cs-txt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2663+ .crit-summary .f-chev { margin: 0 2px 0 0; transform: rotate(45deg); }
2664+
2665+ .search-zone.open {
2666+ display: flex; flex-direction: column; gap: 12px;
2667+ position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
2668+ margin: 0; border-radius: var(--r-3) var(--r-3) 0 0; background: var(--surface);
2669+ max-height: 86dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
2670+ padding: 10px 18px calc(16px + env(safe-area-inset-bottom));
2671+ box-shadow: 0 -18px 50px rgba(20, 24, 20, 0.3);
2672+ animation: sheet-up 0.26s var(--ease-out);
2673+ }
2674+ .search-zone.open .sheet-handle {
2675+ display: block; flex: none; width: 40px; height: 4.5px;
2676+ border-radius: 999px; background: var(--line); margin: 0 auto;
2677+ }
2678+ .search-zone.open .sheet-head {
2679+ display: flex; justify-content: space-between; align-items: center;
2680+ font-family: var(--font-display); font-weight: 700; font-size: 17px;
2681+ letter-spacing: -0.01em; color: var(--ink);
2682+ position: sticky; top: -10px; background: var(--surface); padding: 6px 0 8px;
2683+ border-bottom: 1px solid var(--hairline); z-index: 1;
2684+ }
2685+ .search-zone.open .crit-summary { display: none; }
2686+ .search-zone.open .q-big { padding: 2px 0 10px; }
2687+ .search-zone.open .q-big input { font-size: 17px; }
2688+ .search-zone.open .crit-line { display: flex; flex-direction: column; }
2689+ .search-zone.open .crit { border-right: 0; border-bottom: 1px solid var(--hairline); margin: 0; padding: 10px 0; }
2690+ .search-zone.open .crit select { max-width: none; width: 100%; font-size: 16px; }
2691+ .search-zone.open .crit .range-pair select { width: auto; flex: 1; }
2692+ .search-zone.open .crit-more { display: none; }
2693+ .search-zone.open .f-adv { display: grid; border-top: 0; padding-top: 4px; margin-top: 0; }
2694+ .search-zone.open .sheet-apply {
2695+ display: flex; position: sticky; bottom: 0; z-index: 1; width: 100%; min-height: 50px;
2696+ box-shadow: 0 -14px 18px -12px rgba(20, 24, 20, 0.35);
2697+ }
2698+}
2699+
2700+/* ---- Widget KA Agent : dégagé de la tabbar flottante au doigt ---- */
2701+@media (max-width: 760px) {
2702+ .kaa-btn.kaa-btn { bottom: calc(86px + env(safe-area-inset-bottom, 0px)); right: 14px; }
2703+ .kaa-panel.kaa-panel:not(.kaa-full) { bottom: calc(152px + env(safe-area-inset-bottom, 0px)); }
2704+}
2705+
2706+/* les fiches s'alignent en haut : pas d'étirement vide sous les petites */
2707+.grid.grid-edito { align-items: start; }
2410 2708
Menu

Theme