HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1/**2 * =============================================================================3 * Job·Ka — Groupe KA4 * Auteur : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * Fichier : frontend/src/favorites.tsx7 * Rôle : Favoris ♥ « Mon univers Ka » — le hub Groupe KA est le magasin8 * central des favoris du groupe (aucun stockage navigateur) :9 * lecture/écriture via l'API locale /api/favorites qui relaie au10 * hub. FavProvider charge la liste une fois ; FavButton = cœur sur11 * les cartes d'offres et la fiche. Non connecté → redirection vers12 * la connexion KA ID avec retour à la page courante.13 * Créé : 2026-08-23 Modifié : 2026-08-2314 * =============================================================================15 */16import {17 ReactNode, createContext, useCallback, useContext,18 useEffect, useMemo, useState,19} from "react";20import { displayTitle, formatSalary, Job } from "./api";2122/** Item de favori tel que poussé au hub Groupe KA. */23export interface FavItem {24 item_id: string;25 title: string;26 subtitle?: string;27 price_label?: string;28 image_url?: string;29 url?: string;30}3132interface FavState {33 ready: boolean; // premier chargement terminé34 connected: boolean; // session KA ID active35 ids: Set<string>; // uid des offres en favoris36 items: FavItem[];37 toggle: (item: FavItem) => void;38}3940const FavContext = createContext<FavState>({41 ready: false, connected: false, ids: new Set(), items: [], toggle: () => {},42});4344/** Offre -> item de favori du hub (titre du poste, employeur · ville, salaire). */45export function jobFavItem(job: Job): FavItem {46 return {47 item_id: job.uid,48 title: displayTitle(job) || "Offre d'emploi",49 subtitle: [job.employer, job.city || job.region]50 .filter(Boolean).join(" · "),51 price_label: formatSalary(job) || "",52 image_url: job.company_logo || "",53 url: `https://www.job-ka.com/emploi/${encodeURIComponent(job.uid)}`,54 };55}5657/** Redirection vers la connexion KA ID, retour à la page courante. */58export function goLogin() {59 const next = encodeURIComponent(60 window.location.pathname + window.location.search);61 window.location.assign(`/api/auth/ka/login?next=${next}`);62}6364export function FavProvider({ children }: { children: ReactNode }) {65 const [ready, setReady] = useState(false);66 const [connected, setConnected] = useState(false);67 const [items, setItems] = useState<FavItem[]>([]);6869 useEffect(() => {70 fetch("/api/favorites", { credentials: "same-origin" })71 .then(async (res) => {72 if (res.status === 401) return; // pas connecté : cœurs vides73 if (!res.ok) throw new Error(`API ${res.status}`);74 const data = (await res.json()) as { items?: FavItem[] };75 setConnected(true);76 setItems(data.items ?? []);77 })78 .catch(() => {}) // hub muet : cœurs vides79 .finally(() => setReady(true));80 }, []);8182 const toggle = useCallback((item: FavItem) => {83 if (!connected) {84 goLogin(); // non connecté : connexion KA ID puis retour85 return;86 }87 const prev = items;88 const on = !prev.some((i) => i.item_id === item.item_id);89 // optimiste : le hub confirme (sinon on rétablit)90 setItems(on ? [...prev, item]91 : prev.filter((i) => i.item_id !== item.item_id));92 fetch("/api/favorites/toggle", {93 method: "POST",94 credentials: "same-origin",95 headers: { "Content-Type": "application/json" },96 body: JSON.stringify({ on, item }),97 })98 .then((res) => { if (!res.ok) throw new Error(`API ${res.status}`); })99 .catch(() => setItems(prev));100 }, [connected, items]);101102 const ids = useMemo(103 () => new Set(items.map((i) => i.item_id)), [items]);104105 return (106 <FavContext.Provider value={{ ready, connected, ids, items, toggle }}>107 {children}108 </FavContext.Provider>109 );110}111112export const useFav = () => useContext(FavContext);113114/** Cœur ♥ — cartes d'offres (par défaut) et fiche d'offre (`big`). */115export function FavButton({ job, big = false }: { job: Job; big?: boolean }) {116 const { ids, toggle } = useFav();117 const on = ids.has(job.uid);118 const label = on119 ? "Retirer de mes favoris (Mon univers Ka)"120 : "Ajouter à mes favoris (Mon univers Ka)";121 return (122 <button123 type="button"124 className={`fav-btn${on ? " on" : ""}${big ? " big" : ""}`}125 aria-label={label}126 aria-pressed={on}127 title={label}128 onClick={(e) => {129 e.preventDefault(); // la carte entière est un lien130 e.stopPropagation();131 toggle(jobFavItem(job));132 }}133 >134 <svg viewBox="0 0 24 24" aria-hidden="true">135 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />136 </svg>137 </button>138 );139}140