52 lines
1.8 KiB
TypeScript
52 lines
1.8 KiB
TypeScript
/**
|
|
* 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<T>(path: string, init?: RequestInit): Promise<T> {
|
|
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<Broadcast | null>("/broadcasts/pending"),
|
|
|
|
/** Dismiss it. Idempotent, so a double-tap or two tabs are both fine. */
|
|
markSeen: (id: string) =>
|
|
call<void>(`/broadcasts/${id}/seen`, { method: "POST" }),
|
|
};
|