ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.
TypeScript 91.9%
Python 6%
CSS 2.1%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Cœur ♥ « Mon univers Ka » — favoris unifiés du Groupe KA.4 * Le hub groupe-ka.com est le magasin central : le composant lit la liste UNE5 * fois par page (magasin client partagé entre tous les cœurs) puis pousse6 * chaque toggle au serveur (/api/favorites/toggle → hub, synchrone). Sans7 * session KA ID, le clic envoie vers la connexion SSO (retour sur la page).8 */9"use client";10import { useEffect, useReducer, useState } from "react";11import { useLang } from "./LangContext";1213export type FavHeartItem = {14 item_id: string;15 title: string;16 subtitle?: string;17 price_label?: string;18 image_url?: string;19 url?: string;20};2122// -- magasin client partagé (un seul GET /api/favorites par page) -------------23// undefined = pas encore chargé · null = pas de session KA ID · Set = favoris24let favSet: Set<string> | null | undefined;25let inflight: Promise<void> | null = null;26const subs = new Set<() => void>();27const notify = () => subs.forEach((f) => f());2829async function loadFavs(): Promise<void> {30 try {31 const r = await fetch("/api/favorites", { cache: "no-store" });32 if (r.status === 401) {33 favSet = null;34 return;35 }36 if (!r.ok) {37 favSet = null;38 return;39 }40 const body = (await r.json()) as { favorites?: { item_id?: string }[] };41 favSet = new Set(42 (body.favorites ?? []).map((f) => String(f.item_id ?? "")).filter(Boolean),43 );44 } catch {45 favSet = null;46 }47}4849function ensureLoaded() {50 if (favSet === undefined && !inflight) {51 inflight = loadFavs().finally(() => {52 inflight = null;53 notify();54 });55 }56}5758/** À appeler après un retrait effectué ailleurs (ex. page /favoris). */59export function favStoreRemove(itemId: string) {60 if (favSet instanceof Set) {61 favSet.delete(itemId);62 notify();63 }64}6566export default function FavHeart({67 item,68 small = false,69}: {70 item: FavHeartItem;71 small?: boolean;72}) {73 const { lang } = useLang();74 const fr = lang === "fr";75 const [, force] = useReducer((x: number) => x + 1, 0);76 const [busy, setBusy] = useState(false);7778 useEffect(() => {79 subs.add(force);80 ensureLoaded();81 return () => {82 subs.delete(force);83 };84 }, []);8586 const fav = favSet instanceof Set && favSet.has(item.item_id);8788 const onClick = async (e: React.MouseEvent) => {89 // le cœur vit parfois DANS un <Link> (cartes) — ne pas naviguer90 e.preventDefault();91 e.stopPropagation();92 if (busy) return;93 if (!(favSet instanceof Set)) {94 // pas de session KA ID → connexion SSO, retour sur la page courante95 const next = window.location.pathname + window.location.search;96 window.location.href = `/api/auth/ka/login?next=${encodeURIComponent(next)}`;97 return;98 }99 const action = fav ? "remove" : "add";100 setBusy(true);101 // optimiste — retour arrière si le hub refuse102 if (action === "add") favSet.add(item.item_id);103 else favSet.delete(item.item_id);104 notify();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.ok) throw new Error(String(r.status));112 } catch {113 if (action === "add") favSet.delete(item.item_id);114 else favSet.add(item.item_id);115 notify();116 } finally {117 setBusy(false);118 }119 };120121 const label = fav122 ? fr123 ? "Retirer de mes favoris (Mon univers Ka)"124 : "Remove from my favourites (My Ka universe)"125 : fr126 ? "Ajouter à mes favoris (Mon univers Ka)"127 : "Add to my favourites (My Ka universe)";128 const size = small ? "h-9 w-9" : "h-11 w-11";129 const glyph = small ? 15 : 18;130131 return (132 <button133 type="button"134 onClick={onClick}135 aria-label={label}136 title={label}137 aria-pressed={fav}138 disabled={busy}139 className={`inline-flex ${size} shrink-0 cursor-pointer items-center justify-center rounded-full border-[1.5px] border-ink transition-all ${140 fav141 ? "rotate-[-3deg] bg-ink text-lime"142 : "bg-surface text-ink hover:bg-ink hover:text-lime"143 } ${busy ? "opacity-60" : ""}`}144 >145 <svg146 width={glyph}147 height={glyph}148 viewBox="0 0 24 24"149 aria-hidden="true"150 fill={fav ? "currentColor" : "none"}151 stroke="currentColor"152 strokeWidth="2.2"153 strokeLinejoin="round"154 >155 <path d="M12 20.7l-1.3-1.2C6 15.3 2.9 12.5 2.9 9 2.9 6.2 5.1 4 7.9 4c1.6 0 3.1.7 4.1 1.9C13 4.7 14.5 4 16.1 4c2.8 0 5 2.2 5 5 0 3.5-3.1 6.3-7.8 10.5L12 20.7z" />156 </svg>157 </button>158 );159}160