spb/food-ka Public
Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com
Python 57.7%
TypeScript 24.9%
CSS 16.7%
HTML 0.6%
1// -----------------------------------------------------------------------------2// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// App.tsx : layout global (header + ticker en direct + footer encre) et routage5// -----------------------------------------------------------------------------6import { useEffect, useState } from "react";7import { NavLink, Route, Routes, useLocation } from "react-router-dom";8import { fetchFacets, fetchSources, fetchStats, fmtPrice, registerSourceNames, sourceName } from "./api";9import CookieConsent from "./components/CookieConsent";10import Home from "./pages/Home";11import ProductPage from "./pages/Product";12import PrivacyPage from "./pages/Privacy";13import SourcesPage from "./pages/Sources";14import StatsPage from "./pages/Stats";1516function Ticker() {17 const [items, setItems] = useState<string[]>([]);1819 useEffect(() => {20 Promise.all([fetchStats(), fetchFacets(), fetchSources()])21 .then(([stats, facets, src]) => {22 registerSourceNames(src.sources);23 const parts: string[] = [24 `${stats.total} produits suivis`,25 `${stats.sources} bannières`,26 `${stats.on_sale} soldes actifs`,27 ];28 if (stats.avg_price != null)29 parts.push(`Prix moyen ${fmtPrice(stats.avg_price)}`);30 for (const s of facets.sources.slice(0, 10))31 parts.push(`${sourceName(s.source)} · ${s.n}`);32 parts.push("Mise à jour automatique");33 setItems(parts);34 })35 .catch(() => setItems(["Food·Ka — tous les prix d'épicerie du Québec"]));36 }, []);3738 if (items.length === 0) return null;39 // contenu doublé pour une boucle de défilement continue40 return (41 <div className="ticker" aria-hidden="true">42 <div className="ticker-track">43 {[...items, ...items].map((t, i) => (44 <span key={i}>{t}</span>45 ))}46 </div>47 </div>48 );49}5051const NAV_LINKS = [52 { to: "/", label: "Produits", icon: "🛒", end: true },53 { to: "/aubaines", label: "Aubaines", icon: "🔥", end: false },54 { to: "/sources", label: "Sources", icon: "🗂", end: false },55 { to: "/stats", label: "Stats", icon: "📊", end: false },56 { to: "/confidentialite", label: "Confidentialité", icon: "🔒", end: false },57];5859function Header() {60 const [open, setOpen] = useState(false);61 const location = useLocation();6263 // fermer le menu à chaque navigation + verrouiller le défilement en dessous64 useEffect(() => { setOpen(false); }, [location]);65 useEffect(() => {66 document.body.style.overflow = open ? "hidden" : "";67 return () => { document.body.style.overflow = ""; };68 }, [open]);6970 return (71 <>72 <header className="header">73 <div className="container header-inner">74 <NavLink to="/" className="brand" aria-label="Food·Ka — accueil">75 <span className="brand-mark" aria-hidden="true">76 {/* panier d'épicerie */}77 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"78 strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">79 <path d="M5 9l2.5-5M19 9l-2.5-5" />80 <path d="M2.5 9h19l-1.8 9.2a2 2 0 0 1-2 1.8H6.3a2 2 0 0 1-2-1.8Z" />81 <path d="M9 13v3M12 13v3M15 13v3" />82 </svg>83 </span>84 Food<span className="ka">Ka</span>85 <span className="brand-tag">Épicerie — tout le Québec, toujours à jour</span>86 </NavLink>87 <nav className="nav" aria-label="Navigation principale">88 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>89 Produits90 </NavLink>91 <NavLink to="/aubaines" className={({ isActive }) => (isActive ? "active" : "")}>92 Aubaines93 </NavLink>94 <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>95 Sources96 </NavLink>97 <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>98 Stats99 </NavLink>100 </nav>101 <button102 className={`menu-btn ${open ? "open" : ""}`}103 aria-expanded={open}104 aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}105 onClick={() => setOpen(!open)}106 >107 <span /><span /><span />108 </button>109 </div>110111 {/* menu déroulant mobile */}112 <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">113 {NAV_LINKS.map((l, i) => (114 <NavLink115 key={l.to}116 to={l.to}117 end={l.end}118 style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}119 className={({ isActive }) => `mm-link ${isActive ? "active" : ""}`}120 onClick={() => setOpen(false)}121 >122 <span className="mm-ico" aria-hidden="true">{l.icon}</span>123 {l.label}124 <span className="mm-arrow" aria-hidden="true">→</span>125 </NavLink>126 ))}127 <div className="mm-foot">128 Agrégateur indépendant — mis à jour automatiquement, chaque fiche129 renvoie à la page produit originale.130 </div>131 </div>132 </header>133 {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}134 <Ticker />135 </>136 );137}138139function Footer() {140 return (141 <footer className="footer">142 <div className="container">143 <div className="fbrand">144 Food<span className="ka">Ka</span>145 </div>146 <div className="frow">147 <div>148 <b>Agrégateur indépendant</b> de produits d'épicerie de la province de Québec.149 Les prix proviennent des sites publics des bannières et sont rafraîchis150 automatiquement — chaque fiche renvoie vers la page produit originale.151 </div>152 </div>153 <div className="fmono">154 © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai155 {" · "}156 www.food-ka.com157 {" · "}158 <NavLink to="/confidentialite">Confidentialité</NavLink>159 {" · "}160 <button161 className="flink"162 onClick={() => window.dispatchEvent(new Event("foodka:openConsent"))}163 >164 Gérer mes témoins165 </button>166 </div>167 </div>168 </footer>169 );170}171172export default function App() {173 return (174 <>175 <Header />176 <main>177 <Routes>178 <Route path="/" element={<Home />} />179 <Route path="/aubaines" element={<Home aubaines />} />180 <Route path="/produit/:uid" element={<ProductPage />} />181 <Route path="/stats" element={<StatsPage />} />182 <Route path="/sources" element={<SourcesPage />} />183 <Route path="/confidentialite" element={<PrivacyPage />} />184 <Route185 path="*"186 element={187 <div className="notice container">188 <div className="big">🧭</div>189 <h2>Page introuvable</h2>190 <p>Le lien demandé n'existe pas.</p>191 </div>192 }193 />194 </Routes>195 </main>196 <Footer />197 <CookieConsent />198 </>199 );200}201