/** * ============================================================================= * Job·Ka — Groupe KA * Auteur : Simon-Pierre Boucher * Contact : contact@spboucher.ai * Fichier : frontend/src/favorites.tsx * Rôle : Favoris ♥ « Mon univers Ka » — le hub Groupe KA est le magasin * central des favoris du groupe (aucun stockage navigateur) : * lecture/écriture via l'API locale /api/favorites qui relaie au * hub. FavProvider charge la liste une fois ; FavButton = cœur sur * les cartes d'offres et la fiche. Non connecté → redirection vers * la connexion KA ID avec retour à la page courante. * Créé : 2026-08-23 Modifié : 2026-08-23 * ============================================================================= */ import { ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react"; import { displayTitle, formatSalary, Job } from "./api"; /** Item de favori tel que poussé au hub Groupe KA. */ export interface FavItem { item_id: string; title: string; subtitle?: string; price_label?: string; image_url?: string; url?: string; } interface FavState { ready: boolean; // premier chargement terminé connected: boolean; // session KA ID active ids: Set; // uid des offres en favoris items: FavItem[]; toggle: (item: FavItem) => void; } const FavContext = createContext({ ready: false, connected: false, ids: new Set(), items: [], toggle: () => {}, }); /** Offre -> item de favori du hub (titre du poste, employeur · ville, salaire). */ export function jobFavItem(job: Job): FavItem { return { item_id: job.uid, title: displayTitle(job) || "Offre d'emploi", subtitle: [job.employer, job.city || job.region] .filter(Boolean).join(" · "), price_label: formatSalary(job) || "", image_url: job.company_logo || "", url: `https://www.job-ka.com/emploi/${encodeURIComponent(job.uid)}`, }; } /** Redirection vers la connexion KA ID, retour à la page courante. */ export function goLogin() { const next = encodeURIComponent( window.location.pathname + window.location.search); window.location.assign(`/api/auth/ka/login?next=${next}`); } export function FavProvider({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); const [connected, setConnected] = useState(false); const [items, setItems] = useState([]); useEffect(() => { fetch("/api/favorites", { credentials: "same-origin" }) .then(async (res) => { if (res.status === 401) return; // pas connecté : cœurs vides if (!res.ok) throw new Error(`API ${res.status}`); const data = (await res.json()) as { items?: FavItem[] }; setConnected(true); setItems(data.items ?? []); }) .catch(() => {}) // hub muet : cœurs vides .finally(() => setReady(true)); }, []); const toggle = useCallback((item: FavItem) => { if (!connected) { goLogin(); // non connecté : connexion KA ID puis retour return; } const prev = items; const on = !prev.some((i) => i.item_id === item.item_id); // optimiste : le hub confirme (sinon on rétablit) setItems(on ? [...prev, item] : prev.filter((i) => i.item_id !== item.item_id)); fetch("/api/favorites/toggle", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ on, item }), }) .then((res) => { if (!res.ok) throw new Error(`API ${res.status}`); }) .catch(() => setItems(prev)); }, [connected, items]); const ids = useMemo( () => new Set(items.map((i) => i.item_id)), [items]); return ( {children} ); } export const useFav = () => useContext(FavContext); /** Cœur ♥ — cartes d'offres (par défaut) et fiche d'offre (`big`). */ export function FavButton({ job, big = false }: { job: Job; big?: boolean }) { const { ids, toggle } = useFav(); const on = ids.has(job.uid); const label = on ? "Retirer de mes favoris (Mon univers Ka)" : "Ajouter à mes favoris (Mon univers Ka)"; return ( ); }