import { useCallback, useEffect, useMemo, useState } from "react" import { Link } from "react-router" import { CheckCircle2, Megaphone, Plus, RefreshCw, Trash2, } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client" import { ActionsCell, BadgeCell, DataTable, DateCell, Pagination, useTable, type ActionItem, type BadgeTone, type Column, } from "@crema/table-ui" import { SearchInput } from "@crema/search-ui" import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog" import { Input } from "~/components/ui/input" import { Label } from "~/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select" import { Switch } from "~/components/ui/switch" import { Textarea } from "~/components/ui/textarea" import { createAnnouncement, deleteAnnouncement, listAnnouncements, updateAnnouncement, type Announcement, type AnnouncementInput, type AnnouncementType, } from "~/lib/arcadia/announcements" import { listTenants, type Tenant } from "~/lib/arcadia/tenants" import { pageTitle } from "~/lib/page-meta" import { useSession } from "~/lib/session" import { useRegisterAdminContext } from "~/lib/admin-context" export const meta = () => pageTitle("Announcements") const TYPES: AnnouncementType[] = ["info", "warning", "maintenance", "incident", "feature"] type Editor = | { kind: "create" } | { kind: "edit"; announcement: Announcement } | null export default function AnnouncementsRoute() { const session = useSession() const arcadia = useArcadiaClient() const [items, setItems] = useState([]) const [tenants, setTenants] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [info, setInfo] = useState(null) const [search, setSearch] = useState("") const [editor, setEditor] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) const refresh = useCallback(async () => { setError(null) setLoading(true) try { const [a, t] = await Promise.all([ listAnnouncements(arcadia), listTenants(arcadia).catch(() => [] as Tenant[]), ]) setItems(a) setTenants(t) } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.") } finally { setLoading(false) } }, [arcadia]) useEffect(() => { if (session) refresh() }, [session, refresh]) const columns = useMemo[]>( () => [ { id: "title", header: "Title", accessor: "title", sortable: true, cell: (a) => (
{a.title} {a.body ? ( {a.body} ) : null}
), }, { id: "type", header: "Type", accessor: "announcement_type", sortable: true, cell: (a) => , }, { id: "scope", header: "Scope", cell: (a) => a.tenant_id ? ( tenant ) : ( platform ), }, { id: "active", header: "Active", accessor: "active", sortable: true, cell: (a) => ( ), }, { id: "window", header: "Window", cell: (a) => ( {a.starts_at ? new Date(a.starts_at).toLocaleDateString() : "—"} {" → "} {a.ends_at ? new Date(a.ends_at).toLocaleDateString() : "∞"} ), }, { id: "updated", header: "Updated", accessor: "updated_at", sortable: true, cell: (a) => , }, { id: "actions", header: "", align: "right", cell: (a) => { const items: ActionItem[] = [ { id: "edit", label: "Edit", dataAction: `announcement-${a.id}-edit`, onSelect: () => setEditor({ kind: "edit", announcement: a }), }, { id: "toggle", label: a.active ? "Deactivate" : "Activate", dataAction: `announcement-${a.id}-toggle`, onSelect: async () => { try { await updateAnnouncement(arcadia, a.id, { active: !a.active }) setInfo(a.active ? "Announcement deactivated." : "Announcement activated.") await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Toggle failed.") } }, }, { id: "delete", label: "Delete", icon: , destructive: true, dataAction: `announcement-${a.id}-delete`, onSelect: () => setPendingDelete(a), }, ] return }, }, ], [arcadia, refresh], ) const summary = useMemo( () => ({ total: items.length, active: items.filter((a) => a.active).length, byType: countBy(items, (a) => a.announcement_type), }), [items], ) useRegisterAdminContext("announcements", summary) const table = useTable({ data: items, columns, getRowId: (a) => a.id, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) if (!session) { return (
Sign in required Announcements require an admin session.
) } return (

Announcements

Platform-wide and per-tenant banners. Apps consuming arcadia surface these to users.

{error ? ( setError(null)}> {error} ) : null} {info ? ( setInfo(null)}> {info} ) : null}
{table.total} of {items.length}
{table.total === 0 && !loading ? ( } title={search ? "No announcements match." : "No announcements yet."} description={ search ? "Try a different search." : "Post the first one — platform-wide or scoped to a tenant." } className="py-12" /> ) : ( <> a.id} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && items.length > 0} stickyHeader /> )}
!o && setPendingDelete(null)} title="Delete announcement?" description={pendingDelete ? `${pendingDelete.title} will be removed for all users.` : ""} confirmLabel="Delete" variant="danger" onConfirm={async () => { if (!pendingDelete) return try { await deleteAnnouncement(arcadia, pendingDelete.id) setPendingDelete(null) setInfo("Announcement deleted.") await refresh() } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Delete failed.") setPendingDelete(null) } }} /> setEditor(null)} onSaved={async (msg) => { setEditor(null) if (msg) setInfo(msg) await refresh() }} onError={setError} />
) } function typeTone(t: AnnouncementType): BadgeTone { if (t === "incident") return "danger" if (t === "warning" || t === "maintenance") return "warning" if (t === "feature") return "success" return "default" } function countBy(arr: T[], key: (x: T) => string): Record { return arr.reduce>((acc, x) => { const k = key(x) acc[k] = (acc[k] ?? 0) + 1 return acc }, {}) } function AnnouncementEditorDialog({ state, tenants, onClose, onSaved, onError, }: { state: Editor tenants: Tenant[] onClose: () => void onSaved: (msg?: string) => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const open = state !== null const isEdit = state?.kind === "edit" const initial = isEdit ? state.announcement : null const [title, setTitle] = useState("") const [body, setBody] = useState("") const [type, setType] = useState("info") const [audience, setAudience] = useState<"platform" | "tenant">("platform") const [tenantId, setTenantId] = useState("") const [actionLabel, setActionLabel] = useState("") const [actionUrl, setActionUrl] = useState("") const [startsAt, setStartsAt] = useState("") const [endsAt, setEndsAt] = useState("") const [dismissible, setDismissible] = useState(true) const [active, setActive] = useState(true) const [saving, setSaving] = useState(false) useEffect(() => { if (!open) return if (initial) { setTitle(initial.title) setBody(initial.body ?? "") setType(initial.announcement_type) setAudience(initial.tenant_id ? "tenant" : "platform") setTenantId(initial.tenant_id ?? "") setActionLabel(initial.action_label ?? "") setActionUrl(initial.action_url ?? "") setStartsAt(initial.starts_at ? initial.starts_at.slice(0, 16) : "") setEndsAt(initial.ends_at ? initial.ends_at.slice(0, 16) : "") setDismissible(initial.dismissible) setActive(initial.active) } else { setTitle("") setBody("") setType("info") setAudience("platform") setTenantId("") setActionLabel("") setActionUrl("") setStartsAt("") setEndsAt("") setDismissible(true) setActive(true) } }, [open, initial]) const submit = async () => { onError(null) setSaving(true) try { const input: AnnouncementInput = { title, body: body || undefined, announcement_type: type, audience, action_label: actionLabel || null, action_url: actionUrl || null, starts_at: startsAt ? new Date(startsAt).toISOString() : null, ends_at: endsAt ? new Date(endsAt).toISOString() : null, dismissible, active, tenant_id: audience === "tenant" ? tenantId || null : null, } if (isEdit && initial) { await updateAnnouncement(arcadia, initial.id, input) await onSaved("Announcement updated.") } else { await createAnnouncement(arcadia, input) await onSaved("Announcement posted.") } } catch (err) { onError( err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed.", ) } finally { setSaving(false) } } return ( !o && onClose()}> {isEdit ? "Edit announcement" : "New announcement"} Banners surface in apps that consume arcadia. Active + currently within the start/end window = visible.
setTitle(e.target.value)} data-action="announcement-form-title" />