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 { ExportOutlined, ImportOutlined } from "@ant-design/icons";
|
||||||
import type { OrderFormConfig } from "./OrderFormConfigBuilder";
|
import type { OrderFormConfig } from "./OrderFormConfigBuilder";
|
||||||
import ExportSelectionModal, { type ExportSelectionState } from "../modals/ExportSelectionModal";
|
import ExportSelectionModal, { type ExportSelectionState } from "../modals/ExportSelectionModal";
|
||||||
import type { OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
import type { OrderNotifications, OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
||||||
|
|
||||||
type MenuConfigImportExportProps = {
|
type MenuConfigImportExportProps = {
|
||||||
config?: OrderFormConfig | null;
|
config?: OrderFormConfig | null;
|
||||||
description?: string;
|
description?: string;
|
||||||
templates?: OrderTemplates | null;
|
templates?: OrderTemplates | null;
|
||||||
payment?: OrderPaymentDetails | null;
|
payment?: OrderPaymentDetails | null;
|
||||||
|
notifications?: OrderNotifications | null;
|
||||||
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||||
showImport?: boolean;
|
showImport?: boolean;
|
||||||
showExport?: boolean;
|
showExport?: boolean;
|
||||||
@@ -30,6 +31,7 @@ export default function MenuConfigImportExport({
|
|||||||
description,
|
description,
|
||||||
templates,
|
templates,
|
||||||
payment,
|
payment,
|
||||||
|
notifications,
|
||||||
onImportConfig,
|
onImportConfig,
|
||||||
showImport = true,
|
showImport = true,
|
||||||
showExport = true,
|
showExport = true,
|
||||||
@@ -44,6 +46,7 @@ export default function MenuConfigImportExport({
|
|||||||
description: true,
|
description: true,
|
||||||
templates: true,
|
templates: true,
|
||||||
payment: true,
|
payment: true,
|
||||||
|
notifications: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -94,12 +97,14 @@ export default function MenuConfigImportExport({
|
|||||||
const handleExportJson = () => {
|
const handleExportJson = () => {
|
||||||
const exportTemplates = !!templates && exportSelection.templates;
|
const exportTemplates = !!templates && exportSelection.templates;
|
||||||
const exportPayment = !!payment && exportSelection.payment;
|
const exportPayment = !!payment && exportSelection.payment;
|
||||||
|
const exportNotifications = !!notifications && exportSelection.notifications;
|
||||||
if (
|
if (
|
||||||
!exportSelection.title &&
|
!exportSelection.title &&
|
||||||
!exportSelection.menu &&
|
!exportSelection.menu &&
|
||||||
!exportSelection.description &&
|
!exportSelection.description &&
|
||||||
!exportTemplates &&
|
!exportTemplates &&
|
||||||
!exportPayment
|
!exportPayment &&
|
||||||
|
!exportNotifications
|
||||||
) {
|
) {
|
||||||
message.warning("Select at least one section to export");
|
message.warning("Select at least one section to export");
|
||||||
return;
|
return;
|
||||||
@@ -111,6 +116,7 @@ export default function MenuConfigImportExport({
|
|||||||
menu?: OrderFormConfig;
|
menu?: OrderFormConfig;
|
||||||
templates?: OrderTemplates;
|
templates?: OrderTemplates;
|
||||||
payment?: OrderPaymentDetails;
|
payment?: OrderPaymentDetails;
|
||||||
|
notifications?: OrderNotifications;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (exportSelection.title) {
|
if (exportSelection.title) {
|
||||||
@@ -133,6 +139,10 @@ export default function MenuConfigImportExport({
|
|||||||
payload.payment = payment;
|
payload.payment = payment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exportNotifications) {
|
||||||
|
payload.notifications = notifications;
|
||||||
|
}
|
||||||
|
|
||||||
const safeBase = String(fileNameBase || "order")
|
const safeBase = String(fileNameBase || "order")
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, "-")
|
.replace(/\s+/g, "-")
|
||||||
@@ -192,6 +202,7 @@ export default function MenuConfigImportExport({
|
|||||||
{ key: "description", label: "Description" },
|
{ key: "description", label: "Description" },
|
||||||
...(templates ? [{ key: "templates", label: "Templates" }] : []),
|
...(templates ? [{ key: "templates", label: "Templates" }] : []),
|
||||||
...(payment ? [{ key: "payment", label: "Payment details" }] : []),
|
...(payment ? [{ key: "payment", label: "Payment details" }] : []),
|
||||||
|
...(notifications ? [{ key: "notifications", label: "Notifications (contains webhook URLs)" }] : []),
|
||||||
]}
|
]}
|
||||||
selected={exportSelection}
|
selected={exportSelection}
|
||||||
onCancel={() => setIsExportModalOpen(false)}
|
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 OrderTemplatesEditor from "../forms/OrderTemplatesEditor";
|
||||||
import PaymentDetailsEditor from "../forms/PaymentDetailsEditor";
|
import PaymentDetailsEditor from "../forms/PaymentDetailsEditor";
|
||||||
import type { TemplateContext } from "../../lib/orderTemplating";
|
import type { TemplateContext } from "../../lib/orderTemplating";
|
||||||
import type { OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
import type { OrderNotifications, OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
||||||
|
|
||||||
type Order = {
|
type Order = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -59,6 +59,9 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
templates,
|
templates,
|
||||||
templatePreviewContext,
|
templatePreviewContext,
|
||||||
payment,
|
payment,
|
||||||
|
notifications,
|
||||||
|
allowExport = false,
|
||||||
|
onSavePayment,
|
||||||
extraTabs = [],
|
extraTabs = [],
|
||||||
activeTabKey,
|
activeTabKey,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@@ -71,6 +74,12 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
templates?: OrderTemplates | null;
|
templates?: OrderTemplates | null;
|
||||||
templatePreviewContext?: TemplateContext | null;
|
templatePreviewContext?: TemplateContext | null;
|
||||||
payment?: OrderPaymentDetails | 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"]>;
|
extraTabs?: NonNullable<TabsProps["items"]>;
|
||||||
activeTabKey?: string;
|
activeTabKey?: string;
|
||||||
onTabChange?: (key: string) => void;
|
onTabChange?: (key: string) => void;
|
||||||
@@ -101,14 +110,17 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<MenuConfigImportExport
|
allowExport ? (
|
||||||
config={currentConfig}
|
<MenuConfigImportExport
|
||||||
description={currentDescription}
|
config={currentConfig}
|
||||||
templates={templates}
|
description={currentDescription}
|
||||||
payment={payment}
|
templates={templates}
|
||||||
showImport={false}
|
payment={payment}
|
||||||
fileNameBase={order.title || "order"}
|
notifications={notifications}
|
||||||
/>
|
showImport={false}
|
||||||
|
fileNameBase={order.title || "order"}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -211,7 +223,8 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
<PaymentDetailsEditor
|
<PaymentDetailsEditor
|
||||||
payment={payment}
|
payment={payment}
|
||||||
previewContext={{ title: order.title || "", orderDate: order.created_at || null }}
|
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,
|
OrderFormConfig,
|
||||||
OrderTemplates,
|
OrderTemplates,
|
||||||
OrderPaymentDetails,
|
OrderPaymentDetails,
|
||||||
|
OrderNotifications,
|
||||||
GetAdminViewResponse,
|
GetAdminViewResponse,
|
||||||
OrderAccessInfo,
|
OrderAccessInfo,
|
||||||
GetOrdersResponse,
|
GetOrdersResponse,
|
||||||
@@ -14,6 +15,7 @@ type CreateOrderPayload = {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
config: OrderFormConfig;
|
config: OrderFormConfig;
|
||||||
|
notifications?: OrderNotifications;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpdateOrderDetailsPayload = {
|
type UpdateOrderDetailsPayload = {
|
||||||
@@ -74,6 +76,18 @@ export const ordersService = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(payment),
|
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) => {
|
uploadImage: (orderId: string, file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|||||||
@@ -319,3 +319,38 @@ export type SubmissionSortBy = "time" | "order" | "email" | "amount";
|
|||||||
export type OrderStateFilter = "all" | "open" | "closed";
|
export type OrderStateFilter = "all" | "open" | "closed";
|
||||||
|
|
||||||
export type OrderOwnerRoleFilter = "all" | "admin" | "participant";
|
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 React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { BellOutlined } from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
Space,
|
Space,
|
||||||
message,
|
message,
|
||||||
@@ -13,6 +14,7 @@ import type {
|
|||||||
GetAdminViewResponse,
|
GetAdminViewResponse,
|
||||||
Order,
|
Order,
|
||||||
OrderFormConfig,
|
OrderFormConfig,
|
||||||
|
OrderNotifications,
|
||||||
OrderPaymentDetails,
|
OrderPaymentDetails,
|
||||||
OrderTemplates,
|
OrderTemplates,
|
||||||
Submission,
|
Submission,
|
||||||
@@ -34,8 +36,11 @@ import {
|
|||||||
type TemplateContext,
|
type TemplateContext,
|
||||||
} from "../lib/orderTemplating";
|
} from "../lib/orderTemplating";
|
||||||
import { normalizePaymentDetails } from "../lib/paymentDetails";
|
import { normalizePaymentDetails } from "../lib/paymentDetails";
|
||||||
|
import { normalizeNotifications } from "../lib/notifications";
|
||||||
|
import NotificationsEditor from "../components/forms/NotificationsEditor";
|
||||||
import AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
|
import AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
|
||||||
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
|
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
|
||||||
|
import AdminNotificationCenterCard from "../components/views/AdminNotificationCenterCard";
|
||||||
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
||||||
import DistributionModal from "../components/modals/DistributionModal";
|
import DistributionModal from "../components/modals/DistributionModal";
|
||||||
import { PrintJob } from "../components/orders/PrintSheet";
|
import { PrintJob } from "../components/orders/PrintSheet";
|
||||||
@@ -45,6 +50,7 @@ type AdminViewData = Order & {
|
|||||||
config: OrderFormConfig;
|
config: OrderFormConfig;
|
||||||
templates: OrderTemplates;
|
templates: OrderTemplates;
|
||||||
payment: OrderPaymentDetails;
|
payment: OrderPaymentDetails;
|
||||||
|
notifications: OrderNotifications;
|
||||||
creator_email?: string | null;
|
creator_email?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,6 +68,8 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
const [savingStatusKey, setSavingStatusKey] = useState<string | null>(null);
|
const [savingStatusKey, setSavingStatusKey] = useState<string | null>(null);
|
||||||
const [updatingOrderStatus, setUpdatingOrderStatus] = useState(false);
|
const [updatingOrderStatus, setUpdatingOrderStatus] = useState(false);
|
||||||
const [deletingOrder, setDeletingOrder] = useState(false);
|
const [deletingOrder, setDeletingOrder] = useState(false);
|
||||||
|
const [sendingNotificationKey, setSendingNotificationKey] = useState<string | null>(null);
|
||||||
|
const [notifyingDelivery, setNotifyingDelivery] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedSubmissionState, setSelectedSubmissionState] = useState<SubmissionState>("all");
|
const [selectedSubmissionState, setSelectedSubmissionState] = useState<SubmissionState>("all");
|
||||||
const [sortBy, setSortBy] = useState<SubmissionSortBy>("time");
|
const [sortBy, setSortBy] = useState<SubmissionSortBy>("time");
|
||||||
@@ -71,11 +79,12 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
|
|
||||||
const loadAdminData = useCallback(
|
const loadAdminData = useCallback(
|
||||||
async (): Promise<AdminViewData> => {
|
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.getAdminView(orderId),
|
||||||
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
||||||
apiService.orders.getTemplates(orderId).catch(() => null),
|
apiService.orders.getTemplates(orderId).catch(() => null),
|
||||||
apiService.orders.getPayment(orderId).catch(() => null),
|
apiService.orders.getPayment(orderId).catch(() => null),
|
||||||
|
apiService.orders.getNotifications(orderId).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const typedPayload = adminPayload as GetAdminViewResponse;
|
const typedPayload = adminPayload as GetAdminViewResponse;
|
||||||
@@ -93,6 +102,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
},
|
},
|
||||||
templates: normalizeOrderTemplates(templatesPayload),
|
templates: normalizeOrderTemplates(templatesPayload),
|
||||||
payment: normalizePaymentDetails(paymentPayload),
|
payment: normalizePaymentDetails(paymentPayload),
|
||||||
|
notifications: normalizeNotifications(notificationsPayload),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[orderId],
|
[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 () => {
|
const deleteOrder = async () => {
|
||||||
setDeletingOrder(true);
|
setDeletingOrder(true);
|
||||||
try {
|
try {
|
||||||
@@ -361,6 +407,22 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
templates={data?.templates}
|
templates={data?.templates}
|
||||||
templatePreviewContext={templateContext}
|
templatePreviewContext={templateContext}
|
||||||
payment={data?.payment}
|
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
|
<AdminControlCenterCard
|
||||||
@@ -371,6 +433,12 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
onDeleteOrder={deleteOrder}
|
onDeleteOrder={deleteOrder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AdminNotificationCenterCard
|
||||||
|
notifications={data?.notifications || { webhooks: [], custom: [] }}
|
||||||
|
sendingKey={sendingNotificationKey}
|
||||||
|
onSend={sendNotification}
|
||||||
|
/>
|
||||||
|
|
||||||
<AdminSubmissionsCard
|
<AdminSubmissionsCard
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
selectedSubmissionState={selectedSubmissionState}
|
selectedSubmissionState={selectedSubmissionState}
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import type { UploadFile, UploadProps } from "antd";
|
|||||||
import { apiService } from "../lib/services";
|
import { apiService } from "../lib/services";
|
||||||
import { navigateTo } from "../lib/routing";
|
import { navigateTo } from "../lib/routing";
|
||||||
import { ORDER_FORMAT_VALUE_PLACEHOLDER } from "../lib/orderFormatting";
|
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 { DEFAULT_ORDER_TEMPLATES, normalizeOrderTemplates } from "../lib/orderTemplating";
|
||||||
import { normalizePaymentDetails } from "../lib/paymentDetails";
|
import { normalizePaymentDetails } from "../lib/paymentDetails";
|
||||||
|
import { getNotificationValidationErrors, normalizeNotifications } from "../lib/notifications";
|
||||||
|
import NotificationsEditor from "../components/forms/NotificationsEditor";
|
||||||
import OrderFormConfigBuilder from "../components/forms/OrderFormConfigBuilder";
|
import OrderFormConfigBuilder from "../components/forms/OrderFormConfigBuilder";
|
||||||
import OrderTemplatesEditor from "../components/forms/OrderTemplatesEditor";
|
import OrderTemplatesEditor from "../components/forms/OrderTemplatesEditor";
|
||||||
import PaymentDetailsEditor from "../components/forms/PaymentDetailsEditor";
|
import PaymentDetailsEditor from "../components/forms/PaymentDetailsEditor";
|
||||||
@@ -135,6 +137,7 @@ export default function CreateOrderView() {
|
|||||||
const [formConfig, setFormConfig] = useState<OrderFormConfig>(DEFAULT_CONFIG);
|
const [formConfig, setFormConfig] = useState<OrderFormConfig>(DEFAULT_CONFIG);
|
||||||
const [templates, setTemplates] = useState<OrderTemplates>(DEFAULT_ORDER_TEMPLATES);
|
const [templates, setTemplates] = useState<OrderTemplates>(DEFAULT_ORDER_TEMPLATES);
|
||||||
const [payment, setPayment] = useState<OrderPaymentDetails>(() => normalizePaymentDetails(null));
|
const [payment, setPayment] = useState<OrderPaymentDetails>(() => normalizePaymentDetails(null));
|
||||||
|
const [notifications, setNotifications] = useState<OrderNotifications>(() => normalizeNotifications(null));
|
||||||
const title = Form.useWatch("title", form);
|
const title = Form.useWatch("title", form);
|
||||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||||
const [exportOptions, setExportOptions] = useState<ExportSelectionState>({
|
const [exportOptions, setExportOptions] = useState<ExportSelectionState>({
|
||||||
@@ -143,6 +146,7 @@ export default function CreateOrderView() {
|
|||||||
menu: true,
|
menu: true,
|
||||||
templates: true,
|
templates: true,
|
||||||
payment: true,
|
payment: true,
|
||||||
|
notifications: true,
|
||||||
});
|
});
|
||||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -229,6 +233,13 @@ export default function CreateOrderView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notificationErrors = getNotificationValidationErrors(notifications);
|
||||||
|
if (notificationErrors.length > 0) {
|
||||||
|
setValidationErrors(notificationErrors);
|
||||||
|
setActiveTabKey("notifications");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setValidationErrors([]);
|
setValidationErrors([]);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +247,7 @@ export default function CreateOrderView() {
|
|||||||
title: values.title,
|
title: values.title,
|
||||||
description: values.description || "",
|
description: values.description || "",
|
||||||
config: formConfig,
|
config: formConfig,
|
||||||
|
notifications,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedImage = getNativeFile(imageFileList[0]);
|
const selectedImage = getNativeFile(imageFileList[0]);
|
||||||
@@ -288,6 +300,7 @@ export default function CreateOrderView() {
|
|||||||
setFormConfig(DEFAULT_CONFIG);
|
setFormConfig(DEFAULT_CONFIG);
|
||||||
setTemplates(DEFAULT_ORDER_TEMPLATES);
|
setTemplates(DEFAULT_ORDER_TEMPLATES);
|
||||||
setPayment(normalizePaymentDetails(null));
|
setPayment(normalizePaymentDetails(null));
|
||||||
|
setNotifications(normalizeNotifications(null));
|
||||||
|
|
||||||
navigateTo(`/order/${order.id}/admin`);
|
navigateTo(`/order/${order.id}/admin`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -349,6 +362,7 @@ export default function CreateOrderView() {
|
|||||||
menu?: unknown;
|
menu?: unknown;
|
||||||
templates?: unknown;
|
templates?: unknown;
|
||||||
payment?: unknown;
|
payment?: unknown;
|
||||||
|
notifications?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const importedSections: string[] = [];
|
const importedSections: string[] = [];
|
||||||
@@ -387,6 +401,11 @@ export default function CreateOrderView() {
|
|||||||
importedSections.push("payment details");
|
importedSections.push("payment details");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(parsed, "notifications")) {
|
||||||
|
setNotifications(normalizeNotifications(parsed.notifications));
|
||||||
|
importedSections.push("notifications");
|
||||||
|
}
|
||||||
|
|
||||||
if (importedSections.length === 0) {
|
if (importedSections.length === 0) {
|
||||||
message.warning("No supported sections found in import file");
|
message.warning("No supported sections found in import file");
|
||||||
return;
|
return;
|
||||||
@@ -412,6 +431,7 @@ export default function CreateOrderView() {
|
|||||||
menu?: OrderFormConfig;
|
menu?: OrderFormConfig;
|
||||||
templates?: OrderTemplates;
|
templates?: OrderTemplates;
|
||||||
payment?: OrderPaymentDetails;
|
payment?: OrderPaymentDetails;
|
||||||
|
notifications?: OrderNotifications;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (exportOptions.title) {
|
if (exportOptions.title) {
|
||||||
@@ -434,6 +454,10 @@ export default function CreateOrderView() {
|
|||||||
payload.payment = payment;
|
payload.payment = payment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exportOptions.notifications) {
|
||||||
|
payload.notifications = notifications;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) {
|
if (Object.keys(payload).length === 0) {
|
||||||
message.warning("Select at least one section to export");
|
message.warning("Select at least one section to export");
|
||||||
return;
|
return;
|
||||||
@@ -562,6 +586,11 @@ export default function CreateOrderView() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "notifications",
|
||||||
|
label: "Notifications",
|
||||||
|
children: <NotificationsEditor notifications={notifications} onChange={setNotifications} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -603,6 +632,7 @@ export default function CreateOrderView() {
|
|||||||
{ key: "menu", label: "Menu" },
|
{ key: "menu", label: "Menu" },
|
||||||
{ key: "templates", label: "Templates" },
|
{ key: "templates", label: "Templates" },
|
||||||
{ key: "payment", label: "Payment details" },
|
{ key: "payment", label: "Payment details" },
|
||||||
|
{ key: "notifications", label: "Notifications (contains webhook URLs)" },
|
||||||
]}
|
]}
|
||||||
selected={exportOptions}
|
selected={exportOptions}
|
||||||
onCancel={() => setIsExportModalOpen(false)}
|
onCancel={() => setIsExportModalOpen(false)}
|
||||||
|
|||||||
Reference in new issue
Block a user