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>
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 | ||