/** * Trouve-KA — magasin client des favoris « Mon univers Ka » * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * * Singleton partagé par tous les cœurs ♥ de la page (useSyncExternalStore) : * une seule lecture de /api/favorites (le hub, via le serveur), toggles * optimistes annulés si le hub refuse. Aucun stockage local — le hub * groupe-ka.com est la source de vérité, ce magasin n'est qu'un miroir. */ "use client"; import { useSyncExternalStore } from "react"; export type Fav = { item_id: string; title: string; subtitle?: string; price_label?: string; image_url?: string; url?: string; }; export type FavStatus = "idle" | "loading" | "ready" | "anon" | "error"; export type FavState = { status: FavStatus; items: readonly Fav[]; }; let state: FavState = { status: "idle", items: [] }; const listeners = new Set<() => void>(); let loadPromise: Promise | null = null; function setState(next: FavState) { state = next; for (const l of listeners) l(); } function subscribe(listener: () => void): () => void { listeners.add(listener); return () => listeners.delete(listener); } const getSnapshot = () => state; const SERVER_SNAPSHOT: FavState = { status: "idle", items: [] }; const getServerSnapshot = () => SERVER_SNAPSHOT; /** Charge la liste des favoris (une seule requête partagée par page). */ export function ensureLoaded(force = false): Promise { if (!force && (loadPromise || state.status === "ready" || state.status === "anon")) { return loadPromise ?? Promise.resolve(); } if (state.status === "idle" || force) { setState({ ...state, status: "loading" }); } loadPromise = fetch("/api/favorites", { cache: "no-store" }) .then(async (r) => { if (r.status === 401) { setState({ status: "anon", items: [] }); return; } if (!r.ok) throw new Error(`Erreur ${r.status}`); const data = (await r.json()) as { favorites?: Fav[] }; setState({ status: "ready", items: data.favorites ?? [] }); }) .catch(() => { setState({ status: "error", items: state.items }); }) .finally(() => { loadPromise = null; }); return loadPromise; } function hasFav(items: readonly Fav[], url: string): boolean { return items.some((f) => f.url === url); } /** * Toggle optimiste d'un favori (par URL — identité d'un résultat Trouve-KA). * Rendu : "added" | "removed" en cas de succès, "error" si le hub refuse * (l'état local est alors restauré), "anon" si non connecté. */ export async function toggleFav(item: { url: string; title: string; subtitle?: string; image_url?: string; }): Promise<"added" | "removed" | "error" | "anon"> { if (state.status === "anon") return "anon"; const before = state.items; const removing = hasFav(before, item.url); const action = removing ? "remove" : "add"; // Optimiste : on reflète le geste tout de suite, le hub tranche ensuite. setState({ status: "ready", items: removing ? before.filter((f) => f.url !== item.url) : [{ item_id: "", ...item }, ...before], }); try { const r = await fetch("/api/favorites/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, item }), }); if (r.status === 401) { setState({ status: "anon", items: [] }); return "anon"; } if (!r.ok) throw new Error(`Erreur ${r.status}`); return removing ? "removed" : "added"; } catch { setState({ status: state.status, items: before }); // annule l'optimisme return "error"; } } /** État réactif des favoris — à consommer dans les composants clients. */ export function useFavorites(): FavState { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } export function isFavorite(stateNow: FavState, url: string): boolean { return hasFav(stateNow.items, url); }