Add clipboard and email templating
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m37s

This commit is contained in:
sim committed 2026-10-20 21:15:55 +02:00
1 parent d813fc0b5b
commit 7d5ce7bcc8
11 files changed
+1328 -35

No files matched your search

+42
View File
@@ -37,9 +37,12 @@ from .services.order_file_service import (
delete_order_config,
delete_order_description,
delete_order_images,
delete_order_templates,
load_order_config,
load_order_templates,
save_order_config,
save_order_description,
save_order_templates,
save_uploaded_image,
)
from .services.order_service import ensure_order_exists, ensure_order_open
@@ -116,6 +119,13 @@ class OrderConfig(BaseModel):
categories: list[OrderConfigCategory] = Field(default_factory=list)
class OrderTemplates(BaseModel):
"""Admin-only clipboard / email templating settings (validated by the frontend)."""
model_config = {"extra": "allow"}
clipboard: dict[str, Any] = Field(default_factory=dict)
email: dict[str, Any] = Field(default_factory=dict)
class UserProfileUpdateRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
@@ -770,6 +780,7 @@ def delete_order(order_id: str, user_id: str = Depends(get_existing_user_id)) ->
delete_order_description(admin_token)
delete_order_config(admin_token)
delete_order_templates(admin_token)
delete_order_images(admin_token, order["image_url"])
return {"deleted": True}
@@ -1189,3 +1200,34 @@ def admin_update_order_config(
save_order_config(admin_token, config_dict)
return config_dict
@app.get("/api/orders/{order_id}/admin/templates")
def admin_get_order_templates(order_id: str, user_id: str = Depends(get_existing_user_id)) -> dict[str, Any]:
"""Get order-specific clipboard / email templates."""
clean_id = clean_user_id(user_id)
with get_connection() as conn:
auth = ensure_owner_access(conn, clean_id, order_id)
admin_token = auth["admin_token"]
return load_order_templates(admin_token)
@app.put("/api/orders/{order_id}/admin/templates")
def admin_update_order_templates(
order_id: str,
payload: OrderTemplates,
user_id: str = Depends(get_existing_user_id),
) -> dict[str, Any]:
"""Update order-specific clipboard / email templates."""
clean_id = clean_user_id(user_id)
with get_connection() as conn:
auth = ensure_owner_access(conn, clean_id, order_id)
admin_token = auth["admin_token"]
templates_dict = payload.model_dump()
save_order_templates(admin_token, templates_dict)
return templates_dict
@@ -10,10 +10,12 @@ BASE_DIR = Path(__file__).resolve().parents[2]
IMAGES_DIR = BASE_DIR / "data" / "images"
CONFIGS_DIR = BASE_DIR / "data" / "configs"
DESCRIPTIONS_DIR = BASE_DIR / "data" / "descriptions"
TEMPLATES_DIR = BASE_DIR / "data" / "templates"
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
DESCRIPTIONS_DIR.mkdir(parents=True, exist_ok=True)
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
def load_order_config(admin_token: str) -> dict[str, Any]:
@@ -78,6 +80,37 @@ def delete_order_config(admin_token: str) -> None:
pass
def load_order_templates(admin_token: str) -> dict[str, Any]:
templates_path = TEMPLATES_DIR / f"{admin_token}.json"
if not templates_path.exists():
return {}
try:
with templates_path.open("r", encoding="utf-8") as file:
loaded = json.load(file)
return loaded if isinstance(loaded, dict) else {}
except Exception:
return {}
def save_order_templates(admin_token: str, templates: dict[str, Any]) -> None:
templates_path = TEMPLATES_DIR / f"{admin_token}.json"
try:
with templates_path.open("w", encoding="utf-8") as file:
json.dump(templates, file, indent=2, ensure_ascii=False)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to save templates: {str(exc)}")
def delete_order_templates(admin_token: str) -> None:
templates_path = TEMPLATES_DIR / f"{admin_token}.json"
try:
if templates_path.exists():
templates_path.unlink()
except Exception:
pass
def delete_order_images(admin_token: str, image_url: str | None) -> None:
try:
if image_url and not image_url.startswith("http"):
@@ -3,10 +3,12 @@ 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 { OrderTemplates } from "../../lib/types";
type MenuConfigImportExportProps = {
config?: OrderFormConfig | null;
description?: string;
templates?: OrderTemplates | null;
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
showImport?: boolean;
showExport?: boolean;
@@ -25,6 +27,7 @@ function normalizeConfig(value: unknown): OrderFormConfig {
export default function MenuConfigImportExport({
config,
description,
templates,
onImportConfig,
showImport = true,
showExport = true,
@@ -37,6 +40,7 @@ export default function MenuConfigImportExport({
title: true,
menu: true,
description: true,
templates: true,
});
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -85,7 +89,8 @@ export default function MenuConfigImportExport({
};
const handleExportJson = () => {
if (!exportSelection.title && !exportSelection.menu && !exportSelection.description) {
const exportTemplates = !!templates && exportSelection.templates;
if (!exportSelection.title && !exportSelection.menu && !exportSelection.description && !exportTemplates) {
message.warning("Select at least one section to export");
return;
}
@@ -94,6 +99,7 @@ export default function MenuConfigImportExport({
title?: string;
description?: string;
menu?: OrderFormConfig;
templates?: OrderTemplates;
} = {};
if (exportSelection.title) {
@@ -108,6 +114,10 @@ export default function MenuConfigImportExport({
payload.menu = normalizeConfig(config);
}
if (exportTemplates) {
payload.templates = templates;
}
const safeBase = String(fileNameBase || "order")
.trim()
.replace(/\s+/g, "-")
@@ -165,6 +175,7 @@ export default function MenuConfigImportExport({
{ key: "title", label: "Title" },
{ key: "menu", label: "Menu" },
{ key: "description", label: "Description" },
...(templates ? [{ key: "templates", label: "Templates" }] : []),
]}
selected={exportSelection}
onCancel={() => setIsExportModalOpen(false)}
@@ -0,0 +1,533 @@
import React, { useEffect, useMemo, useState } from "react";
import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
PlusOutlined,
QuestionCircleOutlined,
SaveOutlined,
UndoOutlined,
} from "@ant-design/icons";
import {
Alert,
Button,
Card,
Checkbox,
Col,
Collapse,
Flex,
Form,
Input,
Popover,
Row,
Select,
Space,
Switch,
Typography,
} from "antd";
import {
BODY_PLACEHOLDERS,
DEFAULT_ENTRY_TEMPLATE,
DEFAULT_GROUP_ENTRY_TEMPLATE,
ENTRY_PLACEHOLDERS,
GROUP_PLACEHOLDERS,
SECTION_PLACEHOLDERS,
VALID_SORTS,
buildSectionsPerCategory,
isGroupedMode,
renderEmail,
renderTemplateText,
type TemplateContext,
} from "../../lib/orderTemplating";
import type {
OrderFormConfig,
OrderTemplates,
TemplateGrouping,
TemplateListSettings,
TemplateRecipients,
TemplateRecipientSource,
TemplateSection,
TemplateSort,
} from "../../lib/types";
const { Text } = Typography;
const previewStyle: React.CSSProperties = {
whiteSpace: "pre-wrap",
wordBreak: "break-word",
margin: 0,
maxHeight: 280,
overflowY: "auto",
fontFamily: "monospace",
fontSize: 12,
};
const GROUPING_OPTIONS: Array<{ label: string; value: TemplateGrouping }> = [
{ label: "One per submission", value: "none" },
{ label: "Group identical orders", value: "order" },
{ label: "Group per category item", value: "category" },
{ label: "Category sections", value: "sections" },
];
const SORT_LABELS: Record<TemplateGrouping, Partial<Record<TemplateSort, string>>> = {
none: { table: "As in table", category: "By category value", alpha: "Alphabetical" },
order: { table: "First appearance", count: "Count (highest first)", alpha: "Alphabetical" },
category: { category: "Menu order", count: "Count (highest first)", alpha: "Alphabetical", table: "First appearance" },
sections: { category: "Menu order", count: "Count (highest first)", alpha: "Alphabetical", table: "First appearance" },
};
// Keep the entry template meaningful when switching between per-submission and grouped modes.
function adaptEntryTemplate(template: string, grouped: boolean): string {
if (grouped && template.includes("{order}")) {
return template.split("{order}").join(DEFAULT_GROUP_ENTRY_TEMPLATE);
}
if (!grouped && template.includes(DEFAULT_GROUP_ENTRY_TEMPLATE)) {
return template.split(DEFAULT_GROUP_ENTRY_TEMPLATE).join(DEFAULT_ENTRY_TEMPLATE);
}
return template;
}
function PlaceholderHelp({ items }: { items: Array<{ key: string; description: string }> }) {
return (
<Popover
title="Placeholders"
trigger="click"
content={
<Space direction="vertical" size={2} style={{ maxWidth: 320 }}>
{items.map((item) => (
<Text key={item.key}>
<Text code>{item.key}</Text> {item.description}
</Text>
))}
</Space>
}
>
<Button type="link" size="small" icon={<QuestionCircleOutlined />}>
Placeholders
</Button>
</Popover>
);
}
function LabelWithPlaceholders({
label,
items,
}: {
label: string;
items: Array<{ key: string; description: string }>;
}) {
return (
<Space size={0}>
{label}
<PlaceholderHelp items={items} />
</Space>
);
}
function SectionsEditor({
sections,
config,
onChange,
}: {
sections: TemplateSection[];
config: OrderFormConfig;
onChange: (sections: TemplateSection[]) => void;
}) {
const categoryOptions = config.categories.map((category) => ({ label: category.label, value: category.id }));
const updateSection = (index: number, changes: Partial<TemplateSection>) =>
onChange(sections.map((section, i) => (i === index ? { ...section, ...changes } : section)));
const moveSection = (index: number, offset: number) => {
const next = [...sections];
const [moved] = next.splice(index, 1);
next.splice(index + offset, 0, moved);
onChange(next);
};
return (
<Form.Item
label="Sections"
tooltip="A section with one category counts each item. With several categories, the combination is counted, e.g. Burger + Extras → “2x Cheeseburger (well done)”."
>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
{sections.map((section, index) => (
<Flex key={index} gap={8} wrap align="center">
<Input
value={section.heading}
placeholder="Heading"
style={{ flex: "1 1 140px" }}
onChange={(event) => updateSection(index, { heading: event.target.value })}
/>
<Select
mode="multiple"
value={section.categoryIds}
placeholder="Categories (first one is the main item)"
style={{ flex: "2 1 220px" }}
options={categoryOptions}
onChange={(categoryIds) => updateSection(index, { categoryIds })}
/>
<Space size={4}>
<Button
size="small"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => moveSection(index, -1)}
/>
<Button
size="small"
icon={<ArrowDownOutlined />}
disabled={index === sections.length - 1}
onClick={() => moveSection(index, 1)}
/>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => onChange(sections.filter((_, i) => i !== index))}
/>
</Space>
</Flex>
))}
<Flex gap={8} wrap>
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => onChange([...sections, { heading: "", categoryIds: [] }])}
>
Add section
</Button>
<Button size="small" onClick={() => onChange(buildSectionsPerCategory(config))}>
Reset to one section per category
</Button>
</Flex>
</Space>
</Form.Item>
);
}
function ListSettingsFields({
value,
config,
onChange,
}: {
value: TemplateListSettings;
config: OrderFormConfig;
onChange: (changes: Partial<TemplateListSettings>) => void;
}) {
const categoryOptions = config.categories.map((category) => ({ label: category.label, value: category.id }));
const grouped = isGroupedMode(value.grouping);
const validSorts = VALID_SORTS[value.grouping];
const sortOptions = validSorts.map((sort) => ({ label: SORT_LABELS[value.grouping][sort] || sort, value: sort }));
const changeGrouping = (grouping: TemplateGrouping) => {
const changes: Partial<TemplateListSettings> = {
grouping,
sortBy: VALID_SORTS[grouping].includes(value.sortBy) ? value.sortBy : VALID_SORTS[grouping][0],
entryTemplate: adaptEntryTemplate(value.entryTemplate, isGroupedMode(grouping)),
};
if (grouping === "sections" && value.sections.length === 0) {
changes.sections = buildSectionsPerCategory(config);
}
onChange(changes);
};
return (
<>
<Row gutter={12}>
<Col xs={24} md={12}>
<Form.Item label="Entries">
<Flex gap={8} wrap>
<Select
value={value.grouping}
style={{ flex: "1 1 180px" }}
options={GROUPING_OPTIONS}
onChange={changeGrouping}
/>
{value.grouping === "category" && (
<Select
value={value.groupCategoryId || undefined}
placeholder="Category"
style={{ flex: "1 1 140px" }}
options={categoryOptions}
onChange={(groupCategoryId) => onChange({ groupCategoryId })}
/>
)}
</Flex>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={value.grouping === "sections" ? "Sort within sections" : "Sort"}>
<Flex gap={8} wrap>
<Select
value={validSorts.includes(value.sortBy) ? value.sortBy : validSorts[0]}
style={{ flex: "1 1 180px" }}
options={sortOptions}
onChange={(sortBy) => onChange({ sortBy })}
/>
{value.grouping === "none" && value.sortBy === "category" && (
<Select
value={value.sortCategoryId || undefined}
placeholder="Category"
style={{ flex: "1 1 140px" }}
options={categoryOptions}
onChange={(sortCategoryId) => onChange({ sortCategoryId })}
/>
)}
</Flex>
</Form.Item>
</Col>
</Row>
{value.grouping === "sections" && (
<>
<SectionsEditor
sections={value.sections}
config={config}
onChange={(sections) => onChange({ sections })}
/>
<Row gutter={12}>
<Col xs={24} md={16}>
<Form.Item label={<LabelWithPlaceholders label="Section template" items={SECTION_PLACEHOLDERS} />}>
<Input.TextArea
autoSize={{ minRows: 2, maxRows: 6 }}
value={value.sectionTemplate}
onChange={(event) => onChange({ sectionTemplate: event.target.value })}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="Section separator" tooltip="Use \n for a line break and \t for a tab.">
<Input
value={value.sectionSeparator}
placeholder="\n\n"
onChange={(event) => onChange({ sectionSeparator: event.target.value })}
/>
</Form.Item>
</Col>
</Row>
</>
)}
<Row gutter={12}>
<Col xs={24} md={16}>
<Form.Item
label={
<LabelWithPlaceholders
label={grouped ? "Entry template (per group)" : "Entry template (per submission)"}
items={grouped ? GROUP_PLACEHOLDERS : ENTRY_PLACEHOLDERS}
/>
}
>
<Input
value={value.entryTemplate}
placeholder={grouped ? DEFAULT_GROUP_ENTRY_TEMPLATE : DEFAULT_ENTRY_TEMPLATE}
onChange={(event) => onChange({ entryTemplate: event.target.value })}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="Entry separator" tooltip="Use \n for a line break and \t for a tab.">
<Input
value={value.separator}
placeholder="\n"
onChange={(event) => onChange({ separator: event.target.value })}
/>
</Form.Item>
</Col>
</Row>
</>
);
}
function RecipientsField({
label,
value,
onChange,
}: {
label: string;
value: TemplateRecipients;
onChange: (next: TemplateRecipients) => void;
}) {
return (
<Form.Item label={label} style={{ marginBottom: 12 }}>
<Flex gap={8} align="center" wrap>
<Checkbox.Group
value={value.sources}
options={[
{ label: "Order creator", value: "creator" },
{ label: "Order participants", value: "participants" },
{ label: "Custom", value: "custom" },
]}
onChange={(sources) => onChange({ ...value, sources: sources as TemplateRecipientSource[] })}
/>
{value.sources.includes("custom") && (
<Input
value={value.custom}
placeholder="name@example.com, other@example.com"
style={{ flex: "1 1 220px" }}
onChange={(event) => onChange({ ...value, custom: event.target.value })}
/>
)}
</Flex>
</Form.Item>
);
}
function Preview({ enabled, actionLabel, text }: { enabled: boolean; actionLabel: string; text: string }) {
return (
<Form.Item
label="Preview"
style={{ marginBottom: 0 }}
extra={enabled ? undefined : `Templating is off, ${actionLabel} uses the default output.`}
>
<Card size="small">
<pre style={previewStyle}>{text || " "}</pre>
</Card>
</Form.Item>
);
}
function EnabledSwitch({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
return (
<Switch
size="small"
checked={checked}
checkedChildren="On"
unCheckedChildren="Off"
// Keep toggling independent from expanding / collapsing the panel.
onClick={(_checked, event) => event.stopPropagation()}
onChange={onChange}
/>
);
}
export default function OrderTemplatesEditor({
templates,
config,
previewContext,
onSave,
}: {
templates: OrderTemplates;
config: OrderFormConfig;
previewContext?: TemplateContext | null;
onSave: (templates: OrderTemplates) => Promise<void>;
}) {
const [draft, setDraft] = useState<OrderTemplates>(templates);
const [saving, setSaving] = useState(false);
const [activePanels, setActivePanels] = useState<string[]>(() =>
(["clipboard", "email"] as const).filter((key) => templates[key].enabled),
);
useEffect(() => {
setDraft(templates);
}, [templates]);
const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(templates), [draft, templates]);
const updateClipboard = (changes: Partial<OrderTemplates["clipboard"]>) =>
setDraft((previous) => ({ ...previous, clipboard: { ...previous.clipboard, ...changes } }));
const updateEmail = (changes: Partial<OrderTemplates["email"]>) =>
setDraft((previous) => ({ ...previous, email: { ...previous.email, ...changes } }));
const clipboardPreview = previewContext
? renderTemplateText(draft.clipboard.body, draft.clipboard, previewContext)
: null;
const emailPreview = previewContext ? renderEmail(draft.email, previewContext) : null;
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="Templates shape the Copy and Email actions of the submissions list. The current search, status filter, sort order and selection apply."
/>
<Collapse
activeKey={activePanels}
onChange={(keys) => setActivePanels(Array.isArray(keys) ? keys : [keys])}
items={[
{
key: "clipboard",
label: "Clipboard templating",
extra: <EnabledSwitch checked={draft.clipboard.enabled} onChange={(enabled) => updateClipboard({ enabled })} />,
children: (
<>
<ListSettingsFields value={draft.clipboard} config={config} onChange={updateClipboard} />
<Form.Item label={<LabelWithPlaceholders label="Body" items={BODY_PLACEHOLDERS} />}>
<Input.TextArea
autoSize={{ minRows: 3, maxRows: 12 }}
value={draft.clipboard.body}
onChange={(event) => updateClipboard({ body: event.target.value })}
/>
</Form.Item>
{clipboardPreview !== null && (
<Preview enabled={draft.clipboard.enabled} actionLabel="Copy" text={clipboardPreview} />
)}
</>
),
},
{
key: "email",
label: "Email templating",
extra: <EnabledSwitch checked={draft.email.enabled} onChange={(enabled) => updateEmail({ enabled })} />,
children: (
<>
<RecipientsField label="To" value={draft.email.to} onChange={(to) => updateEmail({ to })} />
<RecipientsField label="CC" value={draft.email.cc} onChange={(cc) => updateEmail({ cc })} />
<RecipientsField label="BCC" value={draft.email.bcc} onChange={(bcc) => updateEmail({ bcc })} />
<ListSettingsFields value={draft.email} config={config} onChange={updateEmail} />
<Form.Item label={<LabelWithPlaceholders label="Subject" items={BODY_PLACEHOLDERS} />}>
<Input
value={draft.email.subject}
onChange={(event) => updateEmail({ subject: event.target.value })}
/>
</Form.Item>
<Form.Item label={<LabelWithPlaceholders label="Body" items={BODY_PLACEHOLDERS} />}>
<Input.TextArea
autoSize={{ minRows: 3, maxRows: 12 }}
value={draft.email.body}
onChange={(event) => updateEmail({ body: event.target.value })}
/>
</Form.Item>
{emailPreview && (
<Preview
enabled={draft.email.enabled}
actionLabel="Email"
text={[
`To: ${emailPreview.to.join(", ")}`,
`CC: ${emailPreview.cc.join(", ")}`,
`BCC: ${emailPreview.bcc.join(", ")}`,
`Subject: ${emailPreview.subject}`,
"",
emailPreview.body,
].join("\n")}
/>
)}
</>
),
},
]}
/>
<Flex gap={8} justify="flex-end" wrap>
<Button icon={<UndoOutlined />} disabled={!isDirty || saving} onClick={() => setDraft(templates)}>
Discard changes
</Button>
<Button type="primary" icon={<SaveOutlined />} disabled={!isDirty} loading={saving} onClick={handleSave}>
Save templates
</Button>
</Flex>
</Space>
</Form>
);
}
@@ -5,6 +5,9 @@ import MenuConfigImportExport from "../forms/MenuConfigImportExport";
import OrderFormConfigBuilder, {
OrderFormConfig,
} from "../forms/OrderFormConfigBuilder";
import OrderTemplatesEditor from "../forms/OrderTemplatesEditor";
import type { TemplateContext } from "../../lib/orderTemplating";
import type { OrderTemplates } from "../../lib/types";
type Order = {
id?: string;
@@ -45,10 +48,16 @@ export default function ReadOnlyOrderOverviewCard({
order,
config,
onImportConfig,
templates,
templatePreviewContext,
onSaveTemplates,
}: {
order: Order | null;
config?: OrderFormConfig | null;
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
templates?: OrderTemplates | null;
templatePreviewContext?: TemplateContext | null;
onSaveTemplates?: (templates: OrderTemplates) => Promise<void>;
}) {
if (!order) {
return null;
@@ -77,6 +86,7 @@ export default function ReadOnlyOrderOverviewCard({
<MenuConfigImportExport
config={currentConfig}
description={currentDescription}
templates={templates}
showImport={false}
onImportConfig={onImportConfig}
fileNameBase={order.title || "order"}
@@ -148,6 +158,22 @@ export default function ReadOnlyOrderOverviewCard({
/>
),
},
...(templates && onSaveTemplates
? [
{
key: "templates",
label: "Templates",
children: (
<OrderTemplatesEditor
templates={templates}
config={currentConfig}
previewContext={templatePreviewContext}
onSave={onSaveTemplates}
/>
),
},
]
: []),
]}
></Tabs>
</Card>
+2 -2
View File
@@ -36,7 +36,7 @@ function resolveItemPrice(item?: OrderFormItem) {
return Number(item.price);
}
function getMultipleSeparator(category: OrderFormCategory) {
export function getMultipleSeparator(category: OrderFormCategory) {
if (typeof category.multipleSeparator === "string") {
return category.multipleSeparator;
}
@@ -53,7 +53,7 @@ function getCategorySnippet(category: OrderFormCategory) {
return rawSnippet;
}
function applyCategorySnippet(category: OrderFormCategory, value: string) {
export function applyCategorySnippet(category: OrderFormCategory, value: string) {
const snippet = getCategorySnippet(category);
return snippet
.split(ORDER_FORMAT_VALUE_PLACEHOLDER)
+545
View File
@@ -0,0 +1,545 @@
import { applyCategorySnippet, getMultipleSeparator, stripPriceDecorations } from "./orderFormatting";
import type {
EmailTemplateSettings,
OrderFormCategory,
OrderFormConfig,
OrderTemplates,
Submission,
SubmissionStatus,
TemplateGrouping,
TemplateListSettings,
TemplateRecipients,
TemplateRecipientSource,
TemplateSection,
TemplateSort,
} from "./types";
export const DEFAULT_ENTRY_TEMPLATE = "{order}";
export const DEFAULT_GROUP_ENTRY_TEMPLATE = "{count}x {value}";
const DEFAULT_LIST_SETTINGS: TemplateListSettings = {
grouping: "none",
groupCategoryId: "",
sortBy: "table",
sortCategoryId: "",
sections: [],
sectionTemplate: "{heading}:\n{entries}",
sectionSeparator: "\\n\\n",
entryTemplate: DEFAULT_ENTRY_TEMPLATE,
separator: "\\n",
};
export const DEFAULT_ORDER_TEMPLATES: OrderTemplates = {
clipboard: {
...DEFAULT_LIST_SETTINGS,
enabled: false,
body: "{entries}",
},
email: {
...DEFAULT_LIST_SETTINGS,
enabled: false,
subject: "{title}",
to: { sources: [], custom: "" },
cc: { sources: ["participants"], custom: "" },
bcc: { sources: [], custom: "" },
body: "{entries}\n",
entryTemplate: "- {order}",
},
};
// Which sort options make sense per entry mode.
export const VALID_SORTS: Record<TemplateGrouping, TemplateSort[]> = {
none: ["table", "category", "alpha"],
order: ["table", "count", "alpha"],
category: ["category", "count", "alpha", "table"],
sections: ["category", "count", "alpha", "table"],
};
export const ENTRY_PLACEHOLDERS = [
{ key: "{index}", description: "Running number (1, 2, …)" },
{ key: "{order}", description: "Formatted order text" },
{ key: "{email}", description: "Participant email" },
{ key: "{total}", description: "Estimated amount" },
{ key: "{status}", description: "pending / unpaid / paid" },
{ key: "{cat:Label}", description: "Selected value(s) of the category with that label" },
];
export const GROUP_PLACEHOLDERS = [
{ key: "{index}", description: "Running number (1, 2, …)" },
{ key: "{count}", description: "Number of occurrences" },
{ key: "{value}", description: "Grouped order text / category item" },
{ key: "{emails}", description: "Emails of the participants in this group" },
{ key: "{total}", description: "Estimated amount of the group" },
];
export const SECTION_PLACEHOLDERS = [
{ key: "{heading}", description: "Section heading" },
{ key: "{entries}", description: "The section's entries, joined by the entry separator" },
{ key: "{count}", description: "Number of items in this section" },
{ key: "{total}", description: "Estimated amount of this section" },
];
export const BODY_PLACEHOLDERS = [
{ key: "{entries}", description: "The rendered entries (or sections)" },
{ key: "{count}", description: "Number of submissions" },
{ key: "{total}", description: "Estimated total of all submissions" },
{ key: "{title}", description: "Order title" },
{ key: "{creator}", description: "Order creator email" },
{ key: "{participants}", description: "Participant emails (comma separated)" },
{ key: "{link}", description: "Participant page link" },
{ key: "{date}", description: "Today's date" },
{ key: "{tally:Label}", description: "Count per item of a category, e.g. \"3x Fries\"" },
];
export type TemplateContext = {
title: string;
creatorEmail: string | null;
participantUrl: string;
config: OrderFormConfig;
// Already filtered, sorted and selection-reduced in the order of the submissions table.
submissions: Submission[];
};
export type RenderedEmail = {
to: string[];
cc: string[];
bcc: string[];
subject: string;
body: string;
};
const GROUPINGS: TemplateGrouping[] = ["none", "order", "category", "sections"];
const SORTS: TemplateSort[] = ["table", "category", "count", "alpha"];
const RECIPIENT_SOURCES: TemplateRecipientSource[] = ["creator", "participants", "custom"];
export function isGroupedMode(grouping: TemplateGrouping): boolean {
return grouping !== "none";
}
export function buildSectionsPerCategory(config: OrderFormConfig): TemplateSection[] {
return config.categories.map((category) => ({ heading: category.label, categoryIds: [category.id] }));
}
function asString(value: unknown, fallback: string): string {
return typeof value === "string" ? value : fallback;
}
function normalizeSections(raw: unknown): TemplateSection[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((section) => section && typeof section === "object")
.map((section: any) => ({
heading: asString(section.heading, ""),
categoryIds: Array.isArray(section.categoryIds)
? section.categoryIds.filter((id: unknown): id is string => typeof id === "string")
: [],
}));
}
function normalizeListSettings(raw: any, defaults: TemplateListSettings): TemplateListSettings {
return {
grouping: GROUPINGS.includes(raw?.grouping) ? raw.grouping : defaults.grouping,
groupCategoryId: asString(raw?.groupCategoryId, defaults.groupCategoryId),
sortBy: SORTS.includes(raw?.sortBy) ? raw.sortBy : defaults.sortBy,
sortCategoryId: asString(raw?.sortCategoryId, defaults.sortCategoryId),
sections: normalizeSections(raw?.sections),
sectionTemplate: asString(raw?.sectionTemplate, defaults.sectionTemplate),
sectionSeparator: asString(raw?.sectionSeparator, defaults.sectionSeparator),
entryTemplate: asString(raw?.entryTemplate, defaults.entryTemplate),
separator: asString(raw?.separator, defaults.separator),
};
}
function normalizeRecipients(raw: any, defaults: TemplateRecipients): TemplateRecipients {
const sources = Array.isArray(raw?.sources)
? raw.sources.filter((source: unknown) => RECIPIENT_SOURCES.includes(source as TemplateRecipientSource))
: defaults.sources;
return {
sources,
custom: asString(raw?.custom, defaults.custom),
};
}
export function normalizeOrderTemplates(raw: unknown): OrderTemplates {
const value = (raw && typeof raw === "object" ? raw : {}) as any;
const clipboardDefaults = DEFAULT_ORDER_TEMPLATES.clipboard;
const emailDefaults = DEFAULT_ORDER_TEMPLATES.email;
return {
clipboard: {
...normalizeListSettings(value.clipboard, clipboardDefaults),
enabled: !!value.clipboard?.enabled,
body: asString(value.clipboard?.body, clipboardDefaults.body),
},
email: {
...normalizeListSettings(value.email, emailDefaults),
enabled: !!value.email?.enabled,
subject: asString(value.email?.subject, emailDefaults.subject),
to: normalizeRecipients(value.email?.to, emailDefaults.to),
cc: normalizeRecipients(value.email?.cc, emailDefaults.cc),
bcc: normalizeRecipients(value.email?.bcc, emailDefaults.bcc),
body: asString(value.email?.body, emailDefaults.body),
},
};
}
/* ==================== Rendering ==================== */
function decodeSeparator(separator: string): string {
return separator.replace(/\\n/g, "\n").replace(/\\t/g, "\t");
}
function formatAmount(value: number | null): string {
return value === null ? "" : `€${value.toFixed(2)}`;
}
function toNumber(rawValue: unknown): number | null {
const parsed = typeof rawValue === "string" ? Number(rawValue) : rawValue;
return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null;
}
function sumAmounts(values: Array<number | null>): number | null {
const numbers = values.filter((value): value is number => value !== null);
return numbers.length > 0 ? numbers.reduce((sum, value) => sum + value, 0) : null;
}
function getOrderText(submission: Submission): string {
return stripPriceDecorations(String(submission.formatted_string || "")).trim();
}
function getEmail(submission: Submission): string {
return String(submission.email || "").trim();
}
function getStatus(submission: Submission): SubmissionStatus {
if (submission.paid) return "paid";
if (submission.accepted) return "unpaid";
return "pending";
}
function findCategory(config: OrderFormConfig, idOrLabel: string): OrderFormCategory | undefined {
const needle = idOrLabel.trim().toLowerCase();
if (!needle) return undefined;
return (
config.categories.find((category) => category.id === idOrLabel) ||
config.categories.find((category) => category.label.trim().toLowerCase() === needle)
);
}
function getItemPrice(category: OrderFormCategory, itemName: string): number | null {
const item = category.items.find((entry) => entry.name === itemName);
return item && Number.isFinite(item.price) ? Number(item.price) : null;
}
function getItemRank(category: OrderFormCategory, itemName: string): number {
const index = category.items.findIndex((entry) => entry.name === itemName);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
}
// Values in menu order, so equal selections in a different order count as the same group.
function getCategoryValues(submission: Submission, category: OrderFormCategory): string[] {
const raw = submission.choices?.[category.id];
const values = Array.isArray(raw) ? raw : [raw];
return values
.map((value) => String(value ?? "").trim())
.filter(Boolean)
.sort((a, b) => getItemRank(category, a) - getItemRank(category, b) || a.localeCompare(b));
}
function replacePlaceholders(
template: string,
resolve: (key: string, arg: string | undefined) => string | undefined,
): string {
return template.replace(/\{([a-z_]+)(?::([^{}]+))?\}/gi, (match, key: string, arg?: string) => {
const resolved = resolve(key.toLowerCase(), arg);
return resolved === undefined ? match : resolved;
});
}
type GroupItem = {
value: string;
amount: number | null;
rank: number;
};
type Group = {
value: string;
count: number;
emails: string[];
amounts: Array<number | null>;
rank: number;
firstSeen: number;
};
function collectGroups(
context: TemplateContext,
sortBy: TemplateSort,
getItems: (submission: Submission) => GroupItem[],
): Group[] {
const groups = new Map<string, Group>();
for (const submission of context.submissions) {
const email = getEmail(submission);
for (const item of getItems(submission)) {
const existing = groups.get(item.value);
if (existing) {
existing.count += 1;
existing.amounts.push(item.amount);
if (email && !existing.emails.includes(email)) existing.emails.push(email);
continue;
}
groups.set(item.value, {
value: item.value,
count: 1,
emails: email ? [email] : [],
amounts: [item.amount],
rank: item.rank,
firstSeen: groups.size,
});
}
}
const list = Array.from(groups.values());
list.sort((a, b) => {
switch (sortBy) {
case "count":
return b.count - a.count || a.value.localeCompare(b.value);
case "alpha":
return a.value.localeCompare(b.value);
case "category":
// Menu order of the (first) category's items.
return a.rank - b.rank || a.value.localeCompare(b.value);
case "table":
default:
return a.firstSeen - b.firstSeen;
}
});
return list;
}
function getCategoryItems(submission: Submission, category: OrderFormCategory): GroupItem[] {
return getCategoryValues(submission, category).map((value) => ({
value,
amount: getItemPrice(category, value),
rank: getItemRank(category, value),
}));
}
// One category: every selected item counts on its own.
// Several categories: the combination counts, e.g. "Cheeseburger (well done, Vegan)".
// Submissions without a value in the first category are skipped.
function getSectionItems(submission: Submission, categories: OrderFormCategory[]): GroupItem[] {
const [primary, ...rest] = categories;
if (!primary) return [];
if (rest.length === 0) return getCategoryItems(submission, primary);
const primaryValues = getCategoryValues(submission, primary);
if (primaryValues.length === 0) return [];
const parts = [primaryValues.join(getMultipleSeparator(primary))];
const amounts = primaryValues.map((value) => getItemPrice(primary, value));
for (const category of rest) {
const values = getCategoryValues(submission, category);
if (values.length === 0) continue;
parts.push(applyCategorySnippet(category, values.join(getMultipleSeparator(category))));
amounts.push(...values.map((value) => getItemPrice(category, value)));
}
return [{ value: parts.join(" "), amount: sumAmounts(amounts), rank: getItemRank(primary, primaryValues[0]) }];
}
function renderGroups(groups: Group[], entryTemplate: string): string[] {
return groups
.map((group, index) =>
replacePlaceholders(entryTemplate, (key) => {
switch (key) {
case "index": return String(index + 1);
case "count": return String(group.count);
case "value": return group.value;
case "emails": return group.emails.join(", ");
case "total": return formatAmount(sumAmounts(group.amounts));
default: return undefined;
}
}),
)
.filter((entry) => entry.trim());
}
function sortSubmissions(settings: TemplateListSettings, context: TemplateContext): Submission[] {
const list = [...context.submissions];
if (settings.sortBy === "alpha") {
list.sort((a, b) => getOrderText(a).localeCompare(getOrderText(b)));
} else if (settings.sortBy === "category") {
const category = findCategory(context.config, settings.sortCategoryId);
if (category) {
const key = (submission: Submission) => getCategoryValues(submission, category).join(", ");
list.sort((a, b) => {
const aKey = key(a);
const bKey = key(b);
if (!aKey !== !bKey) return aKey ? -1 : 1; // empty values last
return aKey.localeCompare(bKey);
});
}
}
return list;
}
function renderSubmissionEntries(settings: TemplateListSettings, context: TemplateContext): string[] {
return sortSubmissions(settings, context)
.filter((submission) => getOrderText(submission))
.map((submission, index) =>
replacePlaceholders(settings.entryTemplate, (key, arg) => {
switch (key) {
case "index": return String(index + 1);
case "order": return getOrderText(submission);
case "email": return getEmail(submission);
case "total": return formatAmount(toNumber(submission.estimated_total));
case "status": return getStatus(submission);
case "cat": {
const category = arg ? findCategory(context.config, arg) : undefined;
return category ? getCategoryValues(submission, category).join(", ") : undefined;
}
default: return undefined;
}
}),
)
.filter((entry) => entry.trim());
}
function renderSections(settings: TemplateListSettings, context: TemplateContext): string[] {
const entrySeparator = decodeSeparator(settings.separator);
return settings.sections
.map((section) => {
const categories = section.categoryIds
.map((id) => findCategory(context.config, id))
.filter((category): category is OrderFormCategory => !!category);
const groups = collectGroups(context, settings.sortBy, (submission) => getSectionItems(submission, categories));
if (groups.length === 0) return "";
return replacePlaceholders(settings.sectionTemplate, (key) => {
switch (key) {
case "heading": return section.heading;
case "entries": return renderGroups(groups, settings.entryTemplate).join(entrySeparator);
case "count": return String(groups.reduce((sum, group) => sum + group.count, 0));
case "total": return formatAmount(sumAmounts(groups.flatMap((group) => group.amounts)));
default: return undefined;
}
});
})
.filter((section) => section.trim());
}
function renderEntries(settings: TemplateListSettings, context: TemplateContext): string {
const entrySeparator = decodeSeparator(settings.separator);
switch (settings.grouping) {
case "sections":
return renderSections(settings, context).join(decodeSeparator(settings.sectionSeparator));
case "order":
return renderGroups(
collectGroups(context, settings.sortBy, (submission) => {
const value = getOrderText(submission);
return value ? [{ value, amount: toNumber(submission.estimated_total), rank: 0 }] : [];
}),
settings.entryTemplate,
).join(entrySeparator);
case "category": {
const category = findCategory(context.config, settings.groupCategoryId);
if (!category) return "";
return renderGroups(
collectGroups(context, settings.sortBy, (submission) => getCategoryItems(submission, category)),
settings.entryTemplate,
).join(entrySeparator);
}
case "none":
default:
return renderSubmissionEntries(settings, context).join(entrySeparator);
}
}
function renderTally(categoryLabel: string, context: TemplateContext): string | undefined {
const category = findCategory(context.config, categoryLabel);
if (!category) return undefined;
return renderGroups(
collectGroups(context, "category", (submission) => getCategoryItems(submission, category)),
DEFAULT_GROUP_ENTRY_TEMPLATE,
).join("\n");
}
function getParticipantEmails(context: TemplateContext): string[] {
return Array.from(new Set(context.submissions.map(getEmail).filter(Boolean)));
}
export function renderTemplateText(
template: string,
settings: TemplateListSettings,
context: TemplateContext,
): string {
let entries: string | null = null;
return replacePlaceholders(template, (key, arg) => {
switch (key) {
case "entries": return (entries ??= renderEntries(settings, context));
case "count": return String(context.submissions.length);
case "total": return formatAmount(sumAmounts(context.submissions.map((s) => toNumber(s.estimated_total))));
case "title": return context.title;
case "creator": return context.creatorEmail || "";
case "participants": return getParticipantEmails(context).join(", ");
case "link": return context.participantUrl;
case "date": return new Date().toLocaleDateString();
case "tally": return arg ? renderTally(arg, context) : undefined;
default: return undefined;
}
});
}
export function hasRenderableEntries(context: TemplateContext): boolean {
return context.submissions.some((submission) => getOrderText(submission));
}
function resolveRecipients(recipients: TemplateRecipients, context: TemplateContext): string[] {
const result: string[] = [];
for (const source of recipients.sources) {
if (source === "creator" && context.creatorEmail) {
result.push(context.creatorEmail);
} else if (source === "participants") {
result.push(...getParticipantEmails(context));
} else if (source === "custom") {
result.push(...recipients.custom.split(/[\s,;]+/).map((entry) => entry.trim()).filter(Boolean));
}
}
return Array.from(new Set(result));
}
export function renderEmail(settings: EmailTemplateSettings, context: TemplateContext): RenderedEmail {
return {
to: resolveRecipients(settings.to, context),
cc: resolveRecipients(settings.cc, context),
bcc: resolveRecipients(settings.bcc, context),
subject: renderTemplateText(settings.subject, settings, context).replace(/\s*\n\s*/g, " ").trim(),
body: renderTemplateText(settings.body, settings, context),
};
}
export function buildMailtoHref(email: RenderedEmail): string {
const encodeList = (list: string[]) => list.map(encodeURIComponent).join(",");
const queryParts: string[] = [];
if (email.cc.length > 0) queryParts.push(`cc=${encodeList(email.cc)}`);
if (email.bcc.length > 0) queryParts.push(`bcc=${encodeList(email.bcc)}`);
queryParts.push(`subject=${encodeURIComponent(email.subject)}`);
queryParts.push(`body=${encodeURIComponent(email.body)}`);
return `mailto:${encodeList(email.to)}?${queryParts.join("&")}`;
}
@@ -2,6 +2,7 @@ import { apiFetch } from "../api";
import type {
Order,
OrderFormConfig,
OrderTemplates,
GetAdminViewResponse,
OrderAccessInfo,
GetOrdersResponse,
@@ -58,6 +59,13 @@ export const ordersService = {
method: "PUT",
body: JSON.stringify(config),
}),
getTemplates: (orderId: string) =>
apiFetch<Partial<OrderTemplates>>(`/api/orders/${orderId}/admin/templates`),
updateTemplates: (orderId: string, templates: OrderTemplates) =>
apiFetch<OrderTemplates>(`/api/orders/${orderId}/admin/templates`, {
method: "PUT",
body: JSON.stringify(templates),
}),
uploadImage: (orderId: string, file: File) => {
const formData = new FormData();
formData.append("file", file);
+52
View File
@@ -56,6 +56,58 @@ export type OrderFormConfig = {
categories: OrderFormCategory[];
};
/* ==================== Order Templating ==================== */
export type TemplateGrouping = "none" | "order" | "category" | "sections";
// "category" sorts entries by a category value, or grouped category items by menu order.
export type TemplateSort = "table" | "category" | "count" | "alpha";
// A section counts the items of one category, or the combination of several categories
// (e.g. Burger + Extras), where later categories are rendered with their menu snippet.
export type TemplateSection = {
heading: string;
categoryIds: string[];
};
export type TemplateRecipientSource = "creator" | "participants" | "custom";
export type TemplateRecipients = {
sources: TemplateRecipientSource[];
custom: string;
};
export type TemplateListSettings = {
grouping: TemplateGrouping;
groupCategoryId: string;
sortBy: TemplateSort;
sortCategoryId: string;
sections: TemplateSection[];
sectionTemplate: string;
sectionSeparator: string;
entryTemplate: string;
separator: string;
};
export type ClipboardTemplateSettings = TemplateListSettings & {
enabled: boolean;
body: string;
};
export type EmailTemplateSettings = TemplateListSettings & {
enabled: boolean;
subject: string;
to: TemplateRecipients;
cc: TemplateRecipients;
bcc: TemplateRecipients;
body: string;
};
export type OrderTemplates = {
clipboard: ClipboardTemplateSettings;
email: EmailTemplateSettings;
};
export type OrderBase = {
id: string;
admin_token: string;
+47 -31
View File
@@ -13,10 +13,20 @@ import type {
GetAdminViewResponse,
Order,
OrderFormConfig,
OrderTemplates,
Submission,
SubmissionStatus,
} from "../lib/types";
import { formatEstimatedTotal, stripPriceDecorations } from "../lib/orderFormatting";
import {
DEFAULT_ORDER_TEMPLATES,
buildMailtoHref,
hasRenderableEntries,
normalizeOrderTemplates,
renderEmail,
renderTemplateText,
type TemplateContext,
} from "../lib/orderTemplating";
import AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
@@ -24,6 +34,8 @@ import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
type AdminViewData = Order & {
submissions: Submission[];
config: OrderFormConfig;
templates: OrderTemplates;
creator_email?: string | null;
};
function shouldRedirectToParticipant(error: unknown): boolean {
@@ -49,10 +61,6 @@ function getEstimatedTotalNumber(rawValue: unknown): number | null {
return null;
}
function getCleanSubmissionText(submission: Submission): string {
return stripPriceDecorations(String(submission.formatted_string || "")).trim();
}
export default function AdminView({ orderId }: { orderId: string }) {
const [deletingId, setDeletingId] = useState<string | number | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
@@ -77,9 +85,10 @@ export default function AdminView({ orderId }: { orderId: string }) {
const loadAdminData = useCallback(
async (): Promise<AdminViewData> => {
const [adminPayload, configPayload] = await Promise.all([
const [adminPayload, configPayload, templatesPayload] = await Promise.all([
apiService.orders.getAdminView(orderId),
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
apiService.orders.getTemplates(orderId).catch(() => null),
]);
const typedPayload = adminPayload as GetAdminViewResponse;
@@ -95,6 +104,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
? (configPayload as OrderFormConfig).categories
: [],
},
templates: normalizeOrderTemplates(templatesPayload),
};
},
[orderId],
@@ -232,6 +242,18 @@ export default function AdminView({ orderId }: { orderId: string }) {
message.success("Menu configuration updated");
};
const handleSaveTemplates = async (nextTemplates: OrderTemplates) => {
try {
const saved = await apiService.orders.updateTemplates(orderId, nextTemplates);
setData((previous: AdminViewData | null) =>
previous ? { ...previous, templates: normalizeOrderTemplates(saved) } : previous,
);
message.success("Templates saved");
} catch (error: any) {
message.error(error.message);
}
};
const refreshSubmissions = useCallback(async () => {
try {
const adminPayload = await apiService.orders.getAdminView(orderId);
@@ -337,32 +359,23 @@ export default function AdminView({ orderId }: { orderId: string }) {
selectedRowKeys.includes(submission.id),
);
const rowsToCopy = selected.length > 0 ? selected : sortedAndFilteredSubmissions;
const copyText = rowsToCopy
.map((submission: Submission) => getCleanSubmissionText(submission))
.filter(Boolean)
.join("\n");
const ccRecipients = Array.from(
new Set(
rowsToCopy
.map((submission: Submission) => String(submission.email || "").trim())
.filter(Boolean),
),
);
const emailBody = rowsToCopy
.map((submission: Submission) => getCleanSubmissionText(submission))
.filter(Boolean)
.map((line) => `- ${line}`)
.join("\n");
let mailtoHref = "";
if (emailBody) {
const queryParts: string[] = [];
if (ccRecipients.length > 0) {
queryParts.push(`cc=${encodeURIComponent(ccRecipients.join(","))}`);
}
queryParts.push(`subject=${encodeURIComponent(data?.title || "Lunch order submissions")}`);
queryParts.push(`body=${encodeURIComponent(`${emailBody}\n`)}`);
mailtoHref = `mailto:?${queryParts.join("&")}`;
}
const templateContext: TemplateContext = {
title: data?.title || "Lunch order submissions",
creatorEmail: data?.creator_email || null,
participantUrl: `${window.location.origin}/order/${orderId}`,
config: data?.config || { categories: [] },
submissions: rowsToCopy,
};
const templates = data?.templates || DEFAULT_ORDER_TEMPLATES;
const clipboardTemplate = templates.clipboard.enabled
? templates.clipboard
: DEFAULT_ORDER_TEMPLATES.clipboard;
const emailTemplate = templates.email.enabled ? templates.email : DEFAULT_ORDER_TEMPLATES.email;
const hasEntries = hasRenderableEntries(templateContext);
const copyText = hasEntries
? renderTemplateText(clipboardTemplate.body, clipboardTemplate, templateContext)
: "";
const mailtoHref = hasEntries ? buildMailtoHref(renderEmail(emailTemplate, templateContext)) : "";
const totalEstimatedValue = rowsToCopy.reduce((sum: number, submission: any) => {
const value = getEstimatedTotalNumber(submission.estimated_total);
@@ -428,6 +441,9 @@ export default function AdminView({ orderId }: { orderId: string }) {
order={data}
config={data?.config}
onImportConfig={handleImportConfig}
templates={data?.templates}
templatePreviewContext={templateContext}
onSaveTemplates={handleSaveTemplates}
/>
<AdminControlCenterCard
+28 -1
View File
@@ -20,7 +20,8 @@ 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 } from "../lib/types";
import type { OrderFormConfig, OrderTemplates } from "../lib/types";
import { normalizeOrderTemplates } from "../lib/orderTemplating";
import OrderFormConfigBuilder from "../components/forms/OrderFormConfigBuilder";
import CreateOrderHeaderActions from "../components/views/CreateOrderHeaderActions";
import ExportSelectionModal, { type ExportSelectionState } from "../components/modals/ExportSelectionModal";
@@ -129,11 +130,14 @@ export default function CreateOrderView() {
const [activeTabKey, setActiveTabKey] = useState<string>("details");
const [imageFileList, setImageFileList] = useState<UploadFile[]>([]);
const [formConfig, setFormConfig] = useState<OrderFormConfig>(DEFAULT_CONFIG);
// Templates are edited in the admin view; here they are only carried over from an import.
const [importedTemplates, setImportedTemplates] = useState<OrderTemplates | null>(null);
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const [exportOptions, setExportOptions] = useState<ExportSelectionState>({
title: true,
description: true,
menu: true,
templates: true,
});
const importFileInputRef = useRef<HTMLInputElement>(null);
@@ -213,8 +217,19 @@ export default function CreateOrderView() {
}
}
if (importedTemplates) {
try {
await apiService.orders.updateTemplates(order.id, importedTemplates);
} catch (error: any) {
message.warning(
"Order created but templates save failed: " + error.message,
);
}
}
setImageFileList([]);
setFormConfig(DEFAULT_CONFIG);
setImportedTemplates(null);
navigateTo(`/order/${order.id}/admin`);
} catch (error: any) {
@@ -274,6 +289,7 @@ export default function CreateOrderView() {
title?: unknown;
description?: unknown;
menu?: unknown;
templates?: unknown;
};
const importedSections: string[] = [];
@@ -302,6 +318,11 @@ export default function CreateOrderView() {
importedSections.push("menu");
}
if (Object.prototype.hasOwnProperty.call(parsed, "templates")) {
setImportedTemplates(normalizeOrderTemplates(parsed.templates));
importedSections.push("templates");
}
if (importedSections.length === 0) {
message.warning("No supported sections found in import file");
return;
@@ -325,6 +346,7 @@ export default function CreateOrderView() {
title?: string;
description?: string;
menu?: OrderFormConfig;
templates?: OrderTemplates;
} = {};
if (exportOptions.title) {
@@ -339,6 +361,10 @@ export default function CreateOrderView() {
payload.menu = formConfig;
}
if (importedTemplates && exportOptions.templates) {
payload.templates = importedTemplates;
}
if (Object.keys(payload).length === 0) {
message.warning("Select at least one section to export");
return;
@@ -484,6 +510,7 @@ export default function CreateOrderView() {
{ key: "title", label: "Title" },
{ key: "description", label: "Description" },
{ key: "menu", label: "Menu" },
...(importedTemplates ? [{ key: "templates", label: "Templates" }] : []),
]}
selected={exportOptions}
onCancel={() => setIsExportModalOpen(false)}