SPB Git forge

spb/valoplex

Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

11commits 1branches 0releases
2.4 MBsize
maindefault branch
20 days agolast push
TypeScript 91.9% Python 6% CSS 2.1%

Mon univers Ka : SSO KA ID + favoris unifiés au hub Groupe KA

- SSO KA ID (patron vrai-prix) : lib/ka-auth.ts (state signé, JWT HS256 du hub,
  session cookie httpOnly vpx_ka_session 30 j) + routes /api/auth/ka/login,
  /api/auth/ka/callback, /api/auth/logout, /api/auth/me
- Favoris « Mon univers Ka » (patron food-ka hubfav) : lib/hubfav.ts — AUCUN
  stockage local, toggle synchrone au hub (HMAC client_id.ka_id.ts, timeout 6 s),
  lecture au hub avec cache mémoire 30 s invalidé au toggle
- API : GET /api/favorites (401 sans session), POST /api/favorites/toggle
- UI : cœur ♥ FavHeart (magasin client partagé, optimiste, redirige vers le SSO
  sans session) sur la fiche d'estimation et les cartes du parc ; page /favoris
  (connexion KA ID, liste, retrait) ; entrée « ♥ Favoris » au menu
- .env.local (non versionné) : KA_SSO_SECRET, KA_AUTH_SECRET, KA_HUB_URL, KA_BASE_URL

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

14 changed files +937 −3

