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 { NavLink, Route, Routes, useLocation } from "react-router-dom";9import { AccountProvider, useAccount } from "./account";10import {11 fetchAuthConfig, fetchFacets, fetchSources, fetchStats, logout,12 registerSourceNames, sourceName,13} from "./api";14import {15 IcoChart, IcoCompass, IcoFolder, IcoPlate, IcoSearch,16} from "./components/Icons";17import { LogoIcon } from "./components/Logo";18import GroupeKaBadge from "./ka/GroupeKaBadge";19import KaFooter from "./ka/KaFooter";20import ContactPage from "./pages/Contact";21import FavorisPage from "./pages/Favoris";22import Home from "./pages/Home";23import RestoPage from "./pages/Resto";24import SourcesPage from "./pages/Sources";25import StatsPage from "./pages/Stats";2627/** Bouton « Se connecter avec KA » ou avatar + menu compte (hub Groupe KA). */28function AccountMenu() {29 const [enabled, setEnabled] = useState(false);30 const { me, refresh } = useAccount();31 const [menuOpen, setMenuOpen] = useState(false);3233 useEffect(() => {34 fetchAuthConfig().then((c) => setEnabled(c.ka)).catch(() => {});35 }, []);3637 if (!enabled) return null;38 if (!me) {39 return (40 <a className="login-btn" href="/api/auth/ka/login">41 <span className="ka-badge" aria-hidden="true">KA</span>42 Connexion43 </a>44 );45 }46 return (47 <div className="account">48 <button49 className="account-btn"50 onClick={() => setMenuOpen(!menuOpen)}51 aria-expanded={menuOpen}52 aria-label={`Compte : ${me.name || me.email}`}53 >54 {me.picture55 ? <img src={me.picture} alt="" referrerPolicy="no-referrer" />56 : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>}57 </button>58 {menuOpen && (59 <>60 <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" />61 <div className="account-menu" role="menu">62 <div className="account-id">63 <b>{me.name || "Mon compte"}</b>64 <span>{me.email}</span>65 {me.ka_id && <span className="account-kaid">{me.ka_id}</span>}66 </div>67 <NavLink to="/favoris" role="menuitem" className="account-link"68 onClick={() => setMenuOpen(false)}>69 Mes favoris70 </NavLink>71 <a href="https://www.groupe-ka.com/compte" target="_blank"72 rel="noopener noreferrer" role="menuitem" className="account-link">73 Mon profil Groupe KA ↗74 </a>75 <button76 role="menuitem"77 onClick={async () => { await logout(); refresh(); setMenuOpen(false); }}78 >79 Se déconnecter80 </button>81 </div>82 </>83 )}84 </div>85 );86}8788function Ticker() {89 const [items, setItems] = useState<string[]>([]);9091 useEffect(() => {92 Promise.all([fetchStats(), fetchFacets(), fetchSources()])93 .then(([stats, facets, src]) => {94 registerSourceNames(src.sources);95 const parts: string[] = [96 `${stats.restaurants} restaurants`,97 `${stats.items} plats avec prix`,98 `${stats.regions} région${stats.regions > 1 ? "s" : ""} couvertes`,99 ];100 for (const r of stats.by_region.slice(0, 6))101 parts.push(`${r.region} · ${r.n}`);102 for (const s of facets.sources.slice(0, 6))103 parts.push(`${sourceName(s.source)} · ${s.n}`);104 parts.push("Prix réels étiquetés par contexte");105 parts.push("Mise à jour automatique");106 setItems(parts);107 })108 .catch(() => setItems(["Resto-Ka — les restaurants du Québec, menus et prix"]));109 }, []);110111 if (items.length === 0) return null;112 // contenu doublé pour une boucle de défilement continue113 return (114 <div className="ticker" aria-hidden="true">115 <div className="ticker-track">116 {[...items, ...items].map((t, i) => (117 <span key={i}>{t}</span>118 ))}119 </div>120 </div>121 );122}123124const NAV_LINKS = [125 { to: "/", label: "Restos", icon: <IcoPlate size={17} />, end: true },126 { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },127 { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },128 { to: "/contact", label: "Contact", icon: <IcoCompass size={17} />, end: false },129];130131function Header() {132 const [open, setOpen] = useState(false);133 const location = useLocation();134135 // fermer le menu à chaque navigation + verrouiller le défilement en dessous136 useEffect(() => { setOpen(false); }, [location]);137 useEffect(() => {138 document.body.style.overflow = open ? "hidden" : "";139 return () => { document.body.style.overflow = ""; };140 }, [open]);141142 return (143 <>144 <header className="header">145 <div className="container header-inner">146 <NavLink to="/" className="brand" aria-label="Resto-Ka — accueil">147 <LogoIcon size={30} />148 Resto-<span className="ka">Ka</span>149 <span className="brand-tag">Chaque resto, chaque plat, chaque prix.</span>150 </NavLink>151 <span className="hdr-badge"><GroupeKaBadge /></span>152 <nav className="nav" aria-label="Navigation principale">153 {NAV_LINKS.map((l) => (154 <NavLink key={l.to} to={l.to} end={l.end}155 className={({ isActive }) => (isActive ? "active" : "")}>156 {l.label}157 </NavLink>158 ))}159 </nav>160 <AccountMenu />161 <button162 className={`menu-btn ${open ? "open" : ""}`}163 aria-expanded={open}164 aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}165 onClick={() => setOpen(!open)}166 >167 <span /><span /><span />168 </button>169 </div>170171 {/* menu déroulant mobile */}172 <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">173 {NAV_LINKS.map((l, i) => (174 <NavLink175 key={l.to}176 to={l.to}177 end={l.end}178 style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}179 className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`}180 onClick={() => setOpen(false)}181 >182 <span className="mm-ico" aria-hidden="true">{l.icon}</span>183 {l.label}184 <span className="mm-arrow" aria-hidden="true">→</span>185 </NavLink>186 ))}187 <div className="mm-badge"><GroupeKaBadge /></div>188 <div className="mm-foot">189 Agrégateur indépendant — menus et prix captés aux sources, chaque190 fiche renvoie au resto original et affiche le contexte du prix.191 </div>192 </div>193 </header>194 {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}195 <Ticker />196 </>197 );198}199200/** Rangée de nav locale + footer commun Groupe KA (ka-ui/KaFooter). */201function Footer() {202 return (203 <>204 <div className="prefoot">205 <div className="container prefoot-inner">206 <nav className="prefoot-nav" aria-label="Navigation du pied de page">207 {NAV_LINKS.map((l) => (208 <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink>209 ))}210 <NavLink to="/favoris">Favoris</NavLink>211 </nav>212 <div className="prefoot-osm">213 Données de découverte ©{" "}214 <a href="https://www.openstreetmap.org/copyright" target="_blank"215 rel="noopener noreferrer">contributeurs OpenStreetMap</a> (ODbL)216 </div>217 </div>218 </div>219 <KaFooter siteId="resto-ka" />220 </>221 );222}223224/** Barre de navigation inférieure (mobile) — icône active en orange. */225function MobileTabBar() {226 const location = useLocation();227 const tabs = [228 { to: "/", label: "Rechercher", icon: <IcoSearch size={20} />, on: location.pathname === "/" },229 { to: "/stats", label: "Stats", icon: <IcoChart size={20} />, on: location.pathname.startsWith("/stats") },230 { to: "/sources", label: "Sources", icon: <IcoFolder size={20} />, on: location.pathname.startsWith("/sources") },231 ];232 return (233 <nav className="tabbar tabbar-3" aria-label="Navigation mobile">234 {tabs.map((t) => (235 <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>236 {t.icon}237 {t.label}238 </NavLink>239 ))}240 </nav>241 );242}243244export default function App() {245 return (246 <AccountProvider>247 <Header />248 <main>249 <Routes>250 <Route path="/" element={<Home />} />251 <Route path="/resto/:uid" element={<RestoPage />} />252 <Route path="/favoris" element={<FavorisPage />} />253 <Route path="/stats" element={<StatsPage />} />254 <Route path="/sources" element={<SourcesPage />} />255 <Route path="/contact" element={<ContactPage />} />256 <Route257 path="*"258 element={259 <div className="notice container">260 <div className="big"><IcoCompass size={40} /></div>261 <h2>Page introuvable</h2>262 <p>Le lien demandé n'existe pas.</p>263 </div>264 }265 />266 </Routes>267 </main>268 <Footer />269 <MobileTabBar />270 </AccountProvider>271 );272}273