Add notification center, webhook editor and admin-only export
- Notifications tab (create and admin view) to manage webhooks, event texts and custom notifications - Notification center card on the admin page for manual notifications - Export restricted to order admins, optionally including notifications - Payment details editable on existing orders (menu stays read-only) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cad2f4bef3
commit
e60de3ac17
9 files changed
+666
-14
No files matched your search
@@ -3,13 +3,14 @@ import { Button, message, Space } from "antd";
|
||||
import { ExportOutlined, ImportOutlined } from "@ant-design/icons";
|
||||
import type { OrderFormConfig } from "./OrderFormConfigBuilder";
|
||||
import ExportSelectionModal, { type ExportSelectionState } from "../modals/ExportSelectionModal";
|
||||
import type { OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
||||
import type { OrderNotifications, OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
||||
|
||||
type MenuConfigImportExportProps = {
|
||||
config?: OrderFormConfig | null;
|
||||
description?: string;
|
||||
templates?: OrderTemplates | null;
|
||||
payment?: OrderPaymentDetails | null;
|
||||
notifications?: OrderNotifications | null;
|
||||
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||
showImport?: boolean;
|
||||
showExport?: boolean;
|
||||
@@ -30,6 +31,7 @@ export default function MenuConfigImportExport({
|
||||
description,
|
||||
templates,
|
||||
payment,
|
||||
notifications,
|
||||
onImportConfig,
|
||||
showImport = true,
|
||||
showExport = true,
|
||||
@@ -44,6 +46,7 @@ export default function MenuConfigImportExport({
|
||||
description: true,
|
||||
templates: true,
|
||||
payment: true,
|
||||
notifications: true,
|
||||
});
|
||||
|
||||
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -94,12 +97,14 @@ export default function MenuConfigImportExport({
|
||||
const handleExportJson = () => {
|
||||
const exportTemplates = !!templates && exportSelection.templates;
|
||||
const exportPayment = !!payment && exportSelection.payment;
|
||||
const exportNotifications = !!notifications && exportSelection.notifications;
|
||||
if (
|
||||
!exportSelection.title &&
|
||||
!exportSelection.menu &&
|
||||
!exportSelection.description &&
|
||||
!exportTemplates &&
|
||||
!exportPayment
|
||||
!exportPayment &&
|
||||
!exportNotifications
|
||||
) {
|
||||
message.warning("Select at least one section to export");
|
||||
return;
|
||||
@@ -111,6 +116,7 @@ export default function MenuConfigImportExport({
|
||||
menu?: OrderFormConfig;
|
||||
templates?: OrderTemplates;
|
||||
payment?: OrderPaymentDetails;
|
||||
notifications?: OrderNotifications;
|
||||
} = {};
|
||||
|
||||
if (exportSelection.title) {
|
||||
@@ -133,6 +139,10 @@ export default function MenuConfigImportExport({
|
||||
payload.payment = payment;
|
||||
}
|
||||
|
||||
if (exportNotifications) {
|
||||
payload.notifications = notifications;
|
||||
}
|
||||
|
||||
const safeBase = String(fileNameBase || "order")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
@@ -192,6 +202,7 @@ export default function MenuConfigImportExport({
|
||||
{ key: "description", label: "Description" },
|
||||
...(templates ? [{ key: "templates", label: "Templates" }] : []),
|
||||
...(payment ? [{ key: "payment", label: "Payment details" }] : []),
|
||||
...(notifications ? [{ key: "notifications", label: "Notifications (contains webhook URLs)" }] : []),
|
||||
]}
|
||||
selected={exportSelection}
|
||||
onCancel={() => setIsExportModalOpen(false)}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { DeleteOutlined, PlusOutlined, SaveOutlined, UndoOutlined } from "@ant-design/icons";
|
||||
import { Alert, Button, Card, Collapse, Empty, Flex, Form, Input, Space, Switch, Typography } from "antd";
|
||||
import { LabelWithPlaceholders } from "./OrderTemplatesEditor";
|
||||
import {
|
||||
NOTIFICATION_EVENTS,
|
||||
NOTIFICATION_PLACEHOLDERS,
|
||||
createCustomNotification,
|
||||
createWebhook,
|
||||
customEventKey,
|
||||
getNotificationValidationErrors,
|
||||
isValidWebhookUrl,
|
||||
} from "../../lib/notifications";
|
||||
import type { CustomNotification, NotificationEventSettings, OrderNotifications, Webhook } from "../../lib/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function NotificationsEditor({
|
||||
notifications,
|
||||
onSave,
|
||||
onChange,
|
||||
}: {
|
||||
notifications: OrderNotifications;
|
||||
/** Explicit save with a save/reset footer. Omit together with `onChange` for no persistence. */
|
||||
onSave?: (notifications: OrderNotifications) => Promise<void>;
|
||||
/** Reports every edit to the parent; replaces the save/reset footer. */
|
||||
onChange?: (notifications: OrderNotifications) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<OrderNotifications>(notifications);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activePanels, setActivePanels] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(notifications);
|
||||
}, [notifications]);
|
||||
|
||||
const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(notifications), [draft, notifications]);
|
||||
const validationErrors = useMemo(() => getNotificationValidationErrors(draft), [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange && isDirty) {
|
||||
onChange(draft);
|
||||
}
|
||||
}, [draft]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const setWebhooks = (update: (webhooks: Webhook[]) => Webhook[]) =>
|
||||
setDraft((previous) => ({ ...previous, webhooks: update(previous.webhooks) }));
|
||||
const updateWebhook = (id: string, changes: Partial<Webhook>) =>
|
||||
setWebhooks((webhooks) => webhooks.map((webhook) => (webhook.id === id ? { ...webhook, ...changes } : webhook)));
|
||||
const updateEvent = (webhook: Webhook, key: string, changes: Partial<NotificationEventSettings>) =>
|
||||
updateWebhook(webhook.id, { events: { ...webhook.events, [key]: { ...webhook.events[key], ...changes } } });
|
||||
|
||||
const setCustom = (update: (custom: CustomNotification[]) => CustomNotification[]) =>
|
||||
setDraft((previous) => ({ ...previous, custom: update(previous.custom) }));
|
||||
const updateCustom = (id: string, changes: Partial<CustomNotification>) =>
|
||||
setCustom((custom) => custom.map((entry) => (entry.id === id ? { ...entry, ...changes } : entry)));
|
||||
// A new custom notification reaches all existing webhooks; the admin can opt single webhooks out.
|
||||
const addCustom = () => setCustom((custom) => [...custom, createCustomNotification()]);
|
||||
|
||||
const addWebhook = () => {
|
||||
const webhook = createWebhook();
|
||||
setWebhooks((webhooks) => [...webhooks, webhook]);
|
||||
setActivePanels((previous) => [...previous, webhook.id]);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave?.(draft);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form layout="vertical">
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="Webhooks notify a chat or automation service about order events. They are only visible to you as the order admin. A JSON message is posted, compatible with Slack, Mattermost, Teams and Discord incoming webhooks."
|
||||
/>
|
||||
|
||||
{draft.webhooks.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No webhooks configured" />
|
||||
) : (
|
||||
<Collapse
|
||||
activeKey={activePanels}
|
||||
onChange={(keys) => setActivePanels(Array.isArray(keys) ? keys : [keys])}
|
||||
items={draft.webhooks.map((webhook, index) => ({
|
||||
key: webhook.id,
|
||||
label: webhook.name.trim() || `Webhook ${index + 1}`,
|
||||
extra: (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setWebhooks((webhooks) => webhooks.filter((entry) => entry.id !== webhook.id));
|
||||
}}
|
||||
/>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item label="Name (optional)">
|
||||
<Input
|
||||
value={webhook.name}
|
||||
placeholder="Team chat"
|
||||
onChange={(event) => updateWebhook(webhook.id, { name: event.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Webhook URL"
|
||||
required
|
||||
validateStatus={!!webhook.url.trim() && !isValidWebhookUrl(webhook.url) ? "error" : undefined}
|
||||
help={
|
||||
!!webhook.url.trim() && !isValidWebhookUrl(webhook.url)
|
||||
? "Enter a valid http(s) URL"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input.Password
|
||||
value={webhook.url}
|
||||
autoComplete="off"
|
||||
placeholder="https://hooks.example.com/..."
|
||||
onChange={(event) => updateWebhook(webhook.id, { url: event.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{NOTIFICATION_EVENTS.map((event) => {
|
||||
const settings = webhook.events[event.key];
|
||||
return (
|
||||
<Form.Item
|
||||
key={event.key}
|
||||
label={
|
||||
<Space size={8}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={settings.enabled}
|
||||
onChange={(enabled) => updateEvent(webhook, event.key, { enabled })}
|
||||
/>
|
||||
{event.label}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
disabled={!settings.enabled}
|
||||
value={settings.text}
|
||||
onChange={(inputEvent) => updateEvent(webhook, event.key, { text: inputEvent.target.value })}
|
||||
/>
|
||||
<Button
|
||||
disabled={!settings.enabled || settings.text === event.defaultText}
|
||||
onClick={() => updateEvent(webhook, event.key, { text: event.defaultText })}
|
||||
>
|
||||
Default
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
{draft.custom.map((entry, customIndex) => {
|
||||
const key = customEventKey(entry.id);
|
||||
return (
|
||||
<Form.Item
|
||||
key={entry.id}
|
||||
label={
|
||||
<Space size={8}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={webhook.events[key]?.enabled ?? true}
|
||||
onChange={(enabled) => updateEvent(webhook, key, { enabled, text: "" })}
|
||||
/>
|
||||
{entry.label.trim() || `Custom notification ${customIndex + 1}`}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Text type="secondary">
|
||||
<LabelWithPlaceholders label="Available placeholders" items={NOTIFICATION_PLACEHOLDERS} />
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="Custom notifications"
|
||||
extra={
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={addCustom}>
|
||||
Add
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{draft.custom.length === 0 ? (
|
||||
<Text type="secondary">
|
||||
Custom notifications are sent manually from the notification center, to every webhook that has them
|
||||
enabled.
|
||||
</Text>
|
||||
) : (
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
{draft.custom.map((entry) => (
|
||||
<Space.Compact key={entry.id} style={{ width: "100%" }}>
|
||||
<Input
|
||||
style={{ width: "30%" }}
|
||||
maxLength={100}
|
||||
value={entry.label}
|
||||
placeholder="Button label"
|
||||
onChange={(event) => updateCustom(entry.id, { label: event.target.value })}
|
||||
/>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
value={entry.text}
|
||||
placeholder="Message, e.g. Pizza is cold, sorry! {url}"
|
||||
onChange={(event) => updateCustom(entry.id, { text: event.target.value })}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => setCustom((custom) => custom.filter((item) => item.id !== entry.id))}
|
||||
/>
|
||||
</Space.Compact>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Flex justify="space-between" wrap="wrap" gap={8}>
|
||||
<Button icon={<PlusOutlined />} onClick={addWebhook}>
|
||||
Add webhook
|
||||
</Button>
|
||||
{onSave && (
|
||||
<Space>
|
||||
<Button icon={<UndoOutlined />} disabled={!isDirty || saving} onClick={() => setDraft(notifications)}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
disabled={!isDirty || validationErrors.length > 0}
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save notifications
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Flex>
|
||||
</Space>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import OrderFormConfigBuilder, {
|
||||
import OrderTemplatesEditor from "../forms/OrderTemplatesEditor";
|
||||
import PaymentDetailsEditor from "../forms/PaymentDetailsEditor";
|
||||
import type { TemplateContext } from "../../lib/orderTemplating";
|
||||
import type { OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
||||
import type { OrderNotifications, OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
||||
|
||||
type Order = {
|
||||
id?: string;
|
||||
@@ -59,6 +59,9 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
templates,
|
||||
templatePreviewContext,
|
||||
payment,
|
||||
notifications,
|
||||
allowExport = false,
|
||||
onSavePayment,
|
||||
extraTabs = [],
|
||||
activeTabKey,
|
||||
onTabChange,
|
||||
@@ -71,6 +74,12 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
templates?: OrderTemplates | null;
|
||||
templatePreviewContext?: TemplateContext | null;
|
||||
payment?: OrderPaymentDetails | null;
|
||||
/** Webhook settings (admin only); only included in the export, the editor is passed as an extra tab. */
|
||||
notifications?: OrderNotifications | null;
|
||||
/** Shows the export button; reserved for the order admin. */
|
||||
allowExport?: boolean;
|
||||
/** Makes the payment tab editable (order admin only); without it the tab is read-only. */
|
||||
onSavePayment?: (payment: OrderPaymentDetails) => Promise<void>;
|
||||
extraTabs?: NonNullable<TabsProps["items"]>;
|
||||
activeTabKey?: string;
|
||||
onTabChange?: (key: string) => void;
|
||||
@@ -101,14 +110,17 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
<MenuConfigImportExport
|
||||
config={currentConfig}
|
||||
description={currentDescription}
|
||||
templates={templates}
|
||||
payment={payment}
|
||||
showImport={false}
|
||||
fileNameBase={order.title || "order"}
|
||||
/>
|
||||
allowExport ? (
|
||||
<MenuConfigImportExport
|
||||
config={currentConfig}
|
||||
description={currentDescription}
|
||||
templates={templates}
|
||||
payment={payment}
|
||||
notifications={notifications}
|
||||
showImport={false}
|
||||
fileNameBase={order.title || "order"}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
@@ -211,7 +223,8 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
<PaymentDetailsEditor
|
||||
payment={payment}
|
||||
previewContext={{ title: order.title || "", orderDate: order.created_at || null }}
|
||||
readOnly
|
||||
readOnly={!onSavePayment}
|
||||
onSave={onSavePayment}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import { CarOutlined, NotificationOutlined, SendOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, List, Popconfirm, Typography } from "antd";
|
||||
import { NOTIFICATION_EVENTS, customEventKey } from "../../lib/notifications";
|
||||
import type { OrderNotifications } from "../../lib/types";
|
||||
|
||||
type NotificationAction = {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
const PRESET_ICONS: Record<string, React.ReactNode> = {
|
||||
last_call: <NotificationOutlined />,
|
||||
delivery_started: <CarOutlined />,
|
||||
};
|
||||
|
||||
export default function AdminNotificationCenterCard({
|
||||
notifications,
|
||||
sendingKey,
|
||||
onSend,
|
||||
}: {
|
||||
notifications: OrderNotifications;
|
||||
sendingKey: string | null;
|
||||
onSend: (event: string) => void;
|
||||
}) {
|
||||
const actions: NotificationAction[] = [
|
||||
...NOTIFICATION_EVENTS.filter((event) => event.manual).map((event) => ({
|
||||
key: event.key,
|
||||
title: event.label,
|
||||
description: event.defaultText,
|
||||
icon: PRESET_ICONS[event.key],
|
||||
})),
|
||||
...notifications.custom
|
||||
.filter((entry) => entry.label.trim() && entry.text.trim())
|
||||
.map((entry) => ({
|
||||
key: customEventKey(entry.id),
|
||||
title: entry.label,
|
||||
description: entry.text,
|
||||
icon: <SendOutlined />,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="Notification center">
|
||||
{notifications.webhooks.length === 0 && (
|
||||
<Typography.Paragraph type="warning">
|
||||
No webhooks are configured yet. Add one in the Notifications tab above to send these messages.
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
<List
|
||||
size="small"
|
||||
dataSource={actions}
|
||||
renderItem={(action) => (
|
||||
<List.Item
|
||||
style={{ paddingInline: 0 }}
|
||||
actions={[
|
||||
<Popconfirm
|
||||
key={action.key}
|
||||
title={`Send "${action.title}"?`}
|
||||
description="Sends the message to all webhooks that have it enabled."
|
||||
okText="Send"
|
||||
cancelText="Cancel"
|
||||
onConfirm={() => onSend(action.key)}
|
||||
>
|
||||
<Button icon={action.icon} loading={sendingKey === action.key}>
|
||||
Send
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={<Typography.Text strong>{action.title}</Typography.Text>}
|
||||
description={<Typography.Text type="secondary">{action.description}</Typography.Text>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
CustomNotification,
|
||||
NotificationEventKey,
|
||||
OrderNotifications,
|
||||
Webhook,
|
||||
} from "./types";
|
||||
|
||||
export type NotificationEventDefinition = {
|
||||
key: NotificationEventKey;
|
||||
label: string;
|
||||
defaultText: string;
|
||||
/** Sent by the admin from the notification center instead of being triggered by an order state change. */
|
||||
manual: boolean;
|
||||
};
|
||||
|
||||
export const NOTIFICATION_EVENTS: NotificationEventDefinition[] = [
|
||||
{
|
||||
key: "order_created",
|
||||
label: "Order has been created",
|
||||
defaultText: 'New lunch order "{title}" has been created: {url}',
|
||||
manual: false,
|
||||
},
|
||||
{
|
||||
key: "order_closed",
|
||||
label: "Order has been closed",
|
||||
defaultText: 'Lunch order "{title}" has been closed: {url}',
|
||||
manual: false,
|
||||
},
|
||||
{
|
||||
key: "order_opened",
|
||||
label: "Order has been opened",
|
||||
defaultText: 'Lunch order "{title}" has been opened: {url}',
|
||||
manual: false,
|
||||
},
|
||||
{
|
||||
key: "last_call",
|
||||
label: "Last call",
|
||||
defaultText: 'Last call for lunch order "{title}"! Place your order now: {url}',
|
||||
manual: true,
|
||||
},
|
||||
{
|
||||
key: "delivery_started",
|
||||
label: "Delivery has started",
|
||||
defaultText: 'Delivery for lunch order "{title}" has started: {url}',
|
||||
manual: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const NOTIFICATION_PLACEHOLDERS = [
|
||||
{ key: "{title}", description: "Order title" },
|
||||
{ key: "{url}", description: "Link to the order" },
|
||||
];
|
||||
|
||||
export const customEventKey = (customId: string) => `custom:${customId}`;
|
||||
|
||||
function randomId(prefix: string) {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function createWebhook(): Webhook {
|
||||
return {
|
||||
id: randomId("webhook"),
|
||||
name: "",
|
||||
url: "",
|
||||
events: Object.fromEntries(
|
||||
NOTIFICATION_EVENTS.map((event) => [event.key, { enabled: true, text: event.defaultText }]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCustomNotification(): CustomNotification {
|
||||
return { id: randomId("custom"), label: "", text: "" };
|
||||
}
|
||||
|
||||
export function normalizeNotifications(raw: unknown): OrderNotifications {
|
||||
const source = (raw && typeof raw === "object" ? raw : {}) as { webhooks?: unknown; custom?: unknown };
|
||||
const asRecords = (value: unknown) =>
|
||||
(Array.isArray(value) ? value : []).filter(
|
||||
(entry): entry is Record<string, unknown> => !!entry && typeof entry === "object",
|
||||
);
|
||||
|
||||
const custom = asRecords(source.custom).map((entry) => {
|
||||
const base = createCustomNotification();
|
||||
return {
|
||||
id: typeof entry.id === "string" && entry.id ? entry.id : base.id,
|
||||
label: typeof entry.label === "string" ? entry.label : "",
|
||||
text: typeof entry.text === "string" ? entry.text : "",
|
||||
};
|
||||
});
|
||||
|
||||
const webhooks = asRecords(source.webhooks).map((entry) => {
|
||||
const base = createWebhook();
|
||||
const rawEvents = (entry.events && typeof entry.events === "object" ? entry.events : {}) as Record<
|
||||
string,
|
||||
{ enabled?: unknown; text?: unknown } | undefined
|
||||
>;
|
||||
|
||||
const events: Webhook["events"] = {};
|
||||
for (const { key, defaultText } of NOTIFICATION_EVENTS) {
|
||||
const rawEvent = rawEvents[key];
|
||||
events[key] = {
|
||||
enabled: !!rawEvent?.enabled,
|
||||
text: typeof rawEvent?.text === "string" && rawEvent.text.trim() ? rawEvent.text : defaultText,
|
||||
};
|
||||
}
|
||||
// Custom notifications reach every webhook unless it opted out.
|
||||
for (const { id } of custom) {
|
||||
const rawEvent = rawEvents[customEventKey(id)];
|
||||
events[customEventKey(id)] = { enabled: rawEvent ? !!rawEvent.enabled : true, text: "" };
|
||||
}
|
||||
|
||||
return {
|
||||
id: typeof entry.id === "string" && entry.id ? entry.id : base.id,
|
||||
name: typeof entry.name === "string" ? entry.name : "",
|
||||
url: typeof entry.url === "string" ? entry.url : "",
|
||||
events,
|
||||
};
|
||||
});
|
||||
|
||||
return { webhooks, custom };
|
||||
}
|
||||
|
||||
export function isValidWebhookUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url.trim());
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotificationValidationErrors(notifications: OrderNotifications): string[] {
|
||||
const webhookErrors = notifications.webhooks
|
||||
.filter((webhook) => !isValidWebhookUrl(webhook.url))
|
||||
.map((webhook, index) => `Webhook ${webhook.name.trim() || index + 1}: a valid http(s) URL is required`);
|
||||
const customErrors = notifications.custom
|
||||
.filter((entry) => !entry.label.trim() || !entry.text.trim())
|
||||
.map((entry, index) => `Custom notification ${entry.label.trim() || index + 1}: label and text are required`);
|
||||
|
||||
return [...webhookErrors, ...customErrors];
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
OrderFormConfig,
|
||||
OrderTemplates,
|
||||
OrderPaymentDetails,
|
||||
OrderNotifications,
|
||||
GetAdminViewResponse,
|
||||
OrderAccessInfo,
|
||||
GetOrdersResponse,
|
||||
@@ -14,6 +15,7 @@ type CreateOrderPayload = {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
config: OrderFormConfig;
|
||||
notifications?: OrderNotifications;
|
||||
};
|
||||
|
||||
type UpdateOrderDetailsPayload = {
|
||||
@@ -74,6 +76,18 @@ export const ordersService = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payment),
|
||||
}),
|
||||
getNotifications: (orderId: string) =>
|
||||
apiFetch<Partial<OrderNotifications>>(`/api/orders/${orderId}/admin/notifications`),
|
||||
updateNotifications: (orderId: string, notifications: OrderNotifications) =>
|
||||
apiFetch<OrderNotifications>(`/api/orders/${orderId}/admin/notifications`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(notifications),
|
||||
}),
|
||||
sendNotification: (orderId: string, event: string) =>
|
||||
apiFetch<{ queued: boolean }>(`/api/orders/${orderId}/admin/notifications/send`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ event }),
|
||||
}),
|
||||
uploadImage: (orderId: string, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
@@ -319,3 +319,38 @@ export type SubmissionSortBy = "time" | "order" | "email" | "amount";
|
||||
export type OrderStateFilter = "all" | "open" | "closed";
|
||||
|
||||
export type OrderOwnerRoleFilter = "all" | "admin" | "participant";
|
||||
|
||||
/* ==================== Notifications (Webhooks) ==================== */
|
||||
|
||||
export type NotificationEventKey =
|
||||
| "order_created"
|
||||
| "order_closed"
|
||||
| "order_opened"
|
||||
| "delivery_started"
|
||||
| "last_call";
|
||||
|
||||
export type NotificationEventSettings = {
|
||||
enabled: boolean;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type Webhook = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
/** Preset events, plus `custom:<id>` keys (only `enabled` applies; the text comes from the custom notification). */
|
||||
events: Record<string, NotificationEventSettings>;
|
||||
};
|
||||
|
||||
/** Admin-only; webhook URLs are secrets and are never sent to participants. */
|
||||
export type CustomNotification = {
|
||||
id: string;
|
||||
label: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type OrderNotifications = {
|
||||
webhooks: Webhook[];
|
||||
/** Messages the admin can send manually from the notification center. */
|
||||
custom: CustomNotification[];
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { BellOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Space,
|
||||
message,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
GetAdminViewResponse,
|
||||
Order,
|
||||
OrderFormConfig,
|
||||
OrderNotifications,
|
||||
OrderPaymentDetails,
|
||||
OrderTemplates,
|
||||
Submission,
|
||||
@@ -34,8 +36,11 @@ import {
|
||||
type TemplateContext,
|
||||
} from "../lib/orderTemplating";
|
||||
import { normalizePaymentDetails } from "../lib/paymentDetails";
|
||||
import { normalizeNotifications } from "../lib/notifications";
|
||||
import NotificationsEditor from "../components/forms/NotificationsEditor";
|
||||
import AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
|
||||
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
|
||||
import AdminNotificationCenterCard from "../components/views/AdminNotificationCenterCard";
|
||||
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
||||
import DistributionModal from "../components/modals/DistributionModal";
|
||||
import { PrintJob } from "../components/orders/PrintSheet";
|
||||
@@ -45,6 +50,7 @@ type AdminViewData = Order & {
|
||||
config: OrderFormConfig;
|
||||
templates: OrderTemplates;
|
||||
payment: OrderPaymentDetails;
|
||||
notifications: OrderNotifications;
|
||||
creator_email?: string | null;
|
||||
};
|
||||
|
||||
@@ -62,6 +68,8 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
const [savingStatusKey, setSavingStatusKey] = useState<string | null>(null);
|
||||
const [updatingOrderStatus, setUpdatingOrderStatus] = useState(false);
|
||||
const [deletingOrder, setDeletingOrder] = useState(false);
|
||||
const [sendingNotificationKey, setSendingNotificationKey] = useState<string | null>(null);
|
||||
const [notifyingDelivery, setNotifyingDelivery] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedSubmissionState, setSelectedSubmissionState] = useState<SubmissionState>("all");
|
||||
const [sortBy, setSortBy] = useState<SubmissionSortBy>("time");
|
||||
@@ -71,11 +79,12 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
|
||||
const loadAdminData = useCallback(
|
||||
async (): Promise<AdminViewData> => {
|
||||
const [adminPayload, configPayload, templatesPayload, paymentPayload] = await Promise.all([
|
||||
const [adminPayload, configPayload, templatesPayload, paymentPayload, notificationsPayload] = await Promise.all([
|
||||
apiService.orders.getAdminView(orderId),
|
||||
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
||||
apiService.orders.getTemplates(orderId).catch(() => null),
|
||||
apiService.orders.getPayment(orderId).catch(() => null),
|
||||
apiService.orders.getNotifications(orderId).catch(() => null),
|
||||
]);
|
||||
|
||||
const typedPayload = adminPayload as GetAdminViewResponse;
|
||||
@@ -93,6 +102,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
},
|
||||
templates: normalizeOrderTemplates(templatesPayload),
|
||||
payment: normalizePaymentDetails(paymentPayload),
|
||||
notifications: normalizeNotifications(notificationsPayload),
|
||||
};
|
||||
},
|
||||
[orderId],
|
||||
@@ -199,6 +209,42 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const sendNotification = async (event: string) => {
|
||||
setSendingNotificationKey(event);
|
||||
try {
|
||||
await apiService.orders.sendNotification(orderId, event);
|
||||
message.success("Notification sent");
|
||||
} catch (error: any) {
|
||||
message.error(error.message);
|
||||
} finally {
|
||||
setSendingNotificationKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const savePayment = async (payment: OrderPaymentDetails) => {
|
||||
try {
|
||||
const saved = await apiService.orders.updatePayment(orderId, payment);
|
||||
setData((previous: AdminViewData | null) =>
|
||||
previous ? { ...previous, payment: normalizePaymentDetails(saved) } : previous,
|
||||
);
|
||||
message.success("Payment details saved");
|
||||
} catch (error: any) {
|
||||
message.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNotifications = async (notifications: OrderNotifications) => {
|
||||
try {
|
||||
const saved = await apiService.orders.updateNotifications(orderId, notifications);
|
||||
setData((previous: AdminViewData | null) =>
|
||||
previous ? { ...previous, notifications: normalizeNotifications(saved) } : previous,
|
||||
);
|
||||
message.success("Notifications saved");
|
||||
} catch (error: any) {
|
||||
message.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOrder = async () => {
|
||||
setDeletingOrder(true);
|
||||
try {
|
||||
@@ -361,6 +407,22 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
templates={data?.templates}
|
||||
templatePreviewContext={templateContext}
|
||||
payment={data?.payment}
|
||||
notifications={data?.notifications}
|
||||
allowExport
|
||||
onSavePayment={savePayment}
|
||||
extraTabs={[
|
||||
{
|
||||
key: "notifications",
|
||||
label: "Notifications",
|
||||
icon: <BellOutlined />,
|
||||
children: (
|
||||
<NotificationsEditor
|
||||
notifications={data?.notifications || { webhooks: [], custom: [] }}
|
||||
onSave={saveNotifications}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AdminControlCenterCard
|
||||
@@ -371,6 +433,12 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
onDeleteOrder={deleteOrder}
|
||||
/>
|
||||
|
||||
<AdminNotificationCenterCard
|
||||
notifications={data?.notifications || { webhooks: [], custom: [] }}
|
||||
sendingKey={sendingNotificationKey}
|
||||
onSend={sendNotification}
|
||||
/>
|
||||
|
||||
<AdminSubmissionsCard
|
||||
searchQuery={searchQuery}
|
||||
selectedSubmissionState={selectedSubmissionState}
|
||||
|
||||
@@ -20,9 +20,11 @@ import type { UploadFile, UploadProps } from "antd";
|
||||
import { apiService } from "../lib/services";
|
||||
import { navigateTo } from "../lib/routing";
|
||||
import { ORDER_FORMAT_VALUE_PLACEHOLDER } from "../lib/orderFormatting";
|
||||
import type { OrderFormConfig, OrderPaymentDetails, OrderTemplates } from "../lib/types";
|
||||
import type { OrderFormConfig, OrderNotifications, OrderPaymentDetails, OrderTemplates } from "../lib/types";
|
||||
import { DEFAULT_ORDER_TEMPLATES, normalizeOrderTemplates } from "../lib/orderTemplating";
|
||||
import { normalizePaymentDetails } from "../lib/paymentDetails";
|
||||
import { getNotificationValidationErrors, normalizeNotifications } from "../lib/notifications";
|
||||
import NotificationsEditor from "../components/forms/NotificationsEditor";
|
||||
import OrderFormConfigBuilder from "../components/forms/OrderFormConfigBuilder";
|
||||
import OrderTemplatesEditor from "../components/forms/OrderTemplatesEditor";
|
||||
import PaymentDetailsEditor from "../components/forms/PaymentDetailsEditor";
|
||||
@@ -135,6 +137,7 @@ export default function CreateOrderView() {
|
||||
const [formConfig, setFormConfig] = useState<OrderFormConfig>(DEFAULT_CONFIG);
|
||||
const [templates, setTemplates] = useState<OrderTemplates>(DEFAULT_ORDER_TEMPLATES);
|
||||
const [payment, setPayment] = useState<OrderPaymentDetails>(() => normalizePaymentDetails(null));
|
||||
const [notifications, setNotifications] = useState<OrderNotifications>(() => normalizeNotifications(null));
|
||||
const title = Form.useWatch("title", form);
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
const [exportOptions, setExportOptions] = useState<ExportSelectionState>({
|
||||
@@ -143,6 +146,7 @@ export default function CreateOrderView() {
|
||||
menu: true,
|
||||
templates: true,
|
||||
payment: true,
|
||||
notifications: true,
|
||||
});
|
||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -229,6 +233,13 @@ export default function CreateOrderView() {
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationErrors = getNotificationValidationErrors(notifications);
|
||||
if (notificationErrors.length > 0) {
|
||||
setValidationErrors(notificationErrors);
|
||||
setActiveTabKey("notifications");
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationErrors([]);
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -236,6 +247,7 @@ export default function CreateOrderView() {
|
||||
title: values.title,
|
||||
description: values.description || "",
|
||||
config: formConfig,
|
||||
notifications,
|
||||
});
|
||||
|
||||
const selectedImage = getNativeFile(imageFileList[0]);
|
||||
@@ -288,6 +300,7 @@ export default function CreateOrderView() {
|
||||
setFormConfig(DEFAULT_CONFIG);
|
||||
setTemplates(DEFAULT_ORDER_TEMPLATES);
|
||||
setPayment(normalizePaymentDetails(null));
|
||||
setNotifications(normalizeNotifications(null));
|
||||
|
||||
navigateTo(`/order/${order.id}/admin`);
|
||||
} catch (error: any) {
|
||||
@@ -349,6 +362,7 @@ export default function CreateOrderView() {
|
||||
menu?: unknown;
|
||||
templates?: unknown;
|
||||
payment?: unknown;
|
||||
notifications?: unknown;
|
||||
};
|
||||
|
||||
const importedSections: string[] = [];
|
||||
@@ -387,6 +401,11 @@ export default function CreateOrderView() {
|
||||
importedSections.push("payment details");
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, "notifications")) {
|
||||
setNotifications(normalizeNotifications(parsed.notifications));
|
||||
importedSections.push("notifications");
|
||||
}
|
||||
|
||||
if (importedSections.length === 0) {
|
||||
message.warning("No supported sections found in import file");
|
||||
return;
|
||||
@@ -412,6 +431,7 @@ export default function CreateOrderView() {
|
||||
menu?: OrderFormConfig;
|
||||
templates?: OrderTemplates;
|
||||
payment?: OrderPaymentDetails;
|
||||
notifications?: OrderNotifications;
|
||||
} = {};
|
||||
|
||||
if (exportOptions.title) {
|
||||
@@ -434,6 +454,10 @@ export default function CreateOrderView() {
|
||||
payload.payment = payment;
|
||||
}
|
||||
|
||||
if (exportOptions.notifications) {
|
||||
payload.notifications = notifications;
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
message.warning("Select at least one section to export");
|
||||
return;
|
||||
@@ -562,6 +586,11 @@ export default function CreateOrderView() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
label: "Notifications",
|
||||
children: <NotificationsEditor notifications={notifications} onChange={setNotifications} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
@@ -603,6 +632,7 @@ export default function CreateOrderView() {
|
||||
{ key: "menu", label: "Menu" },
|
||||
{ key: "templates", label: "Templates" },
|
||||
{ key: "payment", label: "Payment details" },
|
||||
{ key: "notifications", label: "Notifications (contains webhook URLs)" },
|
||||
]}
|
||||
selected={exportOptions}
|
||||
onCancel={() => setIsExportModalOpen(false)}
|
||||
|
||||
Reference in new issue
Block a user