web : favoris unifiés « Mon univers Ka » (KA ID) — hub groupe-ka.com
· lib/hubfav.ts : client serveur du hub (HMAC-SHA256 KA_SSO_SECRET, GET cache 30 s invalidé au toggle, POST synchrone timeout 6 s) — transposition TypeScript de food-ka/foodka/hubfav.py, AUCUN stockage local. · /api/favorites (GET) + /api/favorites/toggle (POST) : session tk_ka_session requise (401 sinon), 502 si hub injoignable ; item_id stable dérivé de l URL du résultat (pg-<sha256[:40]>). · UI : cœur ♥ sur chaque carte de résultat (fav-button + fav-store partagé, toggle optimiste), page /favoris (CTA « Se connecter avec KA ID » hors session), liens nav + footer. fr-CA, design éditorial sharp du site. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9 changed files +669 −0
added
apps/web/app/api/favorites/route.ts
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Favoris « Mon univers Ka » du membre connecté, lus au hub groupe-ka.com | |
| 3 | +// (magasin central — aucun stockage local, cache serveur 30 s dans 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 async function GET(req: NextRequest) { | |
| 10 | + const session = readSession(req.cookies.get(SESSION_COOKIE)?.value); | |
| 11 | + if (!session) { | |
| 12 | + return NextResponse.json({ error: "Connexion KA ID requise." }, { status: 401 }); | |
| 13 | + } | |
| 14 | + const favorites = await hubList(session.ka_id); | |
| 15 | + if (favorites === null) { | |
| 16 | + return NextResponse.json( | |
| 17 | + { error: "Le hub Groupe KA est momentanément injoignable." }, | |
| 18 | + { status: 502 }, | |
| 19 | + ); | |
| 20 | + } | |
| 21 | + return NextResponse.json( | |
| 22 | + { favorites }, | |
| 23 | + { headers: { "Cache-Control": "no-store" } }, | |
| 24 | + ); | |
| 25 | +} | |
added
apps/web/app/api/favorites/toggle/route.ts
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Toggle d'un favori « Mon univers Ka » : pousse « add » / « remove » au hub | |
| 3 | +// groupe-ka.com (POST signé HMAC, SYNCHRONE — un échec remonte au client). | |
| 4 | +// item_id = identifiant stable du résultat, dérivé de son URL si absent. | |
| 5 | +import { NextRequest, NextResponse } from "next/server"; | |
| 6 | +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth"; | |
| 7 | +import { cleanItem, hubToggle, itemIdForUrl } from "@/lib/hubfav"; | |
| 8 | + | |
| 9 | +export async function POST(req: NextRequest) { | |
| 10 | + const session = readSession(req.cookies.get(SESSION_COOKIE)?.value); | |
| 11 | + if (!session) { | |
| 12 | + return NextResponse.json({ error: "Connexion KA ID requise." }, { status: 401 }); | |
| 13 | + } | |
| 14 | + | |
| 15 | + let body: { action?: unknown; item?: unknown }; | |
| 16 | + try { | |
| 17 | + body = await req.json(); | |
| 18 | + } catch { | |
| 19 | + return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 }); | |
| 20 | + } | |
| 21 | + | |
| 22 | + const action = body.action; | |
| 23 | + if (action !== "add" && action !== "remove") { | |
| 24 | + return NextResponse.json( | |
| 25 | + { error: "action doit être « add » ou « remove »." }, | |
| 26 | + { status: 400 }, | |
| 27 | + ); | |
| 28 | + } | |
| 29 | + if (typeof body.item !== "object" || body.item === null) { | |
| 30 | + return NextResponse.json({ error: "item requis." }, { status: 400 }); | |
| 31 | + } | |
| 32 | + | |
| 33 | + const item = cleanItem(body.item as Record<string, unknown>); | |
| 34 | + if (!item.item_id && item.url) item.item_id = itemIdForUrl(item.url); | |
| 35 | + if (!item.item_id) { | |
| 36 | + return NextResponse.json( | |
| 37 | + { error: "item.item_id ou item.url requis." }, | |
| 38 | + { status: 400 }, | |
| 39 | + ); | |
| 40 | + } | |
| 41 | + if (action === "add" && !item.title) { | |
| 42 | + return NextResponse.json({ error: "item.title requis." }, { status: 400 }); | |
| 43 | + } | |
| 44 | + | |
| 45 | + const ok = await hubToggle(session.ka_id, action, item); | |
| 46 | + if (!ok) { | |
| 47 | + return NextResponse.json( | |
| 48 | + { error: "Le hub Groupe KA est momentanément injoignable." }, | |
| 49 | + { status: 502 }, | |
| 50 | + ); | |
| 51 | + } | |
| 52 | + return NextResponse.json({ ok: true, action, item_id: item.item_id }); | |
| 53 | +} | |
added
apps/web/app/favoris/favoris-content.tsx
+158 −0
@@ -0,0 +1,158 @@ | ||
| 1 | +/** | |
| 2 | + * Trouve-KA — contenu client de la page « Mes favoris » (Mon univers Ka) | |
| 3 | + * Author: Simon-Pierre Boucher | |
| 4 | + * Contact: contact@spboucher.ai | |
| 5 | + * | |
| 6 | + * Liste vivante des favoris du membre, lue au hub Groupe KA via | |
| 7 | + * /api/favorites (magasin partagé du fav-store — mêmes cœurs que les | |
| 8 | + * résultats). Retrait immédiat (toggle optimiste, le hub tranche). | |
| 9 | + */ | |
| 10 | + | |
| 11 | +"use client"; | |
| 12 | + | |
| 13 | +import { useEffect } from "react"; | |
| 14 | +import Link from "next/link"; | |
| 15 | +import { Favicon } from "@/components/favicon"; | |
| 16 | +import { | |
| 17 | + ensureLoaded, | |
| 18 | + toggleFav, | |
| 19 | + useFavorites, | |
| 20 | + type Fav, | |
| 21 | +} from "@/components/fav-store"; | |
| 22 | + | |
| 23 | +function domainOf(url: string | undefined): string { | |
| 24 | + if (!url) return ""; | |
| 25 | + try { | |
| 26 | + return new URL(url).hostname.replace(/^www\./, ""); | |
| 27 | + } catch { | |
| 28 | + return ""; | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +function FavCard({ fav }: { fav: Fav }) { | |
| 33 | + const domain = domainOf(fav.url); | |
| 34 | + return ( | |
| 35 | + <article className="group flex items-start gap-4 py-6 sm:gap-6"> | |
| 36 | + <div className="min-w-0 flex-1"> | |
| 37 | + {domain ? ( | |
| 38 | + <div className="flex items-center gap-2"> | |
| 39 | + <Favicon domain={domain} /> | |
| 40 | + <span className="gk-mono truncate text-xs font-medium text-green"> | |
| 41 | + {fav.subtitle || domain} | |
| 42 | + </span> | |
| 43 | + </div> | |
| 44 | + ) : null} | |
| 45 | + <h3 className="mt-1.5 text-[17px] font-bold leading-snug tracking-[-0.02em] sm:text-lg"> | |
| 46 | + <a | |
| 47 | + href={fav.url || "#"} | |
| 48 | + className="rounded-sm text-ink underline decoration-transparent decoration-[3px] underline-offset-[3px] transition-colors hover:decoration-lime focus-visible:decoration-lime" | |
| 49 | + > | |
| 50 | + {fav.title || fav.url} | |
| 51 | + </a> | |
| 52 | + </h3> | |
| 53 | + {fav.price_label ? ( | |
| 54 | + <p className="mt-1.5 text-sm font-medium text-ink-2"> | |
| 55 | + {fav.price_label} | |
| 56 | + </p> | |
| 57 | + ) : null} | |
| 58 | + <button | |
| 59 | + type="button" | |
| 60 | + onClick={() => | |
| 61 | + void toggleFav({ | |
| 62 | + url: fav.url || fav.item_id, | |
| 63 | + title: fav.title, | |
| 64 | + subtitle: fav.subtitle, | |
| 65 | + image_url: fav.image_url, | |
| 66 | + }) | |
| 67 | + } | |
| 68 | + className="gk-display mt-3 inline-flex min-h-[36px] items-center gap-1.5 rounded-full border-[1.5px] border-ink bg-surface px-3.5 text-[12px] font-bold text-ink transition-colors hover:bg-[var(--accent-soft)]" | |
| 69 | + > | |
| 70 | + <svg | |
| 71 | + width="12" | |
| 72 | + height="12" | |
| 73 | + viewBox="0 0 24 24" | |
| 74 | + fill="currentColor" | |
| 75 | + aria-hidden="true" | |
| 76 | + > | |
| 77 | + <path d="M12 21c-4.8-3.6-9-6.9-9-11a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 4.1-4.2 7.4-9 11Z" /> | |
| 78 | + </svg> | |
| 79 | + Retirer | |
| 80 | + </button> | |
| 81 | + </div> | |
| 82 | + {fav.image_url ? ( | |
| 83 | + // eslint-disable-next-line @next/next/no-img-element | |
| 84 | + <img | |
| 85 | + src={fav.image_url} | |
| 86 | + alt="" | |
| 87 | + loading="lazy" | |
| 88 | + className="h-[76px] w-[76px] shrink-0 rounded-card border border-line object-cover sm:h-[110px] sm:w-[110px]" | |
| 89 | + /> | |
| 90 | + ) : null} | |
| 91 | + </article> | |
| 92 | + ); | |
| 93 | +} | |
| 94 | + | |
| 95 | +export function FavorisContent({ name }: { name: string }) { | |
| 96 | + const favs = useFavorites(); | |
| 97 | + | |
| 98 | + useEffect(() => { | |
| 99 | + // Liste fraîche à l'arrivée sur la page (le cache serveur reste à 30 s). | |
| 100 | + void ensureLoaded(true); | |
| 101 | + }, []); | |
| 102 | + | |
| 103 | + const loading = favs.status === "idle" || favs.status === "loading"; | |
| 104 | + | |
| 105 | + return ( | |
| 106 | + <main id="contenu" className="mx-auto w-full max-w-3xl flex-1 px-4 py-8"> | |
| 107 | + <p className="klabel">Mon univers Ka</p> | |
| 108 | + <h1 className="mt-2 text-3xl font-bold leading-tight tracking-[-0.03em] sm:text-4xl"> | |
| 109 | + Mes favoris | |
| 110 | + </h1> | |
| 111 | + <p className="mt-3 max-w-xl text-sm leading-relaxed text-ink-2"> | |
| 112 | + {name ? `${name}, voici` : "Voici"} les pages que vous avez gardées — | |
| 113 | + la même liste vous suit sur toutes les plateformes du Groupe KA. | |
| 114 | + </p> | |
| 115 | + | |
| 116 | + {loading ? ( | |
| 117 | + <div aria-hidden="true" className="mt-6 divide-y divide-line"> | |
| 118 | + {Array.from({ length: 3 }, (_, i) => ( | |
| 119 | + <div key={i} className="animate-pulse space-y-2.5 py-6"> | |
| 120 | + <div className="h-3.5 w-1/3 rounded bg-[var(--line)]" /> | |
| 121 | + <div className="h-5 w-2/3 rounded bg-[var(--line)]" /> | |
| 122 | + <div className="h-9 w-24 rounded-full bg-[var(--line)]" /> | |
| 123 | + </div> | |
| 124 | + ))} | |
| 125 | + </div> | |
| 126 | + ) : favs.status === "error" ? ( | |
| 127 | + <div className="mt-10 text-center"> | |
| 128 | + <p className="font-display font-bold text-ink"> | |
| 129 | + Le hub Groupe KA est momentanément injoignable. | |
| 130 | + </p> | |
| 131 | + <p className="mt-2 text-sm text-ink-2"> | |
| 132 | + Vos favoris sont en sécurité — réessayez dans quelques instants. | |
| 133 | + </p> | |
| 134 | + </div> | |
| 135 | + ) : favs.items.length === 0 ? ( | |
| 136 | + <div className="rise mt-10 py-10 text-center"> | |
| 137 | + <p className="klabel">0 favori</p> | |
| 138 | + <h2 className="mx-auto mt-4 max-w-xl text-2xl font-bold leading-tight tracking-[-0.03em] sm:text-3xl"> | |
| 139 | + Rien dans votre univers — <span className="hl">encore</span>. | |
| 140 | + </h2> | |
| 141 | + <p className="mx-auto mt-5 max-w-md text-sm leading-relaxed text-ink-2"> | |
| 142 | + Lancez une recherche et touchez le cœur ♥ d'un résultat pour | |
| 143 | + le retrouver ici, et sur tous les sites du Groupe KA. | |
| 144 | + </p> | |
| 145 | + <Link href="/" className="btn btn-primary mt-8"> | |
| 146 | + Lancer une recherche | |
| 147 | + </Link> | |
| 148 | + </div> | |
| 149 | + ) : ( | |
| 150 | + <div className="stagger mt-2 divide-y divide-line"> | |
| 151 | + {favs.items.map((fav) => ( | |
| 152 | + <FavCard key={fav.item_id || fav.url} fav={fav} /> | |
| 153 | + ))} | |
| 154 | + </div> | |
| 155 | + )} | |
| 156 | + </main> | |
| 157 | + ); | |
| 158 | +} | |
added
apps/web/app/favoris/page.tsx
+53 −0
@@ -0,0 +1,53 @@ | ||
| 1 | +/** | |
| 2 | + * Trouve-KA — page « Mes favoris » (Mon univers Ka) | |
| 3 | + * Author: Simon-Pierre Boucher | |
| 4 | + * Contact: contact@spboucher.ai | |
| 5 | + * | |
| 6 | + * Enveloppe serveur : vérifie la session KA ID (cookie tk_ka_session). | |
| 7 | + * Connecté → liste des favoris lue au hub Groupe KA (client, /api/favorites) ; | |
| 8 | + * sinon → invitation « Se connecter avec KA ID » (flux SSO existant). | |
| 9 | + */ | |
| 10 | + | |
| 11 | +import type { Metadata } from "next"; | |
| 12 | +import { cookies } from "next/headers"; | |
| 13 | +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth"; | |
| 14 | +import { FavorisContent } from "./favoris-content"; | |
| 15 | + | |
| 16 | +export const metadata: Metadata = { | |
| 17 | + title: "Mes favoris", | |
| 18 | + description: | |
| 19 | + "Mon univers Ka — vos favoris, partagés entre toutes les plateformes du Groupe KA.", | |
| 20 | + robots: { index: false, follow: true }, | |
| 21 | +}; | |
| 22 | + | |
| 23 | +export default async function FavorisPage() { | |
| 24 | + const jar = await cookies(); | |
| 25 | + const session = readSession(jar.get(SESSION_COOKIE)?.value); | |
| 26 | + | |
| 27 | + if (!session) { | |
| 28 | + return ( | |
| 29 | + <main id="contenu" className="mx-auto w-full max-w-3xl flex-1 px-4 py-16"> | |
| 30 | + <div className="rise text-center"> | |
| 31 | + <p className="klabel">Mon univers Ka</p> | |
| 32 | + <h1 className="mx-auto mt-4 max-w-xl text-3xl font-bold leading-tight tracking-[-0.03em] sm:text-4xl"> | |
| 33 | + Vos favoris, sur <span className="hl">toutes</span> les plateformes | |
| 34 | + KA. | |
| 35 | + </h1> | |
| 36 | + <p className="mx-auto mt-5 max-w-md text-sm leading-relaxed text-ink-2"> | |
| 37 | + Touchez le cœur ♥ d'un résultat pour le garder dans Mon | |
| 38 | + univers Ka — la même liste vous suit sur les 14 sites du Groupe | |
| 39 | + KA. Connectez-vous avec votre KA ID pour commencer. | |
| 40 | + </p> | |
| 41 | + <a | |
| 42 | + href="/api/auth/ka/login?next=%2Ffavoris" | |
| 43 | + className="btn btn-primary mt-8" | |
| 44 | + > | |
| 45 | + Se connecter avec KA ID | |
| 46 | + </a> | |
| 47 | + </div> | |
| 48 | + </main> | |
| 49 | + ); | |
| 50 | + } | |
| 51 | + | |
| 52 | + return <FavorisContent name={session.name} />; | |
| 53 | +} | |
modified
apps/web/app/layout.tsx
+2 −0
@@ -84,6 +84,7 @@ export const viewport: Viewport = { | ||
| 84 | 84 | }; |
| 85 | 85 | |
| 86 | 86 | const NAV_LINKS = [ |
| 87 | + { href: "/favoris", label: "Favoris" }, | |
| 87 | 88 | { href: "/status", label: "État du moteur" }, |
| 88 | 89 | { href: "/stats", label: "Stats" }, |
| 89 | 90 | { href: "/soumettre", label: "Soumettre une page" }, |
@@ -91,6 +92,7 @@ const NAV_LINKS = [ | ||
| 91 | 92 | ] as const; |
| 92 | 93 | |
| 93 | 94 | const LOCAL_FOOTER_LINKS = [ |
| 95 | + { href: "/favoris", label: "Mes favoris" }, | |
| 94 | 96 | { href: "/trouveka-bot", label: "À propos du robot" }, |
| 95 | 97 | { href: "/status", label: "État du moteur" }, |
| 96 | 98 | { href: "/stats", label: "Statistiques" }, |
added
apps/web/components/fav-button.tsx
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +/** | |
| 2 | + * Trouve-KA — cœur ♥ « Mon univers Ka » d'un résultat de recherche | |
| 3 | + * Author: Simon-Pierre Boucher | |
| 4 | + * Contact: contact@spboucher.ai | |
| 5 | + * | |
| 6 | + * Toggle du favori au hub Groupe KA (via /api/favorites/toggle). Connecté : | |
| 7 | + * cœur plein/contour ; non connecté : le tap envoie au flux « Se connecter | |
| 8 | + * avec KA ID » existant (retour sur la page courante). Zone tactile 44 px | |
| 9 | + * via ::after (pattern du socle mobile KA). | |
| 10 | + */ | |
| 11 | + | |
| 12 | +"use client"; | |
| 13 | + | |
| 14 | +import { useEffect, useState } from "react"; | |
| 15 | +import { cn } from "@/lib/utils"; | |
| 16 | +import { ensureLoaded, isFavorite, toggleFav, useFavorites } from "@/components/fav-store"; | |
| 17 | + | |
| 18 | +interface FavButtonProps { | |
| 19 | + url: string; | |
| 20 | + title: string; | |
| 21 | + subtitle?: string; | |
| 22 | + imageUrl?: string | null; | |
| 23 | + className?: string; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function FavButton({ url, title, subtitle, imageUrl, className }: FavButtonProps) { | |
| 27 | + const favs = useFavorites(); | |
| 28 | + const [busy, setBusy] = useState(false); | |
| 29 | + | |
| 30 | + useEffect(() => { | |
| 31 | + void ensureLoaded(); | |
| 32 | + }, []); | |
| 33 | + | |
| 34 | + const fav = isFavorite(favs, url); | |
| 35 | + const anon = favs.status === "anon"; | |
| 36 | + | |
| 37 | + const onClick = async () => { | |
| 38 | + if (busy) return; | |
| 39 | + if (anon) { | |
| 40 | + const here = window.location.pathname + window.location.search; | |
| 41 | + window.location.href = `/api/auth/ka/login?next=${encodeURIComponent(here)}`; | |
| 42 | + return; | |
| 43 | + } | |
| 44 | + setBusy(true); | |
| 45 | + await toggleFav({ | |
| 46 | + url, | |
| 47 | + title, | |
| 48 | + subtitle, | |
| 49 | + image_url: imageUrl || undefined, | |
| 50 | + }); | |
| 51 | + setBusy(false); | |
| 52 | + }; | |
| 53 | + | |
| 54 | + return ( | |
| 55 | + <button | |
| 56 | + type="button" | |
| 57 | + onClick={onClick} | |
| 58 | + disabled={busy} | |
| 59 | + aria-pressed={fav} | |
| 60 | + aria-label={ | |
| 61 | + anon | |
| 62 | + ? "Se connecter avec KA ID pour ajouter aux favoris" | |
| 63 | + : fav | |
| 64 | + ? "Retirer des favoris" | |
| 65 | + : "Ajouter aux favoris" | |
| 66 | + } | |
| 67 | + title={ | |
| 68 | + anon | |
| 69 | + ? "Se connecter avec KA ID" | |
| 70 | + : fav | |
| 71 | + ? "Retirer de Mon univers Ka" | |
| 72 | + : "Ajouter à Mon univers Ka" | |
| 73 | + } | |
| 74 | + className={cn( | |
| 75 | + "relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-[1.5px] transition-colors after:absolute after:-inset-1.5 after:content-['']", | |
| 76 | + fav | |
| 77 | + ? "border-ink bg-ink text-lime" | |
| 78 | + : "border-transparent text-ink-2 hover:border-ink hover:text-ink", | |
| 79 | + busy && "opacity-60", | |
| 80 | + className, | |
| 81 | + )} | |
| 82 | + > | |
| 83 | + <svg | |
| 84 | + width="15" | |
| 85 | + height="15" | |
| 86 | + viewBox="0 0 24 24" | |
| 87 | + fill={fav ? "currentColor" : "none"} | |
| 88 | + stroke="currentColor" | |
| 89 | + strokeWidth="2" | |
| 90 | + strokeLinejoin="round" | |
| 91 | + aria-hidden="true" | |
| 92 | + > | |
| 93 | + <path d="M12 21c-4.8-3.6-9-6.9-9-11a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 4.1-4.2 7.4-9 11Z" /> | |
| 94 | + </svg> | |
| 95 | + </button> | |
| 96 | + ); | |
| 97 | +} | |
added
apps/web/components/fav-store.ts
+130 −0
@@ -0,0 +1,130 @@ | ||
| 1 | +/** | |
| 2 | + * Trouve-KA — magasin client des favoris « Mon univers Ka » | |
| 3 | + * Author: Simon-Pierre Boucher | |
| 4 | + * Contact: contact@spboucher.ai | |
| 5 | + * | |
| 6 | + * Singleton partagé par tous les cœurs ♥ de la page (useSyncExternalStore) : | |
| 7 | + * une seule lecture de /api/favorites (le hub, via le serveur), toggles | |
| 8 | + * optimistes annulés si le hub refuse. Aucun stockage local — le hub | |
| 9 | + * groupe-ka.com est la source de vérité, ce magasin n'est qu'un miroir. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +"use client"; | |
| 13 | + | |
| 14 | +import { useSyncExternalStore } from "react"; | |
| 15 | + | |
| 16 | +export type Fav = { | |
| 17 | + item_id: string; | |
| 18 | + title: string; | |
| 19 | + subtitle?: string; | |
| 20 | + price_label?: string; | |
| 21 | + image_url?: string; | |
| 22 | + url?: string; | |
| 23 | +}; | |
| 24 | + | |
| 25 | +export type FavStatus = "idle" | "loading" | "ready" | "anon" | "error"; | |
| 26 | + | |
| 27 | +export type FavState = { | |
| 28 | + status: FavStatus; | |
| 29 | + items: readonly Fav[]; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +let state: FavState = { status: "idle", items: [] }; | |
| 33 | +const listeners = new Set<() => void>(); | |
| 34 | +let loadPromise: Promise<void> | null = null; | |
| 35 | + | |
| 36 | +function setState(next: FavState) { | |
| 37 | + state = next; | |
| 38 | + for (const l of listeners) l(); | |
| 39 | +} | |
| 40 | + | |
| 41 | +function subscribe(listener: () => void): () => void { | |
| 42 | + listeners.add(listener); | |
| 43 | + return () => listeners.delete(listener); | |
| 44 | +} | |
| 45 | + | |
| 46 | +const getSnapshot = () => state; | |
| 47 | +const SERVER_SNAPSHOT: FavState = { status: "idle", items: [] }; | |
| 48 | +const getServerSnapshot = () => SERVER_SNAPSHOT; | |
| 49 | + | |
| 50 | +/** Charge la liste des favoris (une seule requête partagée par page). */ | |
| 51 | +export function ensureLoaded(force = false): Promise<void> { | |
| 52 | + if (!force && (loadPromise || state.status === "ready" || state.status === "anon")) { | |
| 53 | + return loadPromise ?? Promise.resolve(); | |
| 54 | + } | |
| 55 | + if (state.status === "idle" || force) { | |
| 56 | + setState({ ...state, status: "loading" }); | |
| 57 | + } | |
| 58 | + loadPromise = fetch("/api/favorites", { cache: "no-store" }) | |
| 59 | + .then(async (r) => { | |
| 60 | + if (r.status === 401) { | |
| 61 | + setState({ status: "anon", items: [] }); | |
| 62 | + return; | |
| 63 | + } | |
| 64 | + if (!r.ok) throw new Error(`Erreur ${r.status}`); | |
| 65 | + const data = (await r.json()) as { favorites?: Fav[] }; | |
| 66 | + setState({ status: "ready", items: data.favorites ?? [] }); | |
| 67 | + }) | |
| 68 | + .catch(() => { | |
| 69 | + setState({ status: "error", items: state.items }); | |
| 70 | + }) | |
| 71 | + .finally(() => { | |
| 72 | + loadPromise = null; | |
| 73 | + }); | |
| 74 | + return loadPromise; | |
| 75 | +} | |
| 76 | + | |
| 77 | +function hasFav(items: readonly Fav[], url: string): boolean { | |
| 78 | + return items.some((f) => f.url === url); | |
| 79 | +} | |
| 80 | + | |
| 81 | +/** | |
| 82 | + * Toggle optimiste d'un favori (par URL — identité d'un résultat Trouve-KA). | |
| 83 | + * Rendu : "added" | "removed" en cas de succès, "error" si le hub refuse | |
| 84 | + * (l'état local est alors restauré), "anon" si non connecté. | |
| 85 | + */ | |
| 86 | +export async function toggleFav(item: { | |
| 87 | + url: string; | |
| 88 | + title: string; | |
| 89 | + subtitle?: string; | |
| 90 | + image_url?: string; | |
| 91 | +}): Promise<"added" | "removed" | "error" | "anon"> { | |
| 92 | + if (state.status === "anon") return "anon"; | |
| 93 | + const before = state.items; | |
| 94 | + const removing = hasFav(before, item.url); | |
| 95 | + const action = removing ? "remove" : "add"; | |
| 96 | + | |
| 97 | + // Optimiste : on reflète le geste tout de suite, le hub tranche ensuite. | |
| 98 | + setState({ | |
| 99 | + status: "ready", | |
| 100 | + items: removing | |
| 101 | + ? before.filter((f) => f.url !== item.url) | |
| 102 | + : [{ item_id: "", ...item }, ...before], | |
| 103 | + }); | |
| 104 | + | |
| 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.status === 401) { | |
| 112 | + setState({ status: "anon", items: [] }); | |
| 113 | + return "anon"; | |
| 114 | + } | |
| 115 | + if (!r.ok) throw new Error(`Erreur ${r.status}`); | |
| 116 | + return removing ? "removed" : "added"; | |
| 117 | + } catch { | |
| 118 | + setState({ status: state.status, items: before }); // annule l'optimisme | |
| 119 | + return "error"; | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +/** État réactif des favoris — à consommer dans les composants clients. */ | |
| 124 | +export function useFavorites(): FavState { | |
| 125 | + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | |
| 126 | +} | |
| 127 | + | |
| 128 | +export function isFavorite(stateNow: FavState, url: string): boolean { | |
| 129 | + return hasFav(stateNow.items, url); | |
| 130 | +} | |
modified
apps/web/components/result-item.tsx
+9 −0
@@ -4,6 +4,7 @@ | ||
| 4 | 4 | * Contact: contact@spboucher.ai |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | +import { FavButton } from "@/components/fav-button"; | |
| 7 | 8 | import { Favicon } from "@/components/favicon"; |
| 8 | 9 | import { ResultThumbnail } from "@/components/result-thumbnail"; |
| 9 | 10 | import { Snippet } from "@/components/snippet"; |
@@ -40,6 +41,14 @@ export function ResultItem({ result }: ResultItemProps) { | ||
| 40 | 41 | <span className="gk-mono truncate text-xs font-medium text-green"> |
| 41 | 42 | {result.display_url || result.domain} |
| 42 | 43 | </span> |
| 44 | + {/* ♥ Mon univers Ka : favori partagé sur tout le Groupe KA */} | |
| 45 | + <FavButton | |
| 46 | + url={result.url} | |
| 47 | + title={result.title || result.url} | |
| 48 | + subtitle={result.display_url || result.domain} | |
| 49 | + imageUrl={result.image} | |
| 50 | + className="-my-1.5 ml-auto" | |
| 51 | + /> | |
| 43 | 52 | </div> |
| 44 | 53 | <h3 className="mt-1.5 text-[17px] font-bold leading-snug tracking-[-0.02em] sm:text-lg"> |
| 45 | 54 | <a |
added
apps/web/lib/hubfav.ts
+142 −0
@@ -0,0 +1,142 @@ | ||
| 1 | +/** | |
| 2 | + * Trouve-KA — favoris « Mon univers Ka » : client serveur du hub Groupe KA | |
| 3 | + * Author: Simon-Pierre Boucher | |
| 4 | + * Contact: contact@spboucher.ai | |
| 5 | + * | |
| 6 | + * Le hub groupe-ka.com est le MAGASIN CENTRAL des favoris du groupe : | |
| 7 | + * Trouve-KA ne stocke rien localement. Chaque ♥ est poussé au hub (POST | |
| 8 | + * signé HMAC-SHA256, synchrone : un échec remonte à l'appelant) et la liste | |
| 9 | + * est lue au hub (GET signé, cache mémoire 30 s, invalidé à chaque toggle). | |
| 10 | + * Même secret que le SSO (KA_SSO_SECRET). Appels SERVEUR uniquement. | |
| 11 | + * Modèle de référence : food-ka/foodka/hubfav.py. | |
| 12 | + */ | |
| 13 | +import { createHash, createHmac } from "node:crypto"; | |
| 14 | +import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth"; | |
| 15 | + | |
| 16 | +const CACHE_TTL_MS = 30_000; // liste des favoris | |
| 17 | +const TIMEOUT_MS = 6_000; | |
| 18 | + | |
| 19 | +/** Un favori tel que stocké au hub. */ | |
| 20 | +export type HubFavItem = { | |
| 21 | + item_id: string; | |
| 22 | + title: string; | |
| 23 | + subtitle?: string; | |
| 24 | + price_label?: string; | |
| 25 | + image_url?: string; | |
| 26 | + url?: string; | |
| 27 | + meta?: Record<string, unknown>; | |
| 28 | +}; | |
| 29 | + | |
| 30 | +/** Champs d'item acceptés -> longueur maximale (troncature défensive). */ | |
| 31 | +const FIELD_MAX: Record<string, number> = { | |
| 32 | + item_id: 120, | |
| 33 | + title: 200, | |
| 34 | + subtitle: 200, | |
| 35 | + price_label: 60, | |
| 36 | + image_url: 500, | |
| 37 | + url: 500, | |
| 38 | +}; | |
| 39 | + | |
| 40 | +const cache = new Map<string, { at: number; favs: HubFavItem[] }>(); | |
| 41 | + | |
| 42 | +/** Signature HMAC-SHA256 du hub : hex("trouve-ka.<ka_id>.<ts>"). */ | |
| 43 | +function sig(kaId: string, ts: number): string | null { | |
| 44 | + const secret = process.env.KA_SSO_SECRET; | |
| 45 | + if (!secret) return null; | |
| 46 | + return createHmac("sha256", secret) | |
| 47 | + .update(`${CLIENT_ID}.${kaId}.${ts}`) | |
| 48 | + .digest("hex"); | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe). */ | |
| 52 | +export function linked(kaId: string | null | undefined): boolean { | |
| 53 | + return Boolean(kaId) && String(kaId).startsWith("ka-"); | |
| 54 | +} | |
| 55 | + | |
| 56 | +/** Identifiant STABLE d'un résultat : dérivé de son URL (les résultats de | |
| 57 | + * Trouve-KA sont des pages — l'URL est leur identité). */ | |
| 58 | +export function itemIdForUrl(url: string): string { | |
| 59 | + return `pg-${createHash("sha256").update(url).digest("hex").slice(0, 40)}`; | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** Ne garde que les champs d'item connus, en chaînes tronquées. */ | |
| 63 | +export function cleanItem(item: Record<string, unknown>): Partial<HubFavItem> { | |
| 64 | + const out: Record<string, string> = {}; | |
| 65 | + for (const [k, max] of Object.entries(FIELD_MAX)) { | |
| 66 | + const v = item?.[k]; | |
| 67 | + if (v !== undefined && v !== null && v !== "") { | |
| 68 | + out[k] = String(v).slice(0, max); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + return out as Partial<HubFavItem>; | |
| 72 | +} | |
| 73 | + | |
| 74 | +/** Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s : | |
| 75 | + * le hub est le magasin des favoris, l'échec doit remonter à l'appelant. */ | |
| 76 | +export async function hubToggle( | |
| 77 | + kaId: string, | |
| 78 | + action: "add" | "remove", | |
| 79 | + item: Partial<HubFavItem>, | |
| 80 | +): Promise<boolean> { | |
| 81 | + const ts = Math.floor(Date.now() / 1000); | |
| 82 | + const s = sig(kaId, ts); | |
| 83 | + if (!s || !linked(kaId) || (action !== "add" && action !== "remove")) { | |
| 84 | + return false; | |
| 85 | + } | |
| 86 | + let ok = false; | |
| 87 | + try { | |
| 88 | + const r = await fetch(`${KA_HUB_URL}/api/sso/favorites`, { | |
| 89 | + method: "POST", | |
| 90 | + headers: { "Content-Type": "application/json" }, | |
| 91 | + body: JSON.stringify({ | |
| 92 | + client_id: CLIENT_ID, | |
| 93 | + ka_id: kaId, | |
| 94 | + ts: String(ts), | |
| 95 | + sig: s, | |
| 96 | + action, | |
| 97 | + item, | |
| 98 | + }), | |
| 99 | + signal: AbortSignal.timeout(TIMEOUT_MS), | |
| 100 | + cache: "no-store", | |
| 101 | + }); | |
| 102 | + ok = r.status === 200; | |
| 103 | + } catch { | |
| 104 | + ok = false; | |
| 105 | + } | |
| 106 | + if (ok) cache.delete(kaId); // la prochaine lecture reflète le toggle | |
| 107 | + return ok; | |
| 108 | +} | |
| 109 | + | |
| 110 | +/** Favoris Trouve-KA du membre, lus au hub (cache mémoire 30 s). | |
| 111 | + * [] = aucun favori ; null = hub injoignable (erreur, jamais mise en cache). */ | |
| 112 | +export async function hubList(kaId: string): Promise<HubFavItem[] | null> { | |
| 113 | + if (!linked(kaId)) return []; // compte legacy non relié au hub | |
| 114 | + const now = Date.now(); | |
| 115 | + const hit = cache.get(kaId); | |
| 116 | + if (hit && now - hit.at < CACHE_TTL_MS) return hit.favs; | |
| 117 | + const ts = Math.floor(now / 1000); | |
| 118 | + const s = sig(kaId, ts); | |
| 119 | + if (!s) return null; | |
| 120 | + try { | |
| 121 | + const params = new URLSearchParams({ | |
| 122 | + client_id: CLIENT_ID, | |
| 123 | + ka_id: kaId, | |
| 124 | + ts: String(ts), | |
| 125 | + sig: s, | |
| 126 | + }); | |
| 127 | + const r = await fetch(`${KA_HUB_URL}/api/sso/favorites?${params}`, { | |
| 128 | + signal: AbortSignal.timeout(TIMEOUT_MS), | |
| 129 | + cache: "no-store", | |
| 130 | + }); | |
| 131 | + if (r.status !== 200) return null; | |
| 132 | + const data = (await r.json()) as { favorites?: unknown }; | |
| 133 | + if (!Array.isArray(data.favorites)) return null; | |
| 134 | + const favs = data.favorites.filter( | |
| 135 | + (f): f is HubFavItem => typeof f === "object" && f !== null, | |
| 136 | + ); | |
| 137 | + cache.set(kaId, { at: now, favs }); | |
| 138 | + return favs; | |
| 139 | + } catch { | |
| 140 | + return null; | |
| 141 | + } | |
| 142 | +} | |
| 143 | ||