Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
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