SPB Git forge

spb/food-ka

Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

55commits 1branches 0releases
10.2 MBsize
maindefault branch
9 days agolast push
Python 53.9% TypeScript 24% CSS 14.9% JavaScript 5.8% HTML 1.4%

WIP SSO KA ID + favoris committé depuis le nœud — foodka/auth.py, hubfav, hubprofile, favorites.tsx, Profil.tsx, MAJ web.py + frontend

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 17, 2026) parent 44589f8

11 changed files +1,109 −10

added foodka/auth.py +224 −0
@@ -0,0 +1,224 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (hub groupe-ka.com)
5 +#
6 +# Identité PARTAGÉE Groupe-Ka : le hub (https://www.groupe-ka.com) authentifie
7 +# l'utilisateur et le renvoie ici avec un JWT HS256 signé du secret partagé
8 +# KA_SSO_SECRET. Le profil (ka_id « ka-0123456789 », courriel, nom, photo)
9 +# est LE MÊME sur toutes les plateformes du groupe.
10 +#
11 +# Session locale : JWT HS256 maison (hmac/base64, aucune dépendance) signé
12 +# avec SESSION_SECRET, cookie httponly 30 jours.
13 +# Config .env : KA_SSO_SECRET, KA_HUB_URL, FOODKA_BASE_URL, SESSION_SECRET.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import base64
18 +import hashlib
19 +import hmac
20 +import json
21 +import os
22 +import time
23 +import urllib.parse
24 +
25 +from fastapi import APIRouter, Request
26 +from fastapi.responses import JSONResponse, RedirectResponse
27 +
28 +from . import db
29 +from .hubprofile import fetch_hub_profile, to_epoch
30 +
31 +router = APIRouter(prefix="/api/auth")
32 +
33 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
34 +KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")
35 +# base publique de CETTE app (l'URL de rappel en découle)
36 +BASE_URL = os.environ.get("FOODKA_BASE_URL", "http://localhost:8097").rstrip("/")
37 +SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256(
38 + (KA_SSO_SECRET or "foodka-dev").encode()).hexdigest()
39 +COOKIE = "foodka_session"
40 +SESSION_DAYS = 30
41 +
42 +
43 +# -- JWT HS256 minimal (aucune dépendance) -----------------------------------
44 +def _b64(d: bytes) -> str:
45 + return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
46 +
47 +
48 +def _unb64(s: str) -> bytes:
49 + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
50 +
51 +
52 +def jwt_encode(payload: dict) -> str:
53 + head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
54 + body = _b64(json.dumps(payload, separators=(",", ":")).encode())
55 + sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())
56 + return f"{head}.{body}.{sig}"
57 +
58 +
59 +def jwt_decode(token: str) -> dict | None:
60 + try:
61 + head, body, sig = token.split(".")
62 + good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())
63 + if not hmac.compare_digest(sig, good):
64 + return None
65 + payload = json.loads(_unb64(body))
66 + if payload.get("exp", 0) < time.time():
67 + return None
68 + return payload
69 + except Exception:
70 + return None
71 +
72 +
73 +def _ka_verify(token: str) -> dict | None:
74 + """Vérifie le JWT HS256 émis par le hub KA (stdlib seulement) :
75 + signature, alg, iss, aud, exp."""
76 + if not KA_SSO_SECRET:
77 + return None
78 + try:
79 + head, body, sig = token.split(".")
80 + good = _b64(hmac.new(KA_SSO_SECRET.encode(),
81 + f"{head}.{body}".encode(), hashlib.sha256).digest())
82 + if not hmac.compare_digest(sig, good):
83 + return None
84 + if json.loads(_unb64(head)).get("alg") != "HS256":
85 + return None
86 + claims = json.loads(_unb64(body))
87 + if claims.get("iss") != KA_HUB_URL:
88 + return None
89 + if claims.get("aud") != "food-ka":
90 + return None
91 + if claims.get("exp", 0) < time.time():
92 + return None
93 + return claims
94 + except Exception:
95 + return None
96 +
97 +
98 +# -- table users (clé = KA-ID du groupe, identique sur toutes les apps) -------
99 +def _ensure_table(con) -> None:
100 + con.execute("""CREATE TABLE IF NOT EXISTS users (
101 + ka_id TEXT PRIMARY KEY, -- identifiant membre Groupe KA (« ka-0123456789 »)
102 + email TEXT,
103 + name TEXT,
104 + picture TEXT,
105 + provider TEXT,
106 + created REAL,
107 + last_login REAL
108 + )""")
109 +
110 +
111 +def _upsert_user(info: dict) -> dict:
112 + con = db.connect()
113 + try:
114 + _ensure_table(con)
115 + now = time.time()
116 + con.execute(
117 + "INSERT INTO users (ka_id, email, name, picture, provider, created, last_login)"
118 + " VALUES (?,?,?,?,?,?,?)"
119 + " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email,"
120 + " name=excluded.name, picture=excluded.picture,"
121 + " provider=excluded.provider, last_login=excluded.last_login",
122 + (info["ka_id"], info.get("email", ""), info.get("name", ""),
123 + info.get("picture", ""), info.get("provider", ""), now, now))
124 + con.commit()
125 + row = con.execute("SELECT created, last_login FROM users WHERE ka_id=?",
126 + (info["ka_id"],)).fetchone()
127 + finally:
128 + con.close()
129 + return {"ka_id": info["ka_id"], "email": info.get("email", ""),
130 + "name": info.get("name", ""), "picture": info.get("picture", ""),
131 + "provider": info.get("provider", ""),
132 + "created_at": row["created"] if row else now,
133 + "last_login": row["last_login"] if row else now}
134 +
135 +
136 +def current_user(request: Request) -> dict | None:
137 + payload = jwt_decode(request.cookies.get(COOKIE, ""))
138 + return payload.get("user") if payload else None
139 +
140 +
141 +# -- routes --------------------------------------------------------------------
142 +@router.get("/ka/login")
143 +def ka_login(next: str = "/"):
144 + """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe."""
145 + if not KA_SSO_SECRET:
146 + return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},
147 + status_code=503)
148 + state = jwt_encode({"next": next[:200], "exp": time.time() + 600})
149 + params = {
150 + "client_id": "food-ka",
151 + "redirect_uri": f"{BASE_URL}/api/auth/ka/callback",
152 + "state": state,
153 + }
154 + return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")
155 +
156 +
157 +@router.get("/ka/callback")
158 +def ka_callback(ka_token: str = "", state: str = ""):
159 + """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""
160 + st = jwt_decode(state) or {}
161 + dest = st.get("next") or "/"
162 + if not dest.startswith("/"):
163 + dest = "/"
164 + claims = _ka_verify(ka_token) if ka_token else None
165 + if not st or claims is None:
166 + return RedirectResponse("/?auth=echec")
167 + # clé stable = KA-ID du groupe (créé par le hub, identique partout)
168 + ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")
169 + user = _upsert_user({
170 + "ka_id": ka_id,
171 + "email": claims.get("email", ""),
172 + "name": claims.get("name", ""),
173 + "picture": claims.get("picture") or "",
174 + "provider": claims.get("provider") or "ka-id",
175 + })
176 + session = jwt_encode({"user": user, "iss": "groupe-ka",
177 + "exp": time.time() + SESSION_DAYS * 86400})
178 + resp = RedirectResponse(dest)
179 + resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,
180 + samesite="lax", secure=BASE_URL.startswith("https"), path="/")
181 + return resp
182 +
183 +
184 +@router.get("/me")
185 +def me(request: Request):
186 + """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si
187 + la connexion KA ID est configurée (le frontend masque le bouton sinon).
188 + Enrichi avec le profil du HUB Groupe KA (bio, ville, emploi, entreprise,
189 + âge, site web, réseaux sociaux, statut, profil public) — le hub est LA
190 + source de vérité, `profile_source` = « groupe-ka » quand il répond."""
191 + user = current_user(request)
192 + if user:
193 + user = dict(user)
194 + hub = fetch_hub_profile(user.get("ka_id") or "")
195 + if hub:
196 + for k in ("name", "email", "picture"): # le hub prime
197 + if hub.get(k):
198 + user[k] = hub[k]
199 + user.update({
200 + "bio": hub.get("bio") or "",
201 + "city": hub.get("city") or "",
202 + "job_title": hub.get("job_title") or "",
203 + "company": hub.get("company") or "",
204 + "age": hub.get("age"),
205 + "website": hub.get("website") or "",
206 + "socials": hub.get("socials") or {},
207 + "role_label": hub.get("role_label") or "",
208 + "public": bool(hub.get("public")),
209 + "public_url": hub.get("public_url") or "",
210 + "profile_source": "groupe-ka",
211 + })
212 + created = to_epoch(hub.get("created_at"))
213 + if created:
214 + user["created_at"] = created
215 + else:
216 + user["profile_source"] = "local" # hub injoignable ou 404
217 + return {"user": user, "enabled": bool(KA_SSO_SECRET)}
218 +
219 +
220 +@router.post("/logout")
221 +def logout():
222 + resp = JSONResponse({"ok": True})
223 + resp.delete_cookie(COOKIE, path="/")
224 + return resp
added foodka/hubfav.py +103 −0
@@ -0,0 +1,103 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# hubfav.py : favoris « Mon univers Ka » — le hub Groupe KA (groupe-ka.com)
5 +# est le MAGASIN CENTRAL des favoris du groupe : Food·Ka ne stocke rien
6 +# localement. Chaque ♥ est poussé au hub (POST signé HMAC, synchrone : un
7 +# échec remonte à l'appelant) et la liste est lue au hub (GET signé, cache
8 +# mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO.
9 +# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import hashlib
14 +import hmac
15 +import os
16 +import threading
17 +import time
18 +
19 +import requests
20 +
21 +CLIENT_ID = "food-ka"
22 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
23 +CACHE_TTL = 30 # secondes — liste des favoris
24 +TIMEOUT = 6 # secondes
25 +
26 +_cache: dict[str, tuple[float, list[dict]]] = {}
27 +_lock = threading.Lock()
28 +
29 +# champs d'item acceptés -> longueur maximale (troncature défensive)
30 +_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200,
31 + "price_label": 60, "image_url": 500, "url": 500}
32 +
33 +
34 +def _sig(ka_id: str, ts: int) -> str | None:
35 + """Signature HMAC-SHA256 du hub : hex("food-ka.<ka_id>.<ts>")."""
36 + secret = os.environ.get("KA_SSO_SECRET")
37 + if not secret:
38 + return None
39 + return hmac.new(secret.encode(),
40 + f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
41 + hashlib.sha256).hexdigest()
42 +
43 +
44 +def linked(ka_id: str | None) -> bool:
45 + """Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe)."""
46 + return bool(ka_id) and str(ka_id).startswith("ka-")
47 +
48 +
49 +def clean_item(item: dict) -> dict:
50 + """Ne garde que les champs d'item connus, en chaînes tronquées."""
51 + return {k: str(item.get(k) or "")[:n]
52 + for k, n in _FIELDS.items() if item.get(k)}
53 +
54 +
55 +def hub_toggle(ka_id: str, action: str, item: dict) -> bool:
56 + """Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s :
57 + le hub est le magasin des favoris, l'échec doit remonter à l'appelant."""
58 + ts = int(time.time())
59 + sig = _sig(ka_id, ts)
60 + if not sig or not linked(ka_id) or action not in ("add", "remove"):
61 + return False
62 + try:
63 + r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
64 + json={"client_id": CLIENT_ID, "ka_id": ka_id,
65 + "ts": str(ts), "sig": sig,
66 + "action": action, "item": item})
67 + ok = r.status_code == 200
68 + except Exception:
69 + ok = False
70 + if ok:
71 + with _lock:
72 + _cache.pop(ka_id, None) # la prochaine lecture reflète le toggle
73 + return ok
74 +
75 +
76 +def hub_list(ka_id: str) -> list[dict] | None:
77 + """Favoris Food·Ka du membre, lus au hub (cache mémoire 30 s).
78 + [] = aucun favori ; None = hub injoignable (erreur, jamais mise en cache)."""
79 + if not linked(ka_id):
80 + return [] # compte legacy non relié au hub
81 + now = time.time()
82 + with _lock:
83 + hit = _cache.get(ka_id)
84 + if hit and now - hit[0] < CACHE_TTL:
85 + return hit[1]
86 + sig = _sig(ka_id, int(now))
87 + if not sig:
88 + return None
89 + try:
90 + r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT,
91 + params={"client_id": CLIENT_ID, "ka_id": ka_id,
92 + "ts": int(now), "sig": sig})
93 + if r.status_code != 200:
94 + return None
95 + favs = r.json().get("favorites") or []
96 + if not isinstance(favs, list):
97 + return None
98 + except Exception:
99 + return None
100 + favs = [f for f in favs if isinstance(f, dict)]
101 + with _lock:
102 + _cache[ka_id] = (now, favs)
103 + return favs
added foodka/hubprofile.py +79 −0
@@ -0,0 +1,79 @@
1 +# -----------------------------------------------------------------------------
2 +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# hubprofile.py : profil membre lu depuis le HUB Groupe KA (groupe-ka.com)
5 +# Le hub est LA source de vérité du profil (bio, ville, emploi, entreprise,
6 +# site web, réseaux sociaux, photo, statut, profil public) — l'édition se
7 +# fait sur groupe-ka.com/compte, Food·Ka ne fait qu'afficher.
8 +# GET {hub}/api/sso/profile?client_id=food-ka&ka_id=…&ts=…&sig=…
9 +# avec sig = HMAC-SHA256(KA_SSO_SECRET, "food-ka.<ka_id>.<ts>") en hex
10 +# (même secret que le SSO). Cache mémoire 60 s ; None sur toute erreur
11 +# (réseau, 401, 404 : vieux compte non relié) -> l'appelant retombe sur
12 +# les données locales.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import hashlib
17 +import hmac
18 +import os
19 +import threading
20 +import time
21 +from datetime import datetime, timezone
22 +
23 +import requests
24 +
25 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
26 +CLIENT_ID = "food-ka"
27 +CACHE_TTL = 60 # secondes
28 +TIMEOUT = 5 # secondes
29 +
30 +_cache: dict[str, tuple[float, dict | None]] = {}
31 +_lock = threading.Lock()
32 +
33 +
34 +def fetch_hub_profile(ka_id: str) -> dict | None:
35 + """Profil du membre au hub Groupe KA, ou None (inconnu ou injoignable)."""
36 + secret = os.environ.get("KA_SSO_SECRET")
37 + if not secret or not ka_id:
38 + return None
39 + now = time.time()
40 + with _lock:
41 + hit = _cache.get(ka_id)
42 + if hit and now - hit[0] < CACHE_TTL:
43 + return hit[1]
44 + data: dict | None = None
45 + try:
46 + ts = int(now)
47 + sig = hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
48 + hashlib.sha256).hexdigest()
49 + r = requests.get(
50 + f"{KA_HUB_URL}/api/sso/profile",
51 + params={"client_id": CLIENT_ID, "ka_id": ka_id,
52 + "ts": ts, "sig": sig},
53 + timeout=TIMEOUT)
54 + if r.status_code == 200:
55 + data = r.json()
56 + if not isinstance(data, dict):
57 + return None
58 + elif r.status_code != 404:
59 + return None # erreur transitoire (401, 5xx…) : pas de cache
60 + except Exception:
61 + return None # réseau/JSON : pas de cache
62 + with _lock:
63 + _cache[ka_id] = (now, data) # 200 -> data ; 404 -> None (négatif)
64 + return data
65 +
66 +
67 +def to_epoch(v) -> float | None:
68 + """created_at du hub (epoch OU chaîne ISO) -> epoch secondes, sinon None."""
69 + if isinstance(v, (int, float)):
70 + return float(v)
71 + if isinstance(v, str) and v:
72 + try:
73 + dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
74 + if dt.tzinfo is None: # chaîne naïve du hub = UTC
75 + dt = dt.replace(tzinfo=timezone.utc)
76 + return dt.timestamp()
77 + except ValueError:
78 + return None
79 + return None
modified foodka/web.py +38 −2
@@ -9,12 +9,12 @@ import json
9 9 import threading
10 10 from pathlib import Path
11 11
12 −from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
12 +from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request
13 13 from fastapi.middleware.cors import CORSMiddleware
14 14 from fastapi.responses import FileResponse, Response
15 15 from fastapi.staticfiles import StaticFiles
16 16
17 −from . import db, ingest
17 +from . import auth, db, hubfav, ingest
18 18
19 19 ROOT = Path(__file__).resolve().parent.parent
20 20 SOURCES_PATH = ROOT / "data" / "sources.json"
@@ -219,6 +219,42 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None):
219 219 return {"status": "démarré", "source": source or "toutes"}
220 220
221 221
222 +# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) -----
223 +@app.get("/api/favorites")
224 +def favorites(request: Request):
225 + """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local)."""
226 + user = auth.current_user(request)
227 + if not user:
228 + raise HTTPException(401, "Connexion KA ID requise")
229 + items = hubfav.hub_list(user.get("ka_id") or "")
230 + if items is None:
231 + raise HTTPException(502, "Hub Groupe KA injoignable — réessayez")
232 + return {"ids": [i["item_id"] for i in items if i.get("item_id")],
233 + "items": items}
234 +
235 +
236 +@app.post("/api/favorites/toggle")
237 +def toggle_favorite(request: Request, body: dict = Body(...)):
238 + """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub
239 + Groupe KA de façon synchrone : le hub est la seule source de vérité."""
240 + user = auth.current_user(request)
241 + if not user:
242 + raise HTTPException(401, "Connexion KA ID requise")
243 + ka_id = user.get("ka_id") or ""
244 + if not hubfav.linked(ka_id):
245 + raise HTTPException(403, "Compte non relié au hub Groupe KA")
246 + on = bool(body.get("on"))
247 + item = hubfav.clean_item(body.get("item") or {})
248 + if not item.get("item_id"):
249 + raise HTTPException(422, "item.item_id requis")
250 + if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item):
251 + raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré")
252 + return {"ok": True, "on": on}
253 +
254 +
255 +# --- Connexion KA ID (SSO Groupe KA) — avant le catch-all SPA ---------------
256 +app.include_router(auth.router)
257 +
222 258 # --- Frontend React (build Vite) --------------------------------------------
223 259 if FRONTEND_DIST.exists():
224 260 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
modified frontend/src/App.tsx +90 −3
@@ -5,11 +5,13 @@
5 5 // -----------------------------------------------------------------------------
6 6 import { useEffect, useState } from "react";
7 7 import { NavLink, Route, Routes, useLocation } from "react-router-dom";
8 −import { fetchFacets, fetchSources, fetchStats, fmtPrice, registerSourceNames, sourceName } from "./api";
8 +import { Me, fetchAuth, fetchFacets, fetchSources, fetchStats, fmtPrice, logout, registerSourceNames, sourceName } from "./api";
9 9 import CookieConsent from "./components/CookieConsent";
10 +import { FavProvider } from "./favorites";
10 11 import Home from "./pages/Home";
11 12 import ProductPage from "./pages/Product";
12 13 import PrivacyPage from "./pages/Privacy";
14 +import ProfilPage from "./pages/Profil";
13 15 import SourcesPage from "./pages/Sources";
14 16 import StatsPage from "./pages/Stats";
15 17
@@ -53,9 +55,92 @@ const NAV_LINKS = [
53 55 { to: "/aubaines", label: "Aubaines", icon: "🔥", end: false },
54 56 { to: "/sources", label: "Sources", icon: "🗂", end: false },
55 57 { to: "/stats", label: "Stats", icon: "📊", end: false },
58 + { to: "/profil", label: "Mon compte", icon: "👤", end: false },
56 59 { to: "/confidentialite", label: "Confidentialité", icon: "🔒", end: false },
57 60 ];
58 61
62 +/** Bouton « Connexion » ou avatar + menu compte (KA ID — SSO Groupe KA) */
63 +function AccountMenu() {
64 + const [me, setMe] = useState<Me | null>(null);
65 + const [enabled, setEnabled] = useState(false);
66 + const [menuOpen, setMenuOpen] = useState(false);
67 + const location = useLocation();
68 +
69 + useEffect(() => {
70 + fetchAuth().then(({ user, enabled }) => { setMe(user); setEnabled(enabled); });
71 + }, []);
72 + useEffect(() => { setMenuOpen(false); }, [location]);
73 +
74 + if (!enabled) return null;
75 + if (!me) {
76 + const next = encodeURIComponent(location.pathname + location.search);
77 + return (
78 + <a className="login-btn" href={`/api/auth/ka/login?next=${next}`}>
79 + <span className="login-ka" aria-hidden="true">KA</span>
80 + Connexion
81 + </a>
82 + );
83 + }
84 + return (
85 + <div className="account">
86 + <button
87 + className="account-btn"
88 + onClick={() => setMenuOpen(!menuOpen)}
89 + aria-expanded={menuOpen}
90 + aria-label={`Compte : ${me.name || me.email}`}
91 + >
92 + {me.picture
93 + ? <img src={me.picture} alt="" referrerPolicy="no-referrer" />
94 + : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>}
95 + </button>
96 + {menuOpen && (
97 + <>
98 + <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" />
99 + <div className="account-menu" role="menu">
100 + <div className="account-id">
101 + <b>{me.name || "Mon compte"}</b>
102 + <span>{me.email}</span>
103 + <span className="account-kaid">{me.ka_id}</span>
104 + <span className="account-note">
105 + Compte Groupe KA — le même KA-ID sur toutes les plateformes
106 + </span>
107 + </div>
108 + <NavLink
109 + to="/profil"
110 + role="menuitem"
111 + className="account-link"
112 + onClick={() => setMenuOpen(false)}
113 + >
114 + Mon profil
115 + </NavLink>
116 + <a
117 + href="https://www.groupe-ka.com/compte"
118 + target="_blank"
119 + rel="noopener noreferrer"
120 + role="menuitem"
121 + className="account-link"
122 + onClick={() => setMenuOpen(false)}
123 + >
124 + Mes favoris — Mon univers Ka ↗
125 + </a>
126 + <button
127 + role="menuitem"
128 + onClick={async () => {
129 + await logout();
130 + setMenuOpen(false);
131 + setMe(null);
132 + window.location.assign("/");
133 + }}
134 + >
135 + Se déconnecter
136 + </button>
137 + </div>
138 + </>
139 + )}
140 + </div>
141 + );
142 +}
143 +
59 144 function Header() {
60 145 const [open, setOpen] = useState(false);
61 146 const location = useLocation();
@@ -98,6 +183,7 @@ function Header() {
98 183 Stats
99 184 </NavLink>
100 185 </nav>
186 + <AccountMenu />
101 187 <button
102 188 className={`menu-btn ${open ? "open" : ""}`}
103 189 aria-expanded={open}
@@ -171,7 +257,7 @@ function Footer() {
171 257
172 258 export default function App() {
173 259 return (
174 − <>
260 + <FavProvider>
175 261 <Header />
176 262 <main>
177 263 <Routes>
@@ -180,6 +266,7 @@ export default function App() {
180 266 <Route path="/produit/:uid" element={<ProductPage />} />
181 267 <Route path="/stats" element={<StatsPage />} />
182 268 <Route path="/sources" element={<SourcesPage />} />
269 + <Route path="/profil" element={<ProfilPage />} />
183 270 <Route path="/confidentialite" element={<PrivacyPage />} />
184 271 <Route
185 272 path="*"
@@ -195,6 +282,6 @@ export default function App() {
195 282 </main>
196 283 <Footer />
197 284 <CookieConsent />
198 − </>
285 + </FavProvider>
199 286 );
200 287 }
modified frontend/src/api.ts +49 −0
@@ -134,6 +134,55 @@ export interface DetailedStats {
134 134 price_distribution: { range: string; n: number }[];
135 135 }
136 136
137 +// --- Compte (connexion KA ID — SSO Groupe KA) ---------------------------------
138 +export interface Socials {
139 + instagram?: string;
140 + facebook?: string;
141 + x?: string;
142 + linkedin?: string;
143 + tiktok?: string;
144 + youtube?: string;
145 +}
146 +
147 +export interface Me {
148 + ka_id: string; // identifiant membre Groupe KA (« ka-0123456789 »)
149 + email: string;
150 + name: string;
151 + picture: string;
152 + provider: string; // fournisseur d'identité côté hub (google, email…)
153 + created_at: number | null;
154 + last_login: number | null;
155 + // — profil géré au HUB Groupe KA (source de vérité : groupe-ka.com/compte) —
156 + bio?: string;
157 + city?: string;
158 + job_title?: string;
159 + company?: string;
160 + age?: number | null;
161 + website?: string;
162 + socials?: Socials;
163 + role_label?: string; // statut du membre (badge sur la carte)
164 + public?: boolean;
165 + public_url?: string;
166 + profile_source?: string; // « groupe-ka » si le hub a répondu, sinon « local »
167 +}
168 +
169 +/** Profil de l'utilisateur connecté (null si déconnecté) + SSO configuré ou non. */
170 +export async function fetchAuth(): Promise<{ user: Me | null; enabled: boolean }> {
171 + try {
172 + const res = await fetch("/api/auth/me", { credentials: "same-origin" });
173 + if (!res.ok) throw new Error(`API ${res.status}`);
174 + return (await res.json()) as { user: Me | null; enabled: boolean };
175 + } catch {
176 + return { user: null, enabled: false };
177 + }
178 +}
179 +
180 +export async function logout(): Promise<void> {
181 + try {
182 + await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
183 + } catch { /* déconnexion silencieuse */ }
184 +}
185 +
137 186 // --- Noms d'affichage des bannières ------------------------------------------
138 187 const SOURCE_NAMES: Record<string, string> = {
139 188 metro: "Metro",
modified frontend/src/components/ProductCard.tsx +2 −0
@@ -7,6 +7,7 @@
7 7 // -----------------------------------------------------------------------------
8 8 import { Link } from "react-router-dom";
9 9 import { Product, discountPct, fmtPrice } from "../api";
10 +import { FavButton } from "../favorites";
10 11 import SourceLogo from "./SourceLogo";
11 12
12 13 export default function ProductCard({ p }: { p: Product }) {
@@ -24,6 +25,7 @@ export default function ProductCard({ p }: { p: Product }) {
24 25 <span className="badge sale">{pct != null ? `−${pct} %` : "En solde"}</span>
25 26 )}
26 27 {p.in_stock === false && <span className="badge right">Rupture</span>}
28 + <FavButton p={p} />
27 29 </div>
28 30 <div className="card-body">
29 31 <div className="card-price">
added frontend/src/favorites.tsx +133 −0
@@ -0,0 +1,133 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// favorites.tsx : favoris ♥ « Mon univers Ka » — le hub Groupe KA est le
5 +// magasin central des favoris du groupe (aucun stockage navigateur) :
6 +// lecture/écriture via l'API locale /api/favorites qui relaie au hub.
7 +// FavProvider charge la liste une fois ; FavButton = cœur sur les cartes
8 +// produit et la fiche. Non connecté → redirection vers la connexion KA ID
9 +// avec retour à la page courante.
10 +// -----------------------------------------------------------------------------
11 +import {
12 + ReactNode, createContext, useCallback, useContext,
13 + useEffect, useMemo, useState,
14 +} from "react";
15 +import { Product, fmtPrice, sourceName } from "./api";
16 +
17 +/** Item de favori tel que poussé au hub Groupe KA. */
18 +export interface FavItem {
19 + item_id: string;
20 + title: string;
21 + subtitle?: string;
22 + price_label?: string;
23 + image_url?: string;
24 + url?: string;
25 +}
26 +
27 +interface FavState {
28 + ready: boolean; // premier chargement terminé
29 + connected: boolean; // session KA ID active
30 + ids: Set<string>; // uid des produits en favoris
31 + items: FavItem[];
32 + toggle: (item: FavItem) => void;
33 +}
34 +
35 +const FavContext = createContext<FavState>({
36 + ready: false, connected: false, ids: new Set(), items: [], toggle: () => {},
37 +});
38 +
39 +/** Produit -> item de favori du hub (titre, bannière · format, prix, image). */
40 +export function productFavItem(p: Product): FavItem {
41 + let price = fmtPrice(p.price, p.price_label);
42 + if (p.on_sale && p.regular_price != null)
43 + price += ` (rég. ${fmtPrice(p.regular_price)})`;
44 + return {
45 + item_id: p.uid,
46 + title: p.name || "Produit",
47 + subtitle: [sourceName(p.source), p.size_label || p.category]
48 + .filter(Boolean).join(" · "),
49 + price_label: price,
50 + image_url: p.images && p.images.length > 0 ? p.images[0] : "",
51 + url: `https://www.food-ka.com/produit/${encodeURIComponent(p.uid)}`,
52 + };
53 +}
54 +
55 +export function FavProvider({ children }: { children: ReactNode }) {
56 + const [ready, setReady] = useState(false);
57 + const [connected, setConnected] = useState(false);
58 + const [items, setItems] = useState<FavItem[]>([]);
59 +
60 + useEffect(() => {
61 + fetch("/api/favorites", { credentials: "same-origin" })
62 + .then(async (res) => {
63 + if (res.status === 401) return; // pas connecté : cœurs vides
64 + if (!res.ok) throw new Error(`API ${res.status}`);
65 + const data = (await res.json()) as { items?: FavItem[] };
66 + setConnected(true);
67 + setItems(data.items ?? []);
68 + })
69 + .catch(() => {}) // hub muet : cœurs vides
70 + .finally(() => setReady(true));
71 + }, []);
72 +
73 + const toggle = useCallback((item: FavItem) => {
74 + if (!connected) {
75 + // non connecté : passage par la connexion KA ID, retour à la page
76 + const next = encodeURIComponent(
77 + window.location.pathname + window.location.search);
78 + window.location.assign(`/api/auth/ka/login?next=${next}`);
79 + return;
80 + }
81 + const prev = items;
82 + const on = !prev.some((i) => i.item_id === item.item_id);
83 + // optimiste : le hub confirme (sinon on rétablit)
84 + setItems(on ? [...prev, item]
85 + : prev.filter((i) => i.item_id !== item.item_id));
86 + fetch("/api/favorites/toggle", {
87 + method: "POST",
88 + credentials: "same-origin",
89 + headers: { "Content-Type": "application/json" },
90 + body: JSON.stringify({ on, item }),
91 + })
92 + .then((res) => { if (!res.ok) throw new Error(`API ${res.status}`); })
93 + .catch(() => setItems(prev));
94 + }, [connected, items]);
95 +
96 + const ids = useMemo(
97 + () => new Set(items.map((i) => i.item_id)), [items]);
98 +
99 + return (
100 + <FavContext.Provider value={{ ready, connected, ids, items, toggle }}>
101 + {children}
102 + </FavContext.Provider>
103 + );
104 +}
105 +
106 +export const useFav = () => useContext(FavContext);
107 +
108 +/** Cœur ♥ — cartes produit (par défaut) et fiche produit (`big`). */
109 +export function FavButton({ p, big = false }: { p: Product; big?: boolean }) {
110 + const { ids, toggle } = useFav();
111 + const on = ids.has(p.uid);
112 + const label = on
113 + ? "Retirer de mes favoris (Mon univers Ka)"
114 + : "Ajouter à mes favoris (Mon univers Ka)";
115 + return (
116 + <button
117 + type="button"
118 + className={`fav-btn${on ? " on" : ""}${big ? " big" : ""}`}
119 + aria-label={label}
120 + aria-pressed={on}
121 + title={label}
122 + onClick={(e) => {
123 + e.preventDefault(); // la carte entière est un lien
124 + e.stopPropagation();
125 + toggle(productFavItem(p));
126 + }}
127 + >
128 + <svg viewBox="0 0 24 24" aria-hidden="true">
129 + <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" />
130 + </svg>
131 + </button>
132 + );
133 +}
modified frontend/src/pages/Product.tsx +9 −5
@@ -14,6 +14,7 @@ import {
14 14 registerSourceNames, sourceName,
15 15 } from "../api";
16 16 import SourceLogo from "../components/SourceLogo";
17 +import { FavButton } from "../favorites";
17 18
18 19 const NBSP = " ";
19 20
@@ -240,11 +241,14 @@ export default function ProductPage() {
240 241 {/* ------- colonne droite (desktop) : prix, comparaison --------------- */}
241 242 <div className="f-col">
242 243 <section className="f-bloc f-hero">
243 − <div className="price">
244 − {fmtPrice(p.price, p.price_label)}
245 − {p.on_sale && p.regular_price != null && (
246 − <s className="price-old big">{fmtPrice(p.regular_price)}</s>
247 − )}
244 + <div className="price-fav">
245 + <div className="price">
246 + {fmtPrice(p.price, p.price_label)}
247 + {p.on_sale && p.regular_price != null && (
248 + <s className="price-old big">{fmtPrice(p.regular_price)}</s>
249 + )}
250 + </div>
251 + <FavButton p={p} big />
248 252 </div>
249 253 {p.on_sale && (
250 254 <div className="deal-badge deal-good">
added frontend/src/pages/Profil.tsx +228 −0
@@ -0,0 +1,228 @@
1 +// -----------------------------------------------------------------------------
2 +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Profil.tsx : profil utilisateur — carte de membre Groupe KA (KA-ID),
5 +// informations du compte, actions (déconnexion, confidentialité)
6 +// -----------------------------------------------------------------------------
7 +import { useEffect, useState } from "react";
8 +import { Link, useNavigate } from "react-router-dom";
9 +import { Me, Socials, fetchAuth, logout } from "../api";
10 +
11 +const fmtEpoch = (ts: number | null | undefined): string => {
12 + if (!ts) return "—";
13 + return new Date(ts * 1000).toLocaleDateString("fr-CA", {
14 + day: "numeric", month: "long", year: "numeric",
15 + }).replace(/^1 /, "1ᵉʳ ");
16 +};
17 +
18 +const providerLabel = (p: string): string =>
19 + p === "google" ? "KA ID — via Google"
20 + : p === "email" || p === "password" ? "KA ID — courriel et mot de passe"
21 + : "KA ID (groupe-ka.com)";
22 +
23 +// --- Profil Groupe KA (géré au hub — lecture seule ici) -----------------------
24 +const SOCIAL_BASE: Record<string, string> = {
25 + instagram: "https://www.instagram.com/",
26 + facebook: "https://www.facebook.com/",
27 + x: "https://x.com/",
28 + linkedin: "https://www.linkedin.com/in/",
29 + tiktok: "https://www.tiktok.com/@",
30 + youtube: "https://www.youtube.com/@",
31 +};
32 +
33 +/** « @pseudo » ou « pseudo » -> URL complète de la plateforme ; URL laissée telle quelle */
34 +function socialUrl(key: string, value: string): string {
35 + const v = value.trim();
36 + if (/^https?:\/\//i.test(v)) return v;
37 + if (key === "website") return `https://${v}`;
38 + return (SOCIAL_BASE[key] ?? "https://") + v.replace(/^@/, "");
39 +}
40 +
41 +const SOCIAL_FIELDS: { key: keyof Socials; label: string }[] = [
42 + { key: "instagram", label: "Instagram" },
43 + { key: "facebook", label: "Facebook" },
44 + { key: "x", label: "X (Twitter)" },
45 + { key: "linkedin", label: "LinkedIn" },
46 + { key: "tiktok", label: "TikTok" },
47 + { key: "youtube", label: "YouTube" },
48 +];
49 +
50 +/** Profil géré au HUB Groupe KA — LECTURE SEULE (l'édition se fait sur
51 + * groupe-ka.com/compte, une seule saisie pour les huit plateformes). */
52 +function HubProfileSection({ me }: { me: Me }) {
53 + const socialLinks = SOCIAL_FIELDS.filter((s) => (me.socials?.[s.key] ?? "").trim());
54 + return (
55 + <section className="hub-profile">
56 + <h3>Mon profil Groupe KA</h3>
57 + {me.bio && <p className="hub-bio">{me.bio}</p>}
58 + <div className="pub-meta">
59 + {(me.job_title || me.company) && (
60 + <span className="stat-chip">
61 + {[me.job_title, me.company].filter(Boolean).join(" · ")}
62 + </span>
63 + )}
64 + {me.city && <span className="stat-chip">{me.city}</span>}
65 + {me.age != null && <span className="stat-chip">{me.age} ans</span>}
66 + {me.website && (
67 + <a className="stat-chip" href={socialUrl("website", me.website)}
68 + target="_blank" rel="noopener noreferrer">Site web ↗</a>
69 + )}
70 + {socialLinks.map((s) => (
71 + <a key={s.key} className="stat-chip"
72 + href={socialUrl(s.key, me.socials![s.key]!)}
73 + target="_blank" rel="noopener noreferrer" title={s.label}>
74 + {s.label}
75 + </a>
76 + ))}
77 + </div>
78 + <div>
79 + <a className="btn btn-primary"
80 + href="https://www.groupe-ka.com/compte"
81 + target="_blank" rel="noopener noreferrer">
82 + Modifier mon profil sur groupe-ka.com ↗
83 + </a>
84 + </div>
85 + <p className="pe-hint">
86 + Votre profil est géré au niveau du groupe : une seule saisie, visible
87 + sur les huit plateformes du groupe.
88 + </p>
89 + </section>
90 + );
91 +}
92 +
93 +export default function ProfilPage() {
94 + const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = chargement
95 + const [copied, setCopied] = useState(false);
96 + const nav = useNavigate();
97 +
98 + useEffect(() => {
99 + fetchAuth().then(({ user }) => setMe(user));
100 + }, []);
101 +
102 + const copyKaId = async () => {
103 + if (!me?.ka_id) return;
104 + try {
105 + await navigator.clipboard.writeText(me.ka_id);
106 + setCopied(true);
107 + setTimeout(() => setCopied(false), 1800);
108 + } catch { /* presse-papiers indisponible : tant pis */ }
109 + };
110 +
111 + if (me === undefined) {
112 + return <div className="container profil"><div className="notice">Chargement…</div></div>;
113 + }
114 +
115 + if (me === null) {
116 + return (
117 + <div className="container profil">
118 + <span className="kicker">Mon compte</span>
119 + <h1>Connectez-vous pour <span className="hl">votre profil</span>.</h1>
120 + <p className="lede">
121 + Connectez-vous avec votre <b>KA ID</b> — l'identifiant de membre
122 + unique du Groupe KA, valide sur toutes les plateformes du groupe.
123 + </p>
124 + <a className="btn btn-primary" href="/api/auth/ka/login?next=/profil">
125 + Se connecter avec KA ID
126 + </a>
127 + </div>
128 + );
129 + }
130 +
131 + return (
132 + <div className="container profil">
133 + <span className="kicker">Mon compte</span>
134 + <h1>
135 + {me.name ? <>Salut, <span className="hl">{me.name.split(" ")[0]}</span>.</>
136 + : <>Votre <span className="hl">profil</span>.</>}
137 + </h1>
138 +
139 + {/* ——— Carte de membre Groupe KA ——— */}
140 + <div className="pc" role="img" aria-label={`Carte de membre ${me.ka_id}`}>
141 + <div className="pc-watermark" aria-hidden="true">KA</div>
142 + <div className="pc-head">
143 + <span className="pc-brand">Groupe <span className="pc-ka">KA</span></span>
144 + <span className="pc-label">Carte de membre · Groupe KA</span>
145 + </div>
146 + <div className="pc-id-block">
147 + <span className="pc-id-label">KA-ID</span>
148 + <span className="pc-id">{me.ka_id}</span>
149 + </div>
150 + <div className="pc-foot">
151 + <div className="pc-holder">
152 + <span className="pc-holder-name">{me.name || me.email}</span>
153 + {me.role_label && (
154 + <span className="pc-role-badge">{me.role_label}</span>
155 + )}
156 + <span className="pc-holder-since">Membre depuis le {fmtEpoch(me.created_at)}</span>
157 + </div>
158 + {me.picture && (
159 + <img className="pc-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" />
160 + )}
161 + </div>
162 + <div className="pc-strip" aria-hidden="true">
163 + {Array.from({ length: 28 }).map((_, i) => <i key={i} />)}
164 + </div>
165 + </div>
166 +
167 + <button className={`btn btn-ghost pc-copy ${copied ? "ok" : ""}`} onClick={copyKaId}>
168 + {copied ? "✓ Copié" : "Copier mon KA-ID"}
169 + </button>
170 +
171 + {/* ——— Informations ——— */}
172 + <section className="profil-grid">
173 + <div className="pg-item">
174 + <span className="pg-label">Nom</span>
175 + <span className="pg-value">{me.name || "—"}</span>
176 + </div>
177 + <div className="pg-item">
178 + <span className="pg-label">Courriel</span>
179 + <span className="pg-value">{me.email || "—"}</span>
180 + </div>
181 + <div className="pg-item">
182 + <span className="pg-label">Identifiant membre</span>
183 + <span className="pg-value mono">{me.ka_id}</span>
184 + </div>
185 + <div className="pg-item">
186 + <span className="pg-label">Connexion</span>
187 + <span className="pg-value">{providerLabel(me.provider)}</span>
188 + </div>
189 + <div className="pg-item">
190 + <span className="pg-label">Membre depuis</span>
191 + <span className="pg-value">{fmtEpoch(me.created_at)}</span>
192 + </div>
193 + <div className="pg-item">
194 + <span className="pg-label">Dernière connexion</span>
195 + <span className="pg-value">{fmtEpoch(me.last_login)}</span>
196 + </div>
197 + </section>
198 +
199 + {me.profile_source === "groupe-ka" && <HubProfileSection me={me} />}
200 +
201 + <p className="profil-note">
202 + Votre <b>KA-ID</b> est votre identifiant unique dans l'écosystème{" "}
203 + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer">
204 + Groupe KA
205 + </a>{" "}
206 + — le même sur toutes les plateformes du groupe. Food·Ka ne conserve que
207 + les informations de ce profil ; rien d'autre, et jamais revendues.
208 + </p>
209 +
210 + {/* ——— Actions ——— */}
211 + <div className="profil-actions">
212 + <button
213 + className="btn btn-ghost"
214 + onClick={async () => { await logout(); nav("/"); window.location.reload(); }}
215 + >
216 + Se déconnecter
217 + </button>
218 + <button
219 + className="btn btn-ghost"
220 + onClick={() => window.dispatchEvent(new Event("foodka:openConsent"))}
221 + >
222 + Gérer mes témoins
223 + </button>
224 + <Link className="btn btn-ghost" to="/confidentialite">Confidentialité</Link>
225 + </div>
226 + </div>
227 + );
228 +}
modified frontend/src/styles.css +154 −0
@@ -988,3 +988,157 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
988 988 /* ---- bouton rapport PDF (page Stats) ---- */
989 989 .stats-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
990 990 .btn-pdf { text-decoration: none; white-space: nowrap; }
991 +
992 +/* ============ Favoris ♥ « Mon univers Ka » (magasin central au hub) ========= */
993 +.fav-btn {
994 + position: absolute; right: 10px; bottom: 10px; z-index: 2;
995 + width: 34px; height: 34px; padding: 0;
996 + display: inline-flex; align-items: center; justify-content: center;
997 + border: 1.5px solid var(--ink); border-radius: 999px; cursor: pointer;
998 + background: rgba(255, 255, 255, 0.95); color: var(--ink);
999 + transition: transform 0.12s ease, background 0.12s ease;
1000 +}
1001 +.fav-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; }
1002 +.fav-btn:hover { transform: scale(1.08); background: var(--lime-soft); }
1003 +.fav-btn.on { background: var(--tomato); border-color: var(--tomato-deep); color: #fff; }
1004 +.fav-btn.on svg { fill: currentColor; }
1005 +.fav-btn.big { position: static; flex-shrink: 0; width: 42px; height: 42px; }
1006 +.fav-btn.big svg { width: 21px; height: 21px; }
1007 +/* fiche produit : le cœur à droite du prix */
1008 +.price-fav { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
1009 +
1010 +/* ================= Compte (connexion KA ID — SSO Groupe KA) ================= */
1011 +.login-btn {
1012 + display: inline-flex; align-items: center; gap: 8px;
1013 + padding: 8px 15px; margin-left: 10px; min-height: 38px;
1014 + background: var(--surface); border: 1.5px solid var(--ink);
1015 + border-radius: 999px; font-weight: 600; font-size: 13.5px; color: var(--ink);
1016 + transition: all 0.12s ease; white-space: nowrap; text-decoration: none;
1017 +}
1018 +.login-btn:hover { transform: translate(-1px, -1px); box-shadow: 3px 3px 0 var(--ink); background: var(--lime-soft); }
1019 +.login-ka {
1020 + display: inline-flex; align-items: center; justify-content: center;
1021 + width: 18px; height: 16px; border-radius: 4px;
1022 + background: var(--green-deep); color: var(--lime);
1023 + font: 700 9px/1 var(--font-display); letter-spacing: -0.02em;
1024 + transform: rotate(-2deg);
1025 +}
1026 +.account { position: relative; margin-left: 10px; }
1027 +.account-btn {
1028 + width: 38px; height: 38px; padding: 0; border: 2px solid var(--ink);
1029 + border-radius: 999px; overflow: hidden; cursor: pointer;
1030 + background: var(--lime); display: flex; align-items: center; justify-content: center;
1031 +}
1032 +.account-btn img { width: 100%; height: 100%; object-fit: cover; }
1033 +.account-initial { font-family: var(--font-display); font-weight: 700; font-size: 16px; color: var(--ink); }
1034 +.account-backdrop { position: fixed; inset: 0; z-index: 90; }
1035 +.account-menu {
1036 + position: absolute; right: 0; top: calc(100% + 10px); z-index: 91;
1037 + min-width: 250px; max-width: 320px; background: var(--surface);
1038 + border: 2px solid var(--ink); border-radius: var(--r-card);
1039 + box-shadow: var(--shadow-off); overflow: hidden;
1040 +}
1041 +.account-id { display: flex; flex-direction: column; gap: 2px; padding: 12px 14px; border-bottom: 1.5px solid var(--line); }
1042 +.account-id b { font-size: 14px; }
1043 +.account-id span { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); word-break: break-all; }
1044 +.account-kaid { color: var(--green) !important; letter-spacing: 0.08em; }
1045 +.account-note {
1046 + font-family: inherit !important; font-size: 10.5px !important;
1047 + color: var(--ink-3); margin-top: 4px; line-height: 1.35;
1048 +}
1049 +.account-link {
1050 + display: block; padding: 11px 14px; font-weight: 600; font-size: 13.5px;
1051 + color: var(--ink); text-decoration: none; border-bottom: 1.5px solid var(--line);
1052 +}
1053 +.account-link:hover { background: var(--lime-soft); }
1054 +.account-menu button {
1055 + width: 100%; text-align: left; padding: 11px 14px; border: 0; cursor: pointer;
1056 + background: transparent; font-weight: 600; font-size: 13.5px; color: var(--danger);
1057 +}
1058 +.account-menu button:hover { background: var(--lime-soft); color: var(--ink); }
1059 +@media (max-width: 640px) { .login-btn { padding: 8px 12px; font-size: 12.5px; } }
1060 +/* mobile : le nav est masqué — le compte se cale à droite, avant le menu */
1061 +@media (max-width: 760px) {
1062 + .login-btn, .account { margin-left: auto; }
1063 + .login-btn + .menu-btn, .account + .menu-btn { margin-left: 10px; }
1064 +}
1065 +
1066 +/* ================= Profil — carte de membre Groupe KA ================= */
1067 +.profil { padding-top: 40px; padding-bottom: 60px; }
1068 +.profil h1 { font-size: clamp(30px, 5vw, 44px); margin: 10px 0 26px; }
1069 +.profil h1 .hl { background: var(--lime); padding: 0 8px; border-radius: 8px; display: inline-block; }
1070 +.profil .lede { max-width: 520px; color: var(--ink-2); margin-bottom: 22px; }
1071 +
1072 +.pc {
1073 + position: relative; max-width: 520px; overflow: hidden;
1074 + background: var(--ink); color: var(--paper);
1075 + border: 2px solid var(--ink); border-radius: 16px;
1076 + padding: 24px 26px 0; box-shadow: 10px 10px 0 rgba(20, 35, 26, 0.18);
1077 +}
1078 +.pc-watermark {
1079 + position: absolute; right: -18px; top: -34px; pointer-events: none;
1080 + font-family: var(--font-display); font-weight: 700; font-size: 170px;
1081 + letter-spacing: -0.06em; color: rgba(217, 242, 107, 0.07);
1082 + transform: rotate(-8deg); line-height: 1;
1083 +}
1084 +.pc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1085 +.pc-brand { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.04em; }
1086 +.pc-ka { background: var(--lime); color: var(--ink); padding: 1px 6px 3px; border-radius: 5px; margin-left: 3px; display: inline-block; transform: rotate(-2deg); }
1087 +.pc-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.16em; color: rgba(250, 246, 238, 0.55); }
1088 +.pc-id-block { margin: 26px 0 22px; display: flex; flex-direction: column; gap: 4px; }
1089 +.pc-id-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.2em; color: rgba(217, 242, 107, 0.65); }
1090 +.pc-id {
1091 + font-family: var(--font-mono); font-weight: 700; color: var(--lime);
1092 + font-size: clamp(22px, 6vw, 34px); letter-spacing: 0.14em;
1093 + text-shadow: 0 0 24px rgba(217, 242, 107, 0.35);
1094 +}
1095 +.pc-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; padding-bottom: 18px; }
1096 +.pc-holder { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1097 +.pc-holder-name { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1098 +.pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(250, 246, 238, 0.5); letter-spacing: 0.06em; }
1099 +.pc-role-badge {
1100 + display: inline-block; width: fit-content; margin: 3px 0 2px;
1101 + background: var(--lime); color: var(--ink);
1102 + font-family: var(--font-mono); font-size: 9px; font-weight: 700;
1103 + text-transform: uppercase; letter-spacing: 0.12em;
1104 + padding: 2px 7px 3px; border-radius: 5px; transform: rotate(-1deg);
1105 +}
1106 +.pc-avatar { width: 52px; height: 52px; border-radius: 999px; border: 2px solid var(--lime); flex: none; }
1107 +.pc-strip { display: flex; gap: 5px; margin: 0 -26px; padding: 9px 18px; background: rgba(217, 242, 107, 0.1); border-top: 1px solid rgba(217, 242, 107, 0.25); overflow: hidden; }
1108 +.pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--lime); opacity: 0.7; }
1109 +.pc-strip i:nth-child(3n) { height: 14px; opacity: 0.35; }
1110 +.pc-strip i:nth-child(3n+1) { height: 9px; }
1111 +.pc-strip i:nth-child(3n+2) { height: 17px; opacity: 0.9; }
1112 +.pc-copy { margin-top: 16px; }
1113 +.pc-copy.ok { background: var(--lime); border-color: var(--ink); color: var(--ink); }
1114 +
1115 +.profil-grid {
1116 + display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
1117 + gap: 12px; margin-top: 30px; max-width: 760px;
1118 +}
1119 +.pg-item {
1120 + background: var(--surface); border: 1.5px solid var(--ink);
1121 + border-radius: var(--r-card); padding: 14px 16px;
1122 + display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-flat);
1123 +}
1124 +.pg-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); }
1125 +.pg-value { font-weight: 600; font-size: 14.5px; word-break: break-word; }
1126 +.pg-value.mono { font-family: var(--font-mono); color: var(--green-deep); }
1127 +
1128 +/* profil géré au hub Groupe KA — lecture seule */
1129 +.hub-profile {
1130 + margin-top: 34px; padding-top: 24px; border-top: 2px solid var(--ink);
1131 + max-width: 640px; display: flex; flex-direction: column; gap: 14px;
1132 +}
1133 +.hub-profile h3 { font-size: 18px; }
1134 +.hub-profile .hub-bio { color: var(--ink-2); font-size: 14px; margin: 0; max-width: 560px; }
1135 +.hub-profile .pub-meta { display: flex; gap: 8px; flex-wrap: wrap; }
1136 +.hub-profile .stat-chip { display: inline-flex; align-items: center; gap: 6px; }
1137 +.hub-profile a.stat-chip:hover { background: var(--lime-soft); }
1138 +.hub-profile .btn-primary { display: inline-flex; align-items: center; gap: 7px; }
1139 +.hub-profile .pe-hint { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); }
1140 +
1141 +.profil-note { max-width: 640px; color: var(--ink-2); font-size: 13.5px; margin-top: 22px; }
1142 +.profil-note a { text-decoration: underline; text-underline-offset: 3px; }
1143 +.profil-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 26px; }
1144 +.profil-actions .btn { display: inline-flex; align-items: center; gap: 7px; }
991 1145