Completes the arcadia-admin operator surface for the integration registry and the capability/route-guard framework it depends on. - Integration registry: route + Data-group nav entry + `platform.integrations` capability; the in-app client now delegates to the shared `@crema/integration-registry-client` lib (vite alias + tsconfig); the operator Integrations page (committed earlier) is now reachable. - Capability gating: capabilities map + route-guard + jwt helpers + the apps/plan/entitlements routes and supporting tenants/session changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
824 lines
27 KiB
TypeScript
824 lines
27 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react"
|
||
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 { 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<Announcement[]>([])
|
||
const [tenants, setTenants] = useState<Tenant[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [info, setInfo] = useState<string | null>(null)
|
||
const [search, setSearch] = useState("")
|
||
const [editor, setEditor] = useState<Editor>(null)
|
||
const [pendingDelete, setPendingDelete] = useState<Announcement | null>(null)
|
||
const [refreshedAt, setRefreshedAt] = useState<number | null>(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<Column<Announcement>[]>(
|
||
() => [
|
||
{
|
||
id: "title",
|
||
header: "Title",
|
||
accessor: "title",
|
||
sortable: true,
|
||
cell: (a) => (
|
||
<div className="flex flex-col">
|
||
<span className="font-medium">{a.title}</span>
|
||
{a.body ? (
|
||
<span className="line-clamp-1 text-xs text-muted-foreground">{a.body}</span>
|
||
) : null}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
id: "type",
|
||
header: "Type",
|
||
accessor: "announcement_type",
|
||
sortable: true,
|
||
cell: (a) => <BadgeCell label={a.announcement_type} tone={typeTone(a.announcement_type)} />,
|
||
},
|
||
{
|
||
id: "scope",
|
||
header: "Audience",
|
||
cell: (a) => {
|
||
if (!a.tenant_id) return <Badge>All apps</Badge>
|
||
const t = tenants.find((x) => x.id === a.tenant_id)
|
||
return <Badge variant="secondary">{t?.slug ?? "Single tenant"}</Badge>
|
||
},
|
||
},
|
||
{
|
||
id: "active",
|
||
header: "Active",
|
||
accessor: "active",
|
||
sortable: true,
|
||
cell: (a) => (
|
||
<BadgeCell label={a.active ? "live" : "off"} tone={a.active ? "success" : "default"} />
|
||
),
|
||
},
|
||
{
|
||
id: "window",
|
||
header: "Window",
|
||
cell: (a) => (
|
||
<span className="text-xs text-muted-foreground">
|
||
{a.starts_at ? new Date(a.starts_at).toLocaleDateString() : "—"}
|
||
{" → "}
|
||
{a.ends_at ? new Date(a.ends_at).toLocaleDateString() : "∞"}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
id: "updated",
|
||
header: "Updated",
|
||
accessor: "updated_at",
|
||
sortable: true,
|
||
cell: (a) => <DateCell value={a.updated_at} format="short" />,
|
||
},
|
||
{
|
||
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: <Trash2 className="size-4" />,
|
||
destructive: true,
|
||
dataAction: `announcement-${a.id}-delete`,
|
||
onSelect: () => setPendingDelete(a),
|
||
},
|
||
]
|
||
return <ActionsCell items={items} triggerDataAction={`announcement-${a.id}-actions`} />
|
||
},
|
||
},
|
||
],
|
||
[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<Announcement>({
|
||
data: items,
|
||
columns,
|
||
getRowId: (a) => a.id,
|
||
initialPageSize: 25,
|
||
initialSearch: search,
|
||
})
|
||
useEffect(() => {
|
||
table.setSearch(search)
|
||
}, [search, table])
|
||
|
||
return (
|
||
<AppShell>
|
||
<div className="flex flex-col gap-4">
|
||
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||
<div className="min-w-0">
|
||
<h1 className="text-[26px] font-[620] leading-[1.1] tracking-[-0.02em]">
|
||
Announcements
|
||
</h1>
|
||
<p className="mt-1.5 max-w-[56ch] text-[13.5px] leading-[1.5] text-muted-foreground">
|
||
Banners that appear at the top of every Sky AI app. Use them for maintenance
|
||
windows, incidents, or new features.
|
||
</p>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-3">
|
||
{lastRefreshedLabel ? (
|
||
<span
|
||
className="text-xs tabular-nums text-muted-foreground"
|
||
aria-live="polite"
|
||
title={`Last refreshed ${lastRefreshedLabel}`}
|
||
>
|
||
<span className="hidden sm:inline">Updated </span>
|
||
{lastRefreshedLabel}
|
||
</span>
|
||
) : null}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={refresh}
|
||
disabled={loading}
|
||
aria-label="Refresh announcements"
|
||
data-action="announcements-refresh"
|
||
className="text-muted-foreground hover:text-foreground"
|
||
>
|
||
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||
</Button>
|
||
{items.length > 0 ? (
|
||
<Button
|
||
size="sm"
|
||
onClick={() => setEditor({ kind: "create" })}
|
||
data-action="announcements-create"
|
||
>
|
||
<Plus className="size-4" />
|
||
New announcement
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
|
||
{error ? (
|
||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||
{error}
|
||
</AlertBanner>
|
||
) : null}
|
||
{info ? (
|
||
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
||
{info}
|
||
</AlertBanner>
|
||
) : null}
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center gap-3">
|
||
<SearchInput
|
||
value={search}
|
||
onValueChange={setSearch}
|
||
placeholder="Search by title, body, or type"
|
||
data-action="announcements-search"
|
||
className="max-w-sm flex-1"
|
||
/>
|
||
{items.length > 0 ? (
|
||
<div className="ml-auto text-xs tabular-nums text-muted-foreground">
|
||
{search && table.total !== items.length
|
||
? `${table.total} of ${items.length}`
|
||
: `${items.length} ${items.length === 1 ? "announcement" : "announcements"}`}
|
||
</div>
|
||
) : null}
|
||
</CardHeader>
|
||
|
||
<CardContent className="relative p-0">
|
||
<LoadingOverlay
|
||
active={loading && items.length === 0}
|
||
label="Loading announcements…"
|
||
/>
|
||
{table.total === 0 && !loading ? (
|
||
<EmptyState
|
||
icon={
|
||
<div
|
||
className="grid size-14 place-items-center rounded-full"
|
||
style={{
|
||
background:
|
||
"radial-gradient(circle at center, color-mix(in oklch, var(--primary) 22%, transparent), transparent 70%)",
|
||
}}
|
||
>
|
||
<Megaphone
|
||
className="size-6"
|
||
style={{ color: "var(--primary)" }}
|
||
/>
|
||
</div>
|
||
}
|
||
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 ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setSearch("")}
|
||
data-action="announcements-clear-search"
|
||
>
|
||
Clear search
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
size="sm"
|
||
onClick={() => setEditor({ kind: "create" })}
|
||
data-action="announcements-create-empty"
|
||
>
|
||
<Plus className="size-4" />
|
||
New announcement
|
||
</Button>
|
||
)
|
||
}
|
||
/>
|
||
) : (
|
||
<>
|
||
<DataTable
|
||
columns={columns}
|
||
rows={table.pageRows}
|
||
getRowId={(a) => a.id}
|
||
sort={table.sort}
|
||
onSortToggle={table.toggleSort}
|
||
loading={loading && items.length > 0}
|
||
stickyHeader
|
||
/>
|
||
<Pagination
|
||
page={table.page}
|
||
pageSize={table.pageSize}
|
||
total={table.total}
|
||
onPageChange={table.setPage}
|
||
onPageSizeChange={table.setPageSize}
|
||
/>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<ConfirmDialog
|
||
open={pendingDelete !== null}
|
||
onOpenChange={(o) => !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)
|
||
}
|
||
}}
|
||
/>
|
||
|
||
<AnnouncementEditorDialog
|
||
state={editor}
|
||
tenants={tenants}
|
||
onClose={() => setEditor(null)}
|
||
onSaved={async (msg) => {
|
||
setEditor(null)
|
||
if (msg) setInfo(msg)
|
||
await refresh()
|
||
}}
|
||
onError={setError}
|
||
/>
|
||
</AppShell>
|
||
)
|
||
}
|
||
|
||
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<T>(arr: T[], key: (x: T) => string): Record<string, number> {
|
||
return arr.reduce<Record<string, number>>((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<void>
|
||
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<AnnouncementType>("info")
|
||
const [audience, setAudience] = useState<"platform" | "tenant">("platform")
|
||
const [tenantId, setTenantId] = useState<string>("")
|
||
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<string | null>(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 (
|
||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>{isEdit ? "Edit announcement" : "New announcement"}</DialogTitle>
|
||
<DialogDescription>
|
||
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.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{/* Live preview — what users will see. Updates as the form is edited so
|
||
the operator never has to imagine the output or publish blind. */}
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||
Preview
|
||
</Label>
|
||
<div className="rounded-md border bg-muted/30 p-3">
|
||
<AlertBanner
|
||
variant={typeToAlertVariant(type)}
|
||
title={title || "Your banner title appears here"}
|
||
dismissible={dismissible}
|
||
onDismiss={() => {}}
|
||
action={
|
||
actionLabel && actionUrl ? (
|
||
<Button size="xs" variant="outline" type="button" tabIndex={-1}>
|
||
{actionLabel}
|
||
</Button>
|
||
) : undefined
|
||
}
|
||
>
|
||
{body || (
|
||
<span className="italic opacity-60">Body text appears here.</span>
|
||
)}
|
||
</AlertBanner>
|
||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||
{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."}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{localError ? (
|
||
<AlertBanner
|
||
variant="error"
|
||
dismissible
|
||
onDismiss={() => setLocalError(null)}
|
||
>
|
||
{localError}
|
||
</AlertBanner>
|
||
) : null}
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="col-span-2 flex flex-col gap-1.5">
|
||
<Label htmlFor="ann-title">Title</Label>
|
||
<Input
|
||
id="ann-title"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
data-action="announcement-form-title"
|
||
placeholder="Scheduled maintenance Sunday 2am AEST"
|
||
/>
|
||
</div>
|
||
<div className="col-span-2 flex flex-col gap-1.5">
|
||
<Label htmlFor="ann-body">Body</Label>
|
||
<Textarea
|
||
id="ann-body"
|
||
value={body}
|
||
onChange={(e) => setBody(e.target.value)}
|
||
rows={3}
|
||
data-action="announcement-form-body"
|
||
placeholder="Expect ~10 minutes of downtime while we ship the new tenant switcher."
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label>Kind</Label>
|
||
<Select value={type} onValueChange={setType}>
|
||
<SelectTrigger data-action="announcement-form-type">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{KIND_OPTIONS.map((opt) => (
|
||
<SelectItem key={opt.value} value={opt.value}>
|
||
<div className="flex flex-col">
|
||
<span className="font-medium capitalize">{opt.value}</span>
|
||
<span className="text-xs text-muted-foreground">{opt.hint}</span>
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label>Who sees this</Label>
|
||
<Select value={audience} onValueChange={(v) => setAudience(v as "platform" | "tenant")}>
|
||
<SelectTrigger data-action="announcement-form-audience">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="platform">Everyone</SelectItem>
|
||
<SelectItem value="tenant">Just one tenant</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{audience === "tenant" ? (
|
||
<div className="col-span-2 flex flex-col gap-1.5">
|
||
<Label>Which tenant</Label>
|
||
<Select value={tenantId} onValueChange={setTenantId}>
|
||
<SelectTrigger data-action="announcement-form-tenant">
|
||
<SelectValue placeholder="Pick a tenant" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{tenants.map((t) => (
|
||
<SelectItem key={t.id} value={t.id}>
|
||
{t.name} ({t.slug})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label htmlFor="ann-starts">Starts</Label>
|
||
<Input
|
||
id="ann-starts"
|
||
type="datetime-local"
|
||
value={startsAt}
|
||
onChange={(e) => setStartsAt(e.target.value)}
|
||
data-action="announcement-form-starts"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label htmlFor="ann-ends">Ends</Label>
|
||
<Input
|
||
id="ann-ends"
|
||
type="datetime-local"
|
||
value={endsAt}
|
||
onChange={(e) => setEndsAt(e.target.value)}
|
||
data-action="announcement-form-ends"
|
||
/>
|
||
</div>
|
||
|
||
{/* Optional link group — heading clarifies these two are paired. */}
|
||
<div className="col-span-2 flex flex-col gap-2 rounded-md border border-dashed p-3">
|
||
<div className="flex items-baseline justify-between gap-2">
|
||
<Label className="text-sm">Add a link</Label>
|
||
<span className="text-xs text-muted-foreground">Optional</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label htmlFor="ann-action-label" className="text-xs text-muted-foreground">
|
||
Button text
|
||
</Label>
|
||
<Input
|
||
id="ann-action-label"
|
||
value={actionLabel}
|
||
onChange={(e) => setActionLabel(e.target.value)}
|
||
placeholder="Read more"
|
||
data-action="announcement-form-action-label"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label htmlFor="ann-action-url" className="text-xs text-muted-foreground">
|
||
Where it goes
|
||
</Label>
|
||
<Input
|
||
id="ann-action-url"
|
||
value={actionUrl}
|
||
onChange={(e) => setActionUrl(e.target.value)}
|
||
placeholder="/changelog/v2"
|
||
data-action="announcement-form-action-url"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* End-user behavior toggle, not publish state — kept with content fields. */}
|
||
<div className="col-span-2 flex items-center justify-between rounded-md border px-3 py-2">
|
||
<div className="flex flex-col">
|
||
<Label className="text-sm">Let users dismiss</Label>
|
||
<span className="text-xs text-muted-foreground">
|
||
Adds an × users can click to hide the banner.
|
||
</span>
|
||
</div>
|
||
<Switch
|
||
checked={dismissible}
|
||
onCheckedChange={setDismissible}
|
||
data-action="announcement-form-dismissible"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter className="flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
{/* Active = publish state, paired with the publish button. */}
|
||
<label
|
||
htmlFor="ann-active"
|
||
className="flex items-center gap-2 text-xs text-muted-foreground sm:mr-auto"
|
||
>
|
||
<Switch
|
||
id="ann-active"
|
||
checked={active}
|
||
onCheckedChange={setActive}
|
||
data-action="announcement-form-active"
|
||
/>
|
||
<span>{active ? "Switched on" : "Switched off (draft)"}</span>
|
||
</label>
|
||
|
||
<div className="flex items-center justify-end gap-2">
|
||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={submit}
|
||
disabled={saving || !title.trim() || (audience === "tenant" && !tenantId)}
|
||
data-action="announcement-form-save"
|
||
>
|
||
{saving ? (
|
||
<RefreshCw className="size-4 animate-spin" />
|
||
) : (
|
||
<CheckCircle2 className="size-4" />
|
||
)}
|
||
{publishButtonLabel({ isEdit, active, audience, tenantId, tenants })}
|
||
</Button>
|
||
</div>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|