Add clipboard and email templating
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m37s
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m37s
This commit is contained in:
1 parent
d813fc0b5b
commit
7d5ce7bcc8
11 files changed
+1328
-35
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user