added app/src/app/api/auth/ka/callback/route.ts +48 −0
@@ -0,0 +1,48 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Retour SSO KA ID : vérifie le state signé localement puis le ka_token du hub
3 +// (JWT HS256 — signature KA_SSO_SECRET, alg, iss, aud, exp), pose la session
4 +// locale (cookie httpOnly vpx_ka_session signé HMAC-SHA256, 30 jours) et
5 +// redirige vers la destination demandée (ou /favoris).
6 +import { NextRequest, NextResponse } from "next/server";
7 +import {
8 + KA_BASE_URL,
9 + SESSION_COOKIE,
10 + SESSION_DAYS,
11 + sessionFromClaims,
12 + verifyKaToken,
13 + verifyState,
14 +} from "@/lib/ka-auth";
15 +
16 +export function GET(req: NextRequest) {
17 + const q = req.nextUrl.searchParams;
18 + const state = verifyState(q.get("state") ?? "");
19 + if (!state) {
20 + return NextResponse.json(
21 + { error: "state invalide ou expiré — recommencez la connexion" },
22 + { status: 400 },
23 + );
24 + }
25 + const claims = verifyKaToken(q.get("ka_token") ?? "");
26 + if (!claims) {
27 + return NextResponse.json(
28 + { error: "jeton KA invalide ou expiré — recommencez la connexion" },
29 + { status: 401 },
30 + );
31 + }
32 + const made = sessionFromClaims(claims);
33 + if (!made) {
34 + return NextResponse.json(
35 + { error: "jeton KA incomplet (ka_id manquant)" },
36 + { status: 401 },
37 + );
38 + }
39 + const res = NextResponse.redirect(new URL(state.next, KA_BASE_URL));
40 + res.cookies.set(SESSION_COOKIE, made.value, {
41 + httpOnly: true,
42 + secure: KA_BASE_URL.startsWith("https"),
43 + sameSite: "lax",
44 + path: "/",
45 + maxAge: SESSION_DAYS * 86400,
46 + });
47 + return res;
48 +}
added app/src/app/api/auth/ka/login/route.ts +22 −0
@@ -0,0 +1,22 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Départ SSO « Se connecter avec KA ID » : redirige vers le hub d'identité du
3 +// Groupe KA (groupe-ka.com) avec un state opaque signé localement (le state
4 +// transporte aussi la destination `next` de retour — chemin local seulement).
5 +import { NextRequest, NextResponse } from "next/server";
6 +import { CLIENT_ID, KA_BASE_URL, KA_HUB_URL, newState, sanitizeNext } from "@/lib/ka-auth";
7 +
8 +export function GET(req: NextRequest) {
9 + const next = sanitizeNext(req.nextUrl.searchParams.get("next"));
10 + const state = newState(next);
11 + if (!state || !process.env.KA_SSO_SECRET) {
12 + return NextResponse.json(
13 + { error: "SSO KA ID non configuré (KA_SSO_SECRET / KA_AUTH_SECRET manquant)" },
14 + { status: 503 },
15 + );
16 + }
17 + const url = new URL(`${KA_HUB_URL}/sso/authorize`);
18 + url.searchParams.set("client_id", CLIENT_ID);
19 + url.searchParams.set("redirect_uri", `${KA_BASE_URL}/api/auth/ka/callback`);
20 + url.searchParams.set("state", state);
21 + return NextResponse.redirect(url);
22 +}
added app/src/app/api/auth/logout/route.ts +17 −0
@@ -0,0 +1,17 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Déconnexion : efface le cookie de session local (la session est sans
3 +// stockage serveur — supprimer le cookie suffit). POST seulement.
4 +import { NextResponse } from "next/server";
5 +import { KA_BASE_URL, SESSION_COOKIE } from "@/lib/ka-auth";
6 +
7 +export function POST() {
8 + const res = NextResponse.json({ ok: true });
9 + res.cookies.set(SESSION_COOKIE, "", {
10 + httpOnly: true,
11 + secure: KA_BASE_URL.startsWith("https"),
12 + sameSite: "lax",
13 + path: "/",
14 + maxAge: 0,
15 + });
16 + return res;
17 +}
added app/src/app/api/auth/me/route.ts +19 −0
@@ -0,0 +1,19 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Session courante : { user } si le cookie vpx_ka_session est valide, sinon
3 +// { user: null } — consommé par la page /favoris et les cœurs ♥ du site.
4 +import { NextRequest, NextResponse } from "next/server";
5 +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth";
6 +
7 +export function GET(req: NextRequest) {
8 + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
9 + if (!s) return NextResponse.json({ user: null });
10 + return NextResponse.json({
11 + user: {
12 + ka_id: s.ka_id,
13 + email: s.email,
14 + name: s.name,
15 + picture: s.picture,
16 + provider: s.provider ?? "ka-id",
17 + },
18 + });
19 +}
added app/src/app/api/favorites/route.ts +27 −0
@@ -0,0 +1,27 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Favoris « Mon univers Ka » du membre connecté — lus au HUB Groupe KA
3 +// (groupe-ka.com), magasin central des favoris du groupe : aucun stockage
4 +// local, cache mémoire 30 s (voir src/lib/hubfav.ts). 401 sans session KA ID.
5 +import { NextRequest, NextResponse } from "next/server";
6 +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth";
7 +import { hubList } from "@/lib/hubfav";
8 +
9 +export const dynamic = "force-dynamic";
10 +
11 +export async function GET(req: NextRequest) {
12 + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
13 + if (!s) {
14 + return NextResponse.json(
15 + { error: "connexion KA ID requise" },
16 + { status: 401 },
17 + );
18 + }
19 + const favorites = await hubList(s.ka_id);
20 + if (favorites === null) {
21 + return NextResponse.json(
22 + { error: "hub Groupe KA injoignable — réessayez dans un instant" },
23 + { status: 502 },
24 + );
25 + }
26 + return NextResponse.json({ favorites });
27 +}
added app/src/app/api/favorites/toggle/route.ts +44 −0
@@ -0,0 +1,44 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Toggle d'un favori « Mon univers Ka » — poussé de façon SYNCHRONE au HUB
3 +// Groupe KA (POST signé HMAC, timeout 6 s) : le hub est le magasin central,
4 +// un échec remonte à l'appelant (502). 401 sans session KA ID.
5 +import { NextRequest, NextResponse } from "next/server";
6 +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth";
7 +import { cleanItem, hubToggle } from "@/lib/hubfav";
8 +
9 +export const dynamic = "force-dynamic";
10 +
11 +export async function POST(req: NextRequest) {
12 + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
13 + if (!s) {
14 + return NextResponse.json(
15 + { error: "connexion KA ID requise" },
16 + { status: 401 },
17 + );
18 + }
19 + let body: { action?: unknown; item?: unknown };
20 + try {
21 + body = await req.json();
22 + } catch {
23 + return NextResponse.json({ error: "corps JSON invalide" }, { status: 400 });
24 + }
25 + const action = body.action === "add" || body.action === "remove" ? body.action : null;
26 + const item =
27 + typeof body.item === "object" && body.item !== null
28 + ? cleanItem(body.item as Record<string, unknown>)
29 + : null;
30 + if (!action || !item?.item_id || (action === "add" && !item.title)) {
31 + return NextResponse.json(
32 + { error: "action (add|remove) et item.item_id requis (title requis pour add)" },
33 + { status: 400 },
34 + );
35 + }
36 + const ok = await hubToggle(s.ka_id, action, item);
37 + if (!ok) {
38 + return NextResponse.json(
39 + { error: "hub Groupe KA injoignable — favori non sauvegardé" },
40 + { status: 502 },
41 + );
42 + }
43 + return NextResponse.json({ ok: true, action, item_id: item.item_id });
44 +}
added app/src/app/favoris/FavorisClient.tsx +250 −0
@@ -0,0 +1,250 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * /favoris — « Mon univers Ka » : les plex mis en ♥ avec le KA ID, lus au HUB
4 + * Groupe KA (magasin central des favoris — les mêmes favoris suivent le membre
5 + * sur toutes les plateformes du groupe). Aucun stockage local : retrait ici =
6 + * retrait partout. Sans session, la page invite à se connecter avec KA ID.
7 + */
8 +"use client";
9 +import { useEffect, useState } from "react";
10 +import Link from "next/link";
11 +import { useLang } from "@/components/LangContext";
12 +import { favStoreRemove } from "@/components/FavHeart";
13 +
14 +type Fav = {
15 + item_id: string;
16 + title?: string;
17 + subtitle?: string;
18 + price_label?: string;
19 + image_url?: string;
20 + url?: string;
21 +};
22 +
23 +type User = { ka_id: string; name: string; email: string };
24 +
25 +/** Ne suit que les liens du site — un favori transporte une URL absolue. */
26 +function localPath(url: string | undefined): string | null {
27 + if (!url) return null;
28 + try {
29 + const u = new URL(url);
30 + if (u.hostname.endsWith("valoplex.com")) return u.pathname + u.search;
31 + } catch {}
32 + return null;
33 +}
34 +
35 +export default function FavorisClient() {
36 + const { lang } = useLang();
37 + const fr = lang === "fr";
38 + const [user, setUser] = useState<User | null | undefined>(undefined);
39 + const [favs, setFavs] = useState<Fav[] | null | undefined>(undefined);
40 + const [removing, setRemoving] = useState<string | null>(null);
41 +
42 + useEffect(() => {
43 + let alive = true;
44 + (async () => {
45 + try {
46 + const me = (await (await fetch("/api/auth/me")).json()) as {
47 + user: User | null;
48 + };
49 + if (!alive) return;
50 + setUser(me.user);
51 + if (!me.user) {
52 + setFavs(null);
53 + return;
54 + }
55 + const r = await fetch("/api/favorites", { cache: "no-store" });
56 + if (!alive) return;
57 + if (!r.ok) {
58 + setFavs(null);
59 + return;
60 + }
61 + const body = (await r.json()) as { favorites?: Fav[] };
62 + setFavs(body.favorites ?? []);
63 + } catch {
64 + if (alive) {
65 + setUser(null);
66 + setFavs(null);
67 + }
68 + }
69 + })();
70 + return () => {
71 + alive = false;
72 + };
73 + }, []);
74 +
75 + const remove = async (f: Fav) => {
76 + setRemoving(f.item_id);
77 + try {
78 + const r = await fetch("/api/favorites/toggle", {
79 + method: "POST",
80 + headers: { "Content-Type": "application/json" },
81 + body: JSON.stringify({ action: "remove", item: { item_id: f.item_id } }),
82 + });
83 + if (r.ok) {
84 + setFavs((xs) => (xs ?? []).filter((x) => x.item_id !== f.item_id));
85 + favStoreRemove(f.item_id);
86 + }
87 + } finally {
88 + setRemoving(null);
89 + }
90 + };
91 +
92 + const logout = async () => {
93 + await fetch("/api/auth/logout", { method: "POST" });
94 + window.location.href = "/";
95 + };
96 +
97 + return (
98 + <div className="pb-6 pt-10 sm:pt-14">
99 + {/* héros */}
100 + <section>
101 + <span className="kicker">Mon univers Ka</span>
102 + <h1 className="vp-display mt-3 max-w-4xl text-[clamp(30px,5.6vw,58px)] font-bold uppercase leading-[0.98] tracking-[-0.035em]">
103 + {fr ? (
104 + <>
105 + Mes <span className="hl">plex favoris</span>
106 + </>
107 + ) : (
108 + <>
109 + My <span className="hl">favourite plexes</span>
110 + </>
111 + )}
112 + </h1>
113 + <p className="mt-4 max-w-xl text-[16px] text-ink-2">
114 + {fr
115 + ? "Vos favoris sont attachés à votre KA ID et sauvegardés au hub Groupe KA : les mêmes cœurs vous suivent sur toutes les plateformes du groupe."
116 + : "Your favourites are attached to your KA ID and saved at the Groupe KA hub: the same hearts follow you across every platform of the group."}
117 + </p>
118 + </section>
119 +
120 + {/* contenu */}
121 + <section className="mt-9">
122 + {user === undefined || favs === undefined ? (
123 + <div className="vp-card p-6">
124 + <p className="vp-mono text-[12px] font-bold uppercase tracking-[0.08em] text-ink-3">
125 + {fr ? "Chargement…" : "Loading…"}
126 + </p>
127 + </div>
128 + ) : !user ? (
129 + <div className="vp-card p-6 sm:p-8">
130 + <p className="vp-display text-[20px] font-bold uppercase tracking-[-0.02em]">
131 + {fr ? "Connectez-vous avec KA ID" : "Sign in with KA ID"}
132 + </p>
133 + <p className="mt-2 max-w-lg text-[15px] text-ink-2">
134 + {fr
135 + ? "Un seul compte — gratuit — pour tout l'univers Groupe KA : vos favoris ValoPlex y sont réunis avec ceux des autres plateformes."
136 + : "One free account for the whole Groupe KA universe: your ValoPlex favourites live there together with those of the other platforms."}
137 + </p>
138 + <a
139 + href="/api/auth/ka/login?next=/favoris"
140 + className="btn btn-primary mt-5"
141 + >
142 + {fr ? "Se connecter avec KA ID" : "Sign in with KA ID"} →
143 + </a>
144 + </div>
145 + ) : favs === null ? (
146 + <div className="vp-card p-6">
147 + <p className="text-[15px] text-ink-2">
148 + {fr
149 + ? "Le hub Groupe KA est injoignable pour l'instant — réessayez dans un instant."
150 + : "The Groupe KA hub is unreachable right now — try again shortly."}
151 + </p>
152 + </div>
153 + ) : (
154 + <>
155 + <div className="mb-4 flex flex-wrap items-center gap-3">
156 + <p className="vp-mono text-[11.5px] font-bold uppercase tracking-[0.08em] text-ink-3">
157 + {user.name} · {user.ka_id} —{" "}
158 + {fr
159 + ? `${favs.length} favori${favs.length > 1 ? "s" : ""}`
160 + : `${favs.length} favourite${favs.length > 1 ? "s" : ""}`}
161 + </p>
162 + <button
163 + onClick={logout}
164 + className="vp-mono cursor-pointer rounded-full border-[1.5px] border-ink bg-surface px-3 py-1 text-[10.5px] font-bold uppercase tracking-[0.08em] hover:bg-ink hover:text-lime"
165 + >
166 + {fr ? "Déconnexion" : "Sign out"}
167 + </button>
168 + </div>
169 + {favs.length === 0 ? (
170 + <div className="vp-card p-6 sm:p-8">
171 + <p className="vp-display text-[20px] font-bold uppercase tracking-[-0.02em]">
172 + {fr ? "Aucun favori pour l'instant" : "No favourites yet"}
173 + </p>
174 + <p className="mt-2 max-w-lg text-[15px] text-ink-2">
175 + {fr
176 + ? "Évaluez un plex puis touchez le cœur ♥ sur sa fiche : il apparaîtra ici — et partout dans votre univers Ka."
177 + : "Value a plex, then tap the heart ♥ on its page: it will appear here — and everywhere in your Ka universe."}
178 + </p>
179 + <Link href="/" className="btn btn-primary mt-5">
180 + {fr ? "Évaluer un plex" : "Value a plex"} →
181 + </Link>
182 + </div>
183 + ) : (
184 + <div className="grid gap-4 sm:grid-cols-2">
185 + {favs.map((f, i) => {
186 + const href = localPath(f.url);
187 + const inner = (
188 + <>
189 + <div className="flex items-start justify-between gap-3">
190 + <div className="min-w-0">
191 + <p className="vp-mono text-[10px] font-bold text-green">
192 + {String(i + 1).padStart(2, "0")}
193 + </p>
194 + <p className="vp-display mt-0.5 text-[16px] font-bold uppercase leading-tight">
195 + {f.title || f.item_id}
196 + </p>
197 + {f.subtitle ? (
198 + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
199 + {f.subtitle}
200 + </p>
201 + ) : null}
202 + </div>
203 + <button
204 + aria-label={fr ? "Retirer des favoris" : "Remove from favourites"}
205 + title={fr ? "Retirer des favoris" : "Remove from favourites"}
206 + disabled={removing === f.item_id}
207 + onClick={(e) => {
208 + e.preventDefault();
209 + e.stopPropagation();
210 + remove(f);
211 + }}
212 + className="vp-mono shrink-0 cursor-pointer rounded-full border-[1.5px] border-ink bg-ink px-2 py-1 text-[10px] font-bold text-lime hover:bg-surface hover:text-ink disabled:opacity-60"
213 + >
214 + ♥ ✕
215 + </button>
216 + </div>
217 + {f.price_label ? (
218 + <p className="vp-display mt-3 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3 text-[22px] font-bold tracking-[-0.02em]">
219 + {f.price_label}
220 + </p>
221 + ) : null}
222 + </>
223 + );
224 + return href ? (
225 + <Link
226 + key={f.item_id}
227 + href={href}
228 + className="vp-card vp-card-hover rise block p-5"
229 + style={{ animationDelay: `${i * 60}ms` }}
230 + >
231 + {inner}
232 + </Link>
233 + ) : (
234 + <div
235 + key={f.item_id}
236 + className="vp-card rise p-5"
237 + style={{ animationDelay: `${i * 60}ms` }}
238 + >
239 + {inner}
240 + </div>
241 + );
242 + })}
243 + </div>
244 + )}
245 + </>
246 + )}
247 + </section>
248 + </div>
249 + );
250 +}
added app/src/app/favoris/page.tsx +14 −0
@@ -0,0 +1,14 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +import type { Metadata } from "next";
3 +import FavorisClient from "./FavorisClient";
4 +
5 +export const metadata: Metadata = {
6 + title: "Mes favoris — Mon univers Ka",
7 + description:
8 + "Vos plex favoris, sauvegardés avec votre KA ID et partagés sur toutes les plateformes du Groupe KA.",
9 + robots: { index: false, follow: false },
10 +};
11 +
12 +export default function FavorisPage() {
13 + return <FavorisClient />;
14 +}
modified app/src/app/parc/page.tsx +16 −3
@@ -2,6 +2,7 @@
2 2 "use client";
3 3 import { useEffect, useState } from "react";
4 4 import Link from "next/link";
5 +import FavHeart from "@/components/FavHeart";
5 6 import SearchBox, { type Suggestion } from "@/components/SearchBox";
6 7 import { CountUp, locNum, Sparkline, ValueSplit } from "@/components/MetricViz";
7 8 import { useLang } from "@/components/LangContext";
@@ -317,9 +318,21 @@ export default function ParcPage() {
317 318 {u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"}
318 319 </p>
319 320 </div>
320 − <span className={`pill ${CONF[r.confidenceLevel]} shrink-0`}>
321 − {r.confidenceLevel}
322 − </span>
321 + <div className="flex shrink-0 items-center gap-2">
322 + <FavHeart
323 + small
324 + item={{
325 + item_id: u.id,
326 + title: u.adresse ?? "",
327 + subtitle: u.municipalite ?? "",
328 + price_label: fmt(r.estimate, lang),
329 + url: `https://www.valoplex.com/estimation/${encodeURIComponent(u.id)}`,
330 + }}
331 + />
332 + <span className={`pill ${CONF[r.confidenceLevel]}`}>
333 + {r.confidenceLevel}
334 + </span>
335 + </div>
323 336 </div>
324 337 <div className="mt-3 flex items-end justify-between gap-3 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3">
325 338 <div>
added app/src/components/FavHeart.tsx +159 −0
@@ -0,0 +1,159 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Cœur ♥ « Mon univers Ka » — favoris unifiés du Groupe KA.
4 + * Le hub groupe-ka.com est le magasin central : le composant lit la liste UNE
5 + * fois par page (magasin client partagé entre tous les cœurs) puis pousse
6 + * chaque toggle au serveur (/api/favorites/toggle → hub, synchrone). Sans
7 + * session KA ID, le clic envoie vers la connexion SSO (retour sur la page).
8 + */
9 +"use client";
10 +import { useEffect, useReducer, useState } from "react";
11 +import { useLang } from "./LangContext";
12 +
13 +export type FavHeartItem = {
14 + item_id: string;
15 + title: string;
16 + subtitle?: string;
17 + price_label?: string;
18 + image_url?: string;
19 + url?: string;
20 +};
21 +
22 +// -- magasin client partagé (un seul GET /api/favorites par page) -------------
23 +// undefined = pas encore chargé · null = pas de session KA ID · Set = favoris
24 +let favSet: Set<string> | null | undefined;
25 +let inflight: Promise<void> | null = null;
26 +const subs = new Set<() => void>();
27 +const notify = () => subs.forEach((f) => f());
28 +
29 +async function loadFavs(): Promise<void> {
30 + try {
31 + const r = await fetch("/api/favorites", { cache: "no-store" });
32 + if (r.status === 401) {
33 + favSet = null;
34 + return;
35 + }
36 + if (!r.ok) {
37 + favSet = null;
38 + return;
39 + }
40 + const body = (await r.json()) as { favorites?: { item_id?: string }[] };
41 + favSet = new Set(
42 + (body.favorites ?? []).map((f) => String(f.item_id ?? "")).filter(Boolean),
43 + );
44 + } catch {
45 + favSet = null;
46 + }
47 +}
48 +
49 +function ensureLoaded() {
50 + if (favSet === undefined && !inflight) {
51 + inflight = loadFavs().finally(() => {
52 + inflight = null;
53 + notify();
54 + });
55 + }
56 +}
57 +
58 +/** À appeler après un retrait effectué ailleurs (ex. page /favoris). */
59 +export function favStoreRemove(itemId: string) {
60 + if (favSet instanceof Set) {
61 + favSet.delete(itemId);
62 + notify();
63 + }
64 +}
65 +
66 +export default function FavHeart({
67 + item,
68 + small = false,
69 +}: {
70 + item: FavHeartItem;
71 + small?: boolean;
72 +}) {
73 + const { lang } = useLang();
74 + const fr = lang === "fr";
75 + const [, force] = useReducer((x: number) => x + 1, 0);
76 + const [busy, setBusy] = useState(false);
77 +
78 + useEffect(() => {
79 + subs.add(force);
80 + ensureLoaded();
81 + return () => {
82 + subs.delete(force);
83 + };
84 + }, []);
85 +
86 + const fav = favSet instanceof Set && favSet.has(item.item_id);
87 +
88 + const onClick = async (e: React.MouseEvent) => {
89 + // le cœur vit parfois DANS un <Link> (cartes) — ne pas naviguer
90 + e.preventDefault();
91 + e.stopPropagation();
92 + if (busy) return;
93 + if (!(favSet instanceof Set)) {
94 + // pas de session KA ID → connexion SSO, retour sur la page courante
95 + const next = window.location.pathname + window.location.search;
96 + window.location.href = `/api/auth/ka/login?next=${encodeURIComponent(next)}`;
97 + return;
98 + }
99 + const action = fav ? "remove" : "add";
100 + setBusy(true);
101 + // optimiste — retour arrière si le hub refuse
102 + if (action === "add") favSet.add(item.item_id);
103 + else favSet.delete(item.item_id);
104 + notify();
105 + try {
106 + const r = await fetch("/api/favorites/toggle", {
107 + method: "POST",
108 + headers: { "Content-Type": "application/json" },
109 + body: JSON.stringify({ action, item }),
110 + });
111 + if (!r.ok) throw new Error(String(r.status));
112 + } catch {
113 + if (action === "add") favSet.delete(item.item_id);
114 + else favSet.add(item.item_id);
115 + notify();
116 + } finally {
117 + setBusy(false);
118 + }
119 + };
120 +
121 + const label = fav
122 + ? fr
123 + ? "Retirer de mes favoris (Mon univers Ka)"
124 + : "Remove from my favourites (My Ka universe)"
125 + : fr
126 + ? "Ajouter à mes favoris (Mon univers Ka)"
127 + : "Add to my favourites (My Ka universe)";
128 + const size = small ? "h-9 w-9" : "h-11 w-11";
129 + const glyph = small ? 15 : 18;
130 +
131 + return (
132 + <button
133 + type="button"
134 + onClick={onClick}
135 + aria-label={label}
136 + title={label}
137 + aria-pressed={fav}
138 + disabled={busy}
139 + className={`inline-flex ${size} shrink-0 cursor-pointer items-center justify-center rounded-full border-[1.5px] border-ink transition-all ${
140 + fav
141 + ? "rotate-[-3deg] bg-ink text-lime"
142 + : "bg-surface text-ink hover:bg-ink hover:text-lime"
143 + } ${busy ? "opacity-60" : ""}`}
144 + >
145 + <svg
146 + width={glyph}
147 + height={glyph}
148 + viewBox="0 0 24 24"
149 + aria-hidden="true"
150 + fill={fav ? "currentColor" : "none"}
151 + stroke="currentColor"
152 + strokeWidth="2.2"
153 + strokeLinejoin="round"
154 + >
155 + <path d="M12 20.7l-1.3-1.2C6 15.3 2.9 12.5 2.9 9 2.9 6.2 5.1 4 7.9 4c1.6 0 3.1.7 4.1 1.9C13 4.7 14.5 4 16.1 4c2.8 0 5 2.2 5 5 0 3.5-3.1 6.3-7.8 10.5L12 20.7z" />
156 + </svg>
157 + </button>
158 + );
159 +}
modified app/src/components/HeaderNav.tsx +1 −0
@@ -30,6 +30,7 @@ export default function HeaderNav() {
30 30 { href: "/", label: fr ? "Estimer" : "Estimate" },
31 31 { href: "/ka", label: fr ? "Ka · agent IA" : "Ka · AI agent" },
32 32 { href: "/parc", label: fr ? "Parc immobilier" : "Portfolio" },
33 + { href: "/favoris", label: fr ? "♥ Favoris" : "♥ Saved" },
33 34 { href: "/stats", label: fr ? "Statistiques" : "Statistics" },
34 35 { href: "/methodologie", label: fr ? "Méthodologie" : "Methodology" },
35 36 ];
modified app/src/components/ResultView.tsx +12 −0
@@ -3,6 +3,7 @@
3 3 import { useState } from "react";
4 4 import Link from "next/link";
5 5 import { useLang } from "./LangContext";
6 +import FavHeart from "./FavHeart";
6 7 import LeadForm from "./LeadForm";
7 8 import RadarMap from "./RadarMap";
8 9 import {
@@ -136,6 +137,17 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
136 137 <span className="vp-mono rotate-[-1deg] rounded-md border-[1.5px] border-ink bg-lime px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.08em] text-ink">
137 138 {doorsLabel(doors, fr)}
138 139 </span>
140 + {u && (
141 + <FavHeart
142 + item={{
143 + item_id: u.id,
144 + title: u.adresse || doorsLabel(doors, fr),
145 + subtitle: u.municipalite ?? "",
146 + price_label: moneyFmt(r.estimate),
147 + url: `https://www.valoplex.com/estimation/${encodeURIComponent(u.id)}`,
148 + }}
149 + />
150 + )}
139 151 </div>
140 152 {u && (
141 153 <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">
added app/src/lib/hubfav.ts +129 −0
@@ -0,0 +1,129 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// -----------------------------------------------------------------------------
3 +// hubfav — favoris « Mon univers Ka » : le hub Groupe KA (groupe-ka.com) est le
4 +// MAGASIN CENTRAL des favoris du groupe — ValoPlex ne stocke RIEN localement.
5 +// Chaque ♥ est poussé au hub (POST signé HMAC, synchrone : un échec remonte à
6 +// l'appelant) et la liste est lue au hub (GET signé, cache mémoire 30 s,
7 +// invalidé à chaque toggle). Même secret que le SSO (KA_SSO_SECRET).
8 +// sig = HMAC-SHA256(KA_SSO_SECRET, "valoplex.<ka_id>.<ts>") en hex,
9 +// ts en secondes epoch (fenêtre ±300 s côté hub).
10 +// -----------------------------------------------------------------------------
11 +import { createHmac } from "node:crypto";
12 +import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth";
13 +
14 +const CACHE_TTL_MS = 30_000; // liste des favoris
15 +const TIMEOUT_MS = 6_000;
16 +
17 +/** Item de favori tel qu'accepté par le hub (champ → longueur maximale). */
18 +const FIELDS: Record<string, number> = {
19 + item_id: 120,
20 + title: 200,
21 + subtitle: 200,
22 + price_label: 60,
23 + image_url: 500,
24 + url: 500,
25 +};
26 +
27 +export type FavItem = {
28 + item_id: string;
29 + title: string;
30 + subtitle?: string;
31 + price_label?: string;
32 + image_url?: string;
33 + url?: string;
34 +} & Record<string, unknown>;
35 +
36 +const cache = new Map<string, { at: number; favs: FavItem[] }>();
37 +
38 +function sig(kaId: string, ts: number): string | null {
39 + const secret = process.env.KA_SSO_SECRET;
40 + if (!secret) return null;
41 + return createHmac("sha256", secret)
42 + .update(`${CLIENT_ID}.${kaId}.${ts}`)
43 + .digest("hex");
44 +}
45 +
46 +/** Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe). */
47 +export function linked(kaId: string | null | undefined): boolean {
48 + return typeof kaId === "string" && kaId.startsWith("ka-");
49 +}
50 +
51 +/** Ne garde que les champs d'item connus, en chaînes tronquées. */
52 +export function cleanItem(raw: Record<string, unknown>): FavItem {
53 + const out: Record<string, string> = {};
54 + for (const [k, max] of Object.entries(FIELDS)) {
55 + const v = raw?.[k];
56 + if (v != null && String(v)) out[k] = String(v).slice(0, max);
57 + }
58 + return out as FavItem;
59 +}
60 +
61 +/** Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s :
62 + * le hub est le magasin des favoris, l'échec doit remonter à l'appelant. */
63 +export async function hubToggle(
64 + kaId: string,
65 + action: string,
66 + item: FavItem,
67 +): Promise<boolean> {
68 + const ts = Math.floor(Date.now() / 1000);
69 + const s = sig(kaId, ts);
70 + if (!s || !linked(kaId) || (action !== "add" && action !== "remove")) {
71 + return false;
72 + }
73 + let ok = false;
74 + try {
75 + const r = await fetch(`${KA_HUB_URL}/api/sso/favorites`, {
76 + method: "POST",
77 + headers: { "Content-Type": "application/json" },
78 + signal: AbortSignal.timeout(TIMEOUT_MS),
79 + body: JSON.stringify({
80 + client_id: CLIENT_ID,
81 + ka_id: kaId,
82 + ts: String(ts),
83 + sig: s,
84 + action,
85 + item,
86 + }),
87 + });
88 + ok = r.status === 200;
89 + } catch {
90 + ok = false;
91 + }
92 + if (ok) cache.delete(kaId); // la prochaine lecture reflète le toggle
93 + return ok;
94 +}
95 +
96 +/** Favoris ValoPlex du membre, lus au hub (cache mémoire 30 s).
97 + * [] = aucun favori ; null = hub injoignable (erreur, jamais mise en cache). */
98 +export async function hubList(kaId: string): Promise<FavItem[] | null> {
99 + if (!linked(kaId)) return []; // compte non relié au hub
100 + const now = Date.now();
101 + const hit = cache.get(kaId);
102 + if (hit && now - hit.at < CACHE_TTL_MS) return hit.favs;
103 + const ts = Math.floor(now / 1000);
104 + const s = sig(kaId, ts);
105 + if (!s) return null;
106 + let favs: FavItem[];
107 + try {
108 + const q = new URLSearchParams({
109 + client_id: CLIENT_ID,
110 + ka_id: kaId,
111 + ts: String(ts),
112 + sig: s,
113 + });
114 + const r = await fetch(`${KA_HUB_URL}/api/sso/favorites?${q}`, {
115 + signal: AbortSignal.timeout(TIMEOUT_MS),
116 + cache: "no-store",
117 + });
118 + if (r.status !== 200) return null;
119 + const body = (await r.json()) as { favorites?: unknown };
120 + if (!Array.isArray(body.favorites)) return null;
121 + favs = body.favorites.filter(
122 + (f): f is FavItem => typeof f === "object" && f !== null,
123 + );
124 + } catch {
125 + return null;
126 + }
127 + cache.set(kaId, { at: now, favs });
128 + return favs;
129 +}
added app/src/lib/ka-auth.ts +179 −0
@@ -0,0 +1,179 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// -----------------------------------------------------------------------------
3 +// KA ID — SSO du Groupe KA (hub d'identité : groupe-ka.com).
4 +// « Se connecter avec KA ID » : l'utilisateur est envoyé au hub, qui le
5 +// renvoie ici avec un jeton JWT HS256 signé du secret partagé KA_SSO_SECRET
6 +// et transportant son profil (dont le KA-ID « ka-0123456789 », LE MÊME sur
7 +// toutes les plateformes du groupe). Aucune dépendance : node:crypto seulement.
8 +// · state / session locale : base64url(JSON) + "." + HMAC-SHA256 hex,
9 +// signés avec KA_AUTH_SECRET (jamais partagé).
10 +// · ka_token du hub : JWT HS256 vérifié avec KA_SSO_SECRET
11 +// (signature, alg, iss, aud, exp).
12 +// -----------------------------------------------------------------------------
13 +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
14 +
15 +export const SESSION_COOKIE = "vpx_ka_session";
16 +export const SESSION_DAYS = 30;
17 +export const CLIENT_ID = "valoplex";
18 +
19 +export const KA_HUB_URL = (
20 + process.env.KA_HUB_URL || "https://www.groupe-ka.com"
21 +).replace(/\/+$/, "");
22 +
23 +/** URL publique de l'app (redirect_uri — le hub n'accepte que valoplex.com). */
24 +export const KA_BASE_URL = (
25 + process.env.KA_BASE_URL || "https://www.valoplex.com"
26 +).replace(/\/+$/, "");
27 +
28 +/** Session locale posée en cookie httpOnly après un retour SSO réussi. */
29 +export type KaSession = {
30 + ka_id: string;
31 + email: string;
32 + name: string;
33 + picture: string;
34 + provider?: string;
35 + exp: number; // epoch (secondes)
36 +};
37 +
38 +const now = () => Math.floor(Date.now() / 1000);
39 +
40 +function authSecret(): Buffer | null {
41 + const s = process.env.KA_AUTH_SECRET;
42 + return s ? Buffer.from(s, "utf8") : null;
43 +}
44 +
45 +function ssoSecret(): Buffer | null {
46 + const s = process.env.KA_SSO_SECRET;
47 + return s ? Buffer.from(s, "utf8") : null;
48 +}
49 +
50 +/** Comparaison à temps constant (timingSafeEqual exige des longueurs égales). */
51 +function safeEqual(a: Buffer, b: Buffer): boolean {
52 + return a.length === b.length && timingSafeEqual(a, b);
53 +}
54 +
55 +// -- jeton local signé (state SSO + session) : base64url(JSON).hex(HMAC) ------
56 +
57 +export function signLocal(payload: Record<string, unknown>): string | null {
58 + const secret = authSecret();
59 + if (!secret) return null;
60 + const raw = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
61 + const sig = createHmac("sha256", secret).update(raw).digest("hex");
62 + return `${raw}.${sig}`;
63 +}
64 +
65 +export function verifyLocal(token: string): Record<string, unknown> | null {
66 + const secret = authSecret();
67 + if (!secret || !token) return null;
68 + try {
69 + const dot = token.lastIndexOf(".");
70 + if (dot < 0) return null;
71 + const raw = token.slice(0, dot);
72 + const sig = token.slice(dot + 1);
73 + const expected = createHmac("sha256", secret).update(raw).digest("hex");
74 + if (!safeEqual(Buffer.from(sig, "utf8"), Buffer.from(expected, "utf8"))) {
75 + return null;
76 + }
77 + const payload = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
78 + if (typeof payload !== "object" || payload === null) return null;
79 + if (typeof payload.exp !== "number" || payload.exp < now()) return null;
80 + return payload as Record<string, unknown>;
81 + } catch {
82 + return null;
83 + }
84 +}
85 +
86 +// -- state SSO (anti-forgerie, transporte aussi la destination `next`) --------
87 +
88 +/** State opaque signé localement — expire en 10 minutes. */
89 +export function newState(next: string): string | null {
90 + return signLocal({
91 + n: randomBytes(12).toString("base64url"),
92 + next,
93 + exp: now() + 600,
94 + });
95 +}
96 +
97 +/** Vérifie le state et rend la destination `next` (chemin local sûr). */
98 +export function verifyState(state: string): { next: string } | null {
99 + const p = verifyLocal(state);
100 + if (!p) return null;
101 + const next = typeof p.next === "string" ? p.next : "/favoris";
102 + return { next: sanitizeNext(next) };
103 +}
104 +
105 +/** N'accepte qu'un chemin local (« /… », jamais « //… » ni une URL absolue). */
106 +export function sanitizeNext(next: string | null | undefined): string {
107 + if (!next || !next.startsWith("/") || next.startsWith("//")) return "/favoris";
108 + return next;
109 +}
110 +
111 +// -- ka_token : JWT HS256 émis par le hub (vérification stdlib) ---------------
112 +
113 +export function verifyKaToken(token: string): Record<string, unknown> | null {
114 + const secret = ssoSecret();
115 + if (!secret || !token) return null;
116 + try {
117 + const parts = token.split(".");
118 + if (parts.length !== 3) return null;
119 + const [h64, p64, s64] = parts;
120 + const expected = createHmac("sha256", secret)
121 + .update(`${h64}.${p64}`)
122 + .digest();
123 + if (!safeEqual(Buffer.from(s64, "base64url"), expected)) return null;
124 +
125 + const header = JSON.parse(Buffer.from(h64, "base64url").toString("utf8"));
126 + if (header?.alg !== "HS256") return null;
127 +
128 + const claims = JSON.parse(Buffer.from(p64, "base64url").toString("utf8"));
129 + if (claims?.iss !== KA_HUB_URL) return null;
130 + const aud = claims?.aud;
131 + const audOk =
132 + aud === CLIENT_ID || (Array.isArray(aud) && aud.includes(CLIENT_ID));
133 + if (!audOk) return null;
134 + if (typeof claims?.exp !== "number" || claims.exp < now()) return null;
135 + return claims as Record<string, unknown>;
136 + } catch {
137 + return null;
138 + }
139 +}
140 +
141 +// -- session locale ------------------------------------------------------------
142 +
143 +/** Construit le cookie de session (30 jours) depuis les claims du hub. */
144 +export function sessionFromClaims(
145 + claims: Record<string, unknown>,
146 +): { value: string; session: KaSession } | null {
147 + const kaId = typeof claims.ka_id === "string" ? claims.ka_id.trim() : "";
148 + if (!kaId) return null; // le KA-ID est créé À LA SOURCE par le hub — requis
149 + const email =
150 + typeof claims.email === "string" ? claims.email.toLowerCase() : "";
151 + const session: KaSession = {
152 + ka_id: kaId,
153 + email,
154 + name:
155 + typeof claims.name === "string" && claims.name
156 + ? claims.name
157 + : email.split("@")[0] || "membre",
158 + picture: typeof claims.picture === "string" ? claims.picture : "",
159 + provider: typeof claims.provider === "string" ? claims.provider : undefined,
160 + exp: now() + SESSION_DAYS * 86400,
161 + };
162 + const value = signLocal({ ...session });
163 + return value ? { value, session } : null;
164 +}
165 +
166 +/** Lit et vérifie le cookie de session — null si absent/invalide/expiré. */
167 +export function readSession(cookieValue: string | undefined): KaSession | null {
168 + if (!cookieValue) return null;
169 + const p = verifyLocal(cookieValue);
170 + if (!p || typeof p.ka_id !== "string" || !p.ka_id) return null;
171 + return {
172 + ka_id: p.ka_id,
173 + email: typeof p.email === "string" ? p.email : "",
174 + name: typeof p.name === "string" ? p.name : "",
175 + picture: typeof p.picture === "string" ? p.picture : "",
176 + provider: typeof p.provider === "string" ? p.provider : undefined,
177 + exp: p.exp as number,
178 + };
179 +}
180