Payment details integration
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m57s
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m57s
This commit is contained in:
1 parent
f2cbc07738
commit
9e25c6da04
14 files changed
+1069
-148
No files matched your search
@@ -37,11 +37,14 @@ from .services.order_file_service import (
|
||||
delete_order_config,
|
||||
delete_order_description,
|
||||
delete_order_images,
|
||||
delete_order_payment,
|
||||
delete_order_templates,
|
||||
load_order_config,
|
||||
load_order_payment,
|
||||
load_order_templates,
|
||||
save_order_config,
|
||||
save_order_description,
|
||||
save_order_payment,
|
||||
save_order_templates,
|
||||
save_uploaded_image,
|
||||
)
|
||||
@@ -128,6 +131,12 @@ class OrderTemplates(BaseModel):
|
||||
print: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OrderPaymentDetails(BaseModel):
|
||||
"""Payment methods shown to participants (validated by the frontend)."""
|
||||
model_config = {"extra": "allow"}
|
||||
methods: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserProfileUpdateRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
|
||||
@@ -783,6 +792,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_payment(admin_token)
|
||||
delete_order_images(admin_token, order["image_url"])
|
||||
|
||||
return {"deleted": True}
|
||||
@@ -1236,3 +1246,29 @@ def admin_update_order_templates(
|
||||
save_order_templates(admin_token, templates_dict)
|
||||
|
||||
return templates_dict
|
||||
|
||||
|
||||
@app.get("/api/orders/{order_id}/payment")
|
||||
def get_order_payment(order_id: str) -> dict[str, Any]:
|
||||
"""Get order payment details for participants (public endpoint)."""
|
||||
order = ensure_order_exists(order_id)
|
||||
return load_order_payment(order["admin_token"])
|
||||
|
||||
|
||||
@app.put("/api/orders/{order_id}/admin/payment")
|
||||
def admin_update_order_payment(
|
||||
order_id: str,
|
||||
payload: OrderPaymentDetails,
|
||||
user_id: str = Depends(get_existing_user_id),
|
||||
) -> dict[str, Any]:
|
||||
"""Update order payment details."""
|
||||
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"]
|
||||
|
||||
payment_dict = payload.model_dump()
|
||||
save_order_payment(admin_token, payment_dict)
|
||||
|
||||
return payment_dict
|
||||
@@ -11,11 +11,13 @@ IMAGES_DIR = BASE_DIR / "data" / "images"
|
||||
CONFIGS_DIR = BASE_DIR / "data" / "configs"
|
||||
DESCRIPTIONS_DIR = BASE_DIR / "data" / "descriptions"
|
||||
TEMPLATES_DIR = BASE_DIR / "data" / "templates"
|
||||
PAYMENTS_DIR = BASE_DIR / "data" / "payments"
|
||||
|
||||
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)
|
||||
PAYMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load_order_config(admin_token: str) -> dict[str, Any]:
|
||||
@@ -111,6 +113,37 @@ def delete_order_templates(admin_token: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def load_order_payment(admin_token: str) -> dict[str, Any]:
|
||||
payment_path = PAYMENTS_DIR / f"{admin_token}.json"
|
||||
if not payment_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with payment_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_payment(admin_token: str, payment: dict[str, Any]) -> None:
|
||||
payment_path = PAYMENTS_DIR / f"{admin_token}.json"
|
||||
try:
|
||||
with payment_path.open("w", encoding="utf-8") as file:
|
||||
json.dump(payment, file, indent=2, ensure_ascii=False)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save payment details: {str(exc)}")
|
||||
|
||||
|
||||
def delete_order_payment(admin_token: str) -> None:
|
||||
payment_path = PAYMENTS_DIR / f"{admin_token}.json"
|
||||
try:
|
||||
if payment_path.exists():
|
||||
payment_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,12 +3,13 @@ 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";
|
||||
import type { OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
||||
|
||||
type MenuConfigImportExportProps = {
|
||||
config?: OrderFormConfig | null;
|
||||
description?: string;
|
||||
templates?: OrderTemplates | null;
|
||||
payment?: OrderPaymentDetails | null;
|
||||
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||
showImport?: boolean;
|
||||
showExport?: boolean;
|
||||
@@ -28,6 +29,7 @@ export default function MenuConfigImportExport({
|
||||
config,
|
||||
description,
|
||||
templates,
|
||||
payment,
|
||||
onImportConfig,
|
||||
showImport = true,
|
||||
showExport = true,
|
||||
@@ -41,6 +43,7 @@ export default function MenuConfigImportExport({
|
||||
menu: true,
|
||||
description: true,
|
||||
templates: true,
|
||||
payment: true,
|
||||
});
|
||||
|
||||
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -90,7 +93,14 @@ export default function MenuConfigImportExport({
|
||||
|
||||
const handleExportJson = () => {
|
||||
const exportTemplates = !!templates && exportSelection.templates;
|
||||
if (!exportSelection.title && !exportSelection.menu && !exportSelection.description && !exportTemplates) {
|
||||
const exportPayment = !!payment && exportSelection.payment;
|
||||
if (
|
||||
!exportSelection.title &&
|
||||
!exportSelection.menu &&
|
||||
!exportSelection.description &&
|
||||
!exportTemplates &&
|
||||
!exportPayment
|
||||
) {
|
||||
message.warning("Select at least one section to export");
|
||||
return;
|
||||
}
|
||||
@@ -100,6 +110,7 @@ export default function MenuConfigImportExport({
|
||||
description?: string;
|
||||
menu?: OrderFormConfig;
|
||||
templates?: OrderTemplates;
|
||||
payment?: OrderPaymentDetails;
|
||||
} = {};
|
||||
|
||||
if (exportSelection.title) {
|
||||
@@ -118,6 +129,10 @@ export default function MenuConfigImportExport({
|
||||
payload.templates = templates;
|
||||
}
|
||||
|
||||
if (exportPayment) {
|
||||
payload.payment = payment;
|
||||
}
|
||||
|
||||
const safeBase = String(fileNameBase || "order")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
@@ -176,6 +191,7 @@ export default function MenuConfigImportExport({
|
||||
{ key: "menu", label: "Menu" },
|
||||
{ key: "description", label: "Description" },
|
||||
...(templates ? [{ key: "templates", label: "Templates" }] : []),
|
||||
...(payment ? [{ key: "payment", label: "Payment details" }] : []),
|
||||
]}
|
||||
selected={exportSelection}
|
||||
onCancel={() => setIsExportModalOpen(false)}
|
||||
|
||||
@@ -114,7 +114,7 @@ function PlaceholderHelp({ items }: { items: Array<{ key: string; description: s
|
||||
);
|
||||
}
|
||||
|
||||
function LabelWithPlaceholders({
|
||||
export function LabelWithPlaceholders({
|
||||
label,
|
||||
items,
|
||||
}: {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
DeleteOutlined,
|
||||
DownOutlined,
|
||||
PlusOutlined,
|
||||
SaveOutlined,
|
||||
UndoOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Alert, Button, Col, Collapse, Dropdown, Empty, Flex, Form, Input, Row, Space, Typography } from "antd";
|
||||
import { LabelWithPlaceholders } from "./OrderTemplatesEditor";
|
||||
import { PaymentDisclaimer, PaymentMethodDetails } from "../orders/PaymentDetailsCard";
|
||||
import {
|
||||
PAYMENT_PROVIDER_LABELS,
|
||||
PAYMENT_REFERENCE_PLACEHOLDERS,
|
||||
createPaymentMethod,
|
||||
getPaymentMethodTitle,
|
||||
isValidIban,
|
||||
type PaymentContext,
|
||||
} from "../../lib/paymentDetails";
|
||||
import type { OrderPaymentDetails, PaymentMethod, PaymentProviderType } from "../../lib/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// Previews use a sample participant so the QR code shows a realistic pre-filled amount.
|
||||
const SAMPLE_AMOUNT = 12.5;
|
||||
const SAMPLE_EMAIL = "jane.doe@example.com";
|
||||
|
||||
function MethodFields({
|
||||
method,
|
||||
onChange,
|
||||
}: {
|
||||
method: PaymentMethod;
|
||||
onChange: (changes: Partial<PaymentMethod>) => void;
|
||||
}) {
|
||||
const ibanInvalid = !!method.iban.trim() && !isValidIban(method.iban);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Title" required={method.type === "custom"}>
|
||||
<Input
|
||||
value={method.title}
|
||||
placeholder={PAYMENT_PROVIDER_LABELS[method.type]}
|
||||
onChange={(event) => onChange({ title: event.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{method.type === "sepa" && (
|
||||
<>
|
||||
<Form.Item label="Account holder" required>
|
||||
<Input value={method.recipient} onChange={(event) => onChange({ recipient: event.target.value })} />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} md={16}>
|
||||
<Form.Item
|
||||
label="IBAN"
|
||||
required
|
||||
validateStatus={ibanInvalid ? "warning" : undefined}
|
||||
help={ibanInvalid ? "This IBAN doesn't look valid" : undefined}
|
||||
>
|
||||
<Input value={method.iban} onChange={(event) => onChange({ iban: event.target.value })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="BIC (optional)">
|
||||
<Input value={method.bic} onChange={(event) => onChange({ bic: event.target.value })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{method.type === "paypal" && (
|
||||
<Form.Item label="PayPal.me username" required>
|
||||
<Input
|
||||
addonBefore="paypal.me/"
|
||||
value={method.username}
|
||||
onChange={(event) => onChange({ username: event.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<LabelWithPlaceholders
|
||||
label={method.type === "paypal" ? "Note" : "Reference"}
|
||||
items={PAYMENT_REFERENCE_PLACEHOLDERS}
|
||||
/>
|
||||
}
|
||||
extra={method.type === "sepa" ? "Pre-filled as remittance information (max. 140 characters)." : undefined}
|
||||
>
|
||||
<Input
|
||||
value={method.reference}
|
||||
placeholder="Order {date}"
|
||||
onChange={(event) => onChange({ reference: event.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Description" required={method.type === "custom"}>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 3, maxRows: 12 }}
|
||||
value={method.description}
|
||||
placeholder="You can use markdown to format the description."
|
||||
onChange={(event) => onChange({ description: event.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaymentDetailsEditor({
|
||||
payment,
|
||||
previewContext,
|
||||
onSave,
|
||||
}: {
|
||||
payment: OrderPaymentDetails;
|
||||
previewContext: Omit<PaymentContext, "amount" | "email">;
|
||||
onSave: (payment: OrderPaymentDetails) => Promise<void>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<OrderPaymentDetails>(payment);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activePanels, setActivePanels] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(payment);
|
||||
}, [payment]);
|
||||
|
||||
const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(payment), [draft, payment]);
|
||||
const sampleContext: PaymentContext = { ...previewContext, email: SAMPLE_EMAIL, amount: SAMPLE_AMOUNT };
|
||||
|
||||
const setMethods = (update: (methods: PaymentMethod[]) => PaymentMethod[]) =>
|
||||
setDraft((previous) => ({ ...previous, methods: update(previous.methods) }));
|
||||
const updateMethod = (id: string, changes: Partial<PaymentMethod>) =>
|
||||
setMethods((methods) => methods.map((method) => (method.id === id ? { ...method, ...changes } : method)));
|
||||
const moveMethod = (index: number, offset: number) =>
|
||||
setMethods((methods) => {
|
||||
const next = [...methods];
|
||||
const [moved] = next.splice(index, 1);
|
||||
next.splice(index + offset, 0, moved);
|
||||
return next;
|
||||
});
|
||||
const addMethod = (type: PaymentProviderType) => {
|
||||
const method = createPaymentMethod(type);
|
||||
setMethods((methods) => [...methods, method]);
|
||||
setActivePanels((previous) => [...previous, method.id]);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(draft);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form layout="vertical">
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="Payment methods are shown to participants next to the order description. SEPA and PayPal methods get a QR code pre-filled with the participant's estimated total and the reference."
|
||||
/>
|
||||
|
||||
{draft.methods.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No payment methods configured" />
|
||||
) : (
|
||||
<Collapse
|
||||
activeKey={activePanels}
|
||||
onChange={(keys) => setActivePanels(Array.isArray(keys) ? keys : [keys])}
|
||||
items={draft.methods.map((method, index) => ({
|
||||
key: method.id,
|
||||
label: (
|
||||
<Space size={8}>
|
||||
{getPaymentMethodTitle(method)}
|
||||
{method.title.trim() && method.type !== "custom" && (
|
||||
<Text type="secondary">{PAYMENT_PROVIDER_LABELS[method.type]}</Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
extra: (
|
||||
<Space size={0} onClick={(event) => event.stopPropagation()}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => moveMethod(index, -1)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ArrowDownOutlined />}
|
||||
disabled={index === draft.methods.length - 1}
|
||||
onClick={() => moveMethod(index, 1)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => setMethods((methods) => methods.filter((entry) => entry.id !== method.id))}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={14}>
|
||||
<MethodFields method={method} onChange={(changes) => updateMethod(method.id, changes)} />
|
||||
</Col>
|
||||
<Col xs={24} lg={10}>
|
||||
<Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
|
||||
Preview for a participant with an estimated total of €{SAMPLE_AMOUNT.toFixed(2)}
|
||||
</Text>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<PaymentMethodDetails method={method} context={sampleContext} />
|
||||
<PaymentDisclaimer />
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Flex justify="space-between" wrap="wrap" gap={8}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: (Object.keys(PAYMENT_PROVIDER_LABELS) as PaymentProviderType[]).map((type) => ({
|
||||
key: type,
|
||||
label: PAYMENT_PROVIDER_LABELS[type],
|
||||
})),
|
||||
onClick: ({ key }) => addMethod(key as PaymentProviderType),
|
||||
}}
|
||||
>
|
||||
<Button icon={<PlusOutlined />}>
|
||||
Add payment method <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Space>
|
||||
<Button icon={<UndoOutlined />} disabled={!isDirty || saving} onClick={() => setDraft(payment)}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} disabled={!isDirty} loading={saving} onClick={handleSave}>
|
||||
Save payment details
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Space>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Collapse, Empty, Flex, QRCode, Space, Typography, theme } from "antd";
|
||||
import { ExportOutlined, InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { markdownToHtml } from "../../lib/markdown";
|
||||
import {
|
||||
PAYMENT_ESTIMATE_DISCLAIMER,
|
||||
PAYMENT_PROVIDER_LABELS,
|
||||
buildPaymentQrValue,
|
||||
buildPaypalUrl,
|
||||
cleanIban,
|
||||
cleanPaypalUsername,
|
||||
formatAmount,
|
||||
formatIban,
|
||||
getPaymentMethodTitle,
|
||||
renderPaymentReference,
|
||||
type PaymentContext,
|
||||
} from "../../lib/paymentDetails";
|
||||
import type { OrderPaymentDetails, PaymentMethod } from "../../lib/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const hintStyle: React.CSSProperties = { fontSize: 12, lineHeight: 1.4 };
|
||||
|
||||
// Label above value, so long custom values wrap within their own block instead of around the label.
|
||||
function DetailLine({ label, value, copyValue }: { label: string; value: string; copyValue?: string }) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<Text type="secondary" style={hintStyle}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text copyable={{ text: copyValue ?? value }} style={{ overflowWrap: "anywhere" }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentDisclaimer() {
|
||||
return (
|
||||
<Text type="secondary" style={{ ...hintStyle, display: "block" }} ellipsis={{ tooltip: PAYMENT_ESTIMATE_DISCLAIMER }}>
|
||||
<InfoCircleOutlined /> {PAYMENT_ESTIMATE_DISCLAIMER}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentMethodDetails({ method, context }: { method: PaymentMethod; context: PaymentContext }) {
|
||||
const qrValue = buildPaymentQrValue(method, context);
|
||||
const reference = renderPaymentReference(method.reference, context);
|
||||
const amount = context.amount !== null && context.amount > 0 ? `€${formatAmount(context.amount)}` : "";
|
||||
const paypalUrl = method.type === "paypal" ? buildPaypalUrl(method.username, context.amount) : "";
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={12}>
|
||||
<Flex gap={16} wrap="wrap" align="flex-start" justify="center">
|
||||
{qrValue && (
|
||||
// Fixed black-on-white so banking apps can scan it in dark mode too.
|
||||
<QRCode value={qrValue} errorLevel="M" size={168} color="#000000" bgColor="#ffffff" />
|
||||
)}
|
||||
<Space direction="vertical" size={8} style={{ flex: "1 1 180px", minWidth: 0 }}>
|
||||
{method.type === "sepa" && (
|
||||
<>
|
||||
<DetailLine label="Recipient" value={method.recipient.trim()} />
|
||||
<DetailLine label="IBAN" value={formatIban(method.iban)} copyValue={cleanIban(method.iban)} />
|
||||
<DetailLine label="BIC" value={method.bic.trim()} />
|
||||
</>
|
||||
)}
|
||||
{method.type === "paypal" && (
|
||||
<DetailLine label="PayPal" value={cleanPaypalUsername(method.username)} copyValue={paypalUrl} />
|
||||
)}
|
||||
<DetailLine label="Amount" value={amount} copyValue={formatAmount(context.amount)} />
|
||||
<DetailLine label={method.type === "paypal" ? "Note" : "Reference"} value={reference} />
|
||||
{method.type === "sepa" && qrValue && (
|
||||
<Text type="secondary" style={hintStyle}>
|
||||
Scan the code with your banking app to pre-fill the transfer.
|
||||
</Text>
|
||||
)}
|
||||
{paypalUrl && (
|
||||
<Space direction="vertical" size={4}>
|
||||
{reference && (
|
||||
<Text type="secondary" style={hintStyle}>
|
||||
PayPal links can't carry a note, please add the note above manually.
|
||||
</Text>
|
||||
)}
|
||||
<Button size="small" icon={<ExportOutlined />} href={paypalUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open PayPal
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
{!!method.description.trim() && (
|
||||
<Typography.Paragraph style={{ marginBottom: 0, overflowWrap: "anywhere" }}>
|
||||
<span dangerouslySetInnerHTML={{ __html: markdownToHtml(method.description) }} />
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom titles can be long: keep the header to one ellipsized line plus the provider underneath.
|
||||
function PaymentMethodLabel({ method }: { method: PaymentMethod }) {
|
||||
const title = getPaymentMethodTitle(method);
|
||||
const showProvider = !!method.title.trim() && method.type !== "custom";
|
||||
|
||||
return (
|
||||
<Flex vertical style={{ minWidth: 0 }}>
|
||||
<Text strong ellipsis={{ tooltip: title }}>
|
||||
{title}
|
||||
</Text>
|
||||
{showProvider && (
|
||||
<Text type="secondary" style={hintStyle} ellipsis>
|
||||
{PAYMENT_PROVIDER_LABELS[method.type]}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
// "embedded" drops the accordion's own frame so it can share the border of a padding-less card.
|
||||
export function PaymentMethodsAccordion({
|
||||
methods,
|
||||
context,
|
||||
embedded = false,
|
||||
}: {
|
||||
methods: PaymentMethod[];
|
||||
context: PaymentContext;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const { token } = theme.useToken();
|
||||
const accordion = (
|
||||
<Collapse
|
||||
accordion
|
||||
bordered={!embedded}
|
||||
style={embedded ? { background: "transparent", borderRadius: 0 } : undefined}
|
||||
defaultActiveKey={methods[0]?.id}
|
||||
items={methods.map((method) => ({
|
||||
key: method.id,
|
||||
label: <PaymentMethodLabel method={method} />,
|
||||
children: <PaymentMethodDetails method={method} context={context} />,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<>
|
||||
{accordion}
|
||||
<div style={{ padding: "8px 16px", borderTop: `1px solid ${token.colorBorder}` }}>
|
||||
<PaymentDisclaimer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
{accordion}
|
||||
<PaymentDisclaimer />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaymentDetailsCard({
|
||||
payment,
|
||||
context,
|
||||
style,
|
||||
}: {
|
||||
payment: OrderPaymentDetails;
|
||||
context: PaymentContext;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<Card title="Payment" style={style} styles={{ body: payment.methods.length > 0 ? { padding: 0 } : undefined }}>
|
||||
{payment.methods.length > 0 ? (
|
||||
<PaymentMethodsAccordion methods={payment.methods} context={context} embedded />
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No payment details provided" />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import React from "react";
|
||||
import { Card, Col, Empty, Flex, Image, Row, Space, Tabs, Typography } from "antd";
|
||||
import type { TabsProps } from "antd";
|
||||
import {
|
||||
FileTextOutlined,
|
||||
SnippetsOutlined,
|
||||
UnorderedListOutlined,
|
||||
WalletOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { markdownToHtml } from "../../lib/markdown";
|
||||
import MenuConfigImportExport from "../forms/MenuConfigImportExport";
|
||||
import OrderFormConfigBuilder, {
|
||||
OrderFormConfig,
|
||||
} from "../forms/OrderFormConfigBuilder";
|
||||
import OrderTemplatesEditor from "../forms/OrderTemplatesEditor";
|
||||
import PaymentDetailsEditor from "../forms/PaymentDetailsEditor";
|
||||
import type { TemplateContext } from "../../lib/orderTemplating";
|
||||
import type { OrderTemplates } from "../../lib/types";
|
||||
import type { OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
||||
|
||||
type Order = {
|
||||
id?: string;
|
||||
@@ -51,6 +59,12 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
templates,
|
||||
templatePreviewContext,
|
||||
onSaveTemplates,
|
||||
payment,
|
||||
onSavePayment,
|
||||
extraTabs = [],
|
||||
activeTabKey,
|
||||
onTabChange,
|
||||
id,
|
||||
}: {
|
||||
order: Order | null;
|
||||
config?: OrderFormConfig | null;
|
||||
@@ -58,6 +72,12 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
templates?: OrderTemplates | null;
|
||||
templatePreviewContext?: TemplateContext | null;
|
||||
onSaveTemplates?: (templates: OrderTemplates) => Promise<void>;
|
||||
payment?: OrderPaymentDetails | null;
|
||||
onSavePayment?: (payment: OrderPaymentDetails) => Promise<void>;
|
||||
extraTabs?: NonNullable<TabsProps["items"]>;
|
||||
activeTabKey?: string;
|
||||
onTabChange?: (key: string) => void;
|
||||
id?: string;
|
||||
}) {
|
||||
if (!order) {
|
||||
return null;
|
||||
@@ -73,6 +93,7 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
|
||||
return (
|
||||
<Card
|
||||
id={id}
|
||||
title={
|
||||
<Flex vertical gap={0}>
|
||||
{order.title}
|
||||
@@ -87,6 +108,7 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
config={currentConfig}
|
||||
description={currentDescription}
|
||||
templates={templates}
|
||||
payment={payment}
|
||||
showImport={false}
|
||||
onImportConfig={onImportConfig}
|
||||
fileNameBase={order.title || "order"}
|
||||
@@ -95,10 +117,13 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
>
|
||||
<Tabs
|
||||
defaultActiveKey="description"
|
||||
activeKey={activeTabKey}
|
||||
onChange={onTabChange}
|
||||
items={[
|
||||
{
|
||||
key: "description",
|
||||
label: "Description",
|
||||
icon: <FileTextOutlined />,
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={order.image_url ? 14 : 24}>
|
||||
@@ -147,6 +172,7 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
{
|
||||
key: "menu",
|
||||
label: "Menu",
|
||||
icon: <UnorderedListOutlined />,
|
||||
children: (
|
||||
<OrderFormConfigBuilder
|
||||
editable={false}
|
||||
@@ -163,6 +189,7 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
{
|
||||
key: "templates",
|
||||
label: "Templates",
|
||||
icon: <SnippetsOutlined />,
|
||||
children: (
|
||||
<OrderTemplatesEditor
|
||||
templates={templates}
|
||||
@@ -174,6 +201,23 @@ export default function ReadOnlyOrderOverviewCard({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(payment && onSavePayment
|
||||
? [
|
||||
{
|
||||
key: "payment",
|
||||
label: "Payment",
|
||||
icon: <WalletOutlined />,
|
||||
children: (
|
||||
<PaymentDetailsEditor
|
||||
payment={payment}
|
||||
previewContext={{ title: order.title || "", orderDate: order.created_at || null }}
|
||||
onSave={onSavePayment}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...extraTabs,
|
||||
]}
|
||||
></Tabs>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
mediaQuery.addEventListener("change", onChange);
|
||||
return () => mediaQuery.removeEventListener("change", onChange);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
|
||||
return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { OrderPaymentDetails, PaymentMethod, PaymentProviderType } from "./types";
|
||||
|
||||
export const PAYMENT_PROVIDER_LABELS: Record<PaymentProviderType, string> = {
|
||||
sepa: "SEPA bank transfer",
|
||||
paypal: "PayPal",
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
export const DEFAULT_PAYMENT_REFERENCE = "{title} {date}";
|
||||
|
||||
export const PAYMENT_REFERENCE_PLACEHOLDERS = [
|
||||
{ key: "{title}", description: "Order title" },
|
||||
{ key: "{date}", description: "Date the order was created" },
|
||||
{ key: "{email}", description: "Participant's email (empty if unknown)" },
|
||||
{ key: "{total}", description: "Participant's estimated total, e.g. 12.50" },
|
||||
];
|
||||
|
||||
export const PAYMENT_ESTIMATE_DISCLAIMER =
|
||||
"All amounts are non-binding estimates based on the listed menu prices. Errors and omissions excepted; " +
|
||||
"the final amount may differ and remains subject to change, e.g. due to pricing errors, substitutions or fees.";
|
||||
|
||||
// EPC QR codes (GiroCode) limit these fields; longer values make the code unreadable for banking apps.
|
||||
const EPC_NAME_MAX = 70;
|
||||
const EPC_REMITTANCE_MAX = 140;
|
||||
|
||||
export type PaymentContext = {
|
||||
title: string;
|
||||
orderDate: string | null;
|
||||
email: string | null;
|
||||
amount: number | null;
|
||||
};
|
||||
|
||||
const PROVIDER_TYPES = Object.keys(PAYMENT_PROVIDER_LABELS) as PaymentProviderType[];
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function createPaymentMethod(type: PaymentProviderType): PaymentMethod {
|
||||
return {
|
||||
id: `${type}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
type,
|
||||
title: "",
|
||||
recipient: "",
|
||||
iban: "",
|
||||
bic: "",
|
||||
username: "",
|
||||
reference: type === "custom" ? "" : DEFAULT_PAYMENT_REFERENCE,
|
||||
description: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePaymentDetails(raw: unknown): OrderPaymentDetails {
|
||||
const rawMethods = (raw as { methods?: unknown } | null)?.methods;
|
||||
const methods = (Array.isArray(rawMethods) ? rawMethods : [])
|
||||
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === "object")
|
||||
.filter((entry) => PROVIDER_TYPES.includes(entry.type as PaymentProviderType))
|
||||
.map((entry) => {
|
||||
const base = createPaymentMethod(entry.type as PaymentProviderType);
|
||||
return {
|
||||
...base,
|
||||
id: asString(entry.id) || base.id,
|
||||
title: asString(entry.title),
|
||||
recipient: asString(entry.recipient),
|
||||
iban: asString(entry.iban),
|
||||
bic: asString(entry.bic),
|
||||
username: asString(entry.username),
|
||||
reference: typeof entry.reference === "string" ? entry.reference : base.reference,
|
||||
description: asString(entry.description),
|
||||
};
|
||||
});
|
||||
|
||||
return { methods };
|
||||
}
|
||||
|
||||
export function getPaymentMethodTitle(method: PaymentMethod): string {
|
||||
return method.title.trim() || PAYMENT_PROVIDER_LABELS[method.type];
|
||||
}
|
||||
|
||||
export function formatAmount(amount: number | null): string {
|
||||
return amount === null ? "" : amount.toFixed(2);
|
||||
}
|
||||
|
||||
function formatOrderDate(value: string | null): string {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function renderPaymentReference(template: string, context: PaymentContext): string {
|
||||
return template
|
||||
.replace(/\{([a-z_]+)\}/gi, (match, key: string) => {
|
||||
switch (key.toLowerCase()) {
|
||||
case "title": return context.title;
|
||||
case "date": return formatOrderDate(context.orderDate);
|
||||
case "email": return context.email || "";
|
||||
case "total": return formatAmount(context.amount);
|
||||
default: return match;
|
||||
}
|
||||
})
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function cleanIban(iban: string): string {
|
||||
return iban.replace(/\s+/g, "").toUpperCase();
|
||||
}
|
||||
|
||||
export function formatIban(iban: string): string {
|
||||
return cleanIban(iban).replace(/(.{4})/g, "$1 ").trim();
|
||||
}
|
||||
|
||||
export function isValidIban(iban: string): boolean {
|
||||
const clean = cleanIban(iban);
|
||||
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(clean)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ISO 13616 mod-97 check, computed in chunks to stay within number precision.
|
||||
const rearranged = clean.slice(4) + clean.slice(0, 4);
|
||||
const digits = rearranged.replace(/[A-Z]/g, (letter) => String(letter.charCodeAt(0) - 55));
|
||||
let remainder = 0;
|
||||
for (let i = 0; i < digits.length; i += 7) {
|
||||
remainder = Number(String(remainder) + digits.slice(i, i + 7)) % 97;
|
||||
}
|
||||
return remainder === 1;
|
||||
}
|
||||
|
||||
export function cleanPaypalUsername(username: string): string {
|
||||
return username
|
||||
.trim()
|
||||
.replace(/^https?:\/\//i, "")
|
||||
.replace(/^(www\.)?(paypal\.me|paypal\.com\/paypalme)\//i, "")
|
||||
.replace(/^@/, "")
|
||||
.replace(/\/.*$/, "");
|
||||
}
|
||||
|
||||
export function buildPaypalUrl(username: string, amount: number | null): string {
|
||||
const clean = cleanPaypalUsername(username);
|
||||
if (!clean) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const amountPart = amount !== null && amount > 0 ? `/${formatAmount(amount)}EUR` : "";
|
||||
return `https://paypal.me/${encodeURIComponent(clean)}${amountPart}`;
|
||||
}
|
||||
|
||||
// EPC069-12 "BCD" payload, understood by most European banking apps (GiroCode / SEPA QR).
|
||||
export function buildEpcPayload(method: PaymentMethod, reference: string, amount: number | null): string {
|
||||
const iban = cleanIban(method.iban);
|
||||
const recipient = method.recipient.trim();
|
||||
if (!iban || !recipient) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"BCD",
|
||||
"002",
|
||||
"1",
|
||||
"SCT",
|
||||
method.bic.replace(/\s+/g, "").toUpperCase(),
|
||||
recipient.slice(0, EPC_NAME_MAX),
|
||||
iban,
|
||||
amount !== null && amount > 0 ? `EUR${formatAmount(amount)}` : "",
|
||||
"",
|
||||
"",
|
||||
reference.slice(0, EPC_REMITTANCE_MAX),
|
||||
];
|
||||
return lines.join("\n").replace(/\n+$/, "");
|
||||
}
|
||||
|
||||
export function buildPaymentQrValue(method: PaymentMethod, context: PaymentContext): string {
|
||||
const reference = renderPaymentReference(method.reference, context);
|
||||
switch (method.type) {
|
||||
case "sepa": return buildEpcPayload(method, reference, context.amount);
|
||||
case "paypal": return buildPaypalUrl(method.username, context.amount);
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
Order,
|
||||
OrderFormConfig,
|
||||
OrderTemplates,
|
||||
OrderPaymentDetails,
|
||||
GetAdminViewResponse,
|
||||
OrderAccessInfo,
|
||||
GetOrdersResponse,
|
||||
@@ -66,6 +67,13 @@ export const ordersService = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(templates),
|
||||
}),
|
||||
getPayment: (orderId: string) =>
|
||||
apiFetch<Partial<OrderPaymentDetails>>(`/api/orders/${orderId}/payment`),
|
||||
updatePayment: (orderId: string, payment: OrderPaymentDetails) =>
|
||||
apiFetch<OrderPaymentDetails>(`/api/orders/${orderId}/admin/payment`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payment),
|
||||
}),
|
||||
uploadImage: (orderId: string, file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
@@ -123,6 +123,27 @@ export type OrderTemplates = {
|
||||
print: PrintTemplateSettings;
|
||||
};
|
||||
|
||||
/* ==================== Payment Details ==================== */
|
||||
|
||||
export type PaymentProviderType = "sepa" | "paypal" | "custom";
|
||||
|
||||
// One flat shape for all providers; fields that don't apply to a provider are ignored.
|
||||
export type PaymentMethod = {
|
||||
id: string;
|
||||
type: PaymentProviderType;
|
||||
title: string;
|
||||
recipient: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
username: string;
|
||||
reference: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type OrderPaymentDetails = {
|
||||
methods: PaymentMethod[];
|
||||
};
|
||||
|
||||
export type OrderBase = {
|
||||
id: string;
|
||||
admin_token: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
GetAdminViewResponse,
|
||||
Order,
|
||||
OrderFormConfig,
|
||||
OrderPaymentDetails,
|
||||
OrderTemplates,
|
||||
Submission,
|
||||
SubmissionSortBy,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type PrintTable,
|
||||
type TemplateContext,
|
||||
} from "../lib/orderTemplating";
|
||||
import { normalizePaymentDetails } from "../lib/paymentDetails";
|
||||
import AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
|
||||
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
|
||||
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
||||
@@ -42,6 +44,7 @@ type AdminViewData = Order & {
|
||||
submissions: Submission[];
|
||||
config: OrderFormConfig;
|
||||
templates: OrderTemplates;
|
||||
payment: OrderPaymentDetails;
|
||||
creator_email?: string | null;
|
||||
};
|
||||
|
||||
@@ -68,10 +71,11 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
|
||||
const loadAdminData = useCallback(
|
||||
async (): Promise<AdminViewData> => {
|
||||
const [adminPayload, configPayload, templatesPayload] = await Promise.all([
|
||||
const [adminPayload, configPayload, templatesPayload, paymentPayload] = await Promise.all([
|
||||
apiService.orders.getAdminView(orderId),
|
||||
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
||||
apiService.orders.getTemplates(orderId).catch(() => null),
|
||||
apiService.orders.getPayment(orderId).catch(() => null),
|
||||
]);
|
||||
|
||||
const typedPayload = adminPayload as GetAdminViewResponse;
|
||||
@@ -88,6 +92,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
: [],
|
||||
},
|
||||
templates: normalizeOrderTemplates(templatesPayload),
|
||||
payment: normalizePaymentDetails(paymentPayload),
|
||||
};
|
||||
},
|
||||
[orderId],
|
||||
@@ -240,6 +245,18 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePayment = async (nextPayment: OrderPaymentDetails) => {
|
||||
try {
|
||||
const saved = await apiService.orders.updatePayment(orderId, nextPayment);
|
||||
setData((previous: AdminViewData | null) =>
|
||||
previous ? { ...previous, payment: normalizePaymentDetails(saved) } : previous,
|
||||
);
|
||||
message.success("Payment details saved");
|
||||
} catch (error: any) {
|
||||
message.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSubmissions = useCallback(async () => {
|
||||
try {
|
||||
const adminPayload = await apiService.orders.getAdminView(orderId);
|
||||
@@ -387,6 +404,8 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
templates={data?.templates}
|
||||
templatePreviewContext={templateContext}
|
||||
onSaveTemplates={handleSaveTemplates}
|
||||
payment={data?.payment}
|
||||
onSavePayment={handleSavePayment}
|
||||
/>
|
||||
|
||||
<AdminControlCenterCard
|
||||
|
||||
@@ -20,8 +20,9 @@ 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, OrderTemplates } from "../lib/types";
|
||||
import type { OrderFormConfig, OrderPaymentDetails, OrderTemplates } from "../lib/types";
|
||||
import { normalizeOrderTemplates } from "../lib/orderTemplating";
|
||||
import { normalizePaymentDetails } from "../lib/paymentDetails";
|
||||
import OrderFormConfigBuilder from "../components/forms/OrderFormConfigBuilder";
|
||||
import CreateOrderHeaderActions from "../components/views/CreateOrderHeaderActions";
|
||||
import ExportSelectionModal, { type ExportSelectionState } from "../components/modals/ExportSelectionModal";
|
||||
@@ -132,12 +133,14 @@ export default function CreateOrderView() {
|
||||
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 [importedPayment, setImportedPayment] = useState<OrderPaymentDetails | null>(null);
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
const [exportOptions, setExportOptions] = useState<ExportSelectionState>({
|
||||
title: true,
|
||||
description: true,
|
||||
menu: true,
|
||||
templates: true,
|
||||
payment: true,
|
||||
});
|
||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -227,9 +230,20 @@ export default function CreateOrderView() {
|
||||
}
|
||||
}
|
||||
|
||||
if (importedPayment) {
|
||||
try {
|
||||
await apiService.orders.updatePayment(order.id, importedPayment);
|
||||
} catch (error: any) {
|
||||
message.warning(
|
||||
"Order created but payment details save failed: " + error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setImageFileList([]);
|
||||
setFormConfig(DEFAULT_CONFIG);
|
||||
setImportedTemplates(null);
|
||||
setImportedPayment(null);
|
||||
|
||||
navigateTo(`/order/${order.id}/admin`);
|
||||
} catch (error: any) {
|
||||
@@ -290,6 +304,7 @@ export default function CreateOrderView() {
|
||||
description?: unknown;
|
||||
menu?: unknown;
|
||||
templates?: unknown;
|
||||
payment?: unknown;
|
||||
};
|
||||
|
||||
const importedSections: string[] = [];
|
||||
@@ -323,6 +338,11 @@ export default function CreateOrderView() {
|
||||
importedSections.push("templates");
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, "payment")) {
|
||||
setImportedPayment(normalizePaymentDetails(parsed.payment));
|
||||
importedSections.push("payment details");
|
||||
}
|
||||
|
||||
if (importedSections.length === 0) {
|
||||
message.warning("No supported sections found in import file");
|
||||
return;
|
||||
@@ -347,6 +367,7 @@ export default function CreateOrderView() {
|
||||
description?: string;
|
||||
menu?: OrderFormConfig;
|
||||
templates?: OrderTemplates;
|
||||
payment?: OrderPaymentDetails;
|
||||
} = {};
|
||||
|
||||
if (exportOptions.title) {
|
||||
@@ -365,6 +386,10 @@ export default function CreateOrderView() {
|
||||
payload.templates = importedTemplates;
|
||||
}
|
||||
|
||||
if (importedPayment && exportOptions.payment) {
|
||||
payload.payment = importedPayment;
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
message.warning("Select at least one section to export");
|
||||
return;
|
||||
@@ -511,6 +536,7 @@ export default function CreateOrderView() {
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "menu", label: "Menu" },
|
||||
...(importedTemplates ? [{ key: "templates", label: "Templates" }] : []),
|
||||
...(importedPayment ? [{ key: "payment", label: "Payment details" }] : []),
|
||||
]}
|
||||
selected={exportOptions}
|
||||
onCancel={() => setIsExportModalOpen(false)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { DeleteOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import { DeleteOutlined, SaveOutlined, WalletOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -19,6 +19,9 @@ import { navigateTo } from "../lib/routing";
|
||||
import { apiService } from "../lib/services";
|
||||
import LoadingSkeleton from "../components/utils/LoadingSkeleton";
|
||||
import ReadOnlyOrderOverviewCard from "../components/orders/ReadOnlyOrderOverviewCard";
|
||||
import PaymentDetailsCard, { PaymentMethodsAccordion } from "../components/orders/PaymentDetailsCard";
|
||||
import { normalizePaymentDetails, type PaymentContext } from "../lib/paymentDetails";
|
||||
import { useMediaQuery } from "../hooks/useMediaQuery";
|
||||
import {
|
||||
buildFormattedOrderString,
|
||||
calculateEstimatedOrderTotal,
|
||||
@@ -30,6 +33,7 @@ import type {
|
||||
OrderFormConfig,
|
||||
Order,
|
||||
OrderAccessInfo,
|
||||
OrderPaymentDetails,
|
||||
Submission,
|
||||
} from "../lib/types";
|
||||
import ParticipantCategoryFields from "../components/views/ParticipantCategoryFields";
|
||||
@@ -38,6 +42,13 @@ import ParticipantDraftSummaryAlert from "../components/views/ParticipantDraftSu
|
||||
|
||||
const { Text, Link } = Typography;
|
||||
|
||||
// The main column is 980px wide and centered; the payment sidebar only attaches
|
||||
// to its right edge when the gutter can hold it, otherwise it becomes a tab.
|
||||
const PAYMENT_SIDEBAR_WIDTH = 320;
|
||||
const PAYMENT_SIDEBAR_GAP = 16;
|
||||
const PAYMENT_SIDEBAR_QUERY = "(min-width: 1700px)";
|
||||
const OVERVIEW_CARD_ID = "order-overview";
|
||||
|
||||
type SubmissionValues = {
|
||||
choices: NormalizedOrderSelections;
|
||||
};
|
||||
@@ -79,6 +90,7 @@ function toFormValues(
|
||||
type ParticipantLoadData = {
|
||||
order: Order;
|
||||
config: OrderFormConfig;
|
||||
payment: OrderPaymentDetails;
|
||||
};
|
||||
|
||||
export default function ParticipantView({ orderId }: { orderId: string }) {
|
||||
@@ -94,12 +106,13 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
||||
|
||||
const loadParticipantData =
|
||||
useCallback(async (): Promise<ParticipantLoadData> => {
|
||||
const [order, config] = await Promise.all([
|
||||
const [order, config, payment] = await Promise.all([
|
||||
apiService.orders.get(orderId),
|
||||
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
||||
apiService.orders.getPayment(orderId).catch(() => null),
|
||||
]);
|
||||
|
||||
return { order, config };
|
||||
return { order, config, payment: normalizePaymentDetails(payment) };
|
||||
}, [orderId]);
|
||||
|
||||
const {
|
||||
@@ -167,13 +180,14 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
||||
() => buildFormattedOrderString(normalizedSavedValues, config),
|
||||
[normalizedSavedValues, config],
|
||||
);
|
||||
const savedEstimatedTotal = useMemo(
|
||||
() =>
|
||||
formatEstimatedTotal(
|
||||
calculateEstimatedOrderTotal(normalizedSavedValues, config),
|
||||
),
|
||||
const savedEstimatedAmount = useMemo(
|
||||
() => calculateEstimatedOrderTotal(normalizedSavedValues, config),
|
||||
[normalizedSavedValues, config],
|
||||
);
|
||||
const savedEstimatedTotal = formatEstimatedTotal(savedEstimatedAmount);
|
||||
const paymentMethods = data?.payment.methods || [];
|
||||
const showPaymentSidebar = useMediaQuery(PAYMENT_SIDEBAR_QUERY);
|
||||
const [overviewTab, setOverviewTab] = useState("description");
|
||||
const hasActiveChanges =
|
||||
!!existingSubmission &&
|
||||
serializeSelections(normalizedDraftValues) !==
|
||||
@@ -400,6 +414,34 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
||||
return <LoadingSkeleton rows={5} sections={2} />;
|
||||
}
|
||||
|
||||
const paymentContext: PaymentContext = {
|
||||
title: liveOrder?.title || "",
|
||||
orderDate: liveOrder?.created_at || null,
|
||||
email: existingSubmission?.email || null,
|
||||
amount: existingSubmission ? savedEstimatedAmount : null,
|
||||
};
|
||||
const hasPayment = paymentMethods.length > 0;
|
||||
const paymentTabs =
|
||||
hasPayment && !showPaymentSidebar
|
||||
? [
|
||||
{
|
||||
key: "payment",
|
||||
label: "Payment",
|
||||
icon: <WalletOutlined />,
|
||||
children: <PaymentMethodsAccordion methods={paymentMethods} context={paymentContext} />,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
// The payment tab disappears when the sidebar takes over; fall back to the description.
|
||||
const activeOverviewTab =
|
||||
overviewTab === "payment" && paymentTabs.length === 0 ? "description" : overviewTab;
|
||||
const showPaymentPrompt = hasPayment && !showPaymentSidebar && !!existingSubmission;
|
||||
|
||||
const openPaymentTab = () => {
|
||||
setOverviewTab("payment");
|
||||
document.getElementById(OVERVIEW_CARD_ID)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
const canShowSubmittedResult =
|
||||
!!existingSubmission && !isEditingSubmittedOrder;
|
||||
const showSummaryView = isClosed || canShowSubmittedResult;
|
||||
@@ -413,148 +455,192 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
||||
loadingRows={5}
|
||||
loadingSections={2}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
||||
{orderAccess?.is_owner && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={
|
||||
<Text>
|
||||
{"You're the owner of this order! "}
|
||||
<Link
|
||||
href={`/order/${orderId}/admin`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateTo(`/order/${orderId}/admin`);
|
||||
}}
|
||||
>
|
||||
Manage order
|
||||
</Link>
|
||||
</Text>
|
||||
}
|
||||
<div style={{ position: "relative" }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
||||
{orderAccess?.is_owner && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={
|
||||
<Text>
|
||||
{"You're the owner of this order! "}
|
||||
<Link
|
||||
href={`/order/${orderId}/admin`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateTo(`/order/${orderId}/admin`);
|
||||
}}
|
||||
>
|
||||
Manage order
|
||||
</Link>
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isClosed && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="This order is closed. Submissions can no longer be changed."
|
||||
/>
|
||||
)}
|
||||
|
||||
<ReadOnlyOrderOverviewCard
|
||||
id={OVERVIEW_CARD_ID}
|
||||
order={liveOrder}
|
||||
config={config}
|
||||
extraTabs={paymentTabs}
|
||||
activeTabKey={activeOverviewTab}
|
||||
onTabChange={setOverviewTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isClosed && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="This order is closed. Submissions can no longer be changed."
|
||||
/>
|
||||
)}
|
||||
|
||||
<ReadOnlyOrderOverviewCard order={liveOrder} config={config} />
|
||||
|
||||
<Card
|
||||
title="Your order"
|
||||
extra={
|
||||
existingSubmission && !isClosed ? (
|
||||
canShowSubmittedResult ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => setIsEditingSubmittedOrder(true)}
|
||||
>
|
||||
Manage order
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsEditingSubmittedOrder(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
>
|
||||
Back to Summary
|
||||
</Button>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{showSummaryView ? (
|
||||
<ParticipantSubmissionSummary
|
||||
submission={existingSubmission}
|
||||
savedOrderString={savedOrderString}
|
||||
savedEstimatedTotal={savedEstimatedTotal}
|
||||
isClosed={isClosed}
|
||||
/>
|
||||
) : (
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
{config.categories.length === 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="No form configuration available for this order."
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Card
|
||||
title="Your order"
|
||||
extra={
|
||||
existingSubmission && !isClosed ? (
|
||||
canShowSubmittedResult ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => setIsEditingSubmittedOrder(true)}
|
||||
>
|
||||
Manage order
|
||||
</Button>
|
||||
) : (
|
||||
<Row gutter={12}>
|
||||
<ParticipantCategoryFields
|
||||
categories={config.categories}
|
||||
isClosed={isClosed}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsEditingSubmittedOrder(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
>
|
||||
Back to Summary
|
||||
</Button>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{showSummaryView && (
|
||||
<ParticipantSubmissionSummary
|
||||
submission={existingSubmission}
|
||||
savedOrderString={savedOrderString}
|
||||
savedEstimatedTotal={savedEstimatedTotal}
|
||||
isClosed={isClosed}
|
||||
/>
|
||||
)}
|
||||
{showSummaryView && showPaymentPrompt && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<WalletOutlined />}
|
||||
message={
|
||||
savedEstimatedTotal
|
||||
? `Your estimated total is ${savedEstimatedTotal}`
|
||||
: "Payment details are available for this order"
|
||||
}
|
||||
action={
|
||||
<Button size="small" type="primary" onClick={openPaymentTab}>
|
||||
How to pay
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!showSummaryView && (
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
{config.categories.length === 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="No form configuration available for this order."
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
) : (
|
||||
<Row gutter={12}>
|
||||
<ParticipantCategoryFields
|
||||
categories={config.categories}
|
||||
isClosed={isClosed}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{config.categories.length > 0 && (
|
||||
<>
|
||||
<ParticipantDraftSummaryAlert
|
||||
hasExistingSubmission={!!existingSubmission}
|
||||
hasActiveChanges={hasActiveChanges}
|
||||
savedOrderString={savedOrderString}
|
||||
savedEstimatedTotal={savedEstimatedTotal}
|
||||
draftOrderString={draftOrderString}
|
||||
draftEstimatedTotal={draftEstimatedTotal}
|
||||
/>
|
||||
{config.categories.length > 0 && (
|
||||
<>
|
||||
<ParticipantDraftSummaryAlert
|
||||
hasExistingSubmission={!!existingSubmission}
|
||||
hasActiveChanges={hasActiveChanges}
|
||||
savedOrderString={savedOrderString}
|
||||
savedEstimatedTotal={savedEstimatedTotal}
|
||||
draftOrderString={draftOrderString}
|
||||
draftEstimatedTotal={draftEstimatedTotal}
|
||||
/>
|
||||
|
||||
<Space>
|
||||
<Tooltip
|
||||
title={
|
||||
existingSubmission
|
||||
? "Save updates to your submission"
|
||||
: "Submit your order"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SaveOutlined />}
|
||||
loading={submitting}
|
||||
disabled={isClosed}
|
||||
<Space>
|
||||
<Tooltip
|
||||
title={
|
||||
existingSubmission
|
||||
? "Save updates to your submission"
|
||||
: "Submit your order"
|
||||
}
|
||||
>
|
||||
{existingSubmission ? "Update" : "Submit"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SaveOutlined />}
|
||||
loading={submitting}
|
||||
disabled={isClosed}
|
||||
>
|
||||
{existingSubmission ? "Update" : "Submit"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{existingSubmission && (
|
||||
<Popconfirm
|
||||
title="Delete your current submission?"
|
||||
description="This action cannot be undone."
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={deleteSubmission}
|
||||
disabled={isClosed}
|
||||
>
|
||||
<Tooltip title="Delete your current submission">
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={submitting}
|
||||
disabled={isClosed}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
{existingSubmission && (
|
||||
<Popconfirm
|
||||
title="Delete your current submission?"
|
||||
description="This action cannot be undone."
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={deleteSubmission}
|
||||
disabled={isClosed}
|
||||
>
|
||||
<Tooltip title="Delete your current submission">
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={submitting}
|
||||
disabled={isClosed}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
{hasPayment && showPaymentSidebar && (
|
||||
<aside
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: `calc(100% + ${PAYMENT_SIDEBAR_GAP}px)`,
|
||||
width: PAYMENT_SIDEBAR_WIDTH,
|
||||
}}
|
||||
>
|
||||
<PaymentDetailsCard
|
||||
payment={{ methods: paymentMethods }}
|
||||
context={paymentContext}
|
||||
style={{ position: "sticky", top: 24, maxHeight: "calc(100vh - 48px)", overflowY: "auto" }}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</AsyncContent>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user