SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%

Favoris unifiés « Mon univers Ka » (KA ID) — ♥ hub Groupe KA

- lib/hubfav.ts : client HMAC du hub (transposé de food-ka) — aucun stockage
  local, toggle synchrone, lecture GET signée avec cache mémoire 30 s
  invalidé à chaque toggle
- /api/favorites (GET, 401 sans session) + /api/favorites/toggle (POST,
  bascule add/remove selon la liste du hub, action explicite acceptée)
- FavButton ♥ : fiche estimation (panneau prix) + cartes du parc ;
  déconnecté → départ SSO KA ID ; état initial partagé (une requête/page)
- page /favoris : liste (titre, ville, estimation, lien fiche, retrait) ;
  lien « Mes favoris » sur /compte
- Testé bout en bout contre le hub (add → list → remove, rien laissé)

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

9 changed files +584 −3

added src/app/api/favorites/route.ts +21 −0
@@ -0,0 +1,21 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// GET /api/favorites — favoris « Mon univers Ka » du membre connecté, lus au
3 +// hub Groupe KA (magasin central, cache serveur 30 s — voir lib/hubfav).
4 +// 401 sans session KA ID valide ; 502 si le hub est injoignable.
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({ error: "auth_required" }, { status: 401 });
15 + }
16 + const favorites = await hubList(s.ka_id);
17 + if (favorites === null) {
18 + return NextResponse.json({ error: "hub_unreachable" }, { status: 502 });
19 + }
20 + return NextResponse.json({ favorites, count: favorites.length });
21 +}
added src/app/api/favorites/toggle/route.ts +44 −0
@@ -0,0 +1,44 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// POST /api/favorites/toggle — bascule un ♥ « Mon univers Ka » au hub Groupe KA.
3 +// Corps : { item_id, title?, subtitle?, price_label?, image_url?, url?, meta?,
4 +// action? } — sans `action` explicite (« add » / « remove »), on bascule selon
5 +// la présence de l'item dans la liste du hub. SYNCHRONE (aucun stockage
6 +// local) : 401 sans session, 400 item invalide, 502 hub injoignable/refus.
7 +import { NextRequest, NextResponse } from "next/server";
8 +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth";
9 +import { cleanItem, hubList, hubToggle } from "@/lib/hubfav";
10 +
11 +export const dynamic = "force-dynamic";
12 +
13 +export async function POST(req: NextRequest) {
14 + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
15 + if (!s) {
16 + return NextResponse.json({ error: "auth_required" }, { status: 401 });
17 + }
18 + let body: unknown;
19 + try {
20 + body = await req.json();
21 + } catch {
22 + return NextResponse.json({ error: "invalid_json" }, { status: 400 });
23 + }
24 + const item = cleanItem(body);
25 + if (!item) {
26 + return NextResponse.json({ error: "item_id_required" }, { status: 400 });
27 + }
28 + const requested = (body as { action?: unknown }).action;
29 + let action: "add" | "remove";
30 + if (requested === "add" || requested === "remove") {
31 + action = requested;
32 + } else {
33 + const list = await hubList(s.ka_id);
34 + if (list === null) {
35 + return NextResponse.json({ error: "hub_unreachable" }, { status: 502 });
36 + }
37 + action = list.some((f) => f.item_id === item.item_id) ? "remove" : "add";
38 + }
39 + const ok = await hubToggle(s.ka_id, action, item);
40 + if (!ok) {
41 + return NextResponse.json({ error: "hub_error" }, { status: 502 });
42 + }
43 + return NextResponse.json({ ok: true, favorited: action === "add" });
44 +}
modified src/app/compte/CompteClient.tsx +4 −0
@@ -10,6 +10,7 @@
10 10 "use client";
11 11 import { useState } from "react";
12 12 import { useRouter } from "next/navigation";
13 +import Link from "next/link";
13 14 import { useLang } from "@/components/LangContext";
14 15 import type { HubProfile, HubSocials } from "@/lib/hub-profile";
15 16
@@ -244,6 +245,9 @@ export default function CompteClient({
244 245 >
245 246 {copied ? "✓ " + (fr ? "Copié" : "Copied") : fr ? "Copier mon KA-ID" : "Copy my KA-ID"}
246 247 </button>
248 + <Link href="/favoris" className="btn btn-ghost">
249 + ♥ {fr ? "Mes favoris" : "My favourites"}
250 + </Link>
247 251 <button className="btn btn-ghost" onClick={logout} disabled={out}>
248 252 {out ? (fr ? "Déconnexion…" : "Signing out…") : fr ? "Se déconnecter" : "Sign out"}
249 253 </button>
added src/app/favoris/FavorisClient.tsx +158 −0
@@ -0,0 +1,158 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * « Mes favoris » — liste des propriétés mises en ♥ (Mon univers Ka).
4 + * Le hub Groupe KA est le magasin central : retirer un favori = POST synchrone
5 + * /api/favorites/toggle (action « remove »), la rangée disparaît si le hub
6 + * confirme. Design éditorial Vrai-Prix (papier/encre, cartes à ombre franche).
7 + */
8 +"use client";
9 +import { useState } from "react";
10 +import Link from "next/link";
11 +import { useLang } from "@/components/LangContext";
12 +import type { FavItem } from "@/lib/hubfav";
13 +
14 +/** Lien local vers la fiche : chemin du hub si c'est une URL Vrai-Prix,
15 + * sinon retombée sur /estimation/<item_id>. */
16 +function ficheHref(f: FavItem): string {
17 + if (f.url) {
18 + try {
19 + const u = new URL(f.url);
20 + if (u.hostname.endsWith("vrai-prix.com")) return u.pathname + u.search;
21 + } catch {
22 + /* URL invalide — retombée ci-dessous */
23 + }
24 + }
25 + return `/estimation/${encodeURIComponent(f.item_id)}`;
26 +}
27 +
28 +export default function FavorisClient({ initial }: { initial: FavItem[] | null }) {
29 + const { lang } = useLang();
30 + const fr = lang === "fr";
31 + const [items, setItems] = useState<FavItem[] | null>(initial);
32 + const [busy, setBusy] = useState<string | null>(null);
33 + const [err, setErr] = useState(false);
34 +
35 + const remove = async (f: FavItem) => {
36 + if (busy) return;
37 + setBusy(f.item_id);
38 + setErr(false);
39 + try {
40 + const r = await fetch("/api/favorites/toggle", {
41 + method: "POST",
42 + headers: { "Content-Type": "application/json" },
43 + body: JSON.stringify({ ...f, action: "remove" }),
44 + });
45 + if (!r.ok) throw new Error(String(r.status));
46 + setItems((cur) => (cur ?? []).filter((x) => x.item_id !== f.item_id));
47 + } catch {
48 + setErr(true);
49 + } finally {
50 + setBusy(null);
51 + }
52 + };
53 +
54 + return (
55 + <div className="py-10">
56 + {/* en-tête */}
57 + <span className="kicker">{fr ? "Mon univers Ka" : "My Ka universe"}</span>
58 + <h1 className="vp-display mt-2 text-[clamp(28px,5vw,44px)] font-bold uppercase leading-none tracking-[-0.03em]">
59 + {fr ? "Mes favoris" : "My favourites"}
60 + </h1>
61 + <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2">
62 + {fr
63 + ? "Les propriétés que vous avez mises en ♥ — gardées au hub Groupe KA, les mêmes sur toutes les plateformes du groupe."
64 + : "The properties you hearted — stored at the Groupe KA hub, the same across every platform of the group."}
65 + </p>
66 +
67 + {err && (
68 + <p className="vp-mono mt-4 rounded-[8px] border-[1.5px] border-red-600 bg-surface px-4 py-3 text-[12px] uppercase tracking-[0.04em] text-red-700">
69 + {fr
70 + ? "Hub Groupe KA injoignable — le retrait n'a pas été enregistré, réessayez."
71 + : "Groupe KA hub unreachable — removal was not saved, try again."}
72 + </p>
73 + )}
74 +
75 + {/* hub injoignable au chargement */}
76 + {items === null ? (
77 + <div className="vp-card mt-8 p-6 sm:p-8">
78 + <p className="vp-display text-[18px] font-bold">
79 + {fr ? "Hub Groupe KA injoignable" : "Groupe KA hub unreachable"}
80 + </p>
81 + <p className="mt-2 text-[14.5px] text-ink-2">
82 + {fr
83 + ? "Vos favoris vivent sur groupe-ka.com et n'ont pas pu être lus. Rechargez la page dans un instant."
84 + : "Your favourites live on groupe-ka.com and could not be read. Reload the page in a moment."}
85 + </p>
86 + </div>
87 + ) : items.length === 0 ? (
88 + /* aucun favori */
89 + <div className="vp-card mt-8 p-6 sm:p-8">
90 + <p className="vp-display text-[18px] font-bold">
91 + {fr ? "Aucun favori pour l'instant" : "No favourites yet"}
92 + </p>
93 + <p className="mt-2 max-w-xl text-[14.5px] text-ink-2">
94 + {fr
95 + ? "Touchez le ♥ d'une fiche d'estimation pour la garder ici — elle vous suivra sur toutes les plateformes du Groupe KA."
96 + : "Tap the ♥ on any estimate page to keep it here — it follows you across every Groupe KA platform."}
97 + </p>
98 + <Link href="/" className="btn btn-primary vp-display mt-5 inline-flex">
99 + {fr ? "Estimer une propriété" : "Estimate a property"}
100 + </Link>
101 + </div>
102 + ) : (
103 + <>
104 + <p className="vp-mono mt-8 text-[11px] font-bold uppercase tracking-[0.08em] text-ink-3">
105 + {items.length}{" "}
106 + {fr
107 + ? `propriété${items.length > 1 ? "s" : ""} en favoris`
108 + : `favourite propert${items.length > 1 ? "ies" : "y"}`}
109 + </p>
110 + <ul className="mt-3 grid gap-4 sm:grid-cols-2">
111 + {items.map((f, i) => (
112 + <li
113 + key={f.item_id}
114 + className="vp-card vp-card-hover rise relative p-5"
115 + style={{ animationDelay: `${i * 60}ms` }}
116 + >
117 + <Link href={ficheHref(f)} className="block pr-10">
118 + <p className="vp-mono text-[10px] font-bold text-green">
119 + {String(i + 1).padStart(2, "0")}
120 + </p>
121 + <p className="vp-display mt-0.5 text-[16px] font-bold uppercase leading-tight">
122 + {f.title}
123 + </p>
124 + {f.subtitle && (
125 + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">
126 + {f.subtitle}
127 + </p>
128 + )}
129 + <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">
130 + <p className="vp-display text-[20px] font-bold tracking-[-0.02em]">
131 + {f.price_label || "—"}
132 + </p>
133 + <span className="vp-mono text-[11px] font-bold uppercase tracking-[0.04em] text-green">
134 + {fr ? "Voir la fiche →" : "View →"}
135 + </span>
136 + </div>
137 + </Link>
138 + {/* retirer (♥ plein — même geste que sur la fiche) */}
139 + <button
140 + type="button"
141 + onClick={() => remove(f)}
142 + disabled={busy === f.item_id}
143 + aria-label={fr ? "Retirer de mes favoris" : "Remove from my favourites"}
144 + title={fr ? "Retirer de mes favoris" : "Remove from my favourites"}
145 + className={`absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full border-[1.5px] border-ink bg-ink text-[14px] leading-none text-lime transition-all hover:bg-green-deep ${
146 + busy === f.item_id ? "opacity-60" : ""
147 + }`}
148 + >
149 + <span aria-hidden="true" className="translate-y-[-1px]">♥</span>
150 + </button>
151 + </li>
152 + ))}
153 + </ul>
154 + </>
155 + )}
156 + </div>
157 + );
158 +}
added src/app/favoris/page.tsx +28 −0
@@ -0,0 +1,28 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Page « Mes favoris » — Mon univers Ka : les propriétés mises en ♥, lues au
3 +// hub Groupe KA (magasin central des favoris du groupe — rien en local).
4 +// Sans session KA ID valide → départ SSO (retour ici).
5 +import type { Metadata } from "next";
6 +import { cookies } from "next/headers";
7 +import { redirect } from "next/navigation";
8 +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth";
9 +import { hubList } from "@/lib/hubfav";
10 +import FavorisClient from "./FavorisClient";
11 +
12 +export const dynamic = "force-dynamic";
13 +
14 +export const metadata: Metadata = {
15 + title: "Mes favoris",
16 + description:
17 + "Mon univers Ka : vos propriétés favorites, les mêmes sur toutes les plateformes du Groupe KA.",
18 + robots: { index: false, follow: false },
19 +};
20 +
21 +export default async function FavorisPage() {
22 + const jar = await cookies();
23 + const session = readSession(jar.get(SESSION_COOKIE)?.value);
24 + if (!session) redirect("/api/auth/ka/login?next=/favoris");
25 + // Liste au hub Groupe KA (cache 30 s) — null si le hub est injoignable.
26 + const favorites = await hubList(session.ka_id);
27 + return <FavorisClient initial={favorites} />;
28 +}
modified src/app/parc/ParcClient.tsx +15 −2
@@ -5,6 +5,7 @@ import Link from "next/link";
5 5 import SearchBox, { type Suggestion } from "@/components/SearchBox";
6 6 import { CountUp, locNum, Sparkline, ValueSplit } from "@/components/MetricViz";
7 7 import { useLang } from "@/components/LangContext";
8 +import FavButton from "@/components/FavButton";
8 9 import type { PortfolioResult } from "@/lib/estimator";
9 10 import type { TKey } from "@/lib/i18n";
10 11
@@ -317,8 +318,20 @@ export default function ParcClient() {
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}
321 + <span className="flex shrink-0 items-center gap-2">
322 + {/* ♥ Mon univers Ka — favori unifié Groupe KA */}
323 + <FavButton
324 + item={{
325 + item_id: u.id,
326 + title: u.adresse ?? u.id,
327 + subtitle: u.municipalite ?? "",
328 + price_label: fmt(r.estimate, lang),
329 + url: `https://www.vrai-prix.com/estimation/${encodeURIComponent(u.id)}`,
330 + }}
331 + />
332 + <span className={`pill ${CONF[r.confidenceLevel]}`}>
333 + {r.confidenceLevel}
334 + </span>
322 335 </span>
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">
added src/components/FavButton.tsx +148 −0
@@ -0,0 +1,148 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Bouton ♥ « Mon univers Ka » — favoris unifiés du Groupe KA (KA ID).
4 + * · Connecté : bascule le favori au hub via POST /api/favorites/toggle
5 + * (synchrone — le hub groupe-ka.com est le magasin central, rien en local).
6 + * · Déconnecté : le clic envoie au SSO KA ID (retour sur la page courante).
7 + * L'état initial est lu via GET /api/favorites, partagé entre tous les boutons
8 + * de la page (une seule requête, mini-cache module 15 s, invalidé au toggle).
9 + * Utilisable dans un <Link> parent (stopPropagation + preventDefault).
10 + */
11 +"use client";
12 +import { useEffect, useState } from "react";
13 +import { usePathname } from "next/navigation";
14 +import { useLang } from "./LangContext";
15 +
16 +export type FavPayload = {
17 + item_id: string;
18 + title: string;
19 + subtitle?: string;
20 + price_label?: string;
21 + image_url?: string;
22 + url?: string;
23 +};
24 +
25 +type FavState = { authed: boolean; ids: string[] };
26 +
27 +// -- mini-cache module : une requête partagée par page, TTL 15 s --------------
28 +const TTL_MS = 15_000;
29 +let shared: { at: number; p: Promise<FavState> } | null = null;
30 +
31 +function loadFavs(): Promise<FavState> {
32 + const now = Date.now();
33 + if (shared && now - shared.at < TTL_MS) return shared.p;
34 + const p: Promise<FavState> = fetch("/api/favorites")
35 + .then(async (r) => {
36 + if (r.status === 401) return { authed: false, ids: [] };
37 + if (!r.ok) throw new Error(String(r.status));
38 + const d = (await r.json()) as { favorites?: { item_id?: string }[] };
39 + return {
40 + authed: true,
41 + ids: (d.favorites ?? []).map((f) => String(f.item_id ?? "")),
42 + };
43 + })
44 + .catch(() => {
45 + shared = null; // erreur transitoire : jamais mise en cache
46 + return { authed: true, ids: [] };
47 + });
48 + shared = { at: now, p };
49 + return p;
50 +}
51 +
52 +export default function FavButton({
53 + item,
54 + size = "sm",
55 + className = "",
56 +}: {
57 + item: FavPayload;
58 + size?: "sm" | "lg";
59 + className?: string;
60 +}) {
61 + const { lang } = useLang();
62 + const fr = lang === "fr";
63 + const path = usePathname();
64 + const [authed, setAuthed] = useState<boolean | null>(null);
65 + const [fav, setFav] = useState(false);
66 + const [busy, setBusy] = useState(false);
67 + const [flash, setFlash] = useState(false); // échec hub — bref signal visuel
68 +
69 + useEffect(() => {
70 + let alive = true;
71 + loadFavs().then((st) => {
72 + if (!alive) return;
73 + setAuthed(st.authed);
74 + setFav(st.ids.includes(item.item_id));
75 + });
76 + return () => {
77 + alive = false;
78 + };
79 + }, [item.item_id]);
80 +
81 + const onClick = async (e: React.MouseEvent) => {
82 + // le bouton vit parfois dans un <Link> (carte de résultat) : ne pas naviguer
83 + e.preventDefault();
84 + e.stopPropagation();
85 + if (busy) return;
86 + if (authed === false) {
87 + window.location.href = `/api/auth/ka/login?next=${encodeURIComponent(path || "/")}`;
88 + return;
89 + }
90 + setBusy(true);
91 + try {
92 + const r = await fetch("/api/favorites/toggle", {
93 + method: "POST",
94 + headers: { "Content-Type": "application/json" },
95 + body: JSON.stringify({ ...item, action: fav ? "remove" : "add" }),
96 + });
97 + if (r.status === 401) {
98 + window.location.href = `/api/auth/ka/login?next=${encodeURIComponent(path || "/")}`;
99 + return;
100 + }
101 + if (!r.ok) throw new Error(String(r.status));
102 + const d = (await r.json()) as { favorited?: boolean };
103 + setFav(!!d.favorited);
104 + shared = null; // les autres boutons reliront une liste fraîche
105 + } catch {
106 + setFlash(true);
107 + setTimeout(() => setFlash(false), 1200);
108 + } finally {
109 + setBusy(false);
110 + }
111 + };
112 +
113 + const label = fav
114 + ? fr
115 + ? "Retirer de mes favoris"
116 + : "Remove from my favourites"
117 + : fr
118 + ? "Ajouter à mes favoris — Mon univers Ka"
119 + : "Add to my favourites — My Ka universe";
120 + const dims =
121 + size === "lg" ? "h-11 w-11 text-[19px]" : "h-8 w-8 text-[14px]";
122 +
123 + return (
124 + <button
125 + type="button"
126 + onClick={onClick}
127 + disabled={busy}
128 + aria-pressed={fav}
129 + aria-label={label}
130 + title={
131 + flash
132 + ? fr
133 + ? "Hub Groupe KA injoignable — réessayez"
134 + : "Groupe KA hub unreachable — try again"
135 + : label
136 + }
137 + className={`flex shrink-0 items-center justify-center rounded-full border-[1.5px] border-ink leading-none transition-all ${dims} ${
138 + fav
139 + ? "bg-ink text-lime hover:bg-green-deep"
140 + : "bg-surface text-ink-3 hover:bg-lime-soft hover:text-ink"
141 + } ${busy ? "opacity-60" : ""} ${flash ? "!border-red-600 !text-red-600" : ""} ${className}`}
142 + >
143 + <span aria-hidden="true" className="translate-y-[-1px]">
144 + {fav ? "♥" : "♡"}
145 + </span>
146 + </button>
147 + );
148 +}
modified src/components/ResultView.tsx +17 −1
@@ -5,6 +5,7 @@ import Link from "next/link";
5 5 import { useLang } from "./LangContext";
6 6 import LeadForm from "./LeadForm";
7 7 import RadarMap from "./RadarMap";
8 +import FavButton from "./FavButton";
8 9 import {
9 10 BuildingGlyph,
10 11 ConfidenceDial,
@@ -74,7 +75,22 @@ export default function ResultView({ data }: { data: UnitEstimate }) {
74 75 <div className="flex flex-wrap items-start justify-between gap-6">
75 76 {/* basis large : sous ~380px de large la jauge passe dessous au lieu d'écraser la colonne */}
76 77 <div className="min-w-0 flex-1 basis-[300px]">
77 − <span className="kicker">{t("estimatedValue")}</span>
78 + <div className="flex items-center justify-between gap-3">
79 + <span className="kicker">{t("estimatedValue")}</span>
80 + {/* ♥ Mon univers Ka — favori unifié Groupe KA */}
81 + {u && (
82 + <FavButton
83 + size="lg"
84 + item={{
85 + item_id: u.id,
86 + title: `${u.adresse ?? u.id}${s?.apt ? ` app. ${s.apt}` : ""}`,
87 + subtitle: u.municipalite ?? "",
88 + price_label: r.estimate > 0 ? moneyFmt(r.estimate) : "",
89 + url: `https://www.vrai-prix.com/estimation/${encodeURIComponent(u.id)}`,
90 + }}
91 + />
92 + )}
93 + </div>
78 94 {u && (
79 95 <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">
80 96 {u.adresse}
added src/lib/hubfav.ts +149 −0
@@ -0,0 +1,149 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// -----------------------------------------------------------------------------
3 +// Favoris « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) est le MAGASIN
4 +// CENTRAL des favoris du groupe : Vrai-Prix ne stocke RIEN localement.
5 +// · Chaque ♥ est poussé au hub (POST signé HMAC, SYNCHRONE : un échec
6 +// remonte à l'appelant) ; la liste est lue au hub (GET signé, cache
7 +// mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO.
8 +// · sig = HMAC-SHA256(KA_SSO_SECRET, "vrai-prix.<ka_id>.<ts>") en hex,
9 +// ts epoch en secondes (fenêtre ±300 s côté hub).
10 +// · Appels SERVEUR uniquement (route handlers) — jamais depuis le navigateur.
11 +// Aucune dépendance : node:crypto + fetch natif seulement.
12 +// Config .env.local : KA_SSO_SECRET, KA_HUB_URL (optionnel).
13 +// -----------------------------------------------------------------------------
14 +import { createHmac } from "node:crypto";
15 +import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth";
16 +
17 +const CACHE_TTL_MS = 30_000; // 30 s — liste des favoris
18 +const TIMEOUT_MS = 6_000; // 6 s
19 +
20 +/** Item de favori tel qu'accepté / rendu par le hub. */
21 +export type FavItem = {
22 + item_id: string;
23 + title: string;
24 + subtitle?: string;
25 + price_label?: string;
26 + image_url?: string;
27 + url?: string;
28 + meta?: Record<string, unknown>;
29 +};
30 +
31 +// champs d'item acceptés → longueur maximale (troncature défensive)
32 +const FIELDS: { key: Exclude<keyof FavItem, "meta">; max: number }[] = [
33 + { key: "item_id", max: 120 },
34 + { key: "title", max: 200 },
35 + { key: "subtitle", max: 200 },
36 + { key: "price_label", max: 60 },
37 + { key: "image_url", max: 500 },
38 + { key: "url", max: 500 },
39 +];
40 +
41 +/** Signature HMAC-SHA256 du hub : hex("vrai-prix.<ka_id>.<ts>"). */
42 +function sig(kaId: string, ts: number): string | null {
43 + const secret = process.env.KA_SSO_SECRET;
44 + if (!secret) return null;
45 + return createHmac("sha256", secret)
46 + .update(`${CLIENT_ID}.${kaId}.${ts}`)
47 + .digest("hex");
48 +}
49 +
50 +/** Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe). */
51 +export function linked(kaId: string | null | undefined): boolean {
52 + return !!kaId && String(kaId).startsWith("ka-");
53 +}
54 +
55 +/** Ne garde que les champs d'item connus (chaînes tronquées) — null si pas
56 + * d'item_id exploitable. `title` retombe sur l'item_id. */
57 +export function cleanItem(raw: unknown): FavItem | null {
58 + if (typeof raw !== "object" || raw === null) return null;
59 + const src = raw as Record<string, unknown>;
60 + const out: Record<string, unknown> = {};
61 + for (const { key, max } of FIELDS) {
62 + const v = src[key];
63 + if (v == null || v === "") continue;
64 + out[key] = String(v).slice(0, max);
65 + }
66 + if (typeof out.item_id !== "string" || !out.item_id) return null;
67 + if (typeof out.title !== "string" || !out.title) out.title = out.item_id;
68 + if (src.meta && typeof src.meta === "object" && !Array.isArray(src.meta)) {
69 + out.meta = src.meta as Record<string, unknown>;
70 + }
71 + return out as FavItem;
72 +}
73 +
74 +// Cache mémoire process-wide (Map globale — survit au HMR en dev).
75 +type CacheEntry = { at: number; favs: FavItem[] };
76 +const g = globalThis as unknown as { __vpHubFavCache?: Map<string, CacheEntry> };
77 +const cache: Map<string, CacheEntry> =
78 + g.__vpHubFavCache ?? (g.__vpHubFavCache = new Map());
79 +
80 +/** fetch avec délai maximal (AbortController) — null sur toute erreur réseau. */
81 +async function hubFetch(url: string, init?: RequestInit): Promise<Response | null> {
82 + const ctrl = new AbortController();
83 + const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
84 + try {
85 + return await fetch(url, { ...init, signal: ctrl.signal, cache: "no-store" });
86 + } catch {
87 + return null;
88 + } finally {
89 + clearTimeout(timer);
90 + }
91 +}
92 +
93 +/** Favoris Vrai-Prix du membre, lus au hub (cache mémoire 30 s).
94 + * [] = aucun favori ; null = hub injoignable (erreur, jamais mise en cache). */
95 +export async function hubList(kaId: string): Promise<FavItem[] | null> {
96 + if (!linked(kaId)) return []; // compte non relié au hub
97 + const now = Date.now();
98 + const hit = cache.get(kaId);
99 + if (hit && now - hit.at < CACHE_TTL_MS) return hit.favs;
100 +
101 + const ts = Math.floor(now / 1000);
102 + const s = sig(kaId, ts);
103 + if (!s) return null; // KA_SSO_SECRET absent
104 + const url =
105 + `${KA_HUB_URL}/api/sso/favorites?client_id=${encodeURIComponent(CLIENT_ID)}` +
106 + `&ka_id=${encodeURIComponent(kaId)}&ts=${ts}&sig=${s}`;
107 + const r = await hubFetch(url);
108 + if (!r || !r.ok) return null;
109 + let favs: unknown;
110 + try {
111 + favs = ((await r.json()) as { favorites?: unknown })?.favorites ?? [];
112 + } catch {
113 + return null;
114 + }
115 + if (!Array.isArray(favs)) return null;
116 + const clean = favs.filter(
117 + (f): f is FavItem =>
118 + typeof f === "object" && f !== null && typeof (f as FavItem).item_id === "string",
119 + );
120 + cache.set(kaId, { at: now, favs: clean });
121 + return clean;
122 +}
123 +
124 +/** Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s :
125 + * le hub est le magasin des favoris, l'échec doit remonter à l'appelant. */
126 +export async function hubToggle(
127 + kaId: string,
128 + action: "add" | "remove",
129 + item: FavItem,
130 +): Promise<boolean> {
131 + const ts = Math.floor(Date.now() / 1000);
132 + const s = sig(kaId, ts);
133 + if (!s || !linked(kaId) || (action !== "add" && action !== "remove")) return false;
134 + const r = await hubFetch(`${KA_HUB_URL}/api/sso/favorites`, {
135 + method: "POST",
136 + headers: { "Content-Type": "application/json" },
137 + body: JSON.stringify({
138 + client_id: CLIENT_ID,
139 + ka_id: kaId,
140 + ts: String(ts),
141 + sig: s,
142 + action,
143 + item,
144 + }),
145 + });
146 + const ok = r?.status === 200;
147 + if (ok) cache.delete(kaId); // la prochaine lecture reflète le toggle
148 + return ok;
149 +}
150