SPB Git forge

spb/resto-ka

Public

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

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
11.4 KB · 308 lines tsx
Raw Blame History
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, useNavigate } 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 KaTabbar from "./ka/KaTabbar";23import ContactPage from "./pages/Contact";24import FavorisPage from "./pages/Favoris";25import Home from "./pages/Home";26import RestoPage from "./pages/Resto";27import SourcesPage from "./pages/Sources";28import StatsPage from "./pages/Stats";29import VillePage, { VillesPage } from "./pages/Ville";3031/** Bouton « Se connecter avec KA » ou avatar + menu compte (hub Groupe KA). */32function AccountMenu() {33  const [enabled, setEnabled] = useState(false);34  const { me, refresh } = useAccount();35  const [menuOpen, setMenuOpen] = useState(false);3637  useEffect(() => {38    fetchAuthConfig().then((c) => setEnabled(c.ka)).catch(() => {});39  }, []);4041  // menu ouvert : fermeture à Escape (le tap extérieur passe par le backdrop)42  useEffect(() => {43    if (!menuOpen) return;44    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuOpen(false); };45    window.addEventListener("keydown", onKey);46    return () => window.removeEventListener("keydown", onKey);47  }, [menuOpen]);4849  if (!enabled) return null;50  if (!me) {51    return (52      <a className="login-btn" href="/api/auth/ka/login">53        <span className="ka-badge" aria-hidden="true">KA</span>54        Connexion55      </a>56    );57  }58  return (59    <div className="account">60      <button61        className="account-btn"62        onClick={() => setMenuOpen(!menuOpen)}63        aria-expanded={menuOpen}64        aria-label={`Compte : ${me.name || me.email}`}65      >66        {me.picture67          ? <img src={me.picture} alt="" referrerPolicy="no-referrer" />68          : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>}69      </button>70      {/* Backdrop + menu rendus à la racine via createPortal : le71          backdrop-filter du header re-parentait le position:fixed (voile de72          66 px) et le stacking context du header (z-50) plaquait le menu SOUS73          la tabbar (85) et le fab (80). Le menu devient fixed, ancré sous le74          header (voir .account-menu dans styles.css). */}75      {menuOpen && createPortal(76        <>77          <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" />78          <div className="account-menu" role="menu">79            <div className="account-id">80              <b>{me.name || "Mon compte"}</b>81              <span>{me.email}</span>82              {me.ka_id && <span className="account-kaid">{me.ka_id}</span>}83            </div>84            <NavLink to="/favoris" role="menuitem" className="account-link"85              onClick={() => setMenuOpen(false)}>86              Mes favoris87            </NavLink>88            <a href="https://www.groupe-ka.com/compte" target="_blank"89              rel="noopener noreferrer" role="menuitem" className="account-link">90              Mon profil Groupe KA ↗91            </a>92            <button93              role="menuitem"94              onClick={async () => { await logout(); refresh(); setMenuOpen(false); }}95            >96              Se déconnecter97            </button>98          </div>99        </>,100        document.body,101      )}102    </div>103  );104}105106function Ticker() {107  const [items, setItems] = useState<string[]>([]);108109  useEffect(() => {110    Promise.all([fetchStats(), fetchFacets(), fetchSources()])111      .then(([stats, facets, src]) => {112        registerSourceNames(src.sources);113        const parts: string[] = [114          `${stats.restaurants} restaurants`,115          `${stats.items} plats avec prix`,116          `${stats.regions} région${stats.regions > 1 ? "s" : ""} couvertes`,117        ];118        for (const r of stats.by_region.slice(0, 6))119          parts.push(`${r.region} · ${r.n}`);120        for (const s of facets.sources.slice(0, 6))121          parts.push(`${sourceName(s.source)} · ${s.n}`);122        parts.push("Prix réels étiquetés par contexte");123        parts.push("Mise à jour automatique");124        setItems(parts);125      })126      .catch(() => setItems(["Resto-Ka — les restaurants du Québec, menus et prix"]));127  }, []);128129  if (items.length === 0) return null;130  // contenu doublé pour une boucle de défilement continue131  return (132    <div className="ticker" aria-hidden="true">133      <div className="ticker-track">134        {[...items, ...items].map((t, i) => (135          <span key={i}>{t}</span>136        ))}137      </div>138    </div>139  );140}141142const NAV_LINKS = [143  { to: "/", label: "Restos", icon: <IcoPlate size={17} />, end: true },144  { to: "/favoris", label: "Favoris", icon: <IcoHeart size={17} />, end: false },145  { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },146  { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },147  { to: "/contact", label: "Contact", icon: <IcoCompass size={17} />, end: false },148];149150function Header() {151  const [open, setOpen] = useState(false);152  const location = useLocation();153154  // fermer le menu à chaque navigation + verrouiller le défilement en dessous155  useEffect(() => { setOpen(false); }, [location]);156  useEffect(() => {157    document.documentElement.classList.toggle("ka-scroll-lock", open);158    return () => { document.documentElement.classList.remove("ka-scroll-lock"); };159  }, [open]);160161  return (162    <>163      <header className="header">164        <div className="container header-inner">165          <NavLink to="/" className="brand" aria-label="Resto-Ka — accueil">166            <LogoIcon size={30} />167            Resto-<span className="ka">Ka</span>168            <span className="brand-tag">Chaque resto, chaque plat, chaque prix.</span>169          </NavLink>170          <span className="hdr-badge"><GroupeKaBadge /></span>171          <nav className="nav" aria-label="Navigation principale">172            {NAV_LINKS.map((l) => (173              <NavLink key={l.to} to={l.to} end={l.end}174                className={({ isActive }) => (isActive ? "active" : "")}>175                {l.label}176              </NavLink>177            ))}178          </nav>179          <AccountMenu />180          <button181            className={`menu-btn ${open ? "open" : ""}`}182            aria-expanded={open}183            aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}184            onClick={() => setOpen(!open)}185          >186            <span /><span /><span />187          </button>188        </div>189190        {/* menu déroulant mobile */}191        <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">192          {/* ✕ fixe du panneau : visible peu importe le scroll, le burger du header est masqué à l'ouverture */}193          <button type="button" className="mm-close" aria-label="Fermer le menu"194                  onClick={() => setOpen(false)}>✕</button>195          {NAV_LINKS.map((l, i) => (196            <NavLink197              key={l.to}198              to={l.to}199              end={l.end}200              style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}201              className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`}202              onClick={() => setOpen(false)}203            >204              <span className="mm-ico" aria-hidden="true">{l.icon}</span>205              {l.label}206              <span className="mm-arrow" aria-hidden="true">→</span>207            </NavLink>208          ))}209          <div className="mm-badge"><GroupeKaBadge /></div>210          <div className="mm-foot">211            Agrégateur indépendant — menus et prix captés aux sources, chaque212            fiche renvoie au resto original et affiche le contexte du prix.213          </div>214        </div>215      </header>216      {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}217      <Ticker />218    </>219  );220}221222/** Rangée de nav locale + footer commun Groupe KA (ka-ui/KaFooter). */223function Footer() {224  return (225    <>226      <div className="prefoot">227        <div className="container prefoot-inner">228          <nav className="prefoot-nav" aria-label="Navigation du pied de page">229            {NAV_LINKS.map((l) => (230              <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink>231            ))}232            <NavLink to="/villes">Villes</NavLink>233          </nav>234          <div className="prefoot-osm">235            Données de découverte ©{" "}236            <a href="https://www.openstreetmap.org/copyright" target="_blank"237              rel="noopener noreferrer">contributeurs OpenStreetMap</a> (ODbL)238          </div>239        </div>240      </div>241      <KaFooter siteId="resto-ka" localLegal={[{ label: "Documentation", href: "/doc/" }]} />242    </>243  );244}245246/** Barre de navigation mobile commune Groupe KA (ka-ui/nav — KA Tabbar v1) :247    Découvrir · Recherche · Villes · Favoris, accent orange signature. */248function MobileTabBar() {249  const location = useLocation();250  const navigate = useNavigate();251  const p = location.pathname;252  const focusSearch = () => {253    const go = !document.getElementById("f-q");254    if (go) navigate("/");255    window.setTimeout(() => {256      const el = document.getElementById("f-q") as HTMLInputElement | null;257      if (el) {258        el.scrollIntoView({ block: "center", behavior: "smooth" });259        el.focus({ preventScroll: true });260      }261    }, go ? 140 : 0);262  };263  return (264    <KaTabbar265      tabs={[266        { label: "Découvrir", icon: "compass", to: "/", active: p === "/" || p.startsWith("/resto") },267        { label: "Recherche", icon: "search", onPress: focusSearch },268        { label: "Villes", icon: "pin", to: "/villes", active: p.startsWith("/ville") },269        { label: "Favoris", icon: "heart", to: "/favoris", active: p.startsWith("/favoris") },270      ]}271    />272  );273}274275export default function App() {276  return (277    <AccountProvider>278      <FavProvider>279      <Header />280      <main>281        <Routes>282          <Route path="/" element={<Home />} />283          <Route path="/resto/:uid" element={<RestoPage />} />284          <Route path="/villes" element={<VillesPage />} />285          <Route path="/ville/:ville" element={<VillePage />} />286          <Route path="/favoris" element={<FavorisPage />} />287          <Route path="/stats" element={<StatsPage />} />288          <Route path="/sources" element={<SourcesPage />} />289          <Route path="/contact" element={<ContactPage />} />290          <Route291            path="*"292            element={293              <div className="notice container">294                <div className="big"><IcoCompass size={40} /></div>295                <h2>Page introuvable</h2>296                <p>Le lien demandé n'existe pas.</p>297              </div>298            }299          />300        </Routes>301      </main>302      <Footer />303      <MobileTabBar />304      </FavProvider>305    </AccountProvider>306  );307}308