Files
doktainer-upstream/src/app/notifications/components/NotificationsSettingsPanel.tsx
T

1447 lines
51 KiB
TypeScript

"use client";
import { useState } from "react";
import {
Bell,
BellDot,
Blocks,
Loader2,
Mail,
Mailbox,
MessageSquare,
MessagesSquare,
Pencil,
Plus,
Power,
Radio,
Send,
ShieldAlert,
Trash2,
Webhook,
X,
type LucideIcon,
} from "lucide-react";
import TablePagination from "@/components/TablePagination";
import {
FieldLabel,
Toggle,
} from "@/app/settings/components/SettingsPrimitives";
import {
getNotificationActionLabel,
normalizeNotificationActionKeys,
NOTIFICATION_ACTION_OPTIONS,
} from "@/app/notifications/components/notification-actions";
import { createClientId } from "@/lib/random-id";
import type {
EditableNotificationSettings,
NotificationProviderItem,
NotificationProviderKind,
} from "@/app/notifications/components/notification-settings.types";
interface NotificationsSettingsPanelProps {
settings: EditableNotificationSettings;
actionLoading: string | null;
savingProviderId?: string | null;
testNotification: (providerId: string) => Promise<void>;
onUpsertProvider: (provider: NotificationProviderItem) => Promise<void>;
onToggleProvider: (providerId: string) => Promise<void>;
onDeleteProvider: (provider: NotificationProviderItem) => void;
}
const PROVIDER_OPTIONS: Array<{
type: NotificationProviderKind;
label: string;
description: string;
icon: LucideIcon;
color: string;
}> = [
{
type: "email",
label: "Email SMTP",
description: "SMTP relay, transactional mail, or mailbox internal.",
icon: Mail,
color: "#2563eb",
},
{
type: "telegram",
label: "Telegram",
description: "Bot token and chat ID for direct alerts to Telegram.",
icon: Send,
color: "#0ea5e9",
},
{
type: "discord",
label: "Discord",
description: "Discord webhook for incident or ops channel.",
icon: MessagesSquare,
color: "#5865f2",
},
{
type: "slack",
label: "Slack",
description: "Incoming webhook Slack for workspace notifications.",
icon: MessageSquare,
color: "#16a34a",
},
{
type: "lark",
label: "Lark",
description: "Lark/Feishu webhook for operations team.",
icon: BellDot,
color: "#f97316",
},
{
type: "teams",
label: "Microsoft Teams",
description: "Teams webhook for alerts to work channel.",
icon: Blocks,
color: "#4f46e5",
},
{
type: "resend",
label: "Resend",
description: "Provider email API with API key and sender identity.",
icon: Mailbox,
color: "#686f7e",
},
{
type: "gotify",
label: "Gotify",
description: "Push notification self-hosted via Gotify server.",
icon: Radio,
color: "#22c55e",
},
{
type: "ntfy",
label: "ntfy",
description: "Publish alert to ntfy topic, public or self-hosted.",
icon: Bell,
color: "#06b6d4",
},
{
type: "mattermost",
label: "Mattermost",
description: "Webhook for Mattermost incident channel.",
icon: Webhook,
color: "#2563eb",
},
{
type: "pushover",
label: "Pushover",
description: "Push alert to Pushover personal or team application.",
icon: ShieldAlert,
color: "#f59e0b",
},
{
type: "custom",
label: "Custom Webhook",
description: "Flexible webhook endpoint for internal systems.",
icon: Webhook,
color: "#64748b",
},
];
const TESTABLE_PROVIDER_TYPES: NotificationProviderKind[] = [
"email",
"telegram",
"discord",
"custom",
];
const PAGE_SIZE = 6;
function createProvider(
type: NotificationProviderKind,
): NotificationProviderItem {
return {
id: createClientId("provider"),
type,
name:
PROVIDER_OPTIONS.find((item) => item.type === type)?.label ||
"New Provider",
enabled: true,
actions: NOTIFICATION_ACTION_OPTIONS.map((action) => action.value),
channel: "",
webhookUrl: "",
webhookConfigured: false,
smtpHost: "",
smtpPort: 587,
smtpSecure: false,
smtpUsername: "",
smtpPassword: "",
smtpPasswordConfigured: false,
smtpFromEmail: "",
smtpFromName: "",
telegramChatId: "",
telegramBotToken: "",
telegramBotTokenConfigured: false,
serverUrl: "",
topic: "",
userKey: "",
apiKey: "",
apiKeyConfigured: false,
};
}
function getProviderMeta(type: NotificationProviderKind) {
return (
PROVIDER_OPTIONS.find((item) => item.type === type) || PROVIDER_OPTIONS[0]
);
}
function getProviderSummary(provider: NotificationProviderItem) {
switch (provider.type) {
case "email":
return (
provider.smtpFromEmail || provider.smtpHost || "SMTP not configured"
);
case "telegram":
return provider.telegramChatId || "Chat ID not provided";
case "discord":
case "slack":
case "lark":
case "teams":
case "mattermost":
case "custom":
return provider.channel || provider.webhookUrl || "Webhook not provided";
case "resend":
return provider.smtpFromEmail || "Sender email not provided";
case "gotify":
return provider.serverUrl || "Server URL not provided";
case "ntfy":
return provider.topic || provider.serverUrl || "Topic not provided";
case "pushover":
return provider.userKey || "User key not provided";
default:
return "Provider configuration not provided";
}
}
function formatActionSummary(actions: string[]) {
const labels = normalizeNotificationActionKeys(actions).map(
getNotificationActionLabel,
);
if (labels.length === 0) {
return "No active actions";
}
if (labels.length <= 2) {
return labels.join(", ");
}
return `${labels.slice(0, 2).join(", ")} +${labels.length - 2} others`;
}
function SecretHint({
configured,
label,
}: {
configured: boolean;
label: string;
}) {
if (!configured) return null;
return (
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}>
{label} has been saved. Leave blank if you do not want to change it.
</p>
);
}
function StatusButton({
active,
loading,
onClick,
}: {
active: boolean;
loading?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
className="btn btn-ghost"
onClick={onClick}
disabled={loading}
style={{
minWidth: 108,
justifyContent: "center",
gap: 8,
padding: "7px 14px",
fontSize: 12,
color: active ? "#10b981" : "var(--text-secondary)",
borderColor: active ? "rgba(16,185,129,0.24)" : "var(--border)",
background: active ? "rgba(16,185,129,0.08)" : "transparent",
}}
>
{loading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Power size={14} />
)}
{active ? "Active" : "Inactive"}
</button>
);
}
export default function NotificationsSettingsPanel({
settings,
actionLoading,
savingProviderId,
testNotification,
onUpsertProvider,
onToggleProvider,
onDeleteProvider,
}: NotificationsSettingsPanelProps) {
const [draft, setDraft] = useState<NotificationProviderItem | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const providers = settings.notifications.providers;
const totalItems = providers.length;
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
const safePage = Math.min(currentPage, totalPages);
const startIndex = (safePage - 1) * PAGE_SIZE;
const pagedProviders = providers.slice(startIndex, startIndex + PAGE_SIZE);
const startItem = totalItems === 0 ? 0 : startIndex + 1;
const endItem = totalItems === 0 ? 0 : startIndex + pagedProviders.length;
const openCreateModal = () => {
setEditingId(null);
setDraft(createProvider("email"));
};
const openEditModal = (provider: NotificationProviderItem) => {
setEditingId(provider.id);
setDraft({
...provider,
actions: normalizeNotificationActionKeys(provider.actions),
});
};
const closeModal = () => {
setEditingId(null);
setDraft(null);
};
const saveDraft = () => {
if (!draft) return;
void onUpsertProvider({
...draft,
actions: normalizeNotificationActionKeys(draft.actions),
});
closeModal();
};
return (
<>
<div className="card" style={{ overflow: "hidden" }}>
<div
style={{
padding: 20,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 16,
flexWrap: "wrap",
borderBottom: "1px solid var(--border)",
}}
>
<div>
<h2
style={{
fontSize: 15,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Notification Providers
</h2>
<p
style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 4 }}
>
Manage notification providers and select which events will trigger
notifications.
</p>
</div>
<button
type="button"
className="btn btn-primary"
onClick={openCreateModal}
style={{ padding: "7px 14px", fontSize: 12 }}
>
<Plus size={14} /> Add Notification
</button>
</div>
{totalItems === 0 ? (
<div style={{ padding: 48, textAlign: "center" }}>
<Bell
size={34}
style={{
margin: "0 auto 12px",
color: "var(--text-muted)",
opacity: 0.45,
}}
/>
<p
style={{
fontSize: 14,
color: "var(--text-secondary)",
fontWeight: 600,
}}
>
Belum ada notification provider.
</p>
<p
style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 6 }}
>
Tambahkan provider pertama untuk mulai mengirim alert otomatis.
</p>
</div>
) : (
<>
<div style={{ overflowX: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>Provider</th>
<th>Destination</th>
<th>Actions</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{pagedProviders.map((provider) => {
const meta = getProviderMeta(provider.type);
const Icon = meta.icon;
const isSaving = savingProviderId === provider.id;
const canTest = TESTABLE_PROVIDER_TYPES.includes(
provider.type,
);
return (
<tr key={provider.id}>
<td>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 10,
background: `${meta.color}14`,
border: `1px solid ${meta.color}33`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Icon size={16} style={{ color: meta.color }} />
</div>
<div>
<p
style={{
color: "var(--text-primary)",
fontSize: 13,
fontWeight: 600,
}}
>
{provider.name}
</p>
<p
style={{
color: "var(--text-muted)",
fontSize: 11,
}}
>
{meta.label}
</p>
</div>
</div>
</td>
<td>
<div>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
fontWeight: 500,
}}
>
{getProviderSummary(provider)}
</p>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 4,
}}
>
{meta.description}
</p>
</div>
</td>
<td>
<p
style={{
fontSize: 12,
color: "var(--text-secondary)",
fontWeight: 500,
}}
>
{provider.actions.length} action activated
</p>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 4,
}}
>
{formatActionSummary(provider.actions)}
</p>
</td>
<td>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
flexWrap: "wrap",
}}
>
<button
type="button"
className="btn btn-ghost"
onClick={() => void onToggleProvider(provider.id)}
style={{ padding: "7px 8px", fontSize: 12 }}
>
{provider.enabled ? (
<Power color="#10b981" size={13} />
) : (
<Power color="#ef4444" size={13} />
)}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => openEditModal(provider)}
style={{ padding: "7px 8px", fontSize: 12 }}
>
<Pencil size={13} />
</button>
<button
type="button"
className="btn btn-ghost"
disabled={
!canTest ||
!provider.enabled ||
actionLoading ===
`notify-${provider.type}-${provider.id}`
}
onClick={() => void testNotification(provider.id)}
style={{ padding: "7px 8px", fontSize: 12 }}
>
{actionLoading ===
`notify-${provider.type}-${provider.id}` ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Bell size={13} />
)}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => onDeleteProvider(provider)}
disabled={isSaving}
style={{
padding: "7px 8px",
fontSize: 12,
color: "#ef4444",
}}
>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<TablePagination
currentPage={safePage}
totalPages={totalPages}
totalItems={totalItems}
startItem={startItem}
endItem={endItem}
itemLabel="providers"
onPageChange={(page) =>
setCurrentPage(Math.min(Math.max(page, 1), totalPages))
}
/>
</>
)}
</div>
{draft ? (
<div className="modal-overlay modal-overlay-wide">
<div className="modal-shell modal-shell-wide">
<button
type="button"
onClick={closeModal}
className="modal-close"
aria-label="Close modal"
title="Close"
>
<X size={22} />
</button>
<div
className="modal animate-slide-in"
style={{
width: "min(780px, 100%)",
maxWidth: "780px",
maxHeight: "calc(100vh - 40px)",
overflowY: "auto",
padding: 24,
}}
>
<div
style={{
display: "flex",
gap: 12,
alignItems: "flex-start",
marginBottom: 22,
paddingRight: 36,
}}
>
<div>
<h3
style={{
fontSize: 16,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
{editingId ? "Update Notification" : "Add Notification"}
</h3>
<p
style={{
fontSize: 12,
color: "var(--text-muted)",
marginTop: 6,
}}
>
{editingId
? "Update your notification providers for multiple channels."
: "Set up a new notification provider and configure which events will trigger notifications."}
</p>
</div>
</div>
<div
style={{ display: "flex", flexDirection: "column", gap: 20 }}
>
<section>
<p
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--text-primary)",
marginBottom: 12,
}}
>
Provider Type
</p>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fit, minmax(180px, 1fr))",
gap: 10,
}}
>
{PROVIDER_OPTIONS.map((option) => {
const Icon = option.icon;
const selected = draft.type === option.type;
return (
<button
key={option.type}
type="button"
onClick={() =>
setDraft((current) =>
current
? {
...current,
type: option.type,
name:
editingId ||
current.name !==
getProviderMeta(current.type).label
? current.name
: option.label,
}
: current,
)
}
style={{
textAlign: "left",
padding: 14,
borderRadius: 14,
border: selected
? `1px solid ${option.color}`
: "1px solid var(--border)",
background: selected
? `${option.color}12`
: "var(--bg-card)",
display: "flex",
gap: 12,
alignItems: "flex-start",
cursor: "pointer",
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 10,
background: `${option.color}18`,
border: `1px solid ${option.color}33`,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Icon size={15} style={{ color: option.color }} />
</div>
<div>
<p
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{option.label}
</p>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 4,
}}
>
{option.description}
</p>
</div>
</button>
);
})}
</div>
</section>
<section>
<p
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--text-primary)",
marginBottom: 12,
}}
>
Basic Information
</p>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
<div>
<FieldLabel>Provider name</FieldLabel>
<input
className="input"
value={draft.name}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, name: event.target.value }
: current,
)
}
placeholder="Contoh: Discord Infra, Telegram Ops, SMTP Finance"
/>
</div>
{draft.type === "email" ? (
<>
<div>
<FieldLabel>SMTP host</FieldLabel>
<input
className="input"
value={draft.smtpHost}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, smtpHost: event.target.value }
: current,
)
}
placeholder="smtp.mailgun.org"
/>
</div>
<div>
<FieldLabel>SMTP port</FieldLabel>
<input
className="input"
type="number"
min={1}
value={draft.smtpPort}
onChange={(event) => {
const nextPort = Number.parseInt(
event.target.value,
10,
);
setDraft((current) =>
current
? {
...current,
smtpPort: Number.isNaN(nextPort)
? 587
: nextPort,
}
: current,
);
}}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: 14,
border: "1px solid var(--border)",
borderRadius: 12,
background: "var(--bg-input)",
}}
>
<div>
<p
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
Secure SMTP connection
</p>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 4,
}}
>
Activate if your SMTP server requires a secure
connection (TLS/SSL).
</p>
</div>
<Toggle
checked={draft.smtpSecure}
onChange={() =>
setDraft((current) =>
current
? {
...current,
smtpSecure: !current.smtpSecure,
}
: current,
)
}
/>
</div>
<div>
<FieldLabel>SMTP username</FieldLabel>
<input
className="input"
autoComplete="off"
value={draft.smtpUsername}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
smtpUsername: event.target.value,
}
: current,
)
}
placeholder="apikey or mailbox username"
/>
</div>
<div>
<FieldLabel>SMTP password</FieldLabel>
<input
className="input"
autoComplete="new-password"
type="password"
value={draft.smtpPassword}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
smtpPassword: event.target.value,
smtpPasswordConfigured:
current.smtpPasswordConfigured ||
Boolean(event.target.value.trim()),
}
: current,
)
}
/>
<SecretHint
configured={draft.smtpPasswordConfigured}
label="SMTP Password"
/>
</div>
<div>
<FieldLabel>From email</FieldLabel>
<input
className="input"
value={draft.smtpFromEmail}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
smtpFromEmail: event.target.value,
}
: current,
)
}
placeholder="alerts@example.com"
/>
</div>
<div>
<FieldLabel>From name</FieldLabel>
<input
className="input"
value={draft.smtpFromName}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
smtpFromName: event.target.value,
}
: current,
)
}
placeholder="Doktainer Alerts"
/>
</div>
</>
) : null}
{draft.type === "telegram" ? (
<>
<div>
<FieldLabel>Bot token</FieldLabel>
<input
className="input"
type="password"
autoComplete="new-password"
value={draft.telegramBotToken}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
telegramBotToken: event.target.value,
telegramBotTokenConfigured:
current.telegramBotTokenConfigured ||
Boolean(event.target.value.trim()),
}
: current,
)
}
/>
<SecretHint
configured={draft.telegramBotTokenConfigured}
label="Telegram Bot Token"
/>
</div>
<div>
<FieldLabel>Chat ID</FieldLabel>
<input
className="input"
value={draft.telegramChatId}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
telegramChatId: event.target.value,
}
: current,
)
}
placeholder="-100xxxxxxxxxx"
/>
</div>
</>
) : null}
{[
"discord",
"slack",
"lark",
"teams",
"mattermost",
"custom",
].includes(draft.type) ? (
<>
<div>
<FieldLabel>Webhook URL</FieldLabel>
<input
className="input"
value={draft.webhookUrl}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
webhookUrl: event.target.value,
webhookConfigured:
current.webhookConfigured ||
Boolean(event.target.value.trim()),
}
: current,
)
}
placeholder="https://hooks.example.com/..."
/>
<SecretHint
configured={draft.webhookConfigured}
label="Webhook URL"
/>
</div>
<div>
<FieldLabel>Channel / label</FieldLabel>
<input
className="input"
value={draft.channel}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, channel: event.target.value }
: current,
)
}
placeholder="#infra-alerts or Incident Room"
/>
</div>
</>
) : null}
{draft.type === "resend" ? (
<>
<div>
<FieldLabel>API key</FieldLabel>
<input
className="input"
type="password"
autoComplete="new-password"
value={draft.apiKey}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
apiKey: event.target.value,
apiKeyConfigured:
current.apiKeyConfigured ||
Boolean(event.target.value.trim()),
}
: current,
)
}
/>
<SecretHint
configured={draft.apiKeyConfigured}
label="API key Resend"
/>
</div>
<div>
<FieldLabel>From email</FieldLabel>
<input
className="input"
value={draft.smtpFromEmail}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
smtpFromEmail: event.target.value,
}
: current,
)
}
/>
</div>
<div>
<FieldLabel>From name</FieldLabel>
<input
className="input"
value={draft.smtpFromName}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
smtpFromName: event.target.value,
}
: current,
)
}
/>
</div>
</>
) : null}
{draft.type === "gotify" ? (
<>
<div>
<FieldLabel>Server URL</FieldLabel>
<input
className="input"
value={draft.serverUrl}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
serverUrl: event.target.value,
}
: current,
)
}
placeholder="https://gotify.example.com"
/>
</div>
<div>
<FieldLabel>API key</FieldLabel>
<input
className="input"
type="password"
autoComplete="new-password"
value={draft.apiKey}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
apiKey: event.target.value,
apiKeyConfigured:
current.apiKeyConfigured ||
Boolean(event.target.value.trim()),
}
: current,
)
}
/>
<SecretHint
configured={draft.apiKeyConfigured}
label="API key Gotify"
/>
</div>
</>
) : null}
{draft.type === "ntfy" ? (
<>
<div>
<FieldLabel>Server URL</FieldLabel>
<input
className="input"
value={draft.serverUrl}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
serverUrl: event.target.value,
}
: current,
)
}
placeholder="https://ntfy.sh or internal endpoint"
/>
</div>
<div>
<FieldLabel>Topic</FieldLabel>
<input
className="input"
value={draft.topic}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, topic: event.target.value }
: current,
)
}
placeholder="dokta infra alerts"
/>
</div>
</>
) : null}
{draft.type === "pushover" ? (
<>
<div>
<FieldLabel>User key</FieldLabel>
<input
className="input"
value={draft.userKey}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, userKey: event.target.value }
: current,
)
}
/>
</div>
<div>
<FieldLabel>API token</FieldLabel>
<input
className="input"
type="password"
autoComplete="new-password"
value={draft.apiKey}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
apiKey: event.target.value,
apiKeyConfigured:
current.apiKeyConfigured ||
Boolean(event.target.value.trim()),
}
: current,
)
}
/>
<SecretHint
configured={draft.apiKeyConfigured}
label="API token Pushover"
/>
</div>
</>
) : null}
</div>
</section>
<section>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
alignItems: "center",
marginBottom: 12,
}}
>
<div>
<p
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Alert Actions
</p>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 4,
}}
>
Old actions remain available, and new actions can be
enabled per provider.
</p>
</div>
<StatusButton
active={draft.enabled}
onClick={() =>
setDraft((current) =>
current
? { ...current, enabled: !current.enabled }
: current,
)
}
/>
</div>
<div
className="notification-action-grid"
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 10,
}}
>
{NOTIFICATION_ACTION_OPTIONS.map((action) => {
const checked = draft.actions.includes(action.value);
return (
<div
key={action.value}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
padding: 14,
borderRadius: 14,
border: checked
? "1px solid rgba(37,99,235,0.28)"
: "1px solid var(--border)",
background: checked
? "rgba(37,99,235,0.08)"
: "var(--bg-input)",
}}
>
<div>
<p
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{action.label}
</p>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 4,
}}
>
{action.description}
</p>
</div>
<Toggle
checked={checked}
onChange={() =>
setDraft((current) => {
if (!current) return current;
return {
...current,
actions: checked
? current.actions.filter(
(item) => item !== action.value,
)
: [...current.actions, action.value],
};
})
}
/>
</div>
);
})}
</div>
</section>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
marginTop: 24,
flexWrap: "wrap",
}}
>
<div>
{editingId && TESTABLE_PROVIDER_TYPES.includes(draft.type) ? (
<button
type="button"
className="btn btn-ghost"
onClick={() => void testNotification(draft.id)}
disabled={
actionLoading === `notify-${draft.type}-${draft.id}`
}
style={{ padding: "9px 14px", fontSize: 12 }}
>
{actionLoading === `notify-${draft.type}-${draft.id}` ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Bell size={14} />
)}
Test Provider
</button>
) : null}
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<button
type="button"
className="btn btn-ghost"
onClick={closeModal}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
onClick={saveDraft}
>
{editingId ? (
<>
<Pencil size={13} /> Update Provider
</>
) : (
<>
<Pencil size={13} /> Create Provider
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
) : null}
<style jsx>{`
@media (max-width: 720px) {
.notification-action-grid {
grid-template-columns: 1fr !important;
}
}
`}</style>
</>
);
}