SPB Git

spb/resto-ka

Public

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

10.6 KB · 292 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";27import VillePage, { VillesPage } from "./pages/Ville";2829/** Bouton « Se connecter avec KA » ou avatar + menu compte (hub Groupe KA). */30function AccountMenu() {31  const [enabled, setEnabled] = useState(false);32  const { me, refresh } = useAccount();33  const [menuOpen, setMenuOpen] = useState(false);3435  useEffect(() => {36    fetchAuthConfig().then((c) => setEnabled(c.ka)).catch(() => {});37  }, []);3839  // menu ouvert : fermeture à Escape (le tap extérieur passe par le backdrop)40  useEffect(() => {41    if (!menuOpen) return;42    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuOpen(false); };43    window.addEventListener("keydown", onKey);44    return () => window.removeEventListener("keydown", onKey);45  }, [menuOpen]);4647  if (!enabled) return null;48  if (!me) {49    return (50      <a className="login-btn" href="/api/auth/ka/login">51        <span className="ka-badge" aria-hidden="true">KA</span>52        Connexion53      </a>54    );55  }56  return (57    <div className="account">58      <button59        className="account-btn"60        onClick={() => setMenuOpen(!menuOpen)}61        aria-expanded={menuOpen}62        aria-label={`Compte : ${me.name || me.email}`}63      >64        {me.picture65          ? <img src={me.picture} alt="" referrerPolicy="no-referrer" />66          : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>}67      </button>68      {/* Backdrop + menu rendus à la racine via createPortal : le69          backdrop-filter du header re-parentait le position:fixed (voile de70          66 px) et le stacking context du header (z-50) plaquait le menu SOUS71          la tabbar (85) et le fab (80). Le menu devient fixed, ancré sous le72          header (voir .account-menu dans styles.css). */}73      {menuOpen && createPortal(74        <>75          <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" />76          <div className="account-menu" role="menu">77            <div className="account-id">78              <b>{me.name || "Mon compte"}</b>79              <span>{me.email}</span>80              {me.ka_id && <span className="account-kaid">{me.ka_id}</span>}81            </div>82            <NavLink to="/favoris" role="menuitem" className="account-link"83              onClick={() => setMenuOpen(false)}>84              Mes favoris85            </NavLink>86            <a href="https://www.groupe-ka.com/compte" target="_blank"87              rel="noopener noreferrer" role="menuitem" className="account-link">88              Mon profil Groupe KA ↗89            </a>90            <button91              role="menuitem"92              onClick={async () => { await logout(); refresh(); setMenuOpen(false); }}93            >94              Se déconnecter95            </button>96          </div>97        </>,98        document.body,99      )}100    </div>101  );102}103104function Ticker() {105  const [items, setItems] = useState<string[]>([]);106107  useEffect(() => {108    Promise.all([fetchStats(), fetchFacets(), fetchSources()])109      .then(([stats, facets, src]) => {110        registerSourceNames(src.sources);111        const parts: string[] = [112          `${stats.restaurants} restaurants`,113          `${stats.items} plats avec prix`,114          `${stats.regions} région${stats.regions > 1 ? "s" : ""} couvertes`,115        ];116        for (const r of stats.by_region.slice(0, 6))117          parts.push(`${r.region} · ${r.n}`);118        for (const s of facets.sources.slice(0, 6))119          parts.push(`${sourceName(s.source)} · ${s.n}`);120        parts.push("Prix réels étiquetés par contexte");121        parts.push("Mise à jour automatique");122        setItems(parts);123      })124      .catch(() => setItems(["Resto-Ka — les restaurants du Québec, menus et prix"]));125  }, []);126127  if (items.length === 0) return null;128  // contenu doublé pour une boucle de défilement continue129  return (130    <div className="ticker" aria-hidden="true">131      <div className="ticker-track">132        {[...items, ...items].map((t, i) => (133          <span key={i}>{t}</span>134        ))}135      </div>136    </div>137  );138}139140const NAV_LINKS = [141  { to: "/", label: "Restos", icon: <IcoPlate size={17} />, end: true },142  { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },143  { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },144  { to: "/contact", label: "Contact", icon: <IcoCompass size={17} />, end: false },145];146147function Header() {148  const [open, setOpen] = useState(false);149  const location = useLocation();150151  // fermer le menu à chaque navigation + verrouiller le défilement en dessous152  useEffect(() => { setOpen(false); }, [location]);153  useEffect(() => {154    document.documentElement.classList.toggle("ka-scroll-lock", open);155    return () => { document.documentElement.classList.remove("ka-scroll-lock"); };156  }, [open]);157158  return (159    <>160      <header className="header">161        <div className="container header-inner">162          <NavLink to="/" className="brand" aria-label="Resto-Ka — accueil">163            <LogoIcon size={30} />164            Resto-<span className="ka">Ka</span>165            <span className="brand-tag">Chaque resto, chaque plat, chaque prix.</span>166          </NavLink>167          <span className="hdr-badge"><GroupeKaBadge /></span>168          <nav className="nav" aria-label="Navigation principale">169            {NAV_LINKS.map((l) => (170              <NavLink key={l.to} to={l.to} end={l.end}171                className={({ isActive }) => (isActive ? "active" : "")}>172                {l.label}173              </NavLink>174            ))}175          </nav>176          <AccountMenu />177          <button178            className={`menu-btn ${open ? "open" : ""}`}179            aria-expanded={open}180            aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}181            onClick={() => setOpen(!open)}182          >183            <span /><span /><span />184          </button>185        </div>186187        {/* menu déroulant mobile */}188        <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">189          {NAV_LINKS.map((l, i) => (190            <NavLink191              key={l.to}192              to={l.to}193              end={l.end}194              style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}195              className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`}196              onClick={() => setOpen(false)}197            >198              <span className="mm-ico" aria-hidden="true">{l.icon}</span>199              {l.label}200              <span className="mm-arrow" aria-hidden="true">→</span>201            </NavLink>202          ))}203          <div className="mm-badge"><GroupeKaBadge /></div>204          <div className="mm-foot">205            Agrégateur indépendant — menus et prix captés aux sources, chaque206            fiche renvoie au resto original et affiche le contexte du prix.207          </div>208        </div>209      </header>210      {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}211      <Ticker />212    </>213  );214}215216/** Rangée de nav locale + footer commun Groupe KA (ka-ui/KaFooter). */217function Footer() {218  return (219    <>220      <div className="prefoot">221        <div className="container prefoot-inner">222          <nav className="prefoot-nav" aria-label="Navigation du pied de page">223            {NAV_LINKS.map((l) => (224              <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink>225            ))}226            <NavLink to="/villes">Villes</NavLink>227            <NavLink to="/favoris">Favoris</NavLink>228          </nav>229          <div className="prefoot-osm">230            Données de découverte ©{" "}231            <a href="https://www.openstreetmap.org/copyright" target="_blank"232              rel="noopener noreferrer">contributeurs OpenStreetMap</a> (ODbL)233          </div>234        </div>235      </div>236      <KaFooter siteId="resto-ka" />237    </>238  );239}240241/** Barre de navigation inférieure (mobile) — icône active en orange. */242function MobileTabBar() {243  const location = useLocation();244  const tabs = [245    { to: "/", label: "Rechercher", icon: <IcoSearch size={20} />, on: location.pathname === "/" },246    { to: "/stats", label: "Stats", icon: <IcoChart size={20} />, on: location.pathname.startsWith("/stats") },247    { to: "/sources", label: "Sources", icon: <IcoFolder size={20} />, on: location.pathname.startsWith("/sources") },248  ];249  return (250    <nav className="tabbar tabbar-3" aria-label="Navigation mobile">251      {tabs.map((t) => (252        <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>253          {t.icon}254          {t.label}255        </NavLink>256      ))}257    </nav>258  );259}260261export default function App() {262  return (263    <AccountProvider>264      <Header />265      <main>266        <Routes>267          <Route path="/" element={<Home />} />268          <Route path="/resto/:uid" element={<RestoPage />} />269          <Route path="/villes" element={<VillesPage />} />270          <Route path="/ville/:ville" element={<VillePage />} />271          <Route path="/favoris" element={<FavorisPage />} />272          <Route path="/stats" element={<StatsPage />} />273          <Route path="/sources" element={<SourcesPage />} />274          <Route path="/contact" element={<ContactPage />} />275          <Route276            path="*"277            element={278              <div className="notice container">279                <div className="big"><IcoCompass size={40} /></div>280                <h2>Page introuvable</h2>281                <p>Le lien demandé n'existe pas.</p>282              </div>283            }284          />285        </Routes>286      </main>287      <Footer />288      <MobileTabBar />289    </AccountProvider>290  );291}292
Files · 23651d08b107f9396ac6893d7f9441466b8c2768
Menu

Theme