favoris unifiés « Mon univers Ka » : le hub Groupe KA devient le magasin central
- restoka/hubfav.py : client HMAC du hub (calqué sur foodka/hubfav.py) — toggle synchrone, lecture avec cache mémoire 30 s invalidé au toggle - web.py : GET /api/favorites (liste du hub + fiches locales) et POST /api/favorites/toggle, protégés par la session KA ID (401 sinon) - auth.py : retrait des anciennes routes locales /api/favoris (plus aucun stockage local des favoris) - frontend : favorites.tsx (FavProvider/FavButton), cœur ♥ sur les cartes ET la fiche détail (non connecté : clic → connexion KA ID), page /favoris branchée au hub, entrée Favoris dans la nav, icône IcoHeart, .fav-btn.big Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 changed files +370 −107
modified
frontend/src/App.tsx
+5 −2
@@ -13,8 +13,9 @@ import { | ||
| 13 | 13 | registerSourceNames, sourceName, |
| 14 | 14 | } from "./api"; |
| 15 | 15 | import { |
| 16 | − IcoChart, IcoCompass, IcoFolder, IcoPlate, IcoSearch, | |
| 16 | + IcoChart, IcoCompass, IcoFolder, IcoHeart, IcoPlate, IcoSearch, | |
| 17 | 17 | } from "./components/Icons"; |
| 18 | +import { FavProvider } from "./favorites"; | |
| 18 | 19 | import { LogoIcon } from "./components/Logo"; |
| 19 | 20 | import GroupeKaBadge from "./ka/GroupeKaBadge"; |
| 20 | 21 | import KaFooter from "./ka/KaFooter"; |
@@ -139,6 +140,7 @@ function Ticker() { | ||
| 139 | 140 | |
| 140 | 141 | const NAV_LINKS = [ |
| 141 | 142 | { to: "/", label: "Restos", icon: <IcoPlate size={17} />, end: true }, |
| 143 | + { to: "/favoris", label: "Favoris", icon: <IcoHeart size={17} />, end: false }, | |
| 142 | 144 | { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false }, |
| 143 | 145 | { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false }, |
| 144 | 146 | { to: "/contact", label: "Contact", icon: <IcoCompass size={17} />, end: false }, |
@@ -224,7 +226,6 @@ function Footer() { | ||
| 224 | 226 | <NavLink key={l.to} to={l.to} end={l.end}>{l.label}</NavLink> |
| 225 | 227 | ))} |
| 226 | 228 | <NavLink to="/villes">Villes</NavLink> |
| 227 | − <NavLink to="/favoris">Favoris</NavLink> | |
| 228 | 229 | </nav> |
| 229 | 230 | <div className="prefoot-osm"> |
| 230 | 231 | Données de découverte ©{" "} |
@@ -261,6 +262,7 @@ function MobileTabBar() { | ||
| 261 | 262 | export default function App() { |
| 262 | 263 | return ( |
| 263 | 264 | <AccountProvider> |
| 265 | + <FavProvider> | |
| 264 | 266 | <Header /> |
| 265 | 267 | <main> |
| 266 | 268 | <Routes> |
@@ -286,6 +288,7 @@ export default function App() { | ||
| 286 | 288 | </main> |
| 287 | 289 | <Footer /> |
| 288 | 290 | <MobileTabBar /> |
| 291 | + </FavProvider> | |
| 289 | 292 | </AccountProvider> |
| 290 | 293 | ); |
| 291 | 294 | } |
modified
frontend/src/account.tsx
+6 −25
@@ -2,51 +2,32 @@ | ||
| 2 | 2 | // Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | // File: account.tsx |
| 4 | 4 | // Desc: Contexte de compte membre (« Se connecter avec KA » — hub Groupe KA) : |
| 5 | −// profil de session + favoris partagés, comme le account.tsx de Lou·Ka. | |
| 5 | +// profil de session, comme le account.tsx de Lou·Ka. Les favoris ♥ | |
| 6 | +// vivent au hub Groupe KA (« Mon univers Ka ») — voir favorites.tsx. | |
| 6 | 7 | // ============================================================================== |
| 7 | 8 | import { |
| 8 | 9 | createContext, useCallback, useContext, useEffect, useMemo, useState, |
| 9 | 10 | } from "react"; |
| 10 | −import { Me, fetchFavoris, fetchMe, toggleFavori } from "./api"; | |
| 11 | +import { Me, fetchMe } from "./api"; | |
| 11 | 12 | |
| 12 | 13 | interface AccountCtx { |
| 13 | 14 | me: Me | null; |
| 14 | − favs: Set<string>; | |
| 15 | 15 | refresh: () => void; |
| 16 | − toggleFav: (uid: string) => Promise<void>; | |
| 17 | 16 | } |
| 18 | 17 | |
| 19 | −const Ctx = createContext<AccountCtx>({ | |
| 20 | − me: null, favs: new Set(), refresh: () => {}, toggleFav: async () => {}, | |
| 21 | −}); | |
| 18 | +const Ctx = createContext<AccountCtx>({ me: null, refresh: () => {} }); | |
| 22 | 19 | |
| 23 | 20 | export const useAccount = () => useContext(Ctx); |
| 24 | 21 | |
| 25 | 22 | export function AccountProvider({ children }: { children: React.ReactNode }) { |
| 26 | 23 | const [me, setMe] = useState<Me | null>(null); |
| 27 | − const [favs, setFavs] = useState<Set<string>>(new Set()); | |
| 28 | 24 | |
| 29 | 25 | const refresh = useCallback(() => { |
| 30 | − fetchMe().then((m) => { | |
| 31 | − setMe(m); | |
| 32 | − if (m) fetchFavoris().then((f) => setFavs(new Set(f.uids))).catch(() => {}); | |
| 33 | − else setFavs(new Set()); | |
| 34 | − }); | |
| 26 | + fetchMe().then((m) => setMe(m)); | |
| 35 | 27 | }, []); |
| 36 | 28 | |
| 37 | 29 | useEffect(() => { refresh(); }, [refresh]); |
| 38 | 30 | |
| 39 | − const toggleFav = useCallback(async (uid: string) => { | |
| 40 | − const on = await toggleFavori(uid); | |
| 41 | − setFavs((prev) => { | |
| 42 | − const next = new Set(prev); | |
| 43 | − if (on) next.add(uid); | |
| 44 | − else next.delete(uid); | |
| 45 | − return next; | |
| 46 | − }); | |
| 47 | − }, []); | |
| 48 | − | |
| 49 | − const value = useMemo(() => ({ me, favs, refresh, toggleFav }), | |
| 50 | − [me, favs, refresh, toggleFav]); | |
| 31 | + const value = useMemo(() => ({ me, refresh }), [me, refresh]); | |
| 51 | 32 | return <Ctx.Provider value={value}>{children}</Ctx.Provider>; |
| 52 | 33 | } |
modified
frontend/src/api.ts
+23 −5
@@ -306,11 +306,29 @@ export async function logout(): Promise<void> { | ||
| 306 | 306 | await fetch("/api/auth/logout", { method: "POST" }); |
| 307 | 307 | } |
| 308 | 308 | |
| 309 | −export const fetchFavoris = () => | |
| 310 | − get<{ count: number; restaurants: Restaurant[]; uids: string[] }>("/api/favoris"); | |
| 309 | +// --- Favoris « Mon univers Ka » (magasin central : hub Groupe KA) ------------ | |
| 310 | + | |
| 311 | +/** Item de favori tel que poussé au hub Groupe KA (groupe-ka.com). */ | |
| 312 | +export interface FavItem { | |
| 313 | + item_id: string; | |
| 314 | + title: string; | |
| 315 | + subtitle?: string; | |
| 316 | + price_label?: string; | |
| 317 | + image_url?: string; | |
| 318 | + url?: string; | |
| 319 | +} | |
| 311 | 320 | |
| 312 | −export async function toggleFavori(uid: string): Promise<boolean> { | |
| 313 | − const resp = await fetch(`/api/favoris/${encodeURIComponent(uid)}`, { method: "POST" }); | |
| 321 | +/** Favoris du membre (lus au hub) + fiches locales correspondantes. */ | |
| 322 | +export const fetchFavorites = () => | |
| 323 | + get<{ ids: string[]; items: FavItem[]; restaurants: Restaurant[] }>("/api/favorites"); | |
| 324 | + | |
| 325 | +/** Pousse un ♥ (on=true : ajout ; on=false : retrait) au hub, via l'API locale. */ | |
| 326 | +export async function toggleFavorite(on: boolean, item: FavItem): Promise<void> { | |
| 327 | + const resp = await fetch("/api/favorites/toggle", { | |
| 328 | + method: "POST", | |
| 329 | + credentials: "same-origin", | |
| 330 | + headers: { "Content-Type": "application/json" }, | |
| 331 | + body: JSON.stringify({ on, item }), | |
| 332 | + }); | |
| 314 | 333 | if (!resp.ok) throw new Error(`API ${resp.status}`); |
| 315 | − return (await resp.json()).favori as boolean; | |
| 316 | 334 | } |
modified
frontend/src/components/Icons.tsx
+6 −0
@@ -78,3 +78,9 @@ export const IcoBag = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | ||
| 78 | 78 | <path d="M9 8a3 3 0 0 1 6 0" /> |
| 79 | 79 | </svg> |
| 80 | 80 | ); |
| 81 | + | |
| 82 | +export const IcoHeart = ({ size = 18, strokeWidth = 2 }: IcoProps) => ( | |
| 83 | + <svg {...base(size)} strokeWidth={strokeWidth} aria-hidden="true"> | |
| 84 | + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> | |
| 85 | + </svg> | |
| 86 | +); | |
modified
frontend/src/components/RestoCard.tsx
+2 −13
@@ -6,18 +6,16 @@ | ||
| 6 | 6 | // TOUJOURS affiché (CLAUDE.md §6.2), source en pied de carte. |
| 7 | 7 | // ============================================================================== |
| 8 | 8 | import { Link } from "react-router-dom"; |
| 9 | −import { useAccount } from "../account"; | |
| 10 | 9 | import { |
| 11 | 10 | CONTEXT_SHORT, Restaurant, cuisineLabel, fmtPrice, sourceName, typeLabel, |
| 12 | 11 | } from "../api"; |
| 12 | +import { FavButton } from "../favorites"; | |
| 13 | 13 | import { IcoPlate } from "./Icons"; |
| 14 | 14 | |
| 15 | 15 | export default function RestoCard({ r }: { r: Restaurant }) { |
| 16 | 16 | const ms = r.menu_summary; |
| 17 | 17 | const img = ms?.image || (r.images ?? [])[1] || null; // photo de plat d'abord |
| 18 | 18 | const logo = (r.images ?? [])[0] || null; |
| 19 | − const { me, favs, toggleFav } = useAccount(); | |
| 20 | − const fav = favs.has(r.uid); | |
| 21 | 19 | return ( |
| 22 | 20 | <Link to={`/resto/${encodeURIComponent(r.uid)}`} className="card"> |
| 23 | 21 | <div className="card-img"> |
@@ -34,16 +32,7 @@ export default function RestoCard({ r }: { r: Restaurant }) { | ||
| 34 | 32 | {CONTEXT_SHORT[ms.price_context] ?? ms.price_context} |
| 35 | 33 | </span> |
| 36 | 34 | )} |
| 37 | − {me && ( | |
| 38 | − <button | |
| 39 | − className={`fav-btn ${fav ? "on" : ""}`} | |
| 40 | − aria-label={fav ? "Retirer des favoris" : "Ajouter aux favoris"} | |
| 41 | − aria-pressed={fav} | |
| 42 | − onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFav(r.uid); }} | |
| 43 | − > | |
| 44 | − {fav ? "♥" : "♡"} | |
| 45 | − </button> | |
| 46 | − )} | |
| 35 | + <FavButton r={r} /> | |
| 47 | 36 | </div> |
| 48 | 37 | <div className="card-body"> |
| 49 | 38 | <div className="card-price"> |
added
frontend/src/favorites.tsx
+114 −0
@@ -0,0 +1,114 @@ | ||
| 1 | +// ============================================================================== | |
| 2 | +// Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +// File: favorites.tsx | |
| 4 | +// Desc: Favoris ♥ « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) est | |
| 5 | +// le magasin central des favoris du groupe (aucun stockage local ni | |
| 6 | +// navigateur) : lecture/écriture via l'API locale /api/favorites qui | |
| 7 | +// relaie au hub. FavProvider charge la liste une fois ; FavButton = | |
| 8 | +// cœur sur les cartes de restos et la fiche détail. Non connecté → | |
| 9 | +// clic = « Se connecter avec KA ID ». Calqué sur foodka/favorites.tsx. | |
| 10 | +// ============================================================================== | |
| 11 | +import { | |
| 12 | + ReactNode, createContext, useCallback, useContext, | |
| 13 | + useEffect, useMemo, useState, | |
| 14 | +} from "react"; | |
| 15 | +import { FavItem, Restaurant, fmtPrice, toggleFavorite } from "./api"; | |
| 16 | + | |
| 17 | +interface FavState { | |
| 18 | + ready: boolean; // premier chargement terminé | |
| 19 | + connected: boolean; // session KA ID active | |
| 20 | + ids: Set<string>; // uid des restos en favoris | |
| 21 | + items: FavItem[]; | |
| 22 | + toggle: (item: FavItem) => void; | |
| 23 | +} | |
| 24 | + | |
| 25 | +const FavContext = createContext<FavState>({ | |
| 26 | + ready: false, connected: false, ids: new Set(), items: [], toggle: () => {}, | |
| 27 | +}); | |
| 28 | + | |
| 29 | +/** Restaurant -> item de favori du hub (nom, ville, prix médian, image, url). */ | |
| 30 | +export function restoFavItem(r: Restaurant): FavItem { | |
| 31 | + const ms = r.menu_summary; | |
| 32 | + return { | |
| 33 | + item_id: r.uid, | |
| 34 | + title: r.name || "Restaurant", | |
| 35 | + subtitle: [r.city, r.region].filter(Boolean).join(" · ") | |
| 36 | + || r.address || "", | |
| 37 | + price_label: ms?.price_median != null | |
| 38 | + ? `${fmtPrice(ms.price_median)} · plat médian` | |
| 39 | + : (r.price_range || ""), | |
| 40 | + image_url: ms?.image || (r.images ?? [])[1] || (r.images ?? [])[0] || "", | |
| 41 | + url: `https://www.resto-ka.com/resto/${encodeURIComponent(r.uid)}`, | |
| 42 | + }; | |
| 43 | +} | |
| 44 | + | |
| 45 | +export function FavProvider({ children }: { children: ReactNode }) { | |
| 46 | + const [ready, setReady] = useState(false); | |
| 47 | + const [connected, setConnected] = useState(false); | |
| 48 | + const [items, setItems] = useState<FavItem[]>([]); | |
| 49 | + | |
| 50 | + useEffect(() => { | |
| 51 | + fetch("/api/favorites", { credentials: "same-origin" }) | |
| 52 | + .then(async (res) => { | |
| 53 | + if (res.status === 401) return; // pas connecté : cœurs vides | |
| 54 | + if (!res.ok) throw new Error(`API ${res.status}`); | |
| 55 | + const data = (await res.json()) as { items?: FavItem[] }; | |
| 56 | + setConnected(true); | |
| 57 | + setItems(data.items ?? []); | |
| 58 | + }) | |
| 59 | + .catch(() => {}) // hub muet : cœurs vides | |
| 60 | + .finally(() => setReady(true)); | |
| 61 | + }, []); | |
| 62 | + | |
| 63 | + const toggle = useCallback((item: FavItem) => { | |
| 64 | + if (!connected) { | |
| 65 | + // non connecté : passage par la connexion KA ID (hub Groupe KA) | |
| 66 | + window.location.assign("/api/auth/ka/login"); | |
| 67 | + return; | |
| 68 | + } | |
| 69 | + const prev = items; | |
| 70 | + const on = !prev.some((i) => i.item_id === item.item_id); | |
| 71 | + // optimiste : le hub confirme (sinon on rétablit) | |
| 72 | + setItems(on ? [...prev, item] | |
| 73 | + : prev.filter((i) => i.item_id !== item.item_id)); | |
| 74 | + toggleFavorite(on, item).catch(() => setItems(prev)); | |
| 75 | + }, [connected, items]); | |
| 76 | + | |
| 77 | + const ids = useMemo( | |
| 78 | + () => new Set(items.map((i) => i.item_id)), [items]); | |
| 79 | + | |
| 80 | + return ( | |
| 81 | + <FavContext.Provider value={{ ready, connected, ids, items, toggle }}> | |
| 82 | + {children} | |
| 83 | + </FavContext.Provider> | |
| 84 | + ); | |
| 85 | +} | |
| 86 | + | |
| 87 | +export const useFav = () => useContext(FavContext); | |
| 88 | + | |
| 89 | +/** Cœur ♥ — cartes de resto (par défaut) et fiche détail (`big`). */ | |
| 90 | +export function FavButton({ r, big = false }: { r: Restaurant; big?: boolean }) { | |
| 91 | + const { connected, ids, toggle } = useFav(); | |
| 92 | + const on = ids.has(r.uid); | |
| 93 | + const label = !connected | |
| 94 | + ? "Se connecter avec KA ID pour ajouter aux favoris" | |
| 95 | + : on | |
| 96 | + ? "Retirer de mes favoris (Mon univers Ka)" | |
| 97 | + : "Ajouter à mes favoris (Mon univers Ka)"; | |
| 98 | + return ( | |
| 99 | + <button | |
| 100 | + type="button" | |
| 101 | + className={`fav-btn${on ? " on" : ""}${big ? " big" : ""}`} | |
| 102 | + aria-label={label} | |
| 103 | + aria-pressed={on} | |
| 104 | + title={label} | |
| 105 | + onClick={(e) => { | |
| 106 | + e.preventDefault(); // la carte entière est un lien | |
| 107 | + e.stopPropagation(); | |
| 108 | + toggle(restoFavItem(r)); | |
| 109 | + }} | |
| 110 | + > | |
| 111 | + {on ? "♥" : "♡"} | |
| 112 | + </button> | |
| 113 | + ); | |
| 114 | +} | |
modified
frontend/src/pages/Favoris.tsx
+47 −14
@@ -1,56 +1,89 @@ | ||
| 1 | 1 | // ============================================================================== |
| 2 | 2 | // Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | // File: pages/Favoris.tsx |
| 4 | −// Desc: Restos favoris du membre connecté (« Se connecter avec KA »). | |
| 4 | +// Desc: Restos favoris ♥ « Mon univers Ka » du membre connecté — lus au hub | |
| 5 | +// Groupe KA (groupe-ka.com), magasin central des favoris du groupe : | |
| 6 | +// les mêmes ♥ suivent le membre sur toutes les plateformes ·Ka. | |
| 5 | 7 | // ============================================================================== |
| 6 | 8 | import { useEffect, useState } from "react"; |
| 9 | +import { Link } from "react-router-dom"; | |
| 7 | 10 | import { useAccount } from "../account"; |
| 8 | −import { Restaurant, fetchFavoris } from "../api"; | |
| 11 | +import { Restaurant, fetchFavorites } from "../api"; | |
| 9 | 12 | import RestoCard from "../components/RestoCard"; |
| 13 | +import { useFav } from "../favorites"; | |
| 10 | 14 | |
| 11 | 15 | export default function FavorisPage() { |
| 12 | − const { me, favs } = useAccount(); | |
| 16 | + const { me } = useAccount(); | |
| 17 | + const { ready, connected, ids, items } = useFav(); | |
| 13 | 18 | const [restos, setRestos] = useState<Restaurant[]>([]); |
| 14 | 19 | const [loading, setLoading] = useState(true); |
| 15 | 20 | |
| 16 | 21 | useEffect(() => { |
| 17 | − if (!me) { setLoading(false); return; } | |
| 18 | − fetchFavoris() | |
| 22 | + if (!ready) return; | |
| 23 | + if (!connected) { setLoading(false); return; } | |
| 24 | + fetchFavorites() | |
| 19 | 25 | .then((f) => setRestos(f.restaurants)) |
| 20 | 26 | .catch(() => {}) |
| 21 | 27 | .finally(() => setLoading(false)); |
| 22 | − }, [me, favs.size]); | |
| 28 | + }, [ready, connected]); | |
| 23 | 29 | |
| 24 | − if (!me) { | |
| 30 | + if (ready && !connected) { | |
| 25 | 31 | return ( |
| 26 | 32 | <div className="notice container"> |
| 27 | 33 | <h2>Connectez-vous pour vos favoris</h2> |
| 28 | 34 | <p> |
| 29 | 35 | <a className="login-btn" href="/api/auth/ka/login" style={{ display: "inline-flex" }}> |
| 30 | 36 | <span className="ka-badge" aria-hidden="true">KA</span> |
| 31 | − Se connecter avec KA | |
| 37 | + Se connecter avec KA ID | |
| 32 | 38 | </a> |
| 33 | 39 | </p> |
| 34 | − <p>Un seul compte Groupe KA pour Lou·Ka, Resto·Ka et toutes les plateformes ·Ka.</p> | |
| 40 | + <p> | |
| 41 | + Un seul compte Groupe KA : vos ♥ vous suivent sur Resto·Ka, Lou·Ka, | |
| 42 | + Food·Ka et toutes les plateformes ·Ka — c'est « Mon univers Ka ». | |
| 43 | + </p> | |
| 35 | 44 | </div> |
| 36 | 45 | ); |
| 37 | 46 | } |
| 38 | 47 | |
| 48 | + // fiches locales encore en favoris (le toggle retire la carte sans recharger) | |
| 49 | + const shown = restos.filter((r) => ids.has(r.uid)); | |
| 50 | + // favoris du hub sans fiche locale (resto retiré du catalogue) : carte simple | |
| 51 | + const known = new Set(restos.map((r) => r.uid)); | |
| 52 | + const extras = items.filter((i) => i.item_id && !known.has(i.item_id)); | |
| 53 | + | |
| 39 | 54 | return ( |
| 40 | 55 | <div className="container sources"> |
| 41 | − <span className="kicker">Mon compte — {me.ka_id}</span> | |
| 56 | + <span className="kicker">Mon univers Ka{me?.ka_id ? ` — ${me.ka_id}` : ""}</span> | |
| 42 | 57 | <h1>Mes restos favoris</h1> |
| 43 | − <p className="sub">Les restos que vous avez épinglés, sur tous vos appareils.</p> | |
| 44 | − {loading ? ( | |
| 58 | + <p className="sub"> | |
| 59 | + Vos ♥ sont conservés au hub Groupe KA : les mêmes favoris, sur tous vos | |
| 60 | + appareils et toutes les plateformes ·Ka. | |
| 61 | + </p> | |
| 62 | + {loading || !ready ? ( | |
| 45 | 63 | <p>Chargement…</p> |
| 46 | − ) : restos.length === 0 ? ( | |
| 64 | + ) : shown.length === 0 && extras.length === 0 ? ( | |
| 47 | 65 | <div className="notice"> |
| 48 | 66 | <h2>Aucun favori pour l'instant</h2> |
| 49 | 67 | <p>Touchez le ♥ sur une fiche de resto pour l'épingler ici.</p> |
| 50 | 68 | </div> |
| 51 | 69 | ) : ( |
| 52 | 70 | <div className="grid"> |
| 53 | − {restos.map((r) => <RestoCard key={r.uid} r={r} />)} | |
| 71 | + {shown.map((r) => <RestoCard key={r.uid} r={r} />)} | |
| 72 | + {extras.map((i) => ( | |
| 73 | + <Link key={i.item_id} className="card" | |
| 74 | + to={`/resto/${encodeURIComponent(i.item_id)}`}> | |
| 75 | + <div className="card-img"> | |
| 76 | + {i.image_url | |
| 77 | + ? <img src={i.image_url} alt={i.title} loading="lazy" /> | |
| 78 | + : <div className="noimg" />} | |
| 79 | + </div> | |
| 80 | + <div className="card-body"> | |
| 81 | + {i.price_label && <div className="card-price">{i.price_label}</div>} | |
| 82 | + <div className="card-title">{i.title}</div> | |
| 83 | + {i.subtitle && <div className="card-meta"><span>{i.subtitle}</span></div>} | |
| 84 | + </div> | |
| 85 | + </Link> | |
| 86 | + ))} | |
| 54 | 87 | </div> |
| 55 | 88 | )} |
| 56 | 89 | </div> |
modified
frontend/src/pages/Resto.tsx
+2 −0
@@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | fetchRestaurant, fmtDate, fmtPrice, pushRecentUid, sourceName, typeLabel, |
| 14 | 14 | } from "../api"; |
| 15 | 15 | import { IcoCompass } from "../components/Icons"; |
| 16 | +import { FavButton } from "../favorites"; | |
| 16 | 17 | |
| 17 | 18 | function MenuBlock({ menu }: { menu: Menu }) { |
| 18 | 19 | const [openSections, setOpenSections] = useState<Set<number>>( |
@@ -162,6 +163,7 @@ export default function RestoPage() { | ||
| 162 | 163 | <img className="panel-logo" src={resto.images[0]} alt="" loading="lazy" /> |
| 163 | 164 | )} |
| 164 | 165 | {resto.price_range && <div className="price">{resto.price_range}</div>} |
| 166 | + <FavButton r={resto} big /> | |
| 165 | 167 | </div> |
| 166 | 168 | <h1>{resto.name}</h1> |
| 167 | 169 | <div className="loc"> |
modified
frontend/src/styles.css
+5 −0
@@ -793,6 +793,11 @@ img { display: block; } | ||
| 793 | 793 | } |
| 794 | 794 | .fav-btn:hover { transform: scale(1.08); } |
| 795 | 795 | .fav-btn.on { background: var(--accent); color: var(--ink); } |
| 796 | +/* fiche détail : le cœur dans l'en-tête du panneau, en flux (pas absolu) */ | |
| 797 | +.fav-btn.big { | |
| 798 | + position: static; flex-shrink: 0; margin-left: auto; | |
| 799 | + width: 44px; height: 44px; font-size: 21px; | |
| 800 | +} | |
| 796 | 801 | |
| 797 | 802 | /* --- Menu déroulant mobile ------------------------------------------------- */ |
| 798 | 803 | .menu-btn { |
modified
restoka/auth.py
+5 −46
@@ -12,7 +12,7 @@ | ||
| 12 | 12 | # GET /api/me -> profil (fusionné avec le hub) |
| 13 | 13 | # POST /api/auth/logout -> efface le cookie |
| 14 | 14 | # GET /api/auth/config -> {"ka": bool} |
| 15 | −# + FAVORIS : GET /api/favoris, POST /api/favoris/{uid} (toggle). | |
| 15 | +# (FAVORIS : au hub Groupe KA — voir hubfav.py + routes dans web.py.) | |
| 16 | 16 | # Session : jeton HMAC-SHA256 (stdlib) dans un cookie httpOnly. |
| 17 | 17 | # Config .env : KA_SSO_SECRET, SESSION_SECRET, RESTOKA_BASE_URL. |
| 18 | 18 | # ============================================================================== |
@@ -249,48 +249,7 @@ def me(request: Request): | ||
| 249 | 249 | |
| 250 | 250 | |
| 251 | 251 | # -- favoris ------------------------------------------------------------------------ |
| 252 | − | |
| 253 | −@router.get("/favoris") | |
| 254 | −def favoris(request: Request): | |
| 255 | − """Fiches complètes des restos favoris du membre connecté.""" | |
| 256 | − user = current_user(request) | |
| 257 | − if user is None: | |
| 258 | − raise HTTPException(401, "non connecté") | |
| 259 | − from .web import _menu_summaries, _row_to_dict | |
| 260 | − con = db.connect() | |
| 261 | − rows = con.execute( | |
| 262 | − """SELECT r.* FROM favorites f JOIN restaurants r ON r.uid=f.uid | |
| 263 | − WHERE f.user_id=? ORDER BY f.ts DESC""", (user["uid"],)).fetchall() | |
| 264 | − out = [_row_to_dict(r) for r in rows] | |
| 265 | − menus = _menu_summaries(con, [r["uid"] for r in out]) | |
| 266 | − for r in out: | |
| 267 | − r["menu_summary"] = menus.get(r["uid"]) | |
| 268 | − con.close() | |
| 269 | − return {"count": len(out), "restaurants": out, | |
| 270 | − "uids": [r["uid"] for r in out]} | |
| 271 | − | |
| 272 | − | |
| 273 | −@router.post("/favoris/{uid:path}") | |
| 274 | −def toggle_favori(uid: str, request: Request): | |
| 275 | − """Ajoute/retire un resto des favoris. Retourne {favori: bool}.""" | |
| 276 | − user = current_user(request) | |
| 277 | − if user is None: | |
| 278 | − raise HTTPException(401, "non connecté") | |
| 279 | − con = db.connect() | |
| 280 | − if con.execute("SELECT 1 FROM restaurants WHERE uid=?", (uid,)).fetchone() is None: | |
| 281 | − con.close() | |
| 282 | − raise HTTPException(404, "resto introuvable") | |
| 283 | − existing = con.execute( | |
| 284 | − "SELECT 1 FROM favorites WHERE user_id=? AND uid=?", | |
| 285 | − (user["uid"], uid)).fetchone() | |
| 286 | − if existing: | |
| 287 | − con.execute("DELETE FROM favorites WHERE user_id=? AND uid=?", | |
| 288 | − (user["uid"], uid)) | |
| 289 | − fav = False | |
| 290 | − else: | |
| 291 | − con.execute("INSERT INTO favorites (user_id, uid, ts) VALUES (?,?,?)", | |
| 292 | − (user["uid"], uid, time.time())) | |
| 293 | − fav = True | |
| 294 | − con.commit() | |
| 295 | − con.close() | |
| 296 | − return {"favori": fav} | |
| 252 | +# Les favoris « Mon univers Ka » vivent au HUB Groupe KA (groupe-ka.com), | |
| 253 | +# magasin central du groupe — plus AUCUN stockage local. Voir restoka/hubfav.py | |
| 254 | +# et les routes /api/favorites + /api/favorites/toggle dans restoka/web.py. | |
| 255 | +# (Les anciennes routes locales /api/favoris ont été retirées le 2026-08-23.) | |
added
restoka/hubfav.py
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: restoka/hubfav.py | |
| 4 | +# Desc: Favoris « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) est le | |
| 5 | +# MAGASIN CENTRAL des favoris du groupe : Resto·Ka ne stocke rien | |
| 6 | +# localement. Chaque ♥ est poussé au hub (POST signé HMAC, synchrone : | |
| 7 | +# un échec remonte à l'appelant) et la liste est lue au hub (GET signé, | |
| 8 | +# cache mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO. | |
| 9 | +# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel). | |
| 10 | +# Calqué sur foodka/hubfav.py (implémentation de référence du groupe). | |
| 11 | +# ============================================================================== | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import hashlib | |
| 15 | +import hmac | |
| 16 | +import os | |
| 17 | +import threading | |
| 18 | +import time | |
| 19 | + | |
| 20 | +import requests | |
| 21 | + | |
| 22 | +CLIENT_ID = "resto-ka" | |
| 23 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 24 | +CACHE_TTL = 30 # secondes — liste des favoris | |
| 25 | +TIMEOUT = 6 # secondes | |
| 26 | + | |
| 27 | +_cache: dict[str, tuple[float, list[dict]]] = {} | |
| 28 | +_lock = threading.Lock() | |
| 29 | + | |
| 30 | +# champs d'item acceptés -> longueur maximale (troncature défensive) | |
| 31 | +_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200, | |
| 32 | + "price_label": 60, "image_url": 500, "url": 500} | |
| 33 | + | |
| 34 | + | |
| 35 | +def _sig(ka_id: str, ts: int) -> str | None: | |
| 36 | + """Signature HMAC-SHA256 du hub : hex("resto-ka.<ka_id>.<ts>").""" | |
| 37 | + secret = os.environ.get("KA_SSO_SECRET") | |
| 38 | + if not secret: | |
| 39 | + return None | |
| 40 | + return hmac.new(secret.encode(), | |
| 41 | + f"{CLIENT_ID}.{ka_id}.{ts}".encode(), | |
| 42 | + hashlib.sha256).hexdigest() | |
| 43 | + | |
| 44 | + | |
| 45 | +def linked(ka_id: str | None) -> bool: | |
| 46 | + """Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe).""" | |
| 47 | + return bool(ka_id) and str(ka_id).startswith("ka-") | |
| 48 | + | |
| 49 | + | |
| 50 | +def clean_item(item: dict) -> dict: | |
| 51 | + """Ne garde que les champs d'item connus, en chaînes tronquées.""" | |
| 52 | + return {k: str(item.get(k) or "")[:n] | |
| 53 | + for k, n in _FIELDS.items() if item.get(k)} | |
| 54 | + | |
| 55 | + | |
| 56 | +def hub_toggle(ka_id: str, action: str, item: dict) -> bool: | |
| 57 | + """Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s : | |
| 58 | + le hub est le magasin des favoris, l'échec doit remonter à l'appelant.""" | |
| 59 | + ts = int(time.time()) | |
| 60 | + sig = _sig(ka_id, ts) | |
| 61 | + if not sig or not linked(ka_id) or action not in ("add", "remove"): | |
| 62 | + return False | |
| 63 | + try: | |
| 64 | + r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, | |
| 65 | + json={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 66 | + "ts": str(ts), "sig": sig, | |
| 67 | + "action": action, "item": item}) | |
| 68 | + ok = r.status_code == 200 | |
| 69 | + except Exception: | |
| 70 | + ok = False | |
| 71 | + if ok: | |
| 72 | + with _lock: | |
| 73 | + _cache.pop(ka_id, None) # la prochaine lecture reflète le toggle | |
| 74 | + return ok | |
| 75 | + | |
| 76 | + | |
| 77 | +def hub_list(ka_id: str) -> list[dict] | None: | |
| 78 | + """Favoris Resto·Ka du membre, lus au hub (cache mémoire 30 s). | |
| 79 | + [] = aucun favori ; None = hub injoignable (erreur, jamais mise en cache).""" | |
| 80 | + if not linked(ka_id): | |
| 81 | + return [] # compte legacy non relié au hub | |
| 82 | + now = time.time() | |
| 83 | + with _lock: | |
| 84 | + hit = _cache.get(ka_id) | |
| 85 | + if hit and now - hit[0] < CACHE_TTL: | |
| 86 | + return hit[1] | |
| 87 | + sig = _sig(ka_id, int(now)) | |
| 88 | + if not sig: | |
| 89 | + return None | |
| 90 | + try: | |
| 91 | + r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, | |
| 92 | + params={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 93 | + "ts": int(now), "sig": sig}) | |
| 94 | + if r.status_code != 200: | |
| 95 | + return None | |
| 96 | + favs = r.json().get("favorites") or [] | |
| 97 | + if not isinstance(favs, list): | |
| 98 | + return None | |
| 99 | + except Exception: | |
| 100 | + return None | |
| 101 | + favs = [f for f in favs if isinstance(f, dict)] | |
| 102 | + with _lock: | |
| 103 | + _cache[ka_id] = (now, favs) | |
| 104 | + return favs | |
modified
restoka/web.py
+51 −2
@@ -13,13 +13,13 @@ import statistics | ||
| 13 | 13 | import threading |
| 14 | 14 | from pathlib import Path |
| 15 | 15 | |
| 16 | −from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query | |
| 16 | +from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request | |
| 17 | 17 | from fastapi.middleware.cors import CORSMiddleware |
| 18 | 18 | from fastapi.middleware.gzip import GZipMiddleware |
| 19 | 19 | from fastapi.responses import FileResponse, Response |
| 20 | 20 | from fastapi.staticfiles import StaticFiles |
| 21 | 21 | |
| 22 | −from . import db, ingest | |
| 22 | +from . import db, hubfav, ingest | |
| 23 | 23 | from .normalize import PRICE_CONTEXTS |
| 24 | 24 | from .regions import REGIONS |
| 25 | 25 | |
@@ -535,6 +535,55 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None): | ||
| 535 | 535 | return {"status": "démarré", "source": source or "toutes"} |
| 536 | 536 | |
| 537 | 537 | |
| 538 | +# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ----- | |
| 539 | +@app.get("/api/favorites") | |
| 540 | +def favorites(request: Request): | |
| 541 | + """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage | |
| 542 | + local). Retourne aussi les fiches locales correspondantes (ordre du hub) | |
| 543 | + pour l'affichage de la page /favoris.""" | |
| 544 | + user = auth.current_user(request) | |
| 545 | + if not user: | |
| 546 | + raise HTTPException(401, "Connexion KA ID requise") | |
| 547 | + items = hubfav.hub_list(user.get("ka_id") or "") | |
| 548 | + if items is None: | |
| 549 | + raise HTTPException(502, "Hub Groupe KA injoignable — réessayez") | |
| 550 | + ids = [i["item_id"] for i in items if i.get("item_id")] | |
| 551 | + restos: list[dict] = [] | |
| 552 | + if ids: | |
| 553 | + con = db.connect() | |
| 554 | + marks = ",".join("?" * len(ids)) | |
| 555 | + rows = {r["uid"]: _row_to_dict(r) for r in con.execute( | |
| 556 | + f"SELECT * FROM restaurants WHERE uid IN ({marks})", | |
| 557 | + ids).fetchall()} | |
| 558 | + menus = _menu_summaries(con, list(rows)) | |
| 559 | + con.close() | |
| 560 | + for uid in ids: | |
| 561 | + r = rows.get(uid) | |
| 562 | + if r is not None: | |
| 563 | + r["menu_summary"] = menus.get(uid) | |
| 564 | + restos.append(r) | |
| 565 | + return {"ids": ids, "items": items, "restaurants": restos} | |
| 566 | + | |
| 567 | + | |
| 568 | +@app.post("/api/favorites/toggle") | |
| 569 | +def toggle_favorite(request: Request, body: dict = Body(...)): | |
| 570 | + """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub | |
| 571 | + Groupe KA de façon synchrone : le hub est la seule source de vérité.""" | |
| 572 | + user = auth.current_user(request) | |
| 573 | + if not user: | |
| 574 | + raise HTTPException(401, "Connexion KA ID requise") | |
| 575 | + ka_id = user.get("ka_id") or "" | |
| 576 | + if not hubfav.linked(ka_id): | |
| 577 | + raise HTTPException(403, "Compte non relié au hub Groupe KA") | |
| 578 | + on = bool(body.get("on")) | |
| 579 | + item = hubfav.clean_item(body.get("item") or {}) | |
| 580 | + if not item.get("item_id"): | |
| 581 | + raise HTTPException(422, "item.item_id requis") | |
| 582 | + if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): | |
| 583 | + raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") | |
| 584 | + return {"ok": True, "on": on} | |
| 585 | + | |
| 586 | + | |
| 538 | 587 | # --- Frontend React (build Vite) -------------------------------------------- |
| 539 | 588 | if FRONTEND_DIST.exists(): |
| 540 | 589 | app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), |
| 541 | 590 | |