SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

Refonte editoriale v2 de l accueil — direction artistique premium

Moins de boites, plus de typographie : personnalite architecturale et
financiere (Bloomberg x magazine d architecture), cerise en signal seulement.

- Hero : composition majuscule geante en 4 lignes (clamp 40-92px) — plein /
  outline (text-stroke) / ligne decalee / UN SEUL ENDROIT. avec seul souligne
  cerise — plus de gros boutons rectangulaires ni de stat-chips
- Colonne de donnees facon terminal a droite du hero (filet vertical) :
  proprietes indexees (compteur anime), villes, prix moyen, plus haut prix ;
  passe en rangee horizontale sous 900px
- Donnees vivantes : ligne mono LIVE — derniere synchro reelle qui tictaque
  (max ts des recent_syncs), +N proprietes ajoutees aujourd hui, agences
- Recherche au coeur : grand champ Ou voulez-vous vivre ? souligne 2px,
  criteres inline Ville/Type/Prix en filets, Tous les criteres avec badge —
  la boite filterbar disparait
- Barre de resultats sticky : N proprietes · tri mono nu · Liste|Carte
  (onglets a soulignement anime) · Filtres (mobile) — le FAB disparait
- Fiches editoriales : image dominante (radius 10, zoom 1,025), prix 22px,
  adresse, secteur/MLS en micro-mono ; badge type en cerise sur photo
- Grille magazine : 3 colonnes, grand format panoramique 16/9 toutes les 7
  fiches, apparition decalee par colonne, align-items start
- Pagination nue : chiffres sans boites, page courante soulignee cerise
- Feuille mobile premium : resume des criteres actifs sous la recherche,
  poignee, application sticky Voir les N proprietes
- Primitives locales v2 : rayons 0/6/10/18, durees 150/240/350ms, hairlines
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent e22a420

2 changed files +486 −95

