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 | export const fetchRestaurant = (uid: string) => | 241 | export const fetchRestaurant = (uid: string) => | ||
| 242 | get<Restaurant>(`/api/restaurants/${encodeURIComponent(uid)}`); | 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 | export const fetchFacets = (region?: string) => | 274 | export const fetchFacets = (region?: string) => | ||
| 245 | get<Facets>(`/api/facets${region ? `?region=${encodeURIComponent(region)}` : ""}`); | 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 | import { Link, useSearchParams } from "react-router-dom"; | 11 | import { Link, useSearchParams } from "react-router-dom"; | ||
| 12 | import { | 12 | import { | ||
| 13 | CONTEXT_SHORT, Dish, Facets, Restaurant, cuisineLabel, dietLabel, | 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 | } from "../api"; | 16 | } from "../api"; | ||
| 16 | import { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from "../components/Icons"; | 17 | import { IcoBag, IcoLeaf, IcoPin, IcoPlate, IcoSearch, IcoTag } from "../components/Icons"; | ||
| 17 | import Pager from "../components/Pager"; | 18 | import Pager from "../components/Pager"; | ||
@@ -67,6 +68,13 @@ export default function Home() { | |||||
| 67 | const [page, setPage] = useState(0); | 68 | const [page, setPage] = useState(0); | ||
| 68 | const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]); | 69 | const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]); | ||
| 69 | const [sheetOpen, setSheetOpen] = useState(false); | 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 | // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à | 79 | // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à | ||
| 72 | // Escape (même pattern que le menu hamburger d'App.tsx) | 80 | // Escape (même pattern que le menu hamburger d'App.tsx) | ||
@@ -330,6 +338,18 @@ export default function Home() { | |||||
| 330 | </div> | 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 | <div className="results-head"> | 353 | <div className="results-head"> | ||
| 334 | <h2>{mode === "plats" ? "Plats" : "Restaurants"}</h2> | 354 | <h2>{mode === "plats" ? "Plats" : "Restaurants"}</h2> | ||
| 335 | <span> | 355 | <span> | ||
modified
frontend/src/pages/Resto.tsx
+6 −2
@@ -10,7 +10,7 @@ import { useEffect, useState } from "react"; | |||||
| 10 | import { Link, useParams } from "react-router-dom"; | 10 | import { Link, useParams } from "react-router-dom"; | ||
| 11 | import { | 11 | import { | ||
| 12 | CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel, | 12 | CONTEXT_LABELS, CONTEXT_SHORT, Menu, Restaurant, cuisineLabel, dietLabel, | ||
| 13 | − fetchRestaurant, fmtDate, fmtPrice, sourceName, typeLabel, | 13 | + fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel, | ||
| 14 | } from "../api"; | 14 | } from "../api"; | ||
| 15 | import { IcoCompass } from "../components/Icons"; | 15 | import { IcoCompass } from "../components/Icons"; | ||
| 16 | 16 | ||||
@@ -110,7 +110,11 @@ export default function RestoPage() { | |||||
| 110 | setResto(null); | 110 | setResto(null); | ||
| 111 | setNotFound(false); | 111 | setNotFound(false); | ||
| 112 | fetchRestaurant(uid) | 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 | .catch(() => setNotFound(true)); | 118 | .catch(() => setNotFound(true)); | ||
| 115 | window.scrollTo({ top: 0 }); | 119 | window.scrollTo({ top: 0 }); | ||
| 116 | }, [uid]); | 120 | }, [uid]); | ||
modified
frontend/src/styles.css
+11 −0
@@ -223,6 +223,17 @@ img { display: block; } | |||||
| 223 | } | 223 | } | ||
| 224 | @media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; padding-bottom: 24px; } } | 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 | .card { | 237 | .card { | ||
| 227 | background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); | 238 | background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); | ||
| 228 | overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-off-soft); | 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 | chain: str | None = None, | 116 | chain: str | None = None, | ||
| 117 | source: str | None = None, | 117 | source: str | None = None, | ||
| 118 | q: str | None = None, | 118 | q: str | None = None, | ||
| 119 | + uids: str | None = None, # liste d'uids séparés par des virgules | ||||
| 119 | has_menu: int | None = None, # 1 = seulement les restos avec menu | 120 | has_menu: int | None = None, # 1 = seulement les restos avec menu | ||
| 120 | active: int = 1, | 121 | active: int = 1, | ||
| 121 | sort: str = "menu", # menu | name | price | recent | 122 | sort: str = "menu", # menu | name | price | recent | ||
@@ -148,6 +149,11 @@ def list_restaurants( | |||||
| 148 | if q: | 149 | if q: | ||
| 149 | sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)" | 150 | sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)" | ||
| 150 | args += [f"%{q}%"] * 4 | 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 | if has_menu == 1: | 157 | if has_menu == 1: | ||
| 152 | sql += " AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)" | 158 | sql += " AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)" | ||
| 153 | elif has_menu == 0: | 159 | elif has_menu == 0: | ||
| 154 | 160 | ||||