SPB Git

spb/lou-ka

Public

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

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>
simon-pierre boucher committed (Aug 9, 2026) parent a466567 Browse files

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é &amp; 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
Menu

Theme