Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
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