accueil : section « Récemment consultés » — fiches restos vues (localStorage) affichées en premier ; API /api/restaurants?uids=
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5 changed files +74 −3
modified
frontend/src/api.ts
+30 −0
@@ -241,6 +241,36 @@ export function fetchRestaurants(query: RestaurantQuery = {}) { | ||
| 241 | 241 | export const fetchRestaurant = (uid: string) => |
| 242 | 242 | get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`); |
| 243 | 243 | |
| 244 | +// --- récemment consultés (localStorage, par appareil) --------------------------- | |
| 245 | + | |
| 246 | +const RECENT_KEY = "restoka:recent"; | |
| 247 | +const RECENT_MAX = 12; | |
| 248 | + | |
| 249 | +export function getRecentUids(): string[] { | |
| 250 | + try { | |
| 251 | + const raw = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]"); | |
| 252 | + return Array.isArray(raw) ? raw.filter((u) => typeof u === "string") : []; | |
| 253 | + } catch { | |
| 254 | + return []; | |
| 255 | + } | |
| 256 | +} | |
| 257 | + | |
| 258 | +export function pushRecentUid(uid: string) { | |
| 259 | + try { | |
| 260 | + const next = [uid, ...getRecentUids().filter((u) => u !== uid)].slice(0, RECENT_MAX); | |
| 261 | + localStorage.setItem(RECENT_KEY, JSON.stringify(next)); | |
| 262 | + } catch { /* stockage indisponible (navigation privée) */ } | |
| 263 | +} | |
| 264 | + | |
| 265 | +/** Fiches des restos consultés, remises dans l'ordre de consultation. */ | |
| 266 | +export async function fetchRecentRestaurants(uids: string[]): Promise<Restaurant[]> { | |
| 267 | + if (uids.length === 0) return []; | |
| 268 | + const params = new URLSearchParams({ uids: uids.join(","), limit: String(uids.length) }); | |
| 269 | + const r = await get<{ restaurants: Restaurant[] }>(`/api/restaurants?${params.toString()}`); | |
| 270 | + const by = new Map(r.restaurants.map((x) => [x.uid, x])); | |
| 271 | + return uids.map((u) => by.get(u)).filter((x): x is Restaurant => x != null); | |
| 272 | +} | |
| 273 | + | |
| 244 | 274 | export const fetchFacets = (region?: string) => |
| 245 | 275 | get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : ""}`); |
| 246 | 276 | |
modified
frontend/src/pages/Home.tsx
+21 −1
@@ -11,7 +11,8 @@ import { useEffect, useMemo, useState } from "react"; | ||
| 11 | 11 | import { Link, useSearchParams } from "react-router-dom"; |
| 12 | 12 | import { |
| 13 | 13 | CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel, |
| 14 | − fetchDishes, fetchFacets, fetchRestaurants, fetchStats, fmtPrice, typeLabel, | |
| 14 | + fetchDishes, fetchFacets, fetchRecentRestaurants, fetchRestaurants, | |
| 15 | + fetchStats, fmtPrice, getRecentUids, typeLabel, | |
| 15 | 16 | } from "../api"; |
| 16 | 17 | import { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from "../components/Icons"; |
| 17 | 18 | import Pager from "../components/Pager"; |
@@ -67,6 +68,13 @@ export default function Home() { | ||
| 67 | 68 | const [page, setPage] = useState(0); |
| 68 | 69 | const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]); |
| 69 | 70 | const [sheetOpen, setSheetOpen] = useState(false); |
| 71 | + const [recent, setRecent] = useState<Restaurant[]>([]); | |
| 72 | + | |
| 73 | + useEffect(() => { | |
| 74 | + fetchRecentRestaurants(getRecentUids().slice(0, 8)) | |
| 75 | + .then(setRecent) | |
| 76 | + .catch(() => {}); | |
| 77 | + }, []); | |
| 70 | 78 | |
| 71 | 79 | // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à |
| 72 | 80 | // Escape (même pattern que le menu hamburger d'App.tsx) |
@@ -330,6 +338,18 @@ export default function Home() { | ||
| 330 | 338 | </div> |
| 331 | 339 | )} |
| 332 | 340 | |
| 341 | + {page === 0 && recent.length > 0 && ( | |
| 342 | + <section aria-label="Récemment consultés"> | |
| 343 | + <div className="results-head"> | |
| 344 | + <h2>Récemment consultés</h2> | |
| 345 | + <span>vos dernières fiches</span> | |
| 346 | + </div> | |
| 347 | + <div className="recent-row"> | |
| 348 | + {recent.map((r) => <RestoCard key={r.uid} r={r} />)} | |
| 349 | + </div> | |
| 350 | + </section> | |
| 351 | + )} | |
| 352 | + | |
| 333 | 353 | <div className="results-head"> |
| 334 | 354 | <h2>{mode === "plats" ? "Plats" : "Restaurants"}</h2> |
| 335 | 355 | <span> |
modified
frontend/src/pages/Resto.tsx
+6 −2
@@ -10,7 +10,7 @@ import { useEffect, useState } from "react"; | ||
| 10 | 10 | import { Link, useParams } from "react-router-dom"; |
| 11 | 11 | import { |
| 12 | 12 | CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel, |
| 13 | − fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel, | |
| 13 | + fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel, | |
| 14 | 14 | } from "../api"; |
| 15 | 15 | import { IcoCompass } from "../components/Icons"; |
| 16 | 16 | |
@@ -110,7 +110,11 @@ export default function RestoPage() { | ||
| 110 | 110 | setResto(null); |
| 111 | 111 | setNotFound(false); |
| 112 | 112 | fetchRestaurant(uid) |
| 113 | − .then((r) => { setResto(r); setCtx(r.menus?.[0]?.price_context ?? null); }) | |
| 113 | + .then((r) => { | |
| 114 | + setResto(r); | |
| 115 | + setCtx(r.menus?.[0]?.price_context ?? null); | |
| 116 | + pushRecentUid(r.uid); | |
| 117 | + }) | |
| 114 | 118 | .catch(() => setNotFound(true)); |
| 115 | 119 | window.scrollTo({ top: 0 }); |
| 116 | 120 | }, [uid]); |
modified
frontend/src/styles.css
+11 −0
@@ -223,6 +223,17 @@ img { display: block; } | ||
| 223 | 223 | } |
| 224 | 224 | @media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } } |
| 225 | 225 | |
| 226 | +/* récemment consultés — bande horizontale de cartes, défilement latéral */ | |
| 227 | +.recent-row { | |
| 228 | + display: grid; grid-auto-flow: column; grid-auto-columns: 290px; | |
| 229 | + gap: 18px; overflow-x: auto; padding-bottom: 12px; | |
| 230 | + scroll-snap-type: x proximity; | |
| 231 | +} | |
| 232 | +.recent-row > .card { scroll-snap-align: start; } | |
| 233 | +@media (max-width: 640px) { | |
| 234 | + .recent-row { grid-auto-columns: 82%; margin-right: -16px; padding-right: 16px; } | |
| 235 | +} | |
| 236 | + | |
| 226 | 237 | .card { |
| 227 | 238 | background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); |
| 228 | 239 | overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft); |
modified
restoka/web.py
+6 −0
@@ -116,6 +116,7 @@ def list_restaurants( | ||
| 116 | 116 | chain: str | None = None, |
| 117 | 117 | source: str | None = None, |
| 118 | 118 | q: str | None = None, |
| 119 | + uids: str | None = None, # liste d'uids séparés par des virgules | |
| 119 | 120 | has_menu: int | None = None, # 1 = seulement les restos avec menu |
| 120 | 121 | active: int = 1, |
| 121 | 122 | sort: str = "menu", # menu | name | price | recent |
@@ -148,6 +149,11 @@ def list_restaurants( | ||
| 148 | 149 | if q: |
| 149 | 150 | sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)" |
| 150 | 151 | args += [f"%{q}%"] * 4 |
| 152 | + if uids: | |
| 153 | + lst = [u.strip() for u in uids.split(",") if u.strip()][:24] | |
| 154 | + if lst: | |
| 155 | + sql += f" AND uid IN ({','.join('?' * len(lst))})" | |
| 156 | + args += lst | |
| 151 | 157 | if has_menu == 1: |
| 152 | 158 | sql += " AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)" |
| 153 | 159 | elif has_menu == 0: |
| 154 | 160 | |