Mobile : menu déroulant animé + sélecteur de consentement témoins + page confidentialité
- Header : hamburger animé (-> X), panneau déroulant avec liens en cascade (Logements, Carte, Stats, Sources, Confidentialité), backdrop flouté, verrouillage du défilement, fermeture à la navigation - CookieConsent : bandeau au premier chargement (aucun témoin tiers — honnête), 3 choix (tout accepter / essentiels / personnaliser avec cases préférences et statistiques), mémorisé en localStorage, ré-ouvrable depuis le pied de page et la page confidentialité - /confidentialite : politique de confidentialité et de témoins - Vue carte synchronisée avec l'URL (?view=carte depuis le menu) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 changed files +333 −2
modified
frontend/src/App.tsx
+65 −2
@@ -4,10 +4,12 @@ | |||||
| 4 | // App.tsx : layout global (header + ticker en direct + footer encre) et routage | 4 | // App.tsx : layout global (header + ticker en direct + footer encre) et routage | ||
| 5 | // ----------------------------------------------------------------------------- | 5 | // ----------------------------------------------------------------------------- | ||
| 6 | import { useEffect, useState } from "react"; | 6 | import { useEffect, useState } from "react"; | ||
| 7 | −import { NavLink, Route, Routes } from "react-router-dom"; | 7 | +import { NavLink, Route, Routes, useLocation } from "react-router-dom"; | ||
| 8 | import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api"; | 8 | import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api"; | ||
| 9 | +import CookieConsent from "./components/CookieConsent"; | ||||
| 9 | import Home from "./pages/Home"; | 10 | import Home from "./pages/Home"; | ||
| 10 | import ListingPage from "./pages/Listing"; | 11 | import ListingPage from "./pages/Listing"; | ||
| 12 | +import PrivacyPage from "./pages/Privacy"; | ||||
| 11 | import SourcesPage from "./pages/Sources"; | 13 | import SourcesPage from "./pages/Sources"; | ||
| 12 | import StatsPage from "./pages/Stats"; | 14 | import StatsPage from "./pages/Stats"; | ||
| 13 | 15 | ||||
@@ -47,7 +49,25 @@ function Ticker() { | |||||
| 47 | ); | 49 | ); | ||
| 48 | } | 50 | } | ||
| 49 | 51 | ||||
| 52 | +const NAV_LINKS = [ | ||||
| 53 | + { to: "/", label: "Logements", icon: "🏠", end: true }, | ||||
| 54 | + { to: "/?view=carte", label: "Carte", icon: "◈", end: false, force: true }, | ||||
| 55 | + { to: "/stats", label: "Stats", icon: "📊", end: false }, | ||||
| 56 | + { to: "/sources", label: "Sources", icon: "🗂", end: false }, | ||||
| 57 | + { to: "/confidentialite", label: "Confidentialité", icon: "🔒", end: false }, | ||||
| 58 | +]; | ||||
| 59 | + | ||||
| 50 | function Header() { | 60 | function Header() { | ||
| 61 | + const [open, setOpen] = useState(false); | ||||
| 62 | + const location = useLocation(); | ||||
| 63 | + | ||||
| 64 | + // fermer le menu à chaque navigation + verrouiller le défilement en dessous | ||||
| 65 | + useEffect(() => { setOpen(false); }, [location]); | ||||
| 66 | + useEffect(() => { | ||||
| 67 | + document.body.style.overflow = open ? "hidden" : ""; | ||||
| 68 | + return () => { document.body.style.overflow = ""; }; | ||||
| 69 | + }, [open]); | ||||
| 70 | + | ||||
| 51 | return ( | 71 | return ( | ||
| 52 | <> | 72 | <> | ||
| 53 | <header className="header"> | 73 | <header className="header"> | ||
@@ -56,7 +76,7 @@ function Header() { | |||||
| 56 | Lou<span className="ka">Ka</span> | 76 | Lou<span className="ka">Ka</span> | ||
| 57 | <span className="brand-tag">Québec · Lévis · Montréal — toujours à jour</span> | 77 | <span className="brand-tag">Québec · Lévis · Montréal — toujours à jour</span> | ||
| 58 | </NavLink> | 78 | </NavLink> | ||
| 59 | − <nav className="nav"> | 79 | + <nav className="nav" aria-label="Navigation principale"> | ||
| 60 | <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}> | 80 | <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}> | ||
| 61 | Logements | 81 | Logements | ||
| 62 | </NavLink> | 82 | </NavLink> | ||
@@ -67,8 +87,40 @@ function Header() { | |||||
| 67 | Sources | 87 | Sources | ||
| 68 | </NavLink> | 88 | </NavLink> | ||
| 69 | </nav> | 89 | </nav> | ||
| 90 | + <button | ||||
| 91 | + className={`menu-btn ${open ? "open" : ""}`} | ||||
| 92 | + aria-expanded={open} | ||||
| 93 | + aria-label={open ? "Fermer le menu" : "Ouvrir le menu"} | ||||
| 94 | + onClick={() => setOpen(!open)} | ||||
| 95 | + > | ||||
| 96 | + <span /><span /><span /> | ||||
| 97 | + </button> | ||||
| 98 | + </div> | ||||
| 99 | + | ||||
| 100 | + {/* menu déroulant mobile */} | ||||
| 101 | + <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile"> | ||||
| 102 | + {NAV_LINKS.map((l, i) => ( | ||||
| 103 | + <NavLink | ||||
| 104 | + key={l.to} | ||||
| 105 | + to={l.to} | ||||
| 106 | + end={l.end} | ||||
| 107 | + style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }} | ||||
| 108 | + className={({ isActive }) => | ||||
| 109 | + `mm-link ${isActive && !l.force ? "active" : ""}`} | ||||
| 110 | + onClick={() => setOpen(false)} | ||||
| 111 | + > | ||||
| 112 | + <span className="mm-ico" aria-hidden="true">{l.icon}</span> | ||||
| 113 | + {l.label} | ||||
| 114 | + <span className="mm-arrow" aria-hidden="true">→</span> | ||||
| 115 | + </NavLink> | ||||
| 116 | + ))} | ||||
| 117 | + <div className="mm-foot"> | ||||
| 118 | + Agrégateur indépendant — mis à jour automatiquement, chaque fiche | ||||
| 119 | + renvoie à l'annonce originale. | ||||
| 120 | + </div> | ||||
| 70 | </div> | 121 | </div> | ||
| 71 | </header> | 122 | </header> | ||
| 123 | + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />} | ||||
| 72 | <Ticker /> | 124 | <Ticker /> | ||
| 73 | </> | 125 | </> | ||
| 74 | ); | 126 | ); | ||
@@ -90,6 +142,15 @@ function Footer() { | |||||
| 90 | </div> | 142 | </div> | ||
| 91 | <div className="fmono"> | 143 | <div className="fmono"> | ||
| 92 | © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai | 144 | © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai | ||
| 145 | + {" · "} | ||||
| 146 | + <NavLink to="/confidentialite">Confidentialité</NavLink> | ||||
| 147 | + {" · "} | ||||
| 148 | + <button | ||||
| 149 | + className="flink" | ||||
| 150 | + onClick={() => window.dispatchEvent(new Event("louka:openConsent"))} | ||||
| 151 | + > | ||||
| 152 | + Gérer mes témoins | ||||
| 153 | + </button> | ||||
| 93 | </div> | 154 | </div> | ||
| 94 | </div> | 155 | </div> | ||
| 95 | </footer> | 156 | </footer> | ||
@@ -106,6 +167,7 @@ export default function App() { | |||||
| 106 | <Route path="/logement/:uid" element={<ListingPage />} /> | 167 | <Route path="/logement/:uid" element={<ListingPage />} /> | ||
| 107 | <Route path="/stats" element={<StatsPage />} /> | 168 | <Route path="/stats" element={<StatsPage />} /> | ||
| 108 | <Route path="/sources" element={<SourcesPage />} /> | 169 | <Route path="/sources" element={<SourcesPage />} /> | ||
| 170 | + <Route path="/confidentialite" element={<PrivacyPage />} /> | ||||
| 109 | <Route | 171 | <Route | ||
| 110 | path="*" | 172 | path="*" | ||
| 111 | element={ | 173 | element={ | ||
@@ -119,6 +181,7 @@ export default function App() { | |||||
| 119 | </Routes> | 181 | </Routes> | ||
| 120 | </main> | 182 | </main> | ||
| 121 | <Footer /> | 183 | <Footer /> | ||
| 184 | + <CookieConsent /> | ||||
| 122 | </> | 185 | </> | ||
| 123 | ); | 186 | ); | ||
| 124 | } | 187 | } | ||
added
frontend/src/components/CookieConsent.tsx
+103 −0
@@ -0,0 +1,103 @@ | |||||
| 1 | +// ----------------------------------------------------------------------------- | ||||
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +// components/CookieConsent.tsx : bandeau de consentement (témoins/stockage) | ||||
| 5 | +// Honnête par design : Lou-Ka n'utilise AUCUN témoin tiers ni traceur. | ||||
| 6 | +// Le sélecteur couvre le stockage local (préférences) et une éventuelle | ||||
| 7 | +// mesure d'audience anonyme future. Choix mémorisé en localStorage, | ||||
| 8 | +// ré-ouvrable via l'événement « louka:openConsent » (lien du pied de page). | ||||
| 9 | +// ----------------------------------------------------------------------------- | ||||
| 10 | +import { useEffect, useState } from "react"; | ||||
| 11 | +import { Link } from "react-router-dom"; | ||||
| 12 | + | ||||
| 13 | +const KEY = "louka-consent-v1"; | ||||
| 14 | + | ||||
| 15 | +export interface Consent { | ||||
| 16 | + essential: true; // toujours actif (fonctionnement du site) | ||||
| 17 | + preferences: boolean; // mémoriser filtres et vue liste/carte | ||||
| 18 | + statistics: boolean; // mesure d'audience anonyme (si activée un jour) | ||||
| 19 | + ts: number; | ||||
| 20 | +} | ||||
| 21 | + | ||||
| 22 | +export function getConsent(): Consent | null { | ||||
| 23 | + try { | ||||
| 24 | + const raw = localStorage.getItem(KEY); | ||||
| 25 | + return raw ? (JSON.parse(raw) as Consent) : null; | ||||
| 26 | + } catch { | ||||
| 27 | + return null; | ||||
| 28 | + } | ||||
| 29 | +} | ||||
| 30 | + | ||||
| 31 | +function save(preferences: boolean, statistics: boolean): Consent { | ||||
| 32 | + const c: Consent = { essential: true, preferences, statistics, ts: Date.now() }; | ||||
| 33 | + try { localStorage.setItem(KEY, JSON.stringify(c)); } catch { /* stockage bloqué */ } | ||||
| 34 | + return c; | ||||
| 35 | +} | ||||
| 36 | + | ||||
| 37 | +export default function CookieConsent() { | ||||
| 38 | + const [visible, setVisible] = useState(false); | ||||
| 39 | + const [custom, setCustom] = useState(false); | ||||
| 40 | + const [prefs, setPrefs] = useState(true); | ||||
| 41 | + const [stats, setStats] = useState(false); | ||||
| 42 | + | ||||
| 43 | + useEffect(() => { | ||||
| 44 | + if (getConsent() === null) setVisible(true); | ||||
| 45 | + const open = () => { setVisible(true); setCustom(true); }; | ||||
| 46 | + window.addEventListener("louka:openConsent", open); | ||||
| 47 | + return () => window.removeEventListener("louka:openConsent", open); | ||||
| 48 | + }, []); | ||||
| 49 | + | ||||
| 50 | + if (!visible) return null; | ||||
| 51 | + | ||||
| 52 | + const close = (p: boolean, s: boolean) => { save(p, s); setVisible(false); setCustom(false); }; | ||||
| 53 | + | ||||
| 54 | + return ( | ||||
| 55 | + <div className="cookie-banner" role="dialog" aria-modal="false" aria-label="Gestion des témoins"> | ||||
| 56 | + <div className="cookie-inner"> | ||||
| 57 | + <div className="cookie-text"> | ||||
| 58 | + <b>🍪 Vos témoins, vos choix.</b>{" "} | ||||
| 59 | + Lou-Ka n'utilise aucun traceur publicitaire ni témoin tiers. Le site | ||||
| 60 | + mémorise localement vos préférences (filtres, vue carte) et votre choix | ||||
| 61 | + de consentement. <Link to="/confidentialite">Politique de confidentialité</Link> | ||||
| 62 | + </div> | ||||
| 63 | + | ||||
| 64 | + {custom && ( | ||||
| 65 | + <div className="cookie-options"> | ||||
| 66 | + <label className="cookie-opt"> | ||||
| 67 | + <input type="checkbox" checked disabled /> | ||||
| 68 | + <span><b>Essentiels</b> — fonctionnement du site (toujours actifs)</span> | ||||
| 69 | + </label> | ||||
| 70 | + <label className="cookie-opt"> | ||||
| 71 | + <input type="checkbox" checked={prefs} onChange={(e) => setPrefs(e.target.checked)} /> | ||||
| 72 | + <span><b>Préférences</b> — retenir vos filtres et votre vue liste/carte</span> | ||||
| 73 | + </label> | ||||
| 74 | + <label className="cookie-opt"> | ||||
| 75 | + <input type="checkbox" checked={stats} onChange={(e) => setStats(e.target.checked)} /> | ||||
| 76 | + <span><b>Statistiques</b> — mesure d'audience anonyme, sans profilage</span> | ||||
| 77 | + </label> | ||||
| 78 | + </div> | ||||
| 79 | + )} | ||||
| 80 | + | ||||
| 81 | + <div className="cookie-actions"> | ||||
| 82 | + {!custom && ( | ||||
| 83 | + <button className="btn btn-ghost" onClick={() => setCustom(true)}> | ||||
| 84 | + Personnaliser | ||||
| 85 | + </button> | ||||
| 86 | + )} | ||||
| 87 | + <button className="btn btn-ghost" onClick={() => close(false, false)}> | ||||
| 88 | + Essentiels seulement | ||||
| 89 | + </button> | ||||
| 90 | + {custom ? ( | ||||
| 91 | + <button className="btn btn-primary" onClick={() => close(prefs, stats)}> | ||||
| 92 | + Enregistrer mes choix | ||||
| 93 | + </button> | ||||
| 94 | + ) : ( | ||||
| 95 | + <button className="btn btn-primary" onClick={() => close(true, true)}> | ||||
| 96 | + Tout accepter | ||||
| 97 | + </button> | ||||
| 98 | + )} | ||||
| 99 | + </div> | ||||
| 100 | + </div> | ||||
| 101 | + </div> | ||||
| 102 | + ); | ||||
| 103 | +} | ||||
modified
frontend/src/pages/Home.tsx
+4 −0
@@ -37,6 +37,10 @@ export default function Home() { | |||||
| 37 | // vue liste ou carte (mémorisée dans l'URL : /?view=carte) | 37 | // vue liste ou carte (mémorisée dans l'URL : /?view=carte) | ||
| 38 | const [view, setView] = useState<"liste" | "carte">( | 38 | const [view, setView] = useState<"liste" | "carte">( | ||
| 39 | params.get("view") === "carte" ? "carte" : "liste"); | 39 | params.get("view") === "carte" ? "carte" : "liste"); | ||
| 40 | + useEffect(() => { | ||||
| 41 | + // suivre l'URL quand on navigue via le menu (« Carte » -> /?view=carte) | ||||
| 42 | + setView(params.get("view") === "carte" ? "carte" : "liste"); | ||||
| 43 | + }, [params]); | ||||
| 40 | const filters: ListingFilters = useMemo( | 44 | const filters: ListingFilters = useMemo( | ||
| 41 | () => ({ q, city, source, price_max: priceMax, unit_type: unitType }), | 45 | () => ({ q, city, source, price_max: priceMax, unit_type: unitType }), | ||
| 42 | [q, city, source, priceMax, unitType]); | 46 | [q, city, source, priceMax, unitType]); | ||
added
frontend/src/pages/Privacy.tsx
+66 −0
@@ -0,0 +1,66 @@ | |||||
| 1 | +// ----------------------------------------------------------------------------- | ||||
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +// pages/Privacy.tsx : politique de confidentialité et de témoins (cookies) | ||||
| 5 | +// ----------------------------------------------------------------------------- | ||||
| 6 | + | ||||
| 7 | +export default function PrivacyPage() { | ||||
| 8 | + const reopen = () => window.dispatchEvent(new Event("louka:openConsent")); | ||||
| 9 | + | ||||
| 10 | + return ( | ||||
| 11 | + <div className="container legal"> | ||||
| 12 | + <span className="kicker">Confidentialité & témoins</span> | ||||
| 13 | + <h1>Votre vie privée, simplement.</h1> | ||||
| 14 | + | ||||
| 15 | + <section> | ||||
| 16 | + <h3>Ce que Lou-Ka collecte</h3> | ||||
| 17 | + <p> | ||||
| 18 | + Rien qui vous identifie. Lou-Ka n'a pas de compte utilisateur, pas de | ||||
| 19 | + formulaire d'inscription, pas de pixel publicitaire et ne vend aucune | ||||
| 20 | + donnée. Le site consulte des annonces publiques de gestionnaires | ||||
| 21 | + immobiliers et vous les présente — c'est tout. | ||||
| 22 | + </p> | ||||
| 23 | + </section> | ||||
| 24 | + | ||||
| 25 | + <section> | ||||
| 26 | + <h3>Témoins et stockage local</h3> | ||||
| 27 | + <p> | ||||
| 28 | + Lou-Ka n'utilise <b>aucun témoin (cookie) tiers</b>. Le seul stockage | ||||
| 29 | + se fait dans votre navigateur (« localStorage ») : | ||||
| 30 | + </p> | ||||
| 31 | + <ul> | ||||
| 32 | + <li><b>Essentiels</b> — votre choix de consentement lui-même.</li> | ||||
| 33 | + <li><b>Préférences</b> (si acceptées) — vos filtres de recherche et votre vue liste/carte.</li> | ||||
| 34 | + <li><b>Statistiques</b> (si acceptées) — une éventuelle mesure d'audience anonyme, | ||||
| 35 | + sans identifiant ni profilage. Aucune n'est active à ce jour.</li> | ||||
| 36 | + </ul> | ||||
| 37 | + <p> | ||||
| 38 | + <button className="btn btn-ghost" onClick={reopen}>Modifier mes choix de témoins</button> | ||||
| 39 | + </p> | ||||
| 40 | + </section> | ||||
| 41 | + | ||||
| 42 | + <section> | ||||
| 43 | + <h3>Services externes</h3> | ||||
| 44 | + <ul> | ||||
| 45 | + <li><b>Photos des annonces</b> — chargées depuis les serveurs des gestionnaires | ||||
| 46 | + immobiliers ; leur politique s'applique à ces requêtes.</li> | ||||
| 47 | + <li><b>Carte</b> — tuiles vectorielles d'OpenFreeMap (données © OpenStreetMap), | ||||
| 48 | + chargées uniquement si vous ouvrez la vue carte.</li> | ||||
| 49 | + <li><b>Commodités de proximité</b> — calculées côté serveur à partir | ||||
| 50 | + d'OpenStreetMap ; votre navigateur ne contacte pas ces services.</li> | ||||
| 51 | + </ul> | ||||
| 52 | + </section> | ||||
| 53 | + | ||||
| 54 | + <section> | ||||
| 55 | + <h3>Vos annonces, vos sources</h3> | ||||
| 56 | + <p> | ||||
| 57 | + Chaque fiche renvoie vers l'annonce originale du gestionnaire. Pour | ||||
| 58 | + faire retirer un contenu ou pour toute question :{" "} | ||||
| 59 | + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>. | ||||
| 60 | + </p> | ||||
| 61 | + </section> | ||||
| 62 | + | ||||
| 63 | + <div className="fine">Dernière mise à jour : août 2026.</div> | ||||
| 64 | + </div> | ||||
| 65 | + ); | ||||
| 66 | +} | ||||
modified
frontend/src/styles.css
+95 −0
@@ -586,3 +586,98 @@ img { display: block; } | |||||
| 586 | .poi-ico { width: 20px; text-align: center; flex: 0 0 auto; } | 586 | .poi-ico { width: 20px; text-align: center; flex: 0 0 auto; } | ||
| 587 | .poi-name { flex: 1; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | 587 | .poi-name { flex: 1; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||
| 588 | .poi-dist { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); font-weight: 600; flex: 0 0 auto; } | 588 | .poi-dist { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); font-weight: 600; flex: 0 0 auto; } | ||
| 589 | + | ||||
| 590 | +/* --- Menu déroulant mobile ------------------------------------------------- */ | ||||
| 591 | +.menu-btn { | ||||
| 592 | + display: none; position: relative; z-index: 60; | ||||
| 593 | + width: 44px; height: 44px; margin-left: auto; | ||||
| 594 | + border: 1.5px solid var(--ink); border-radius: var(--r-ctl); | ||||
| 595 | + background: var(--surface); cursor: pointer; padding: 0; | ||||
| 596 | + flex-direction: column; align-items: center; justify-content: center; gap: 5px; | ||||
| 597 | + box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15); | ||||
| 598 | + transition: box-shadow 0.15s ease, transform 0.15s ease; | ||||
| 599 | +} | ||||
| 600 | +.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(20,24,20,0.15); } | ||||
| 601 | +.menu-btn span { | ||||
| 602 | + display: block; width: 18px; height: 2px; background: var(--ink); | ||||
| 603 | + border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease; | ||||
| 604 | +} | ||||
| 605 | +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); } | ||||
| 606 | +.menu-btn.open span:nth-child(2) { opacity: 0; } | ||||
| 607 | +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } | ||||
| 608 | + | ||||
| 609 | +.mobile-menu { | ||||
| 610 | + display: none; position: absolute; left: 0; right: 0; top: 100%; | ||||
| 611 | + background: var(--paper); border-bottom: 2px solid var(--ink); | ||||
| 612 | + box-shadow: 0 18px 30px rgba(20, 24, 20, 0.18); | ||||
| 613 | + padding: 8px 16px calc(16px + env(safe-area-inset-bottom)); | ||||
| 614 | + max-height: 0; overflow: hidden; opacity: 0; | ||||
| 615 | + transition: max-height 0.3s ease, opacity 0.22s ease; | ||||
| 616 | +} | ||||
| 617 | +.mobile-menu.open { max-height: 480px; opacity: 1; } | ||||
| 618 | +.mm-link { | ||||
| 619 | + display: flex; align-items: center; gap: 12px; | ||||
| 620 | + padding: 15px 10px; min-height: 52px; | ||||
| 621 | + font-family: var(--font-display); font-weight: 700; font-size: 19px; | ||||
| 622 | + letter-spacing: -0.02em; border-bottom: 1.5px dashed var(--line); | ||||
| 623 | + opacity: 0; transform: translateY(-8px); | ||||
| 624 | + transition: opacity 0.25s ease, transform 0.25s ease; | ||||
| 625 | +} | ||||
| 626 | +.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); } | ||||
| 627 | +.mm-link.active { color: var(--green); } | ||||
| 628 | +.mm-link.active .mm-arrow { opacity: 1; } | ||||
| 629 | +.mm-ico { width: 26px; text-align: center; font-size: 17px; } | ||||
| 630 | +.mm-arrow { margin-left: auto; opacity: 0.25; transition: opacity 0.15s ease; } | ||||
| 631 | +.mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); } | ||||
| 632 | +.mm-foot { | ||||
| 633 | + padding: 12px 10px 4px; font-family: var(--font-mono); | ||||
| 634 | + font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; | ||||
| 635 | +} | ||||
| 636 | +.mm-backdrop { | ||||
| 637 | + position: fixed; inset: 0; z-index: 40; | ||||
| 638 | + background: rgba(20, 24, 20, 0.35); backdrop-filter: blur(2px); | ||||
| 639 | +} | ||||
| 640 | +@media (max-width: 760px) { | ||||
| 641 | + .menu-btn { display: flex; } | ||||
| 642 | + .nav { display: none; } | ||||
| 643 | + .mobile-menu { display: block; } | ||||
| 644 | +} | ||||
| 645 | + | ||||
| 646 | +/* --- Bandeau de consentement (témoins) ------------------------------------- */ | ||||
| 647 | +.cookie-banner { | ||||
| 648 | + position: fixed; left: 12px; right: 12px; | ||||
| 649 | + bottom: calc(12px + env(safe-area-inset-bottom)); z-index: 80; | ||||
| 650 | + animation: cookie-in 0.35s ease; | ||||
| 651 | +} | ||||
| 652 | +@keyframes cookie-in { from { transform: translateY(24px); opacity: 0; } } | ||||
| 653 | +.cookie-inner { | ||||
| 654 | + max-width: 720px; margin: 0 auto; background: var(--surface); | ||||
| 655 | + border: 2px solid var(--ink); border-radius: var(--r-card); | ||||
| 656 | + box-shadow: var(--shadow-off); padding: 16px 18px; | ||||
| 657 | +} | ||||
| 658 | +.cookie-text { font-size: 13.5px; color: var(--ink-2); line-height: 1.5; } | ||||
| 659 | +.cookie-text b { color: var(--ink); } | ||||
| 660 | +.cookie-text a { text-decoration: underline; text-underline-offset: 2px; } | ||||
| 661 | +.cookie-options { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; } | ||||
| 662 | +.cookie-opt { | ||||
| 663 | + display: flex; align-items: flex-start; gap: 10px; font-size: 13px; | ||||
| 664 | + color: var(--ink-2); cursor: pointer; | ||||
| 665 | +} | ||||
| 666 | +.cookie-opt input { margin-top: 2px; accent-color: var(--green); width: 16px; height: 16px; } | ||||
| 667 | +.cookie-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; justify-content: flex-end; } | ||||
| 668 | +@media (max-width: 640px) { | ||||
| 669 | + .cookie-actions { justify-content: stretch; } | ||||
| 670 | + .cookie-actions .btn { flex: 1; text-align: center; justify-content: center; } | ||||
| 671 | +} | ||||
| 672 | + | ||||
| 673 | +/* --- Page confidentialité + lien pied de page ------------------------------- */ | ||||
| 674 | +.legal { padding: 48px 0 70px; max-width: 760px; } | ||||
| 675 | +.legal h1 { font-size: clamp(30px, 5vw, 46px); margin: 12px 0 8px; letter-spacing: -0.03em; } | ||||
| 676 | +.legal section { margin-top: 26px; } | ||||
| 677 | +.legal h3 { font-size: 18px; margin-bottom: 8px; } | ||||
| 678 | +.legal p, .legal li { color: var(--ink-2); font-size: 14.5px; } | ||||
| 679 | +.legal ul { padding-left: 20px; display: flex; flex-direction: column; gap: 6px; } | ||||
| 680 | +.flink { | ||||
| 681 | + border: 0; background: none; padding: 0; cursor: pointer; | ||||
| 682 | + color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 2px; | ||||
| 683 | +} | ||||
| 589 | 684 | ||||