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, 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 {NAV_LINKS.map((l, i) => (192 <NavLink193 key={l.to}194 to={l.to}195 end={l.end}196 style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}197 className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`}198 onClick={() => setOpen(false)}199 >200 <span className="mm-ico" aria-hidden="true">{l.icon}</span>201 {l.label}202 <span className="mm-arrow" aria-hidden="true">→</span>203 </NavLink>204 ))}205 <div className="mm-badge"><GroupeKaBadge /></div>206 <div className="mm-foot">207 Agrégateur indépendant — menus et prix captés aux sources, chaque208 fiche renvoie au resto original et affiche le contexte du prix.209 </div>210 </div>211 </header>212 {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}213 <Ticker />214 </>215 );216}217218/** Rangée de nav locale + footer commun Groupe KA (ka-ui/KaFooter). */219function Footer() {220 return (221 <>222 <div className="prefoot">223 <div className="container prefoot-inner">224 <nav className="prefoot-nav" aria-label="Navigation du pied de page">225 {NAV_LINKS.map((l) => (226 <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink>227 ))}228 <NavLink to="/villes">Villes</NavLink>229 </nav>230 <div className="prefoot-osm">231 Données de découverte ©{" "}232 <a href="https://www.openstreetmap.org/copyright" target="_blank"233 rel="noopener noreferrer">contributeurs OpenStreetMap</a> (ODbL)234 </div>235 </div>236 </div>237 <KaFooter siteId="resto-ka" localLegal={[{ label: "Documentation", href: "/doc/" }]} />238 </>239 );240}241242/** Barre de navigation inférieure (mobile) — icône active en orange. */243function MobileTabBar() {244 const location = useLocation();245 const tabs = [246 { to: "/", label: "Rechercher", icon: <IcoSearch size={20} />, on: location.pathname === "/" },247 { to: "/stats", label: "Stats", icon: <IcoChart size={20} />, on: location.pathname.startsWith("/stats") },248 { to: "/sources", label: "Sources", icon: <IcoFolder size={20} />, on: location.pathname.startsWith("/sources") },249 ];250 return (251 <nav className="tabbar tabbar-3" aria-label="Navigation mobile">252 {tabs.map((t) => (253 <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>254 {t.icon}255 {t.label}256 </NavLink>257 ))}258 </nav>259 );260}261262export default function App() {263 return (264 <AccountProvider>265 <FavProvider>266 <Header />267 <main>268 <Routes>269 <Route path="/" element={<Home />} />270 <Route path="/resto/:uid" element={<RestoPage />} />271 <Route path="/villes" element={<VillesPage />} />272 <Route path="/ville/:ville" element={<VillePage />} />273 <Route path="/favoris" element={<FavorisPage />} />274 <Route path="/stats" element={<StatsPage />} />275 <Route path="/sources" element={<SourcesPage />} />276 <Route path="/contact" element={<ContactPage />} />277 <Route278 path="*"279 element={280 <div className="notice container">281 <div className="big"><IcoCompass size={40} /></div>282 <h2>Page introuvable</h2>283 <p>Le lien demandé n'existe pas.</p>284 </div>285 }286 />287 </Routes>288 </main>289 <Footer />290 <MobileTabBar />291 </FavProvider>292 </AccountProvider>293 );294}295