/** * Browser-side client for admin announcements. * * Deliberately thin: an announcement is fetched, shown once, and dismissed. * Nothing is cached client-side — "have I seen this?" is the server's answer, * so it survives a new device, a cleared browser, and a second tab. */ import type { MessageEntity } from "@/lib/chat"; const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; async function call(path: string, init?: RequestInit): Promise { const res = await fetch(`${BFF_BASE}/api/v1${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, }); if (!res.ok) throw new Error(`broadcast request failed: ${res.status}`); if (res.status === 204) return undefined as T; const body = (await res.json().catch(() => ({}))) as { data?: T; success?: boolean; }; // NOT `body.data ?? body`: /pending answers {data: null} when nothing is // waiting, and `??` would fall through and hand back the whole envelope — // which is truthy, so the modal would try to render it as an announcement. return (body.data ?? (null as unknown as T)) as T; } export type Broadcast = { id: string; title: string; body: string; /** Formatting spans over `body`, same shape chat messages use. */ entities: MessageEntity[]; banner_url?: string | null; cta_label?: string | null; cta_url?: string | null; published_at?: string | null; expires_at?: string | null; }; export const broadcasts = { /** The announcement waiting for this user, or null. */ pending: () => call("/broadcasts/pending"), /** Dismiss it. Idempotent, so a double-tap or two tabs are both fine. */ markSeen: (id: string) => call(`/broadcasts/${id}/seen`, { method: "POST" }), };