"use client"; import type { WatchlistItem } from "@dci/core"; import { Bookmark, BookmarkCheck, Loader2 } from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/components/ui/cn"; import { clientApiBase } from "@/lib/api"; import { routes } from "@/lib/routes"; export type WatchEntityType = WatchlistItem["entityType"]; interface Props { entityType: WatchEntityType; slug: string; entityId?: string; name: string; size?: "xs" | "sm" | "md"; className?: string; /** compact icon-only rendering (tables) */ compact?: boolean; } interface Envelope { data: T; } async function readJson(res: Response): Promise { try { const j = (await res.json()) as Envelope | T; return j && typeof j === "object" && "data" in (j as object) ? (j as Envelope).data : (j as T); } catch { return null; } } /** Module-level cache of the watchlist so several buttons on a page share one GET. */ let cache: Promise | null = null; const listeners = new Set<() => void>(); function invalidate() { cache = null; for (const l of listeners) l(); } function loadList(): Promise { if (!cache) { cache = fetch(`${clientApiBase()}/watchlist`, { credentials: "include", headers: { accept: "application/json" } }) .then(async (r) => (r.ok ? ((await readJson(r)) ?? []) : [])) .catch(() => []); } return cache; } /** * Follow / unfollow an operator, market, country, project or facility. State lives in the private, * cookie-scoped watchlist (POST / DELETE /api/v1/watchlist, credentials included). No account needed. */ export function WatchButton({ entityType, slug, entityId, name, size = "sm", className, compact }: Props) { const [item, setItem] = useState(undefined); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const refresh = useCallback(() => { void loadList().then((list) => setItem(list.find((w) => w.entityType === entityType && (w.slug === slug || (entityId && w.entityId === entityId))) ?? null)); }, [entityType, slug, entityId]); useEffect(() => { refresh(); listeners.add(refresh); return () => { listeners.delete(refresh); }; }, [refresh]); const toggle = async () => { if (busy) return; setBusy(true); setError(null); try { if (item) { const r = await fetch(`${clientApiBase()}/watchlist/${encodeURIComponent(item.id)}`, { method: "DELETE", credentials: "include" }); if (!r.ok && r.status !== 404) throw new Error(`HTTP ${r.status}`); } else { const r = await fetch(`${clientApiBase()}/watchlist`, { method: "POST", credentials: "include", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ entityType, slug, ...(entityId ? { entityId } : {}) }) }); if (r.status === 409) throw new Error("watchlist full (200 items)"); if (!r.ok) throw new Error(`HTTP ${r.status}`); } invalidate(); } catch (e) { setError(e instanceof Error ? e.message : "failed"); } finally { setBusy(false); } }; const watching = Boolean(item); const label = watching ? `Stop watching ${name}` : `Watch ${name}`; return ( {watching && !compact && ( Watchlist → )} {error && {error}} ); }