SPB Git

spb/resto-ka

Public

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

10.4 KB · 288 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, IcoPlate, IcoSearch,17} from "./components/Icons";18import { LogoIcon } from "./components/Logo";19import GroupeKaBadge from "./ka/GroupeKaBadge";20import KaFooter from "./ka/KaFooter";21import ContactPage from "./pages/Contact";22import FavorisPage from "./pages/Favoris";23import Home from "./pages/Home";24import RestoPage from "./pages/Resto";25import SourcesPage from "./pages/Sources";26import StatsPage from "./pages/Stats";2728/** Bouton « Se connecter avec KA » ou avatar + menu compte (hub Groupe KA). */29function AccountMenu() {30  const [enabled, setEnabled] = useState(false);31  const { me, refresh } = useAccount();32  const [menuOpen, setMenuOpen] = useState(false);3334  useEffect(() => {35    fetchAuthConfig().then((c) => setEnabled(c.ka)).catch(() => {});36  }, []);3738  // menu ouvert : fermeture à Escape (le tap extérieur passe par le backdrop)39  useEffect(() => {40    if (!menuOpen) return;41    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuOpen(false); };42    window.addEventListener("keydown", onKey);43    return () => window.removeEventListener("keydown", onKey);44  }, [menuOpen]);4546  if (!enabled) return null;47  if (!me) {48    return (49      <a className="login-btn" href="/api/auth/ka/login">50        <span className="ka-badge" aria-hidden="true">KA</span>51        Connexion52      </a>53    );54  }55  return (56    <div className="account">57      <button58        className="account-btn"59        onClick={() => setMenuOpen(!menuOpen)}60        aria-expanded={menuOpen}61        aria-label={`Compte : ${me.name || me.email}`}62      >63        {me.picture64          ? <img src={me.picture} alt="" referrerPolicy="no-referrer" />65          : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>}66      </button>67      {/* Backdrop + menu rendus à la racine via createPortal : le68          backdrop-filter du header re-parentait le position:fixed (voile de69          66 px) et le stacking context du header (z-50) plaquait le menu SOUS70          la tabbar (85) et le fab (80). Le menu devient fixed, ancré sous le71          header (voir .account-menu dans styles.css). */}72      {menuOpen && createPortal(73        <>74          <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" />75          <div className="account-menu" role="menu">76            <div className="account-id">77              <b>{me.name || "Mon compte"}</b>78              <span>{me.email}</span>79              {me.ka_id && <span className="account-kaid">{me.ka_id}</span>}80            </div>81            <NavLink to="/favoris" role="menuitem" className="account-link"82              onClick={() => setMenuOpen(false)}>83              Mes favoris84            </NavLink>85            <a href="https://www.groupe-ka.com/compte" target="_blank"86              rel="noopener noreferrer" role="menuitem" className="account-link">87              Mon profil Groupe KA ↗88            </a>89            <button90              role="menuitem"91              onClick={async () => { await logout(); refresh(); setMenuOpen(false); }}92            >93              Se déconnecter94            </button>95          </div>96        </>,97        document.body,98      )}99    </div>100  );101}102103function Ticker() {104  const [items, setItems] = useState<string[]>([]);105106  useEffect(() => {107    Promise.all([fetchStats(), fetchFacets(), fetchSources()])108      .then(([stats, facets, src]) => {109        registerSourceNames(src.sources);110        const parts: string[] = [111          `${stats.restaurants} restaurants`,112          `${stats.items} plats avec prix`,113          `${stats.regions} région${stats.regions > 1 ? "s" : ""} couvertes`,114        ];115        for (const r of stats.by_region.slice(0, 6))116          parts.push(`${r.region} · ${r.n}`);117        for (const s of facets.sources.slice(0, 6))118          parts.push(`${sourceName(s.source)} · ${s.n}`);119        parts.push("Prix réels étiquetés par contexte");120        parts.push("Mise à jour automatique");121        setItems(parts);122      })123      .catch(() => setItems(["Resto-Ka — les restaurants du Québec, menus et prix"]));124  }, []);125126  if (items.length === 0) return null;127  // contenu doublé pour une boucle de défilement continue128  return (129    <div className="ticker" aria-hidden="true">130      <div className="ticker-track">131        {[...items, ...items].map((t, i) => (132          <span key={i}>{t}</span>133        ))}134      </div>135    </div>136  );137}138139const NAV_LINKS = [140  { to: "/", label: "Restos", icon: <IcoPlate size={17} />, end: true },141  { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },142  { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },143  { to: "/contact", label: "Contact", icon: <IcoCompass size={17} />, end: false },144];145146function Header() {147  const [open, setOpen] = useState(false);148  const location = useLocation();149150  // fermer le menu à chaque navigation + verrouiller le défilement en dessous151  useEffect(() => { setOpen(false); }, [location]);152  useEffect(() => {153    document.body.style.overflow = open ? "hidden" : "";154    return () => { document.body.style.overflow = ""; };155  }, [open]);156157  return (158    <>159      <header className="header">160        <div className="container header-inner">161          <NavLink to="/" className="brand" aria-label="Resto-Ka — accueil">162            <LogoIcon size={30} />163            Resto-<span className="ka">Ka</span>164            <span className="brand-tag">Chaque resto, chaque plat, chaque prix.</span>165          </NavLink>166          <span className="hdr-badge"><GroupeKaBadge /></span>167          <nav className="nav" aria-label="Navigation principale">168            {NAV_LINKS.map((l) => (169              <NavLink key={l.to} to={l.to} end={l.end}170                className={({ isActive }) => (isActive ? "active" : "")}>171                {l.label}172              </NavLink>173            ))}174          </nav>175          <AccountMenu />176          <button177            className={`menu-btn ${open ? "open" : ""}`}178            aria-expanded={open}179            aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}180            onClick={() => setOpen(!open)}181          >182            <span /><span /><span />183          </button>184        </div>185186        {/* menu déroulant mobile */}187        <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">188          {NAV_LINKS.map((l, i) => (189            <NavLink190              key={l.to}191              to={l.to}192              end={l.end}193              style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}194              className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`}195              onClick={() => setOpen(false)}196            >197              <span className="mm-ico" aria-hidden="true">{l.icon}</span>198              {l.label}199              <span className="mm-arrow" aria-hidden="true">→</span>200            </NavLink>201          ))}202          <div className="mm-badge"><GroupeKaBadge /></div>203          <div className="mm-foot">204            Agrégateur indépendant — menus et prix captés aux sources, chaque205            fiche renvoie au resto original et affiche le contexte du prix.206          </div>207        </div>208      </header>209      {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}210      <Ticker />211    </>212  );213}214215/** Rangée de nav locale + footer commun Groupe KA (ka-ui/KaFooter). */216function Footer() {217  return (218    <>219      <div className="prefoot">220        <div className="container prefoot-inner">221          <nav className="prefoot-nav" aria-label="Navigation du pied de page">222            {NAV_LINKS.map((l) => (223              <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink>224            ))}225            <NavLink to="/favoris">Favoris</NavLink>226          </nav>227          <div className="prefoot-osm">228            Données de découverte ©{" "}229            <a href="https://www.openstreetmap.org/copyright" target="_blank"230              rel="noopener noreferrer">contributeurs OpenStreetMap</a> (ODbL)231          </div>232        </div>233      </div>234      <KaFooter siteId="resto-ka" />235    </>236  );237}238239/** Barre de navigation inférieure (mobile) — icône active en orange. */240function MobileTabBar() {241  const location = useLocation();242  const tabs = [243    { to: "/", label: "Rechercher", icon: <IcoSearch size={20} />, on: location.pathname === "/" },244    { to: "/stats", label: "Stats", icon: <IcoChart size={20} />, on: location.pathname.startsWith("/stats") },245    { to: "/sources", label: "Sources", icon: <IcoFolder size={20} />, on: location.pathname.startsWith("/sources") },246  ];247  return (248    <nav className="tabbar tabbar-3" aria-label="Navigation mobile">249      {tabs.map((t) => (250        <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>251          {t.icon}252          {t.label}253        </NavLink>254      ))}255    </nav>256  );257}258259export default function App() {260  return (261    <AccountProvider>262      <Header />263      <main>264        <Routes>265          <Route path="/" element={<Home />} />266          <Route path="/resto/:uid" element={<RestoPage />} />267          <Route path="/favoris" element={<FavorisPage />} />268          <Route path="/stats" element={<StatsPage />} />269          <Route path="/sources" element={<SourcesPage />} />270          <Route path="/contact" element={<ContactPage />} />271          <Route272            path="*"273            element={274              <div className="notice container">275                <div className="big"><IcoCompass size={40} /></div>276                <h2>Page introuvable</h2>277                <p>Le lien demandé n'existe pas.</p>278              </div>279            }280          />281        </Routes>282      </main>283      <Footer />284      <MobileTabBar />285    </AccountProvider>286  );287}288
Files · e7c6ecee907d0a07cf3bfd92174bbb290100b78d
Menu

Theme