import { useCallback, useEffect, useMemo, useState } from "react" import { CheckCircle2, Megaphone, Plus, RefreshCw, Trash2, } from "lucide-react" import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-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 { useRegisterContext } from "@crema/aifirst-ui/context" export const meta = () => pageTitle("Announcements") const TYPES: AnnouncementType[] = ["info", "warning", "maintenance", "incident", "feature"] const KIND_OPTIONS: { value: AnnouncementType; hint: string }[] = [ { value: "info", hint: "Neutral update" }, { value: "warning", hint: "Degraded service or heads-up" }, { value: "maintenance", hint: "Scheduled work" }, { value: "incident", hint: "Active outage" }, { value: "feature", hint: "Something new shipped" }, ] function typeToAlertVariant( t: AnnouncementType, ): "info" | "success" | "warning" | "error" | "neutral" { if (t === "incident") return "error" if (t === "warning" || t === "maintenance") return "warning" if (t === "feature") return "success" return "info" } function publishButtonLabel(opts: { isEdit: boolean active: boolean audience: "platform" | "tenant" tenantId: string tenants: Tenant[] }): string { if (opts.isEdit) return "Save changes" if (!opts.active) return "Save draft" if (opts.audience === "tenant") { const name = opts.tenants.find((t) => t.id === opts.tenantId)?.name return name ? `Publish to ${name}` : "Publish to tenant" } return "Publish to all users" } 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 [refreshedAt, setRefreshedAt] = useState(null) const [now, setNow] = useState(() => Date.now()) 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) setRefreshedAt(Date.now()) } catch (err) { setError(err instanceof ArcadiaError ? err.message : "Failed to load announcements.") } finally { setLoading(false) } }, [arcadia]) useEffect(() => { if (refreshedAt == null) return const id = window.setInterval(() => setNow(Date.now()), 30_000) return () => window.clearInterval(id) }, [refreshedAt]) const lastRefreshedLabel = useMemo(() => { if (refreshedAt == null) return null const seconds = Math.max(1, Math.round((now - refreshedAt) / 1000)) if (seconds < 60) return `${seconds}s ago` const minutes = Math.round(seconds / 60) if (minutes < 60) return `${minutes}m ago` return `${Math.round(minutes / 60)}h ago` }, [refreshedAt, now]) 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: "Audience", cell: (a) => { if (!a.tenant_id) return All apps const t = tenants.find((x) => x.id === a.tenant_id) return {t?.slug ?? "Single tenant"} }, }, { 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, tenants], ) const summary = useMemo( () => ({ total: items.length, active: items.filter((a) => a.active).length, byType: countBy(items, (a) => a.announcement_type), }), [items], ) useRegisterContext("announcements", summary) const table = useTable({ data: items, columns, getRowId: (a) => a.id, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) return (

Announcements

Banners that appear at the top of every Sky AI app. Use them for maintenance windows, incidents, or new features.

{lastRefreshedLabel ? ( Updated {lastRefreshedLabel} ) : null} {items.length > 0 ? ( ) : null}
{error ? ( setError(null)}> {error} ) : null} {info ? ( setInfo(null)}> {info} ) : null} {items.length > 0 ? (
{search && table.total !== items.length ? `${table.total} of ${items.length}` : `${items.length} ${items.length === 1 ? "announcement" : "announcements"}`}
) : null}
{table.total === 0 && !loading ? (
} title={search ? "No announcements match." : "No announcements yet."} description={ search ? "Try a different search." : "Post your first banner. Show it to everyone, or scope it to a single tenant." } action={ search ? ( ) : ( ) } /> ) : ( <> 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) const [localError, setLocalError] = useState(null) useEffect(() => { if (!open) setLocalError(null) }, [open]) 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) setLocalError(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) { const msg = err instanceof ArcadiaError ? err.message : err instanceof Error ? err.message : "Save failed." setLocalError(msg) } finally { setSaving(false) } } return ( !o && onClose()}> {isEdit ? "Edit announcement" : "New announcement"} A banner shows at the top of every Sky AI app. It's visible when it's switched on and today falls inside its date range. {/* Live preview — what users will see. Updates as the form is edited so the operator never has to imagine the output or publish blind. */}
{}} action={ actionLabel && actionUrl ? ( ) : undefined } > {body || ( Body text appears here. )}

{audience === "tenant" ? `Visible to users of ${ tenants.find((t) => t.id === tenantId)?.name ?? "the selected tenant" } only.` : "Visible to everyone across every Sky AI app."}

{localError ? ( setLocalError(null)} > {localError} ) : null}
setTitle(e.target.value)} data-action="announcement-form-title" placeholder="Scheduled maintenance Sunday 2am AEST" />