SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.9 KB · 192 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// App.tsx : layout global (header + ticker en direct + footer encre) et routage5// -----------------------------------------------------------------------------6import { useEffect, useState } from "react";7import { NavLink, Route, Routes, useLocation } from "react-router-dom";8import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";9import CookieConsent from "./components/CookieConsent";10import Home from "./pages/Home";11import ListingPage from "./pages/Listing";12import BotPage from "./pages/Bot";13import PasserellePage from "./pages/Passerelle";14import PrivacyPage from "./pages/Privacy";15import SourcesPage from "./pages/Sources";16import StatsPage from "./pages/Stats";1718function Ticker() {19  const [items, setItems] = useState<string[]>([]);2021  useEffect(() => {22    Promise.all([fetchStats(), fetchFacets(), fetchSources()])23      .then(([stats, facets, src]) => {24        registerSourceNames(src.sources);25        const parts: string[] = [26          `${stats.total} logements actifs`,27          `Québec ${stats.quebec ?? 0}`,28          `Lévis ${stats.levis ?? 0}`,29        ];30        if ((stats.montreal ?? 0) > 0) parts.push(`Grand Montréal ${stats.montreal}`);31        if (stats.avg_price != null)32          parts.push(`Loyer moyen ${Math.round(stats.avg_price)} $`);33        for (const s of facets.sources.slice(0, 10))34          parts.push(`${sourceName(s.source)} · ${s.n}`);35        parts.push("Mise à jour automatique");36        setItems(parts);37      })38      .catch(() => setItems(["Lou-Ka — logements à louer au Québec"]));39  }, []);4041  if (items.length === 0) return null;42  // contenu doublé pour une boucle de défilement continue43  return (44    <div className="ticker" aria-hidden="true">45      <div className="ticker-track">46        {[...items, ...items].map((t, i) => (47          <span key={i}>{t}</span>48        ))}49      </div>50    </div>51  );52}5354const NAV_LINKS = [55  { to: "/", label: "Logements", icon: "🏠", end: true },56  { to: "/?view=carte", label: "Carte", icon: "◈", end: false, force: true },57  { to: "/stats", label: "Stats", icon: "📊", end: false },58  { to: "/sources", label: "Sources", icon: "🗂", end: false },59  { to: "/confidentialite", label: "Confidentialité", icon: "🔒", end: false },60];6162function Header() {63  const [open, setOpen] = useState(false);64  const location = useLocation();6566  // fermer le menu à chaque navigation + verrouiller le défilement en dessous67  useEffect(() => { setOpen(false); }, [location]);68  useEffect(() => {69    document.body.style.overflow = open ? "hidden" : "";70    return () => { document.body.style.overflow = ""; };71  }, [open]);7273  return (74    <>75      <header className="header">76        <div className="container header-inner">77          <NavLink to="/" className="brand" aria-label="Lou-Ka — accueil">78            Lou<span className="ka">Ka</span>79            <span className="brand-tag">Québec · Lévis · Montréal — toujours à jour</span>80          </NavLink>81          <nav className="nav" aria-label="Navigation principale">82            <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>83              Logements84            </NavLink>85            <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>86              Stats87            </NavLink>88            <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>89              Sources90            </NavLink>91          </nav>92          <button93            className={`menu-btn ${open ? "open" : ""}`}94            aria-expanded={open}95            aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}96            onClick={() => setOpen(!open)}97          >98            <span /><span /><span />99          </button>100        </div>101102        {/* menu déroulant mobile */}103        <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">104          {NAV_LINKS.map((l, i) => (105            <NavLink106              key={l.to}107              to={l.to}108              end={l.end}109              style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}110              className={({ isActive }) =>111                `mm-link ${isActive && !l.force ? "active" : ""}`}112              onClick={() => setOpen(false)}113            >114              <span className="mm-ico" aria-hidden="true">{l.icon}</span>115              {l.label}116              <span className="mm-arrow" aria-hidden="true">→</span>117            </NavLink>118          ))}119          <div className="mm-foot">120            Agrégateur indépendant — mis à jour automatiquement, chaque fiche121            renvoie à l'annonce originale.122          </div>123        </div>124      </header>125      {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}126      <Ticker />127    </>128  );129}130131function Footer() {132  return (133    <footer className="footer">134      <div className="container">135        <div className="fbrand">136          Lou<span className="ka">Ka</span>137        </div>138        <div className="frow">139          <div>140            <b>Agrégateur indépendant</b> de logements à louer dans la province de Québec.141            Les annonces proviennent des sites publics des gestionnaires immobiliers et sont142            rafraîchies automatiquement — chaque fiche renvoie vers l'annonce originale.143          </div>144        </div>145        <div className="fmono">146          © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai147          {" · "}148          <NavLink to="/confidentialite">Confidentialité</NavLink>149          {" · "}150          <button151            className="flink"152            onClick={() => window.dispatchEvent(new Event("louka:openConsent"))}153          >154            Gérer mes témoins155          </button>156        </div>157      </div>158    </footer>159  );160}161162export default function App() {163  return (164    <>165      <Header />166      <main>167        <Routes>168          <Route path="/" element={<Home />} />169          <Route path="/logement/:uid" element={<ListingPage />} />170          <Route path="/stats" element={<StatsPage />} />171          <Route path="/sources" element={<SourcesPage />} />172          <Route path="/confidentialite" element={<PrivacyPage />} />173          <Route path="/passerelle/:uid" element={<PasserellePage />} />174          <Route path="/bot" element={<BotPage />} />175          <Route176            path="*"177            element={178              <div className="notice container">179                <div className="big">🧭</div>180                <h2>Page introuvable</h2>181                <p>Le lien demandé n'existe pas.</p>182              </div>183            }184          />185        </Routes>186      </main>187      <Footer />188      <CookieConsent />189    </>190  );191}192