"use client"; /** * /broadcasts — compose and publish an announcement. * * Publishing is one-way and immediate: it pushes to every subscribed device, * nudges everyone currently connected, and each user is shown the modal once. * There is no recall, which is why publishing is behind a confirmation and * why drafts exist. * * The only delivery signal is the dismissal count, since nothing is stored on * the user's side. */ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { broadcasts, type Broadcast } from "@/lib/api"; export default function BroadcastsPage() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const refresh = () => broadcasts .list() .then((r) => setItems(r.items)) .catch(() => toast.error("Could not load announcements")) .finally(() => setLoading(false)); useEffect(() => { void refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (

Announcements

Pushed to every user and shown once, in a modal. Nothing is saved to their notification history.

Recent

{loading ? (

Loading…

) : items.length === 0 ? (

Nothing sent yet.

) : (
    {items.map((b) => ( ))}
)}
); } function Composer({ onDone }: { onDone: () => void }) { const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const [ctaLabel, setCtaLabel] = useState(""); const [ctaUrl, setCtaUrl] = useState(""); const [expiresAt, setExpiresAt] = useState(""); const [bannerKey, setBannerKey] = useState(null); const [uploading, setUploading] = useState(false); const [busy, setBusy] = useState(false); const uploadBanner = async (file: File) => { setUploading(true); try { const { upload_url, key } = await broadcasts.presignBanner(file.type); const res = await fetch(upload_url, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); if (!res.ok) throw new Error(String(res.status)); setBannerKey(key); toast.success("Banner uploaded"); } catch { toast.error("Banner upload failed"); } finally { setUploading(false); } }; const submit = async (publish: boolean) => { if (!title.trim() || !body.trim()) return; if ( publish && !window.confirm( "Publish now? This pushes to every user immediately and cannot be recalled.", ) ) { return; } setBusy(true); try { await broadcasts.create({ title: title.trim(), body: body.trim(), banner_key: bannerKey, cta_label: ctaLabel.trim() || null, cta_url: ctaUrl.trim() || null, expires_at: expiresAt ? new Date(expiresAt).toISOString() : null, publish, }); toast.success(publish ? "Published" : "Draft saved"); setTitle(""); setBody(""); setCtaLabel(""); setCtaUrl(""); setExpiresAt(""); setBannerKey(null); onDone(); } catch (err) { toast.error(err instanceof Error ? err.message : "Could not save"); } finally { setBusy(false); } }; const field = "w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40"; return (
setTitle(e.target.value)} maxLength={120} placeholder="Title" className={field} />