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:
Simon GruberandClaude Sonnet 5.5 committed 2026-11-04 09:33:47 +01:00
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>
);
}
+141
View File
@@ -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);
+35
View 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[];
};
+69 -1
View File
@@ -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}
+31 -1
View File
@@ -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)}