SPB Git

spb/lou-ka Public

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

HTML 99.7%

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 2 days ago (Aug 9, 2026) parent a466567

Showing 5 changed files with +333 and −2

modified frontend/src/App.tsx +65 −2
@@ -4,10 +4,12 @@
4 4 // App.tsx : layout global (header + ticker en direct + footer encre) et routage
5 5 // -----------------------------------------------------------------------------
6 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 8 import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
9 +import CookieConsent from "./components/CookieConsent";
9 10 import Home from "./pages/Home";
10 11 import ListingPage from "./pages/Listing";
12 +import PrivacyPage from "./pages/Privacy";
11 13 import SourcesPage from "./pages/Sources";
12 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 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 71 return (
52 72 <>
53 73 <header className="header">
@@ -56,7 +76,7 @@ function Header() {
56 76 Lou<span className="ka">Ka</span>
57 77 <span className="brand-tag">Québec · Lévis · Montréal — toujours à jour</span>
58 78 </NavLink>
59 <nav className="nav">
79 + <nav className="nav" aria-label="Navigation principale">
60 80 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
61 81 Logements
62 82 </NavLink>
@@ -67,8 +87,40 @@ function Header() {
67 87 Sources
68 88 </NavLink>
69 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 121 </div>
71 122 </header>
123 + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
72 124 <Ticker />
73 125 </>
74 126 );
@@ -90,6 +142,15 @@ function Footer() {
90 142 </div>
91 143 <div className="fmono">
92 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 154 </div>
94 155 </div>
95 156 </footer>
@@ -106,6 +167,7 @@ export default function App() {
106 167 <Route path="/logement/:uid" element={<ListingPage />} />
107 168 <Route path="/stats" element={<StatsPage />} />
108 169 <Route path="/sources" element={<SourcesPage />} />
170 + <Route path="/confidentialite" element={<PrivacyPage />} />
109 171 <Route
110 172 path="*"
111 173 element={
@@ -119,6 +181,7 @@ export default function App() {
119 181 </Routes>
120 182 </main>
121 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 37 // vue liste ou carte (mémorisée dans l'URL : /?view=carte)
38 38 const [view, setView] = useState<"liste" | "carte">(
39 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 44 const filters: ListingFilters = useMemo(
41 45 () => ({ q, city, source, price_max: priceMax, unit_type: unitType }),
42 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 586 .poi-ico { width: 20px; text-align: center; flex: 0 0 auto; }
587 587 .poi-name { flex: 1; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
588 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