modified frontend/src/pages/Home.tsx +155 −86
@@ -22,6 +22,41 @@ const PAGE = 12;
22 22 const fmtK = (n: number) =>
23 23 n >= 1_000_000 ? `${n / 1_000_000} M$` : `${Math.round(n / 1000)} k$`;
24 24
25 +/** Compteur animé du héro (~0,9 s, easing cubique) — l'impression d'un vrai
26 + moteur qui indexe. Respecte prefers-reduced-motion (valeur directe). */
27 +function useCountUp(target: number | null | undefined, ms = 900): string | null {
28 + const [v, setV] = useState<number | null>(null);
29 + useEffect(() => {
30 + if (target == null) return;
31 + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setV(target); return; }
32 + let raf = 0;
33 + const t0 = performance.now();
34 + const step = (t: number) => {
35 + const p = Math.min(1, (t - t0) / ms);
36 + setV(Math.round(target * (1 - Math.pow(1 - p, 3))));
37 + if (p < 1) raf = requestAnimationFrame(step);
38 + };
39 + raf = requestAnimationFrame(step);
40 + return () => cancelAnimationFrame(raf);
41 + }, [target, ms]);
42 + return v == null ? null : v.toLocaleString("fr-CA");
43 +}
44 +
45 +/** « il y a X s » vivant — re-rendu chaque seconde tant que le ts existe. */
46 +function useAgo(ts: number | null): string | null {
47 + const [, tick] = useState(0);
48 + useEffect(() => {
49 + if (ts == null) return;
50 + const id = setInterval(() => tick((x) => x + 1), 1000);
51 + return () => clearInterval(id);
52 + }, [ts]);
53 + if (ts == null) return null;
54 + const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
55 + if (s < 90) return `il y a ${s} s`;
56 + if (s < 5400) return `il y a ${Math.round(s / 60)} min`;
57 + return `il y a ${Math.round(s / 3600)} h`;
58 +}
59 +
25 60 // fenêtre de pagination : 1 … (p-1) p (p+1) … N
26 61 function pageNumbers(p: number, n: number): (number | "…")[] {
27 62 if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);
@@ -150,6 +185,23 @@ export default function Home() {
150 185 setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin(""); setAreaMin("");
151 186 };
152 187
188 + // données vivantes du héro — synchros réelles des connecteurs (recent_syncs)
189 + const totalLive = useCountUp(stats?.total);
190 + const lastSync = useMemo(() => {
191 + const ss = stats?.recent_syncs ?? [];
192 + if (!ss.length) return null;
193 + const ts = Math.max(...ss.map((s) => s.ts));
194 + return ts > 1e12 ? Math.round(ts / 1000) : ts;
195 + }, [stats]);
196 + const syncAgo = useAgo(lastSync);
197 + const newToday = useMemo(() => {
198 + const ss = stats?.recent_syncs ?? [];
199 + const now = Date.now() / 1000;
200 + return ss
201 + .filter((s) => (s.ts > 1e12 ? s.ts / 1000 : s.ts) > now - 86400)
202 + .reduce((n, s) => n + (s.added || 0), 0);
203 + }, [stats]);
204 +
153 205 const pills: { label: string; clear: () => void }[] = [];
154 206 if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });
155 207 if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });
@@ -167,84 +219,89 @@ export default function Home() {
167 219 return (
168 220 <div className="container">
169 221 <section className="hero">
170 − <span className="kicker">Agrégateur — Toutes les agences du Québec</span>
171 − <h1>
172 − Toutes les propriétés <span className="outline">à vendre</span>,<br />
173 − <span className="hl">un seul</span> endroit.
174 − </h1>
175 − <p className="lede">
176 − Immo-Ka rassemble les propriétés à vendre affichées par les agences de courtage
177 − immobilier du Québec — RE/MAX, Sutton, Via Capitale, Proprio Direct, Barnes et plus —
178 − mises à jour automatiquement, avec toutes les photos, les détails complets et un lien
179 − direct vers l'annonce originale.
180 − </p>
181 − <div className="hero-cta">
182 − <button
183 − className="btn btn-primary btn-lg"
184 − onClick={() => {
185 − setView("liste");
186 − document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });
187 − }}
188 − >
189 − <Ico name="search" size={16} /> Explorer les propriétés
190 − </button>
191 − <button
192 − className="btn btn-lg"
193 − onClick={() => {
194 − setView("carte");
195 − document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });
196 − }}
197 − >
198 − <Ico name="map" size={16} /> Voir la carte
199 − </button>
200 − </div>
201 − <div className="stat-row">
202 − <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>
203 − {stats && (
204 − <>
205 − <span className="stat-chip"><b>{stats.total.toLocaleString("fr-CA")}</b> propriétés</span>
206 − <span className="stat-chip"><b>{stats.sources}</b> agences</span>
207 − <span className="stat-chip"><b>{stats.cities?.toLocaleString("fr-CA")}</b> villes</span>
208 − {stats.avg_price != null && (
209 − <span className="stat-chip">prix moyen <b>{Math.round(stats.avg_price).toLocaleString("fr-CA")} $</b></span>
222 + <div className="hero-wrap">
223 + <div className="hero-main">
224 + <span className="kicker">Agrégateur — toutes les agences du Québec</span>
225 + <h1 className="hero-display" aria-label="Toutes les propriétés à vendre. Un seul endroit.">
226 + <span className="hd-l1" aria-hidden="true">Toutes les</span>
227 + <span className="hd-l2" aria-hidden="true">propriétés</span>
228 + <span className="hd-l3" aria-hidden="true">à vendre.</span>
229 + <span className="hd-l4" aria-hidden="true">Un <em className="signal">seul</em> endroit.</span>
230 + </h1>
231 + <p className="lede">
232 + RE/MAX, Sutton, Via Capitale, Proprio Direct, Barnes et toutes les autres —
233 + agrégées en continu, photos et détails complets, lien direct vers l'annonce.
234 + </p>
235 + <div className="live-line" aria-label="Données en direct">
236 + <span className="live-flag"><span className="live-dot" /> live</span>
237 + {syncAgo && <span className="live-item">synchro {syncAgo}</span>}
238 + {newToday > 0 && (
239 + <span className="live-item"><b>+{newToday.toLocaleString("fr-CA")}</b> aujourd'hui</span>
210 240 )}
211 − </>
212 − )}
241 + {stats && stats.sources > 0 && (
242 + <span className="live-item"><b>{stats.sources}</b> agences</span>
243 + )}
244 + </div>
245 + </div>
246 + <aside className="hero-data" aria-label="Le marché en chiffres">
247 + <div className="hd-row">
248 + <b>{totalLive ?? "—"}</b><span>propriétés indexées</span>
249 + </div>
250 + {stats && stats.cities != null && (
251 + <div className="hd-row">
252 + <b>{stats.cities.toLocaleString("fr-CA")}</b><span>villes</span>
253 + </div>
254 + )}
255 + {stats?.avg_price != null && (
256 + <div className="hd-row">
257 + <b>{Math.round(stats.avg_price).toLocaleString("fr-CA")} $</b><span>prix moyen</span>
258 + </div>
259 + )}
260 + {stats?.max_price != null && (
261 + <div className="hd-row">
262 + <b>{fmtK(stats.max_price)}</b><span>plus haut prix</span>
263 + </div>
264 + )}
265 + </aside>
213 266 </div>
214 267 </section>
215 268
216 269 {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}
217 − <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">
270 + <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Recherche et filtres">
218 271 <div className="sheet-handle" aria-hidden="true" />
219 272 <div className="sheet-head">
220 − <span>Filtres</span>
273 + <span>Affiner la recherche</span>
221 274 <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">✕</button>
222 275 </div>
223 276
224 − <div className="f-primary">
225 − <div className="f-search">
226 − <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
227 − <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
228 − </svg>
229 − <input id="f-q" placeholder="Adresse, ville, n° MLS…" value={q}
230 − onChange={(e) => setQ(e.target.value)} aria-label="Recherche" />
231 − {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>}
232 − </div>
233 − <label className="f-ctl">
277 + {/* — la recherche, au cœur du produit : un grand champ souligné — */}
278 + <div className="q-big">
279 + <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
280 + <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
281 + </svg>
282 + <input id="f-q" placeholder="Où voulez-vous vivre ?" value={q}
283 + onChange={(e) => setQ(e.target.value)}
284 + aria-label="Recherche — adresse, ville ou numéro MLS" />
285 + {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>}
286 + </div>
287 +
288 + {/* — critères inline, séparés par filets (pas de boîtes) — */}
289 + <div className="crit-line">
290 + <label className="crit">
234 291 <span>Ville</span>
235 292 <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>
236 293 <option value="">Toutes</option>
237 294 {(facets?.cities ?? []).map((c) => <option key={c} value={c}>{c}</option>)}
238 295 </select>
239 296 </label>
240 − <label className="f-ctl">
297 + <label className="crit">
241 298 <span>Type</span>
242 299 <select value={ptype} onChange={(e) => setPtype(e.target.value)}>
243 300 <option value="">Tous</option>
244 301 {(facets?.property_types ?? []).map((t) => <option key={t} value={t}>{t}</option>)}
245 302 </select>
246 303 </label>
247 − <div className="f-ctl">
304 + <div className="crit">
248 305 <span>Prix</span>
249 306 <div className="range-pair">
250 307 <select aria-label="Prix minimum" value={priceMin} onChange={(e) => setPriceMin(e.target.value)}>
@@ -262,12 +319,9 @@ export default function Home() {
262 319 </select>
263 320 </div>
264 321 </div>
265 − <button className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>
266 − <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
267 − <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
268 − </svg>
269 − Plus de filtres
270 − {advCount > 0 && <span className="f-more-badge">{advCount}</span>}
322 + <button className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>
323 + Tous les critères
324 + {advCount > 0 && <span className="crit-badge">{advCount}</span>}
271 325 <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" />
272 326 </button>
273 327 </div>
@@ -326,10 +380,23 @@ export default function Home() {
326 380 )}
327 381
328 382 <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
329 − Voir les résultats {listings ? `(${total})` : ""}
383 + Voir {listings ? `les ${total.toLocaleString("fr-CA")} propriétés` : "les résultats"}
330 384 </button>
331 385 </section>
332 386
387 + {/* mobile : résumé des critères — ouvre la feuille (recherche intégrée, pas de FAB) */}
388 + <button className="crit-summary" onClick={() => setSheetOpen(true)}>
389 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
390 + <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
391 + </svg>
392 + <span className="cs-txt">
393 + {pills.length > 0
394 + ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "")
395 + : "Ville, type, prix, chambres…"}
396 + </span>
397 + <span className="f-chev" aria-hidden="true" />
398 + </button>
399 +
333 400 <div className="chips" role="group" aria-label="Filtres rapides">
334 401 {topTypes.map((t) => (
335 402 <button key={t} className={`chip ${ptype === t ? "on" : ""}`} onClick={() => setPtype(ptype === t ? "" : t)}>
@@ -349,28 +416,38 @@ export default function Home() {
349 416 </div>
350 417 )}
351 418
352 − <div className="results-head" id="results-top">
353 − <h2>Propriétés à vendre</h2>
354 − <div className="results-tools">
355 − {listings && <span>{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}</span>}
356 − <label className="sort-ctl">
419 + <div className="results-bar" id="results-top">
420 + <h2 className="rb-count">
421 + {listings
422 + ? <><b>{total.toLocaleString("fr-CA")}</b> propriété{total > 1 ? "s" : ""}</>
423 + : "Propriétés"}
424 + </h2>
425 + <div className="rb-tools">
426 + <label className="rb-sort">
357 427 <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Trier">
358 428 <option value="recent">Plus récents</option>
359 − <option value="price_asc">Prix ↑</option>
360 − <option value="price_desc">Prix ↓</option>
429 + <option value="price_asc">Prix croissant</option>
430 + <option value="price_desc">Prix décroissant</option>
361 431 </select>
362 432 </label>
363 − <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">
364 − <button role="tab" aria-selected={view === "liste"} className={view === "liste" ? "on" : ""} onClick={() => setView("liste")}>
433 + <div className="rb-tabs" role="tablist" aria-label="Mode d'affichage">
434 + <button role="tab" aria-selected={view === "liste"} className={`rb-tab ${view === "liste" ? "on" : ""}`} onClick={() => setView("liste")}>
365 435 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
366 436 <path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01" />
367 437 </svg>
368 438 Liste
369 439 </button>
370 − <button role="tab" aria-selected={view === "carte"} className={view === "carte" ? "on" : ""} onClick={() => setView("carte")}>
440 + <button role="tab" aria-selected={view === "carte"} className={`rb-tab ${view === "carte" ? "on" : ""}`} onClick={() => setView("carte")}>
371 441 <Ico name="map" size={13} /> Carte
372 442 </button>
373 443 </div>
444 + <button className="rb-filters" onClick={() => setSheetOpen(true)}>
445 + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
446 + <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
447 + </svg>
448 + Filtres
449 + {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>}
450 + </button>
374 451 </div>
375 452 </div>
376 453
@@ -384,7 +461,7 @@ export default function Home() {
384 461 )}
385 462
386 463 {!error && view === "liste" && listings === null && (
387 − <div className="grid" aria-busy="true">
464 + <div className="grid grid-edito" aria-busy="true">
388 465 {Array.from({ length: 8 }).map((_, i) => (
389 466 <div className="skel" key={i}><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>
390 467 ))}
@@ -401,7 +478,7 @@ export default function Home() {
401 478 )}
402 479
403 480 {!error && view === "carte" && (
404 − <div className="map-split">
481 + <div className="map-split view-pane">
405 482 <div className="map-list" aria-label="Résultats en liste">
406 483 {(listings ?? []).map((l) => (
407 484 <div
@@ -427,8 +504,8 @@ export default function Home() {
427 504 )}
428 505
429 506 {!error && view === "liste" && listings !== null && listings.length > 0 && (
430 − <>
431 − <div className="grid">
507 + <div className="view-pane">
508 + <div className="grid grid-edito">
432 509 {listings.map((l) => <ListingCard key={l.uid} l={l} />)}
433 510 </div>
434 511 {totalPages > 1 && (
@@ -444,16 +521,8 @@ export default function Home() {
444 521 <span className="pager-info">Page {page} / {totalPages}</span>
445 522 </nav>
446 523 )}
447 − </>
524 + </div>
448 525 )}
449 −
450 − <button className="fab" onClick={() => setSheetOpen(true)} aria-label="Ouvrir les filtres">
451 − <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
452 − <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
453 − </svg>
454 − Filtres
455 − {activeFilters > 0 && <span className="fab-badge">{activeFilters}</span>}
456 − </button>
457 526 </div>
458 527 );
459 528 }
modified frontend/src/styles.css +331 −9
@@ -84,17 +84,8 @@ button { font-family: inherit; }
84 84 .hero h1 .hl { background: var(--lime); color: #fff; padding: 0 10px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); }
85 85 .hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 680px; margin: 20px 0 0; }
86 86
87 −/* — CTA du héro : accès direct aux résultats / à la carte — */
88 −.hero-cta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
89 −.btn-lg { min-height: 54px; padding: 14px 26px; font-size: 15px; border-radius: 10px; }
90 −.btn-lg svg { flex: none; }
91 −.hero-cta .btn-primary { box-shadow: 5px 5px 0 var(--lime); }
92 −.hero-cta .btn-primary:hover { transform: translate(-2px, -2px); box-shadow: 7px 7px 0 var(--lime); background: var(--ink); }
93 −.hero-cta .btn-primary:active { transform: translate(2px, 2px); }
94 87 @media (max-width: 640px) {
95 88 .hero::before { width: 320px; height: 260px; top: -30px; right: -90px; }
96 − .hero-cta { gap: 10px; }
97 − .hero-cta .btn { flex: 1 1 45%; }
98 89 }
99 90
100 91 .stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
@@ -1287,3 +1278,334 @@ table.rooms tbody tr:hover td { background: var(--accent-soft); }
1287 1278 .taux-src-ok::before { background: var(--green, #2f9e44); }
1288 1279 .taux-src-warning::before { background: #e8a000; }
1289 1280 .taux-src-error::before { background: var(--accent-deep); }
1281 +
1282 +/* =============================================================================
1283 + REFONTE ÉDITORIALE v2 (2026-08-25) — accueil « produit premium »
1284 + Personnalité Immo-Ka : architecturale et financière — composition
1285 + typographique majuscule plein/outline décalée, colonne de données mono
1286 + façon terminal, cerise en couleur de SIGNAL (jamais en aplat décoratif).
1287 + Primitives locales : rayons 0/6/10/18, durées 150/240/350 ms.
1288 + Couche posée en FIN de fichier : elle a le dernier mot sur la cascade.
1289 +============================================================================= */
1290 +:root {
1291 + --r-0: 0px; --r-1: 6px; --r-2: 10px; --r-3: 18px;
1292 + --dur-1: 150ms; --dur-2: 240ms; --dur-3: 350ms;
1293 + --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
1294 + --hairline: rgba(20, 24, 20, 0.14);
1295 + --hairline-strong: rgba(20, 24, 20, 0.6);
1296 +}
1297 +
1298 +/* ---- Héro : composition typographique + colonne de données ---- */
1299 +.hero-wrap { display: grid; grid-template-columns: 1fr auto; gap: 24px 56px; align-items: end; }
1300 +.hero h1.hero-display {
1301 + display: flex; flex-direction: column;
1302 + font-size: clamp(40px, 6.8vw, 92px); line-height: 0.96;
1303 + text-transform: uppercase; letter-spacing: -0.04em;
1304 + max-width: none; margin-top: 16px;
1305 +}
1306 +.hero-display .hd-l1, .hero-display .hd-l2,
1307 +.hero-display .hd-l3, .hero-display .hd-l4 { display: block; }
1308 +.hero-display .hd-l2 { color: transparent; -webkit-text-stroke: 2.5px var(--ink); }
1309 +.hero-display .hd-l3 { margin-left: clamp(24px, 6vw, 110px); }
1310 +.hero-display .hd-l4 {
1311 + font-size: 0.5em; letter-spacing: -0.02em; margin-top: 0.35em;
1312 + margin-left: clamp(2px, 1vw, 8px);
1313 +}
1314 +.hero-display .signal {
1315 + font-style: normal; color: var(--lime); position: relative;
1316 + border-bottom: 4px solid var(--lime); padding-bottom: 1px;
1317 +}
1318 +.hero p.lede { font-size: 16px; max-width: 560px; }
1319 +.hero::before { opacity: 0.7; }
1320 +.hero::after { opacity: 0.5; }
1321 +
1322 +/* colonne « terminal » : les chiffres du marché comme élément graphique */
1323 +.hero-data {
1324 + display: flex; flex-direction: column; gap: 18px;
1325 + border-left: 1px solid var(--hairline-strong); padding-left: 28px;
1326 + min-width: 190px;
1327 +}
1328 +.hd-row b {
1329 + display: block; font-family: var(--font-display); font-weight: 700;
1330 + font-size: clamp(20px, 2vw, 27px); letter-spacing: -0.03em; color: var(--ink);
1331 + font-variant-numeric: tabular-nums; line-height: 1.1;
1332 +}
1333 +.hd-row span {
1334 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1335 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3);
1336 +}
1337 +@media (max-width: 900px) {
1338 + .hero-wrap { grid-template-columns: 1fr; align-items: start; }
1339 + .hero-data {
1340 + flex-direction: row; flex-wrap: wrap; gap: 16px 28px;
1341 + border-left: 0; border-top: 1px solid var(--hairline);
1342 + padding: 16px 0 0; min-width: 0; margin-top: 6px;
1343 + }
1344 +}
1345 +
1346 +/* ---- Ligne de données vivantes (mono + filets) ---- */
1347 +.live-line {
1348 + display: flex; flex-wrap: wrap; align-items: baseline;
1349 + margin-top: 30px; font-family: var(--font-mono); font-size: 12px;
1350 + color: var(--ink-2); letter-spacing: 0.02em; row-gap: 10px;
1351 +}
1352 +.live-flag {
1353 + display: inline-flex; align-items: center; gap: 7px;
1354 + font-weight: 700; font-size: 10.5px; letter-spacing: 0.18em;
1355 + text-transform: uppercase; color: var(--accent-deep);
1356 + padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline-strong);
1357 +}
1358 +.live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--lime); animation: pulse 2.4s ease infinite; }
1359 +.live-item { padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline); }
1360 +.live-item:last-child { border-right: 0; padding-right: 0; margin-right: 0; }
1361 +.live-item b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }
1362 +@media (max-width: 640px) {
1363 + .live-line { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
1364 + white-space: nowrap; margin-right: -16px; padding-right: 16px; }
1365 + .live-line::-webkit-scrollbar { display: none; }
1366 +}
1367 +
1368 +/* ---- Recherche au cœur : grand champ souligné + critères en filets ---- */
1369 +.search-zone { margin: 42px 0 0; }
1370 +.q-big {
1371 + display: flex; align-items: center; gap: 14px;
1372 + border: 0; border-bottom: 2px solid var(--ink); border-radius: 0;
1373 + background: transparent; padding: 4px 2px 14px; color: var(--ink-3);
1374 + transition: border-color var(--dur-2) var(--ease-out), color var(--dur-2) ease;
1375 +}
1376 +.q-big:focus-within { border-color: var(--lime); color: var(--accent-deep); }
1377 +.q-big svg { flex: none; }
1378 +.q-big input {
1379 + flex: 1; min-width: 0; border: 0; background: none; outline: none; padding: 0;
1380 + font-family: var(--font-display); font-weight: 600;
1381 + font-size: clamp(19px, 2.8vw, 28px); letter-spacing: -0.02em; color: var(--ink);
1382 +}
1383 +.q-big input::placeholder { color: var(--ink-3); font-weight: 500; }
1384 +.crit-line { display: flex; align-items: stretch; flex-wrap: wrap; }
1385 +.crit {
1386 + display: flex; flex-direction: column; justify-content: center; gap: 1px;
1387 + padding: 12px 26px 12px 0; margin-right: 26px;
1388 + border-right: 1px solid var(--hairline); min-width: 0; cursor: pointer;
1389 +}
1390 +.crit > span {
1391 + font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1392 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3);
1393 +}
1394 +.crit select {
1395 + border: 0; background: transparent; outline: none; cursor: pointer;
1396 + font-family: var(--font-display); font-weight: 700; font-size: 15.5px;
1397 + color: var(--ink); appearance: none; -webkit-appearance: none;
1398 + padding: 2px 18px 2px 0; min-width: 0; max-width: 190px; text-overflow: ellipsis;
1399 + 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");
1400 + background-repeat: no-repeat; background-position: right center;
1401 +}
1402 +.crit:hover select { color: var(--accent-deep); }
1403 +.crit .range-pair select { max-width: 92px; }
1404 +.crit-more {
1405 + display: inline-flex; align-items: center; gap: 9px; align-self: center;
1406 + border: 0; background: none; padding: 12px 0; cursor: pointer;
1407 + font-family: var(--font-display); font-weight: 700; font-size: 14.5px; color: var(--ink);
1408 + transition: color var(--dur-1) ease;
1409 +}
1410 +.crit-more:hover, .crit-more.on { color: var(--accent-deep); }
1411 +.crit-badge, .rb-badge {
1412 + display: inline-grid; place-items: center; min-width: 19px; height: 19px; padding: 0 5px;
1413 + border-radius: 999px; background: var(--lime); color: #fff;
1414 + font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
1415 +}
1416 +.search-zone .f-adv { border-top: 1px solid var(--hairline); margin-top: 2px; padding-top: 18px; }
1417 +.search-zone .seg button {
1418 + border: 1px solid var(--hairline-strong); background: transparent; color: var(--ink-2);
1419 + min-height: 38px; font-weight: 600; margin-left: -1px;
1420 +}
1421 +.search-zone .seg button:first-child { border-radius: var(--r-1) 0 0 var(--r-1); }
1422 +.search-zone .seg button:last-child { border-radius: 0 var(--r-1) var(--r-1) 0; }
1423 +.search-zone .seg button:hover { background: var(--lime-soft); color: var(--accent-deep); }
1424 +.search-zone .seg button.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; }
1425 +.search-zone .f-native { border: 1px solid var(--hairline-strong); background: transparent; }
1426 +
1427 +/* résumé mobile des critères — remplace le FAB (voir media 640 plus bas) */
1428 +.crit-summary { display: none; }
1429 +
1430 +/* ---- Barre de résultats sticky : N propriétés · tri · Liste|Carte ---- */
1431 +.results-bar {
1432 + position: sticky; top: 64px; z-index: var(--z-sticky, 300);
1433 + display: flex; align-items: center; gap: 16px;
1434 + margin: 30px 0 22px; padding: 12px 0 11px;
1435 + border-bottom: 1px solid var(--hairline-strong);
1436 + background: color-mix(in srgb, var(--paper) 86%, transparent);
1437 + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
1438 +}
1439 +@supports not (background: color-mix(in srgb, red 50%, blue)) {
1440 + .results-bar { background: rgba(245, 243, 238, 0.92); }
1441 +}
1442 +.rb-count { margin: 0; font-size: clamp(16px, 2vw, 20px); letter-spacing: -0.02em; color: var(--ink); text-transform: none; }
1443 +.rb-count b { font-variant-numeric: tabular-nums; }
1444 +.rb-tools { margin-left: auto; display: flex; align-items: center; gap: 20px; }
1445 +.rb-sort select {
1446 + border: 0; background: transparent; outline: none; cursor: pointer;
1447 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 700;
1448 + text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-2);
1449 + appearance: none; -webkit-appearance: none; padding: 8px 16px 8px 0;
1450 + 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");
1451 + background-repeat: no-repeat; background-position: right center;
1452 +}
1453 +.rb-sort select:hover { color: var(--ink); }
1454 +.rb-tabs { display: flex; gap: 18px; }
1455 +.rb-tab {
1456 + position: relative; display: inline-flex; align-items: center; gap: 6px;
1457 + border: 0; background: none; padding: 8px 0; cursor: pointer;
1458 + font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink-3);
1459 + transition: color var(--dur-1) ease;
1460 +}
1461 +.rb-tab:hover, .rb-tab.on { color: var(--ink); }
1462 +.rb-tab::after {
1463 + content: ""; position: absolute; left: 0; right: 0; bottom: -12px; height: 2.5px;
1464 + background: var(--lime); transform: scaleX(0); transform-origin: left;
1465 + transition: transform var(--dur-2) var(--ease-out);
1466 +}
1467 +.rb-tab.on::after { transform: scaleX(1); }
1468 +.rb-filters {
1469 + display: inline-flex; align-items: center; gap: 7px;
1470 + border: 0; background: none; padding: 8px 0; cursor: pointer;
1471 + font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink);
1472 +}
1473 +.rb-filters:hover { color: var(--accent-deep); }
1474 +@media (min-width: 641px) { .rb-filters { display: none; } }
1475 +
1476 +/* ---- Transition douce liste ↔ carte ---- */
1477 +.view-pane { animation: pane-in var(--dur-2) var(--ease-out); }
1478 +@keyframes pane-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
1479 +
1480 +/* ---- Chips rapides : plus légères ---- */
1481 +.chip {
1482 + border: 1px solid var(--hairline-strong); background: transparent;
1483 + min-height: 38px; padding: 7px 15px; font-size: 12.5px; box-shadow: none;
1484 +}
1485 +.chip:hover { transform: none; box-shadow: none; background: var(--lime-soft); color: var(--accent-deep); }
1486 +.chip.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; }
1487 +.chips { margin: 20px 0 0; }
1488 +
1489 +/* ---- Pastilles actives : discrètes, survol = retrait ---- */
1490 +.pills { margin: 14px 0 0; }
1491 +.pills .pill { border: 1px solid var(--hairline-strong); background: transparent; box-shadow: none; padding: 5px 12px; font-size: 12px; }
1492 +.pills .pill:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
1493 +
1494 +/* ---- Fiches éditoriales : l'image domine, plus de boîte ---- */
1495 +.card, .card:hover { background: transparent; border: 0; border-radius: 0; box-shadow: none; transform: none; }
1496 +.card-img { border-radius: var(--r-2); aspect-ratio: 4/3; }
1497 +.card-img img { transition: transform var(--dur-3) var(--ease-out); }
1498 +@media (hover: hover) {
1499 + .card:hover .card-img img { transform: scale(1.025); }
1500 + .card:hover .card-price { color: var(--accent-deep); }
1501 +}
1502 +.card:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: var(--r-2); }
1503 +.card-body { padding: 13px 2px 0; gap: 3px; }
1504 +.card-price { font-size: 22px; letter-spacing: -0.03em; color: var(--ink); transition: color var(--dur-1) ease; }
1505 +.card-title { font-size: 14px; font-weight: 500; color: var(--ink-2); }
1506 +.card-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); }
1507 +.card-meta .sep { width: 3px; height: 3px; border: 0; border-radius: 50%; background: var(--lime); transform: none; }
1508 +.card-specs { font-size: 11.5px; color: var(--ink-2); }
1509 +.card-foot { border-top: 0; padding-top: 8px; }
1510 +.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%; }
1511 +.avail { font-size: 11px; color: var(--ink-3); }
1512 +.badge { border: 0; border-radius: 4px; background: rgba(20, 24, 20, 0.78); color: var(--paper);
1513 + backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
1514 + font-size: 9.5px; padding: 4px 9px; letter-spacing: 0.08em; }
1515 +.badge.type { background: var(--lime); color: #fff; }
1516 +.badge.right { background: rgba(20, 24, 20, 0.55); }
1517 +/* la liste latérale de la vue carte garde ses fiches synchronisées lisibles */
1518 +.map-card { border-radius: var(--r-2); }
1519 +.map-card-sel { outline: 2px solid var(--lime); outline-offset: 3px; border-radius: var(--r-2); }
1520 +
1521 +/* ---- Grille à rythme éditorial (accueil) ---- */
1522 +.grid.grid-edito { grid-template-columns: repeat(3, 1fr); gap: 36px 26px; align-items: start; }
1523 +.grid-edito > *:nth-child(7n+1) { grid-column: span 2; }
1524 +.grid-edito > *:nth-child(7n+1) .card-img,
1525 +.grid-edito > .skel:nth-child(7n+1) .sk-img { aspect-ratio: 16/9; }
1526 +.grid-edito > *:nth-child(7n+1) .card-price { font-size: 27px; }
1527 +.grid-edito > *:nth-child(7n+1) .card-title { font-size: 15px; }
1528 +@media (max-width: 980px) {
1529 + .grid.grid-edito { grid-template-columns: 1fr 1fr; gap: 30px 20px; }
1530 +}
1531 +@media (max-width: 640px) {
1532 + .grid.grid-edito { grid-template-columns: 1fr; gap: 32px; }
1533 + .grid-edito > *:nth-child(7n+1) { grid-column: auto; }
1534 +}
1535 +@media (prefers-reduced-motion: no-preference) {
1536 + .grid-edito > * { animation: rise 0.5s var(--ease-out) backwards; }
1537 + .grid-edito > *:nth-child(3n+2) { animation-delay: 60ms; }
1538 + .grid-edito > *:nth-child(3n) { animation-delay: 120ms; }
1539 +}
1540 +@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
1541 +
1542 +/* ---- Squelettes sans boîte ---- */
1543 +.skel { border: 0; border-radius: 0; background: transparent; }
1544 +.skel .sk-img { aspect-ratio: 4/3; border-radius: var(--r-2); }
1545 +.skel .sk-line { margin: 12px 2px; }
1546 +
1547 +/* ---- Pagination éditoriale : chiffres nus, page courante soulignée ---- */
1548 +.pager { border-top: 1px solid var(--hairline); padding-top: 16px; margin-top: 26px; }
1549 +.pager-info { font-family: var(--font-mono); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); }
1550 +.pager-btn {
1551 + min-width: 34px; min-height: 38px; height: auto; padding: 0 10px; border: 0; border-radius: var(--r-1);
1552 + background: none; color: var(--ink-2); box-shadow: none;
1553 + font-family: var(--font-display); font-size: 13.5px; font-weight: 600; position: relative;
1554 +}
1555 +.pager-btn:hover:not(:disabled):not(.on) { background: var(--lime-soft); color: var(--accent-deep); transform: none; box-shadow: none; }
1556 +.pager-btn.on { background: none; border: 0; color: var(--ink); font-weight: 700; box-shadow: none; }
1557 +.pager-btn.on::after { content: ""; position: absolute; left: 9px; right: 9px; bottom: 3px; height: 2.5px; background: var(--lime); }
1558 +.pager-btn:disabled { box-shadow: none; }
1559 +
1560 +/* ---- Mobile : recherche condensée + feuille de critères premium ---- */
1561 +@media (max-width: 640px) {
1562 + .hero h1.hero-display { font-size: clamp(38px, 11vw, 52px); }
1563 + .results-bar { top: 64px; gap: 10px; flex-wrap: wrap; }
1564 + .rb-tools { gap: 14px; }
1565 + .search-zone { margin: 30px 0 0; }
1566 + .crit-line, .search-zone .f-adv, .search-zone .sheet-apply { display: none; }
1567 + .crit-summary {
1568 + display: flex; align-items: center; gap: 10px; width: 100%;
1569 + border: 0; border-bottom: 1px solid var(--hairline); background: none;
1570 + padding: 14px 2px; margin: 0; cursor: pointer; text-align: left;
1571 + font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
1572 + }
1573 + .crit-summary svg { flex: none; color: var(--ink); }
1574 + .crit-summary .cs-txt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1575 + .crit-summary .f-chev { margin: 0 2px 0 0; }
1576 +
1577 + .search-zone.open {
1578 + display: flex; flex-direction: column; gap: 12px;
1579 + position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
1580 + margin: 0; border-radius: var(--r-3) var(--r-3) 0 0; background: var(--surface);
1581 + border: 0;
1582 + max-height: 86dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
1583 + padding: 10px 18px calc(16px + env(safe-area-inset-bottom));
1584 + box-shadow: 0 -18px 50px rgba(20, 24, 20, 0.3);
1585 + animation: sheet-up 0.26s var(--ease-out);
1586 + }
1587 + .search-zone.open .sheet-handle {
1588 + display: block; flex: none; width: 40px; height: 4.5px;
1589 + border-radius: 999px; background: var(--line); margin: 0 auto;
1590 + }
1591 + .search-zone.open .sheet-head {
1592 + display: flex; justify-content: space-between; align-items: center;
1593 + font-family: var(--font-display); font-weight: 700; font-size: 17px;
1594 + letter-spacing: -0.01em; color: var(--ink); text-transform: none;
1595 + position: sticky; top: -10px; background: var(--surface); padding: 6px 0 8px;
1596 + border-bottom: 1px solid var(--hairline); z-index: 1;
1597 + }
1598 + .search-zone.open .crit-summary { display: none; }
1599 + .search-zone.open .q-big { padding: 2px 0 10px; }
1600 + .search-zone.open .q-big input { font-size: 17px; }
1601 + .search-zone.open .crit-line { display: flex; flex-direction: column; }
1602 + .search-zone.open .crit { border-right: 0; border-bottom: 1px solid var(--hairline); margin: 0; padding: 10px 0; }
1603 + .search-zone.open .crit select { max-width: none; width: 100%; font-size: 16px; }
1604 + .search-zone.open .crit .range-pair select { width: auto; flex: 1; }
1605 + .search-zone.open .crit-more { display: none; }
1606 + .search-zone.open .f-adv { display: grid; border-top: 0; padding-top: 4px; margin-top: 0; }
1607 + .search-zone.open .sheet-apply {
1608 + display: flex; position: sticky; bottom: 0; z-index: 1; width: 100%; min-height: 50px;
1609 + box-shadow: 0 -14px 18px -12px rgba(20, 24, 20, 0.35);
1610 + }
1611 +}
1290 1612