From c9ac2bd3b44139aafa979bfebe5cb6c7bc2a0690 Mon Sep 17 00:00:00 2001 From: gosangam Date: Sat, 29 Aug 2026 18:34:33 +0530 Subject: [PATCH] feat: broadcast --- app/(dashboard)/broadcasts/page.tsx | 292 ++++++++++++++++++++++++++++ app/(dashboard)/layout.tsx | 2 + lib/api.ts | 57 ++++++ 3 files changed, 351 insertions(+) create mode 100644 app/(dashboard)/broadcasts/page.tsx diff --git a/app/(dashboard)/broadcasts/page.tsx b/app/(dashboard)/broadcasts/page.tsx new file mode 100644 index 0000000..61b8595 --- /dev/null +++ b/app/(dashboard)/broadcasts/page.tsx @@ -0,0 +1,292 @@ +"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} + /> +