SPB Git

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

11.1 KB · 298 lines tsx
1// ==============================================================================2// Author: Simon-Pierre Boucher <contact@spboucher.ai>3// File:   App.tsx4// Desc:   Layout global (header + ticker en direct + footer encre) et routage —5//         même patron que le App.tsx de Lou·Ka.6// ==============================================================================7import { useEffect, useState } from "react";8import { createPortal } from "react-dom";9import { NavLink, Route, Routes, useLocation } from "react-router-dom";10import { AccountProvider, useAccount } from "./account";11import {12  fetchAuthConfig, fetchFacets, fetchSources, fetchStats, logout,13  registerSourceNames, sourceName,14} from "./api";15import {16  IcoChart, IcoCompass, IcoFolder, IcoHeart, IcoPlate, IcoSearch,17} from "./components/Icons";18import { FavProvider } from "./favorites";19import { LogoIcon } from "./components/Logo";20import GroupeKaBadge from "./ka/GroupeKaBadge";21import KaFooter from "./ka/KaFooter";22import ContactPage from "./pages/Contact";23import FavorisPage from "./pages/Favoris";24import Home from "./pages/Home";25import RestoPage from "./pages/Resto";26import SourcesPage from "./pages/Sources";27import StatsPage from "./pages/Stats";28import VillePage, { VillesPage } from "./pages/Ville";2930/** Bouton « Se connecter avec KA » ou avatar + menu compte (hub Groupe KA). */31function AccountMenu() {32  const [enabled, setEnabled] = useState(false);33  const { me, refresh } = useAccount();34  const [menuOpen, setMenuOpen] = useState(false);3536  useEffect(() => {37    fetchAuthConfig().then((c) => setEnabled(c.ka)).catch(() => {});38  }, []);3940  // menu ouvert : fermeture à Escape (le tap extérieur passe par le backdrop)41  useEffect(() => {42    if (!menuOpen) return;43    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuOpen(false); };44    window.addEventListener("keydown", onKey);45    return () => window.removeEventListener("keydown", onKey);46  }, [menuOpen]);4748  if (!enabled) return null;49  if (!me) {50    return (51      <a className="login-btn" href="/api/auth/ka/login">52        <span className="ka-badge" aria-hidden="true">KA</span>53        Connexion54      </a>55    );56  }57  return (58    <div className="account">59      <button60        className="account-btn"61        onClick={() => setMenuOpen(!menuOpen)}62        aria-expanded={menuOpen}63        aria-label={`Compte : ${me.name || me.email}`}64      >65        {me.picture66          ? <img src={me.picture} alt="" referrerPolicy="no-referrer" />67          : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>}68      </button>69      {/* Backdrop + menu rendus à la racine via createPortal : le70          backdrop-filter du header re-parentait le position:fixed (voile de71          66 px) et le stacking context du header (z-50) plaquait le menu SOUS72          la tabbar (85) et le fab (80). Le menu devient fixed, ancré sous le73          header (voir .account-menu dans styles.css). */}74      {menuOpen && createPortal(75        <>76          <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" />77          <div className="account-menu" role="menu">78            <div className="account-id">79              <b>{me.name || "Mon compte"}</b>80              <span>{me.email}</span>81              {me.ka_id && <span className="account-kaid">{me.ka_id}</span>}82            </div>83            <NavLink to="/favoris" role="menuitem" className="account-link"84              onClick={() => setMenuOpen(false)}>85              Mes favoris86            </NavLink>87            <a href="https://www.groupe-ka.com/compte" target="_blank"88              rel="noopener noreferrer" role="menuitem" className="account-link">89              Mon profil Groupe KA ↗90            </a>91            <button92              role="menuitem"93              onClick={async () => { await logout(); refresh(); setMenuOpen(false); }}94            >95              Se déconnecter96            </button>97          </div>98        </>,99        document.body,100      )}101    </div>102  );103}104105function Ticker() {106  const [items, setItems] = useState<string[]>([]);107108  useEffect(() => {109    Promise.all([fetchStats(), fetchFacets(), fetchSources()])110      .then(([stats, facets, src]) => {111        registerSourceNames(src.sources);112        const parts: string[] = [113          `${stats.restaurants} restaurants`,114          `${stats.items} plats avec prix`,115          `${stats.regions} région${stats.regions > 1 ? "s" : ""} couvertes`,116        ];117        for (const r of stats.by_region.slice(0, 6))118          parts.push(`${r.region} · ${r.n}`);119        for (const s of facets.sources.slice(0, 6))120          parts.push(`${sourceName(s.source)} · ${s.n}`);121        parts.push("Prix réels étiquetés par contexte");122        parts.push("Mise à jour automatique");123        setItems(parts);124      })125      .catch(() => setItems(["Resto-Ka — les restaurants du Québec, menus et prix"]));126  }, []);127128  if (items.length === 0) return null;129  // contenu doublé pour une boucle de défilement continue130  return (131    <div className="ticker" aria-hidden="true">132      <div className="ticker-track">133        {[...items, ...items].map((t, i) => (134          <span key={i}>{t}</span>135        ))}136      </div>137    </div>138  );139}140141const NAV_LINKS = [142  { to: "/", label: "Restos", icon: <IcoPlate size={17} />, end: true },143  { to: "/favoris", label: "Favoris", icon: <IcoHeart size={17} />, end: false },144  { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },145  { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },146  { to: "/contact", label: "Contact", icon: <IcoCompass size={17} />, end: false },147];148149function Header() {150  const [open, setOpen] = useState(false);151  const location = useLocation();152153  // fermer le menu à chaque navigation + verrouiller le défilement en dessous154  useEffect(() => { setOpen(false); }, [location]);155  useEffect(() => {156    document.documentElement.classList.toggle("ka-scroll-lock", open);157    return () => { document.documentElement.classList.remove("ka-scroll-lock"); };158  }, [open]);159160  return (161    <>162      <header className="header">163        <div className="container header-inner">164          <NavLink to="/" className="brand" aria-label="Resto-Ka — accueil">165            <LogoIcon size={30} />166            Resto-<span className="ka">Ka</span>167            <span className="brand-tag">Chaque resto, chaque plat, chaque prix.</span>168          </NavLink>169          <span className="hdr-badge"><GroupeKaBadge /></span>170          <nav className="nav" aria-label="Navigation principale">171            {NAV_LINKS.map((l) => (172              <NavLink key={l.to} to={l.to} end={l.end}173                className={({ isActive }) => (isActive ? "active" : "")}>174                {l.label}175              </NavLink>176            ))}177          </nav>178          <AccountMenu />179          <button180            className={`menu-btn ${open ? "open" : ""}`}181            aria-expanded={open}182            aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}183            onClick={() => setOpen(!open)}184          >185            <span /><span /><span />186          </button>187        </div>188189        {/* menu déroulant mobile */}190        <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">191          {/* ✕ fixe du panneau : visible peu importe le scroll, le burger du header est masqué à l'ouverture */}192          <button type="button" className="mm-close" aria-label="Fermer le menu"193                  onClick={() => setOpen(false)}>✕</button>194          {NAV_LINKS.map((l, i) => (195            <NavLink196              key={l.to}197              to={l.to}198              end={l.end}199              style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}200              className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`}201              onClick={() => setOpen(false)}202            >203              <span className="mm-ico" aria-hidden="true">{l.icon}</span>204              {l.label}205              <span className="mm-arrow" aria-hidden="true">→</span>206            </NavLink>207          ))}208          <div className="mm-badge"><GroupeKaBadge /></div>209          <div className="mm-foot">210            Agrégateur indépendant — menus et prix captés aux sources, chaque211            fiche renvoie au resto original et affiche le contexte du prix.212          </div>213        </div>214      </header>215      {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}216      <Ticker />217    </>218  );219}220221/** Rangée de nav locale + footer commun Groupe KA (ka-ui/KaFooter). */222function Footer() {223  return (224    <>225      <div className="prefoot">226        <div className="container prefoot-inner">227          <nav className="prefoot-nav" aria-label="Navigation du pied de page">228            {NAV_LINKS.map((l) => (229              <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink>230            ))}231            <NavLink to="/villes">Villes</NavLink>232          </nav>233          <div className="prefoot-osm">234            Données de découverte ©{" "}235            <a href="https://www.openstreetmap.org/copyright" target="_blank"236              rel="noopener noreferrer">contributeurs OpenStreetMap</a> (ODbL)237          </div>238        </div>239      </div>240      <KaFooter siteId="resto-ka" localLegal={[{ label: "Documentation", href: "/doc/" }]} />241    </>242  );243}244245/** Barre de navigation inférieure (mobile) — icône active en orange. */246function MobileTabBar() {247  const location = useLocation();248  const tabs = [249    { to: "/", label: "Rechercher", icon: <IcoSearch size={20} />, on: location.pathname === "/" },250    { to: "/stats", label: "Stats", icon: <IcoChart size={20} />, on: location.pathname.startsWith("/stats") },251    { to: "/sources", label: "Sources", icon: <IcoFolder size={20} />, on: location.pathname.startsWith("/sources") },252  ];253  return (254    <nav className="tabbar tabbar-3" aria-label="Navigation mobile">255      {tabs.map((t) => (256        <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>257          {t.icon}258          {t.label}259        </NavLink>260      ))}261    </nav>262  );263}264265export default function App() {266  return (267    <AccountProvider>268      <FavProvider>269      <Header />270      <main>271        <Routes>272          <Route path="/" element={<Home />} />273          <Route path="/resto/:uid" element={<RestoPage />} />274          <Route path="/villes" element={<VillesPage />} />275          <Route path="/ville/:ville" element={<VillePage />} />276          <Route path="/favoris" element={<FavorisPage />} />277          <Route path="/stats" element={<StatsPage />} />278          <Route path="/sources" element={<SourcesPage />} />279          <Route path="/contact" element={<ContactPage />} />280          <Route281            path="*"282            element={283              <div className="notice container">284                <div className="big"><IcoCompass size={40} /></div>285                <h2>Page introuvable</h2>286                <p>Le lien demandé n'existe pas.</p>287              </div>288            }289          />290        </Routes>291      </main>292      <Footer />293      <MobileTabBar />294      </FavProvider>295    </AccountProvider>296  );297}298
Files · d08ebf165cf94ee009b54bfe4a5fc40fdd0dae8e
Menu

Theme