SPB Git

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%
7.3 KB · 201 lines tsx
Raw Blame History
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