SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
20 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
16.9 KB · 422 lines tsx
Raw Blame History
1// ==============================================================================2// Author: Simon-Pierre Boucher <contact@spboucher.ai>3// File:   pages/Home.tsx4// Desc:   Accueil — héros + bascule RESTOS / PLATS (recherche par plat dans5//         tous les menus), chips cuisines, barre de filtres (région, ville,6//         type, diète, prix), grilles de résultats + pagination numérotée.7//         Filtres synchronisés à l'URL, feuille mobile, skeletons (patron8//         Lou·Ka, design éditorial sharp Groupe KA).9// ==============================================================================10import { useEffect, useMemo, useState } from "react";11import { Link, useSearchParams } from "react-router-dom";12import {13  CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel,14  fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants,15  fetchStats, fmtPrice, getRecentUids, typeLabel,16} from "../api";17import { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from "../components/Icons";18import Pager from "../components/Pager";19import RestoCard from "../components/RestoCard";2021const PAGE_SIZE = 24;22const DISH_PAGE_SIZE = 20;2324const FILTER_KEYS = ["q", "region", "city", "cuisine", "establishment_type",25                     "diet", "price_range", "sort", "mode", "has_menu"] as const;26type FilterKey = typeof FILTER_KEYS[number];2728function DishRow({ d }: { d: Dish }) {29  return (30    <Link to={`/resto/${encodeURIComponent(d.uid)}`} className="dish-row">31      {d.image ? (32        <img className="dish-img" src={d.image} alt={d.item} loading="lazy" />33      ) : (34        <span className="dish-img dish-noimg"><IcoPlate size={22} strokeWidth={1.8} /></span>35      )}36      <div className="dish-main">37        <div className="dish-line">38          <span className="dish-name">{d.item}</span>39          <span className="mi-dots" aria-hidden="true" />40          <span className="dish-price">{fmtPrice(d.price)}</span>41        </div>42        {d.description && <p className="dish-desc">{d.description}</p>}43        <div className="dish-meta">44          <b>{d.brand}</b>45          <span className="sep" />46          <span>{d.section}</span>47          <span className="sep" />48          {d.locations > 149            ? <span>{d.locations} emplacements</span>50            : <span>{d.city}{d.region ? ` · ${d.region}` : ""}</span>}51          <span className={`ctx-pill ctx-${d.price_context}`}>52            {CONTEXT_SHORT[d.price_context] ?? d.price_context}53          </span>54        </div>55      </div>56    </Link>57  );58}5960export default function Home() {61  const [params, setParams] = useSearchParams();62  const [facets, setFacets] = useState<Facets | null>(null);63  const [restos, setRestos] = useState<Restaurant[]>([]);64  const [dishes, setDishes] = useState<Dish[]>([]);65  const [total, setTotal] = useState(0);66  const [loading, setLoading] = useState(true);67  const [error, setError] = useState(false);68  const [page, setPage] = useState(0);69  const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]);70  const [sheetOpen, setSheetOpen] = useState(false);71  const [recent, setRecent] = useState<Restaurant[]>([]);7273  useEffect(() => {74    fetchRecentRestaurants(getRecentUids().slice(0, 8))75      .then(setRecent)76      .catch(() => {});77  }, []);7879  // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à80  // Escape (même pattern que le menu hamburger d'App.tsx)81  useEffect(() => {82    document.body.style.overflow = sheetOpen ? "hidden" : "";83    if (!sheetOpen) return () => { document.body.style.overflow = ""; };84    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };85    window.addEventListener("keydown", onKey);86    return () => {87      document.body.style.overflow = "";88      window.removeEventListener("keydown", onKey);89    };90  }, [sheetOpen]);9192  const filters = useMemo(() => {93    const f: Partial<Record<FilterKey, string>> = {};94    for (const k of FILTER_KEYS) {95      const v = params.get(k);96      if (v) f[k] = v;97    }98    return f;99  }, [params]);100101  const mode = filters.mode === "plats" ? "plats" : "restos";102  const pageSize = mode === "plats" ? DISH_PAGE_SIZE : PAGE_SIZE;103104  const setFilter = (key: FilterKey, value: string) => {105    const next = new URLSearchParams(params);106    if (value) next.set(key, value);107    else next.delete(key);108    setParams(next, { replace: true });109    setPage(0);110  };111112  useEffect(() => {113    fetchStats()114      .then((s) => setStatChips([115        { label: "restaurants", value: s.restaurants },116        { label: "avec menu complet", value: s.with_menu },117        { label: "plats avec prix", value: s.items },118        { label: "régions couvertes", value: s.regions },119      ]))120      .catch(() => {});121  }, []);122123  useEffect(() => {124    fetchFacets(filters.region).then(setFacets).catch(() => {});125  }, [filters.region]);126127  useEffect(() => {128    setLoading(true);129    setError(false);130    if (mode === "plats") {131      const q = (filters.q ?? "").trim();132      if (q.length < 2) {133        setDishes([]); setTotal(0); setLoading(false);134        return;135      }136      fetchDishes({137        q, region: filters.region, city: filters.city, cuisine: filters.cuisine,138        limit: DISH_PAGE_SIZE, offset: page * DISH_PAGE_SIZE,139      })140        .then((r) => { setDishes(r.dishes); setTotal(r.total); })141        .catch(() => setError(true))142        .finally(() => setLoading(false));143    } else {144      const { mode: _m, ...rest } = filters;145      fetchRestaurants({ ...rest, limit: PAGE_SIZE, offset: page * PAGE_SIZE })146        .then((r) => { setRestos(r.restaurants); setTotal(r.total); })147        .catch(() => setError(true))148        .finally(() => setLoading(false));149    }150  }, [filters, page, mode]);151152  const activePills: { key: FilterKey; label: string }[] = [];153  if (filters.region) activePills.push({ key: "region", label: filters.region });154  if (filters.city) activePills.push({ key: "city", label: filters.city });155  if (filters.cuisine) activePills.push({ key: "cuisine", label: cuisineLabel(filters.cuisine) });156  if (filters.establishment_type) activePills.push({ key: "establishment_type", label: typeLabel(filters.establishment_type) });157  if (filters.diet) activePills.push({ key: "diet", label: dietLabel(filters.diet) });158  if (filters.price_range) activePills.push({ key: "price_range", label: filters.price_range });159  if (filters.has_menu === "1") activePills.push({ key: "has_menu", label: "Avec menu complet" });160  if (filters.has_menu === "0") activePills.push({ key: "has_menu", label: "Fiche seulement" });161  if (filters.q) activePills.push({ key: "q", label: `« ${filters.q} »` });162163  const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };164165  const filterbar = (166    <div className={`filterbar ${sheetOpen ? "open" : ""}`}>167      <div className="sheet-head">168        Filtres169        <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer">✕</button>170      </div>171      <div className="f-primary">172        <div className="mode-toggle" role="tablist" aria-label="Chercher par">173          <button className={mode === "restos" ? "on" : ""} role="tab"174            aria-selected={mode === "restos"}175            onClick={() => setFilter("mode", "")}>Restos</button>176          <button className={mode === "plats" ? "on" : ""} role="tab"177            aria-selected={mode === "plats"}178            onClick={() => setFilter("mode", "plats")}>Plats</button>179        </div>180        <label className="f-search">181          <IcoSearch size={16} />182          <input183            id="f-q"184            type="search"185            placeholder={mode === "plats"186              ? "Poutine, tartare, pad thaï, sushi…"187              : "Resto, chaîne, ville…"}188            value={filters.q ?? ""}189            onChange={(e) => setFilter("q", e.target.value)}190            aria-label="Recherche"191          />192          {filters.q && (193            <button className="f-clear" onClick={() => setFilter("q", "")} aria-label="Effacer">✕</button>194          )}195        </label>196        <label className="f-ctl">197          <span>Région</span>198          <select value={filters.region ?? ""} onChange={(e) => { setFilter("region", e.target.value); setFilter("city", ""); }}>199            <option value="">Toutes</option>200            {(facets?.regions ?? []).map((r) => (201              <option key={r.region} value={r.region}>{r.region} ({r.n})</option>202            ))}203          </select>204        </label>205        <label className="f-ctl">206          <span>Ville</span>207          <select value={filters.city ?? ""} onChange={(e) => setFilter("city", e.target.value)}>208            <option value="">Toutes</option>209            {(facets?.cities ?? []).map((c) => (210              <option key={c} value={c}>{c}</option>211            ))}212          </select>213        </label>214        {mode === "restos" && (215          <>216            <label className="f-ctl">217              <span>Type</span>218              <select value={filters.establishment_type ?? ""} onChange={(e) => setFilter("establishment_type", e.target.value)}>219                <option value="">Tous</option>220                {(facets?.establishment_types ?? []).map((t) => (221                  <option key={t.t} value={t.t}>{typeLabel(t.t)} ({t.n})</option>222                ))}223              </select>224            </label>225            <label className="f-ctl">226              <span>Diète</span>227              <select value={filters.diet ?? ""} onChange={(e) => setFilter("diet", e.target.value)}>228                <option value="">Toutes</option>229                {(facets?.diets ?? []).map((d) => (230                  <option key={d.diet} value={d.diet}>{dietLabel(d.diet)} ({d.n})</option>231                ))}232              </select>233            </label>234            <label className="f-ctl">235              <span>Prix</span>236              <select value={filters.price_range ?? ""} onChange={(e) => setFilter("price_range", e.target.value)}>237                <option value="">Tous</option>238                {["$", "$$", "$$$", "$$$$"].map((p) => (239                  <option key={p} value={p}>{p}</option>240                ))}241              </select>242            </label>243            <label className="f-ctl">244              <span>Menu</span>245              <select value={filters.has_menu ?? ""} onChange={(e) => setFilter("has_menu", e.target.value)}>246                <option value="">Tous</option>247                <option value="1">Avec menu complet</option>248                <option value="0">Fiche seulement</option>249              </select>250            </label>251            <label className="f-ctl">252              <span>Tri</span>253              <select value={filters.sort ?? "menu"} onChange={(e) => setFilter("sort", e.target.value)}>254                <option value="menu">Menus d'abord</option>255                <option value="name">Nom</option>256                <option value="price">Fourchette de prix</option>257                <option value="recent">Récents</option>258              </select>259            </label>260          </>261        )}262      </div>263      <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>264        Voir les {total} résultats265      </button>266    </div>267  );268269  return (270    <div className="container">271      <section className="hero">272        <span className="kicker">Agrégateur — 17 régions du Québec</span>273        <h1>274          Tous les restos. Tous les menus.<br />275          <span className="hl">Tous les prix.</span>276        </h1>277        <p className="lede">278          Resto-Ka rassemble les restaurants du Québec avec leurs menus complets279          et leurs prix réels — cherchez un resto <em>ou un plat</em>, comparez,280          le contexte du prix (salle, emporter, livraison) est toujours affiché.281        </p>282        {statChips.length > 0 && (283          <div className="vitals bleed band-ink" aria-label="Signes vitaux">284            <div className="vitals-in">285              <div className="vitals-live"><span className="pulse" />En direct — signes vitaux</div>286              {statChips.map((s, i) => (287                <div className="vital" key={s.label}>288                  <span className="vital-i">{String(i + 1).padStart(2, "0")}</span>289                  <b className="vital-v">{s.value.toLocaleString("fr-CA")}</b>290                  <span className="vital-k">{s.label}</span>291                </div>292              ))}293            </div>294          </div>295        )}296        <div className="benefits">297          <div className="benefit">298            <span className="benefit-ico"><IcoTag size={19} /></span>299            <span className="benefit-label">Prix réels, jamais sans contexte</span>300          </div>301          <div className="benefit">302            <span className="benefit-ico blue"><IcoPin size={19} /></span>303            <span className="benefit-label">17 régions du Québec</span>304          </div>305          <div className="benefit">306            <span className="benefit-ico"><IcoBag size={19} /></span>307            <span className="benefit-label">Recherche par plat</span>308          </div>309          <div className="benefit">310            <span className="benefit-ico blue"><IcoLeaf size={19} /></span>311            <span className="benefit-label">Filtres diète & cuisine</span>312          </div>313        </div>314      </section>315316      {/* chips cuisines (défilement horizontal) */}317      <div className="chips" role="tablist" aria-label="Cuisines">318        <button className={`chip ${!filters.cuisine ? "on" : ""}`}319          onClick={() => setFilter("cuisine", "")}>Toutes les cuisines</button>320        {(facets?.cuisines ?? []).slice(0, 14).map((c) => (321          <button322            key={c.cuisine}323            className={`chip ${filters.cuisine === c.cuisine ? "on" : ""}`}324            onClick={() => setFilter("cuisine", filters.cuisine === c.cuisine ? "" : c.cuisine)}325          >326            {cuisineLabel(c.cuisine)} · {c.n}327          </button>328        ))}329      </div>330331      {filterbar}332      {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}333      <button className="fab" onClick={() => setSheetOpen(true)}>334        <IcoSearch size={16} /> Filtres335      </button>336337      {activePills.length > 0 && (338        <div className="pills">339          {activePills.map((p) => (340            <button key={p.key} className="pill" onClick={() => setFilter(p.key, "")}>341              {p.label} <span className="pill-x">✕</span>342            </button>343          ))}344          <button className="pill pill-clear" onClick={clearAll}>Tout effacer</button>345        </div>346      )}347348      {page === 0 && recent.length > 0 && (349        <section aria-label="Récemment consultés">350          <div className="results-head">351            <h2>Récemment consultés</h2>352            <span>vos dernières fiches</span>353          </div>354          <div className="recent-row">355            {recent.map((r) => <RestoCard key={r.uid} r={r} />)}356          </div>357        </section>358      )}359360      <div className="results-head">361        <h2>{mode === "plats" ? "Plats" : "Restaurants"}</h2>362        <span>363          {mode === "plats" && (filters.q ?? "").trim().length < 2364            ? "tapez un plat (min. 2 lettres)"365            : `${total.toLocaleString("fr-CA")} résultat${total > 1 ? "s" : ""}`}366        </span>367      </div>368369      {error && (370        <div className="notice">371          <h2>Impossible de charger les résultats</h2>372          <p>Réessayez dans un instant.</p>373        </div>374      )}375376      {loading ? (377        <div className="grid">378          {Array.from({ length: 8 }).map((_, i) => (379            <div className="skel" key={i}>380              <div className="sk-img" />381              <div className="sk-line" />382              <div className="sk-line short" />383            </div>384          ))}385        </div>386      ) : mode === "plats" ? (387        (filters.q ?? "").trim().length < 2 ? (388          <div className="notice">389            <h2>Cherchez n'importe quel plat</h2>390            <p>« Poutine », « tartare de saumon », « pad thaï »… tous les menus391              du Québec sont indexés, avec leurs prix réels.</p>392          </div>393        ) : dishes.length === 0 ? (394          <div className="notice">395            <h2>Aucun plat trouvé</h2>396            <p>Essayez un autre mot — la couverture grandit chaque semaine.</p>397          </div>398        ) : (399          <>400            <div className="dish-list">401              {dishes.map((d, i) => <DishRow key={`${d.uid}-${d.item}-${i}`} d={d} />)}402            </div>403            <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />404          </>405        )406      ) : restos.length === 0 && !error ? (407        <div className="notice">408          <h2>Aucun resto ne correspond</h2>409          <p>Élargissez les filtres — la couverture grandit chaque semaine.</p>410        </div>411      ) : (412        <>413          <div className="grid">414            {restos.map((r) => <RestoCard key={r.uid} r={r} />)}415          </div>416          <Pager page={page} pageCount={Math.ceil(total / pageSize)} onPage={setPage} />417        </>418      )}419    </div>420  );421}422