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_config,
|
||||||
delete_order_description,
|
delete_order_description,
|
||||||
delete_order_images,
|
delete_order_images,
|
||||||
|
delete_order_templates,
|
||||||
load_order_config,
|
load_order_config,
|
||||||
|
load_order_templates,
|
||||||
save_order_config,
|
save_order_config,
|
||||||
save_order_description,
|
save_order_description,
|
||||||
|
save_order_templates,
|
||||||
save_uploaded_image,
|
save_uploaded_image,
|
||||||
)
|
)
|
||||||
from .services.order_service import ensure_order_exists, ensure_order_open
|
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)
|
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):
|
class UserProfileUpdateRequest(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
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_description(admin_token)
|
||||||
delete_order_config(admin_token)
|
delete_order_config(admin_token)
|
||||||
|
delete_order_templates(admin_token)
|
||||||
delete_order_images(admin_token, order["image_url"])
|
delete_order_images(admin_token, order["image_url"])
|
||||||
|
|
||||||
return {"deleted": True}
|
return {"deleted": True}
|
||||||
@@ -1189,3 +1200,34 @@ def admin_update_order_config(
|
|||||||
save_order_config(admin_token, config_dict)
|
save_order_config(admin_token, config_dict)
|
||||||
|
|
||||||
return 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"
|
IMAGES_DIR = BASE_DIR / "data" / "images"
|
||||||
CONFIGS_DIR = BASE_DIR / "data" / "configs"
|
CONFIGS_DIR = BASE_DIR / "data" / "configs"
|
||||||
DESCRIPTIONS_DIR = BASE_DIR / "data" / "descriptions"
|
DESCRIPTIONS_DIR = BASE_DIR / "data" / "descriptions"
|
||||||
|
TEMPLATES_DIR = BASE_DIR / "data" / "templates"
|
||||||
|
|
||||||
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
|
CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
DESCRIPTIONS_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]:
|
def load_order_config(admin_token: str) -> dict[str, Any]:
|
||||||
@@ -78,6 +80,37 @@ def delete_order_config(admin_token: str) -> None:
|
|||||||
pass
|
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:
|
def delete_order_images(admin_token: str, image_url: str | None) -> None:
|
||||||
try:
|
try:
|
||||||
if image_url and not image_url.startswith("http"):
|
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 { ExportOutlined, ImportOutlined } from "@ant-design/icons";
|
||||||
import type { OrderFormConfig } from "./OrderFormConfigBuilder";
|
import type { OrderFormConfig } from "./OrderFormConfigBuilder";
|
||||||
import ExportSelectionModal, { type ExportSelectionState } from "../modals/ExportSelectionModal";
|
import ExportSelectionModal, { type ExportSelectionState } from "../modals/ExportSelectionModal";
|
||||||
|
import type { OrderTemplates } from "../../lib/types";
|
||||||
|
|
||||||
type MenuConfigImportExportProps = {
|
type MenuConfigImportExportProps = {
|
||||||
config?: OrderFormConfig | null;
|
config?: OrderFormConfig | null;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
templates?: OrderTemplates | null;
|
||||||
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||||
showImport?: boolean;
|
showImport?: boolean;
|
||||||
showExport?: boolean;
|
showExport?: boolean;
|
||||||
@@ -25,6 +27,7 @@ function normalizeConfig(value: unknown): OrderFormConfig {
|
|||||||
export default function MenuConfigImportExport({
|
export default function MenuConfigImportExport({
|
||||||
config,
|
config,
|
||||||
description,
|
description,
|
||||||
|
templates,
|
||||||
onImportConfig,
|
onImportConfig,
|
||||||
showImport = true,
|
showImport = true,
|
||||||
showExport = true,
|
showExport = true,
|
||||||
@@ -37,6 +40,7 @@ export default function MenuConfigImportExport({
|
|||||||
title: true,
|
title: true,
|
||||||
menu: true,
|
menu: true,
|
||||||
description: true,
|
description: true,
|
||||||
|
templates: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -85,7 +89,8 @@ export default function MenuConfigImportExport({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExportJson = () => {
|
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");
|
message.warning("Select at least one section to export");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,6 +99,7 @@ export default function MenuConfigImportExport({
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
menu?: OrderFormConfig;
|
menu?: OrderFormConfig;
|
||||||
|
templates?: OrderTemplates;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (exportSelection.title) {
|
if (exportSelection.title) {
|
||||||
@@ -108,6 +114,10 @@ export default function MenuConfigImportExport({
|
|||||||
payload.menu = normalizeConfig(config);
|
payload.menu = normalizeConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exportTemplates) {
|
||||||
|
payload.templates = templates;
|
||||||
|
}
|
||||||
|
|
||||||
const safeBase = String(fileNameBase || "order")
|
const safeBase = String(fileNameBase || "order")
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, "-")
|
.replace(/\s+/g, "-")
|
||||||
@@ -165,6 +175,7 @@ export default function MenuConfigImportExport({
|
|||||||
{ key: "title", label: "Title" },
|
{ key: "title", label: "Title" },
|
||||||
{ key: "menu", label: "Menu" },
|
{ key: "menu", label: "Menu" },
|
||||||
{ key: "description", label: "Description" },
|
{ key: "description", label: "Description" },
|
||||||
|
...(templates ? [{ key: "templates", label: "Templates" }] : []),
|
||||||
]}
|
]}
|
||||||
selected={exportSelection}
|
selected={exportSelection}
|
||||||
onCancel={() => setIsExportModalOpen(false)}
|
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, {
|
import OrderFormConfigBuilder, {
|
||||||
OrderFormConfig,
|
OrderFormConfig,
|
||||||
} from "../forms/OrderFormConfigBuilder";
|
} from "../forms/OrderFormConfigBuilder";
|
||||||
|
import OrderTemplatesEditor from "../forms/OrderTemplatesEditor";
|
||||||
|
import type { TemplateContext } from "../../lib/orderTemplating";
|
||||||
|
import type { OrderTemplates } from "../../lib/types";
|
||||||
|
|
||||||
type Order = {
|
type Order = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -45,10 +48,16 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
order,
|
order,
|
||||||
config,
|
config,
|
||||||
onImportConfig,
|
onImportConfig,
|
||||||
|
templates,
|
||||||
|
templatePreviewContext,
|
||||||
|
onSaveTemplates,
|
||||||
}: {
|
}: {
|
||||||
order: Order | null;
|
order: Order | null;
|
||||||
config?: OrderFormConfig | null;
|
config?: OrderFormConfig | null;
|
||||||
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||||
|
templates?: OrderTemplates | null;
|
||||||
|
templatePreviewContext?: TemplateContext | null;
|
||||||
|
onSaveTemplates?: (templates: OrderTemplates) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
if (!order) {
|
if (!order) {
|
||||||
return null;
|
return null;
|
||||||
@@ -77,6 +86,7 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
<MenuConfigImportExport
|
<MenuConfigImportExport
|
||||||
config={currentConfig}
|
config={currentConfig}
|
||||||
description={currentDescription}
|
description={currentDescription}
|
||||||
|
templates={templates}
|
||||||
showImport={false}
|
showImport={false}
|
||||||
onImportConfig={onImportConfig}
|
onImportConfig={onImportConfig}
|
||||||
fileNameBase={order.title || "order"}
|
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>
|
></Tabs>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function resolveItemPrice(item?: OrderFormItem) {
|
|||||||
return Number(item.price);
|
return Number(item.price);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMultipleSeparator(category: OrderFormCategory) {
|
export function getMultipleSeparator(category: OrderFormCategory) {
|
||||||
if (typeof category.multipleSeparator === "string") {
|
if (typeof category.multipleSeparator === "string") {
|
||||||
return category.multipleSeparator;
|
return category.multipleSeparator;
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ function getCategorySnippet(category: OrderFormCategory) {
|
|||||||
return rawSnippet;
|
return rawSnippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCategorySnippet(category: OrderFormCategory, value: string) {
|
export function applyCategorySnippet(category: OrderFormCategory, value: string) {
|
||||||
const snippet = getCategorySnippet(category);
|
const snippet = getCategorySnippet(category);
|
||||||
return snippet
|
return snippet
|
||||||
.split(ORDER_FORMAT_VALUE_PLACEHOLDER)
|
.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 {
|
import type {
|
||||||
Order,
|
Order,
|
||||||
OrderFormConfig,
|
OrderFormConfig,
|
||||||
|
OrderTemplates,
|
||||||
GetAdminViewResponse,
|
GetAdminViewResponse,
|
||||||
OrderAccessInfo,
|
OrderAccessInfo,
|
||||||
GetOrdersResponse,
|
GetOrdersResponse,
|
||||||
@@ -58,6 +59,13 @@ export const ordersService = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(config),
|
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) => {
|
uploadImage: (orderId: string, file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|||||||
@@ -56,6 +56,58 @@ export type OrderFormConfig = {
|
|||||||
categories: OrderFormCategory[];
|
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 = {
|
export type OrderBase = {
|
||||||
id: string;
|
id: string;
|
||||||
admin_token: string;
|
admin_token: string;
|
||||||
|
|||||||
@@ -13,10 +13,20 @@ import type {
|
|||||||
GetAdminViewResponse,
|
GetAdminViewResponse,
|
||||||
Order,
|
Order,
|
||||||
OrderFormConfig,
|
OrderFormConfig,
|
||||||
|
OrderTemplates,
|
||||||
Submission,
|
Submission,
|
||||||
SubmissionStatus,
|
SubmissionStatus,
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
import { formatEstimatedTotal, stripPriceDecorations } from "../lib/orderFormatting";
|
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 AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
|
||||||
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
|
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
|
||||||
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
||||||
@@ -24,6 +34,8 @@ import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
|||||||
type AdminViewData = Order & {
|
type AdminViewData = Order & {
|
||||||
submissions: Submission[];
|
submissions: Submission[];
|
||||||
config: OrderFormConfig;
|
config: OrderFormConfig;
|
||||||
|
templates: OrderTemplates;
|
||||||
|
creator_email?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function shouldRedirectToParticipant(error: unknown): boolean {
|
function shouldRedirectToParticipant(error: unknown): boolean {
|
||||||
@@ -49,10 +61,6 @@ function getEstimatedTotalNumber(rawValue: unknown): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCleanSubmissionText(submission: Submission): string {
|
|
||||||
return stripPriceDecorations(String(submission.formatted_string || "")).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminView({ orderId }: { orderId: string }) {
|
export default function AdminView({ orderId }: { orderId: string }) {
|
||||||
const [deletingId, setDeletingId] = useState<string | number | null>(null);
|
const [deletingId, setDeletingId] = useState<string | number | null>(null);
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
|
||||||
@@ -77,9 +85,10 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
|
|
||||||
const loadAdminData = useCallback(
|
const loadAdminData = useCallback(
|
||||||
async (): Promise<AdminViewData> => {
|
async (): Promise<AdminViewData> => {
|
||||||
const [adminPayload, configPayload] = await Promise.all([
|
const [adminPayload, configPayload, templatesPayload] = await Promise.all([
|
||||||
apiService.orders.getAdminView(orderId),
|
apiService.orders.getAdminView(orderId),
|
||||||
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
||||||
|
apiService.orders.getTemplates(orderId).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const typedPayload = adminPayload as GetAdminViewResponse;
|
const typedPayload = adminPayload as GetAdminViewResponse;
|
||||||
@@ -95,6 +104,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
? (configPayload as OrderFormConfig).categories
|
? (configPayload as OrderFormConfig).categories
|
||||||
: [],
|
: [],
|
||||||
},
|
},
|
||||||
|
templates: normalizeOrderTemplates(templatesPayload),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[orderId],
|
[orderId],
|
||||||
@@ -232,6 +242,18 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
message.success("Menu configuration updated");
|
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 () => {
|
const refreshSubmissions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const adminPayload = await apiService.orders.getAdminView(orderId);
|
const adminPayload = await apiService.orders.getAdminView(orderId);
|
||||||
@@ -337,32 +359,23 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
selectedRowKeys.includes(submission.id),
|
selectedRowKeys.includes(submission.id),
|
||||||
);
|
);
|
||||||
const rowsToCopy = selected.length > 0 ? selected : sortedAndFilteredSubmissions;
|
const rowsToCopy = selected.length > 0 ? selected : sortedAndFilteredSubmissions;
|
||||||
const copyText = rowsToCopy
|
const templateContext: TemplateContext = {
|
||||||
.map((submission: Submission) => getCleanSubmissionText(submission))
|
title: data?.title || "Lunch order submissions",
|
||||||
.filter(Boolean)
|
creatorEmail: data?.creator_email || null,
|
||||||
.join("\n");
|
participantUrl: `${window.location.origin}/order/${orderId}`,
|
||||||
const ccRecipients = Array.from(
|
config: data?.config || { categories: [] },
|
||||||
new Set(
|
submissions: rowsToCopy,
|
||||||
rowsToCopy
|
};
|
||||||
.map((submission: Submission) => String(submission.email || "").trim())
|
const templates = data?.templates || DEFAULT_ORDER_TEMPLATES;
|
||||||
.filter(Boolean),
|
const clipboardTemplate = templates.clipboard.enabled
|
||||||
),
|
? templates.clipboard
|
||||||
);
|
: DEFAULT_ORDER_TEMPLATES.clipboard;
|
||||||
const emailBody = rowsToCopy
|
const emailTemplate = templates.email.enabled ? templates.email : DEFAULT_ORDER_TEMPLATES.email;
|
||||||
.map((submission: Submission) => getCleanSubmissionText(submission))
|
const hasEntries = hasRenderableEntries(templateContext);
|
||||||
.filter(Boolean)
|
const copyText = hasEntries
|
||||||
.map((line) => `- ${line}`)
|
? renderTemplateText(clipboardTemplate.body, clipboardTemplate, templateContext)
|
||||||
.join("\n");
|
: "";
|
||||||
let mailtoHref = "";
|
const mailtoHref = hasEntries ? buildMailtoHref(renderEmail(emailTemplate, templateContext)) : "";
|
||||||
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 totalEstimatedValue = rowsToCopy.reduce((sum: number, submission: any) => {
|
const totalEstimatedValue = rowsToCopy.reduce((sum: number, submission: any) => {
|
||||||
const value = getEstimatedTotalNumber(submission.estimated_total);
|
const value = getEstimatedTotalNumber(submission.estimated_total);
|
||||||
@@ -428,6 +441,9 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
order={data}
|
order={data}
|
||||||
config={data?.config}
|
config={data?.config}
|
||||||
onImportConfig={handleImportConfig}
|
onImportConfig={handleImportConfig}
|
||||||
|
templates={data?.templates}
|
||||||
|
templatePreviewContext={templateContext}
|
||||||
|
onSaveTemplates={handleSaveTemplates}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminControlCenterCard
|
<AdminControlCenterCard
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import type { UploadFile, UploadProps } from "antd";
|
|||||||
import { apiService } from "../lib/services";
|
import { apiService } from "../lib/services";
|
||||||
import { navigateTo } from "../lib/routing";
|
import { navigateTo } from "../lib/routing";
|
||||||
import { ORDER_FORMAT_VALUE_PLACEHOLDER } from "../lib/orderFormatting";
|
import { ORDER_FORMAT_VALUE_PLACEHOLDER } from "../lib/orderFormatting";
|
||||||
import type { OrderFormConfig } from "../lib/types";
|
import type { OrderFormConfig, OrderTemplates } from "../lib/types";
|
||||||
|
import { normalizeOrderTemplates } from "../lib/orderTemplating";
|
||||||
import OrderFormConfigBuilder from "../components/forms/OrderFormConfigBuilder";
|
import OrderFormConfigBuilder from "../components/forms/OrderFormConfigBuilder";
|
||||||
import CreateOrderHeaderActions from "../components/views/CreateOrderHeaderActions";
|
import CreateOrderHeaderActions from "../components/views/CreateOrderHeaderActions";
|
||||||
import ExportSelectionModal, { type ExportSelectionState } from "../components/modals/ExportSelectionModal";
|
import ExportSelectionModal, { type ExportSelectionState } from "../components/modals/ExportSelectionModal";
|
||||||
@@ -129,11 +130,14 @@ export default function CreateOrderView() {
|
|||||||
const [activeTabKey, setActiveTabKey] = useState<string>("details");
|
const [activeTabKey, setActiveTabKey] = useState<string>("details");
|
||||||
const [imageFileList, setImageFileList] = useState<UploadFile[]>([]);
|
const [imageFileList, setImageFileList] = useState<UploadFile[]>([]);
|
||||||
const [formConfig, setFormConfig] = useState<OrderFormConfig>(DEFAULT_CONFIG);
|
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 [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||||
const [exportOptions, setExportOptions] = useState<ExportSelectionState>({
|
const [exportOptions, setExportOptions] = useState<ExportSelectionState>({
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
menu: true,
|
menu: true,
|
||||||
|
templates: true,
|
||||||
});
|
});
|
||||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
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([]);
|
setImageFileList([]);
|
||||||
setFormConfig(DEFAULT_CONFIG);
|
setFormConfig(DEFAULT_CONFIG);
|
||||||
|
setImportedTemplates(null);
|
||||||
|
|
||||||
navigateTo(`/order/${order.id}/admin`);
|
navigateTo(`/order/${order.id}/admin`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -274,6 +289,7 @@ export default function CreateOrderView() {
|
|||||||
title?: unknown;
|
title?: unknown;
|
||||||
description?: unknown;
|
description?: unknown;
|
||||||
menu?: unknown;
|
menu?: unknown;
|
||||||
|
templates?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const importedSections: string[] = [];
|
const importedSections: string[] = [];
|
||||||
@@ -302,6 +318,11 @@ export default function CreateOrderView() {
|
|||||||
importedSections.push("menu");
|
importedSections.push("menu");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(parsed, "templates")) {
|
||||||
|
setImportedTemplates(normalizeOrderTemplates(parsed.templates));
|
||||||
|
importedSections.push("templates");
|
||||||
|
}
|
||||||
|
|
||||||
if (importedSections.length === 0) {
|
if (importedSections.length === 0) {
|
||||||
message.warning("No supported sections found in import file");
|
message.warning("No supported sections found in import file");
|
||||||
return;
|
return;
|
||||||
@@ -325,6 +346,7 @@ export default function CreateOrderView() {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
menu?: OrderFormConfig;
|
menu?: OrderFormConfig;
|
||||||
|
templates?: OrderTemplates;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (exportOptions.title) {
|
if (exportOptions.title) {
|
||||||
@@ -339,6 +361,10 @@ export default function CreateOrderView() {
|
|||||||
payload.menu = formConfig;
|
payload.menu = formConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (importedTemplates && exportOptions.templates) {
|
||||||
|
payload.templates = importedTemplates;
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) {
|
if (Object.keys(payload).length === 0) {
|
||||||
message.warning("Select at least one section to export");
|
message.warning("Select at least one section to export");
|
||||||
return;
|
return;
|
||||||
@@ -484,6 +510,7 @@ export default function CreateOrderView() {
|
|||||||
{ key: "title", label: "Title" },
|
{ key: "title", label: "Title" },
|
||||||
{ key: "description", label: "Description" },
|
{ key: "description", label: "Description" },
|
||||||
{ key: "menu", label: "Menu" },
|
{ key: "menu", label: "Menu" },
|
||||||
|
...(importedTemplates ? [{ key: "templates", label: "Templates" }] : []),
|
||||||
]}
|
]}
|
||||||
selected={exportOptions}
|
selected={exportOptions}
|
||||||
onCancel={() => setIsExportModalOpen(false)}
|
onCancel={() => setIsExportModalOpen(false)}
|
||||||
|
|||||||
Reference in new issue
Block a user