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_config,
|
||||||
delete_order_description,
|
delete_order_description,
|
||||||
delete_order_images,
|
delete_order_images,
|
||||||
|
delete_order_payment,
|
||||||
delete_order_templates,
|
delete_order_templates,
|
||||||
load_order_config,
|
load_order_config,
|
||||||
|
load_order_payment,
|
||||||
load_order_templates,
|
load_order_templates,
|
||||||
save_order_config,
|
save_order_config,
|
||||||
save_order_description,
|
save_order_description,
|
||||||
|
save_order_payment,
|
||||||
save_order_templates,
|
save_order_templates,
|
||||||
save_uploaded_image,
|
save_uploaded_image,
|
||||||
)
|
)
|
||||||
@@ -128,6 +131,12 @@ class OrderTemplates(BaseModel):
|
|||||||
print: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class UserProfileUpdateRequest(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
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_description(admin_token)
|
||||||
delete_order_config(admin_token)
|
delete_order_config(admin_token)
|
||||||
delete_order_templates(admin_token)
|
delete_order_templates(admin_token)
|
||||||
|
delete_order_payment(admin_token)
|
||||||
delete_order_images(admin_token, order["image_url"])
|
delete_order_images(admin_token, order["image_url"])
|
||||||
|
|
||||||
return {"deleted": True}
|
return {"deleted": True}
|
||||||
@@ -1236,3 +1246,29 @@ def admin_update_order_templates(
|
|||||||
save_order_templates(admin_token, templates_dict)
|
save_order_templates(admin_token, templates_dict)
|
||||||
|
|
||||||
return 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"
|
CONFIGS_DIR = BASE_DIR / "data" / "configs"
|
||||||
DESCRIPTIONS_DIR = BASE_DIR / "data" / "descriptions"
|
DESCRIPTIONS_DIR = BASE_DIR / "data" / "descriptions"
|
||||||
TEMPLATES_DIR = BASE_DIR / "data" / "templates"
|
TEMPLATES_DIR = BASE_DIR / "data" / "templates"
|
||||||
|
PAYMENTS_DIR = BASE_DIR / "data" / "payments"
|
||||||
|
|
||||||
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)
|
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]:
|
def load_order_config(admin_token: str) -> dict[str, Any]:
|
||||||
@@ -111,6 +113,37 @@ def delete_order_templates(admin_token: str) -> None:
|
|||||||
pass
|
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:
|
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,12 +3,13 @@ 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";
|
import type { OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
||||||
|
|
||||||
type MenuConfigImportExportProps = {
|
type MenuConfigImportExportProps = {
|
||||||
config?: OrderFormConfig | null;
|
config?: OrderFormConfig | null;
|
||||||
description?: string;
|
description?: string;
|
||||||
templates?: OrderTemplates | null;
|
templates?: OrderTemplates | null;
|
||||||
|
payment?: OrderPaymentDetails | null;
|
||||||
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
|
||||||
showImport?: boolean;
|
showImport?: boolean;
|
||||||
showExport?: boolean;
|
showExport?: boolean;
|
||||||
@@ -28,6 +29,7 @@ export default function MenuConfigImportExport({
|
|||||||
config,
|
config,
|
||||||
description,
|
description,
|
||||||
templates,
|
templates,
|
||||||
|
payment,
|
||||||
onImportConfig,
|
onImportConfig,
|
||||||
showImport = true,
|
showImport = true,
|
||||||
showExport = true,
|
showExport = true,
|
||||||
@@ -41,6 +43,7 @@ export default function MenuConfigImportExport({
|
|||||||
menu: true,
|
menu: true,
|
||||||
description: true,
|
description: true,
|
||||||
templates: true,
|
templates: true,
|
||||||
|
payment: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImportJson = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -90,7 +93,14 @@ export default function MenuConfigImportExport({
|
|||||||
|
|
||||||
const handleExportJson = () => {
|
const handleExportJson = () => {
|
||||||
const exportTemplates = !!templates && exportSelection.templates;
|
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");
|
message.warning("Select at least one section to export");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,6 +110,7 @@ export default function MenuConfigImportExport({
|
|||||||
description?: string;
|
description?: string;
|
||||||
menu?: OrderFormConfig;
|
menu?: OrderFormConfig;
|
||||||
templates?: OrderTemplates;
|
templates?: OrderTemplates;
|
||||||
|
payment?: OrderPaymentDetails;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (exportSelection.title) {
|
if (exportSelection.title) {
|
||||||
@@ -118,6 +129,10 @@ export default function MenuConfigImportExport({
|
|||||||
payload.templates = templates;
|
payload.templates = templates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exportPayment) {
|
||||||
|
payload.payment = payment;
|
||||||
|
}
|
||||||
|
|
||||||
const safeBase = String(fileNameBase || "order")
|
const safeBase = String(fileNameBase || "order")
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, "-")
|
.replace(/\s+/g, "-")
|
||||||
@@ -176,6 +191,7 @@ export default function MenuConfigImportExport({
|
|||||||
{ key: "menu", label: "Menu" },
|
{ key: "menu", label: "Menu" },
|
||||||
{ key: "description", label: "Description" },
|
{ key: "description", label: "Description" },
|
||||||
...(templates ? [{ key: "templates", label: "Templates" }] : []),
|
...(templates ? [{ key: "templates", label: "Templates" }] : []),
|
||||||
|
...(payment ? [{ key: "payment", label: "Payment details" }] : []),
|
||||||
]}
|
]}
|
||||||
selected={exportSelection}
|
selected={exportSelection}
|
||||||
onCancel={() => setIsExportModalOpen(false)}
|
onCancel={() => setIsExportModalOpen(false)}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function PlaceholderHelp({ items }: { items: Array<{ key: string; description: s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LabelWithPlaceholders({
|
export function LabelWithPlaceholders({
|
||||||
label,
|
label,
|
||||||
items,
|
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 React from "react";
|
||||||
import { Card, Col, Empty, Flex, Image, Row, Space, Tabs, Typography } from "antd";
|
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 { markdownToHtml } from "../../lib/markdown";
|
||||||
import MenuConfigImportExport from "../forms/MenuConfigImportExport";
|
import MenuConfigImportExport from "../forms/MenuConfigImportExport";
|
||||||
import OrderFormConfigBuilder, {
|
import OrderFormConfigBuilder, {
|
||||||
OrderFormConfig,
|
OrderFormConfig,
|
||||||
} from "../forms/OrderFormConfigBuilder";
|
} from "../forms/OrderFormConfigBuilder";
|
||||||
import OrderTemplatesEditor from "../forms/OrderTemplatesEditor";
|
import OrderTemplatesEditor from "../forms/OrderTemplatesEditor";
|
||||||
|
import PaymentDetailsEditor from "../forms/PaymentDetailsEditor";
|
||||||
import type { TemplateContext } from "../../lib/orderTemplating";
|
import type { TemplateContext } from "../../lib/orderTemplating";
|
||||||
import type { OrderTemplates } from "../../lib/types";
|
import type { OrderPaymentDetails, OrderTemplates } from "../../lib/types";
|
||||||
|
|
||||||
type Order = {
|
type Order = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -51,6 +59,12 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
templates,
|
templates,
|
||||||
templatePreviewContext,
|
templatePreviewContext,
|
||||||
onSaveTemplates,
|
onSaveTemplates,
|
||||||
|
payment,
|
||||||
|
onSavePayment,
|
||||||
|
extraTabs = [],
|
||||||
|
activeTabKey,
|
||||||
|
onTabChange,
|
||||||
|
id,
|
||||||
}: {
|
}: {
|
||||||
order: Order | null;
|
order: Order | null;
|
||||||
config?: OrderFormConfig | null;
|
config?: OrderFormConfig | null;
|
||||||
@@ -58,6 +72,12 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
templates?: OrderTemplates | null;
|
templates?: OrderTemplates | null;
|
||||||
templatePreviewContext?: TemplateContext | null;
|
templatePreviewContext?: TemplateContext | null;
|
||||||
onSaveTemplates?: (templates: OrderTemplates) => Promise<void>;
|
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) {
|
if (!order) {
|
||||||
return null;
|
return null;
|
||||||
@@ -73,6 +93,7 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
id={id}
|
||||||
title={
|
title={
|
||||||
<Flex vertical gap={0}>
|
<Flex vertical gap={0}>
|
||||||
{order.title}
|
{order.title}
|
||||||
@@ -87,6 +108,7 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
config={currentConfig}
|
config={currentConfig}
|
||||||
description={currentDescription}
|
description={currentDescription}
|
||||||
templates={templates}
|
templates={templates}
|
||||||
|
payment={payment}
|
||||||
showImport={false}
|
showImport={false}
|
||||||
onImportConfig={onImportConfig}
|
onImportConfig={onImportConfig}
|
||||||
fileNameBase={order.title || "order"}
|
fileNameBase={order.title || "order"}
|
||||||
@@ -95,10 +117,13 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultActiveKey="description"
|
defaultActiveKey="description"
|
||||||
|
activeKey={activeTabKey}
|
||||||
|
onChange={onTabChange}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: "description",
|
key: "description",
|
||||||
label: "Description",
|
label: "Description",
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
children: (
|
children: (
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} md={order.image_url ? 14 : 24}>
|
<Col xs={24} md={order.image_url ? 14 : 24}>
|
||||||
@@ -147,6 +172,7 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
{
|
{
|
||||||
key: "menu",
|
key: "menu",
|
||||||
label: "Menu",
|
label: "Menu",
|
||||||
|
icon: <UnorderedListOutlined />,
|
||||||
children: (
|
children: (
|
||||||
<OrderFormConfigBuilder
|
<OrderFormConfigBuilder
|
||||||
editable={false}
|
editable={false}
|
||||||
@@ -163,6 +189,7 @@ export default function ReadOnlyOrderOverviewCard({
|
|||||||
{
|
{
|
||||||
key: "templates",
|
key: "templates",
|
||||||
label: "Templates",
|
label: "Templates",
|
||||||
|
icon: <SnippetsOutlined />,
|
||||||
children: (
|
children: (
|
||||||
<OrderTemplatesEditor
|
<OrderTemplatesEditor
|
||||||
templates={templates}
|
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>
|
></Tabs>
|
||||||
</Card>
|
</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,
|
Order,
|
||||||
OrderFormConfig,
|
OrderFormConfig,
|
||||||
OrderTemplates,
|
OrderTemplates,
|
||||||
|
OrderPaymentDetails,
|
||||||
GetAdminViewResponse,
|
GetAdminViewResponse,
|
||||||
OrderAccessInfo,
|
OrderAccessInfo,
|
||||||
GetOrdersResponse,
|
GetOrdersResponse,
|
||||||
@@ -66,6 +67,13 @@ export const ordersService = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(templates),
|
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) => {
|
uploadImage: (orderId: string, file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|||||||
@@ -123,6 +123,27 @@ export type OrderTemplates = {
|
|||||||
print: PrintTemplateSettings;
|
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 = {
|
export type OrderBase = {
|
||||||
id: string;
|
id: string;
|
||||||
admin_token: string;
|
admin_token: string;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
GetAdminViewResponse,
|
GetAdminViewResponse,
|
||||||
Order,
|
Order,
|
||||||
OrderFormConfig,
|
OrderFormConfig,
|
||||||
|
OrderPaymentDetails,
|
||||||
OrderTemplates,
|
OrderTemplates,
|
||||||
Submission,
|
Submission,
|
||||||
SubmissionSortBy,
|
SubmissionSortBy,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
type PrintTable,
|
type PrintTable,
|
||||||
type TemplateContext,
|
type TemplateContext,
|
||||||
} from "../lib/orderTemplating";
|
} from "../lib/orderTemplating";
|
||||||
|
import { normalizePaymentDetails } from "../lib/paymentDetails";
|
||||||
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";
|
||||||
@@ -42,6 +44,7 @@ type AdminViewData = Order & {
|
|||||||
submissions: Submission[];
|
submissions: Submission[];
|
||||||
config: OrderFormConfig;
|
config: OrderFormConfig;
|
||||||
templates: OrderTemplates;
|
templates: OrderTemplates;
|
||||||
|
payment: OrderPaymentDetails;
|
||||||
creator_email?: string | null;
|
creator_email?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,10 +71,11 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
|
|
||||||
const loadAdminData = useCallback(
|
const loadAdminData = useCallback(
|
||||||
async (): Promise<AdminViewData> => {
|
async (): Promise<AdminViewData> => {
|
||||||
const [adminPayload, configPayload, templatesPayload] = await Promise.all([
|
const [adminPayload, configPayload, templatesPayload, paymentPayload] = await Promise.all([
|
||||||
apiService.orders.getAdminView(orderId),
|
apiService.orders.getAdminView(orderId),
|
||||||
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
||||||
apiService.orders.getTemplates(orderId).catch(() => null),
|
apiService.orders.getTemplates(orderId).catch(() => null),
|
||||||
|
apiService.orders.getPayment(orderId).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const typedPayload = adminPayload as GetAdminViewResponse;
|
const typedPayload = adminPayload as GetAdminViewResponse;
|
||||||
@@ -88,6 +92,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
: [],
|
: [],
|
||||||
},
|
},
|
||||||
templates: normalizeOrderTemplates(templatesPayload),
|
templates: normalizeOrderTemplates(templatesPayload),
|
||||||
|
payment: normalizePaymentDetails(paymentPayload),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[orderId],
|
[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 () => {
|
const refreshSubmissions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const adminPayload = await apiService.orders.getAdminView(orderId);
|
const adminPayload = await apiService.orders.getAdminView(orderId);
|
||||||
@@ -387,6 +404,8 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
templates={data?.templates}
|
templates={data?.templates}
|
||||||
templatePreviewContext={templateContext}
|
templatePreviewContext={templateContext}
|
||||||
onSaveTemplates={handleSaveTemplates}
|
onSaveTemplates={handleSaveTemplates}
|
||||||
|
payment={data?.payment}
|
||||||
|
onSavePayment={handleSavePayment}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdminControlCenterCard
|
<AdminControlCenterCard
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ 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, OrderTemplates } from "../lib/types";
|
import type { OrderFormConfig, OrderPaymentDetails, OrderTemplates } from "../lib/types";
|
||||||
import { normalizeOrderTemplates } from "../lib/orderTemplating";
|
import { normalizeOrderTemplates } from "../lib/orderTemplating";
|
||||||
|
import { normalizePaymentDetails } from "../lib/paymentDetails";
|
||||||
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";
|
||||||
@@ -132,12 +133,14 @@ export default function CreateOrderView() {
|
|||||||
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.
|
// Templates are edited in the admin view; here they are only carried over from an import.
|
||||||
const [importedTemplates, setImportedTemplates] = useState<OrderTemplates | null>(null);
|
const [importedTemplates, setImportedTemplates] = useState<OrderTemplates | null>(null);
|
||||||
|
const [importedPayment, setImportedPayment] = useState<OrderPaymentDetails | 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,
|
templates: true,
|
||||||
|
payment: true,
|
||||||
});
|
});
|
||||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
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([]);
|
setImageFileList([]);
|
||||||
setFormConfig(DEFAULT_CONFIG);
|
setFormConfig(DEFAULT_CONFIG);
|
||||||
setImportedTemplates(null);
|
setImportedTemplates(null);
|
||||||
|
setImportedPayment(null);
|
||||||
|
|
||||||
navigateTo(`/order/${order.id}/admin`);
|
navigateTo(`/order/${order.id}/admin`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -290,6 +304,7 @@ export default function CreateOrderView() {
|
|||||||
description?: unknown;
|
description?: unknown;
|
||||||
menu?: unknown;
|
menu?: unknown;
|
||||||
templates?: unknown;
|
templates?: unknown;
|
||||||
|
payment?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const importedSections: string[] = [];
|
const importedSections: string[] = [];
|
||||||
@@ -323,6 +338,11 @@ export default function CreateOrderView() {
|
|||||||
importedSections.push("templates");
|
importedSections.push("templates");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(parsed, "payment")) {
|
||||||
|
setImportedPayment(normalizePaymentDetails(parsed.payment));
|
||||||
|
importedSections.push("payment details");
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -347,6 +367,7 @@ export default function CreateOrderView() {
|
|||||||
description?: string;
|
description?: string;
|
||||||
menu?: OrderFormConfig;
|
menu?: OrderFormConfig;
|
||||||
templates?: OrderTemplates;
|
templates?: OrderTemplates;
|
||||||
|
payment?: OrderPaymentDetails;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (exportOptions.title) {
|
if (exportOptions.title) {
|
||||||
@@ -365,6 +386,10 @@ export default function CreateOrderView() {
|
|||||||
payload.templates = importedTemplates;
|
payload.templates = importedTemplates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (importedPayment && exportOptions.payment) {
|
||||||
|
payload.payment = importedPayment;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -511,6 +536,7 @@ export default function CreateOrderView() {
|
|||||||
{ key: "description", label: "Description" },
|
{ key: "description", label: "Description" },
|
||||||
{ key: "menu", label: "Menu" },
|
{ key: "menu", label: "Menu" },
|
||||||
...(importedTemplates ? [{ key: "templates", label: "Templates" }] : []),
|
...(importedTemplates ? [{ key: "templates", label: "Templates" }] : []),
|
||||||
|
...(importedPayment ? [{ key: "payment", label: "Payment details" }] : []),
|
||||||
]}
|
]}
|
||||||
selected={exportOptions}
|
selected={exportOptions}
|
||||||
onCancel={() => setIsExportModalOpen(false)}
|
onCancel={() => setIsExportModalOpen(false)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { DeleteOutlined, SaveOutlined } from "@ant-design/icons";
|
import { DeleteOutlined, SaveOutlined, WalletOutlined } from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -19,6 +19,9 @@ import { navigateTo } from "../lib/routing";
|
|||||||
import { apiService } from "../lib/services";
|
import { apiService } from "../lib/services";
|
||||||
import LoadingSkeleton from "../components/utils/LoadingSkeleton";
|
import LoadingSkeleton from "../components/utils/LoadingSkeleton";
|
||||||
import ReadOnlyOrderOverviewCard from "../components/orders/ReadOnlyOrderOverviewCard";
|
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 {
|
import {
|
||||||
buildFormattedOrderString,
|
buildFormattedOrderString,
|
||||||
calculateEstimatedOrderTotal,
|
calculateEstimatedOrderTotal,
|
||||||
@@ -30,6 +33,7 @@ import type {
|
|||||||
OrderFormConfig,
|
OrderFormConfig,
|
||||||
Order,
|
Order,
|
||||||
OrderAccessInfo,
|
OrderAccessInfo,
|
||||||
|
OrderPaymentDetails,
|
||||||
Submission,
|
Submission,
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
import ParticipantCategoryFields from "../components/views/ParticipantCategoryFields";
|
import ParticipantCategoryFields from "../components/views/ParticipantCategoryFields";
|
||||||
@@ -38,6 +42,13 @@ import ParticipantDraftSummaryAlert from "../components/views/ParticipantDraftSu
|
|||||||
|
|
||||||
const { Text, Link } = Typography;
|
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 = {
|
type SubmissionValues = {
|
||||||
choices: NormalizedOrderSelections;
|
choices: NormalizedOrderSelections;
|
||||||
};
|
};
|
||||||
@@ -79,6 +90,7 @@ function toFormValues(
|
|||||||
type ParticipantLoadData = {
|
type ParticipantLoadData = {
|
||||||
order: Order;
|
order: Order;
|
||||||
config: OrderFormConfig;
|
config: OrderFormConfig;
|
||||||
|
payment: OrderPaymentDetails;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ParticipantView({ orderId }: { orderId: string }) {
|
export default function ParticipantView({ orderId }: { orderId: string }) {
|
||||||
@@ -94,12 +106,13 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
|||||||
|
|
||||||
const loadParticipantData =
|
const loadParticipantData =
|
||||||
useCallback(async (): Promise<ParticipantLoadData> => {
|
useCallback(async (): Promise<ParticipantLoadData> => {
|
||||||
const [order, config] = await Promise.all([
|
const [order, config, payment] = await Promise.all([
|
||||||
apiService.orders.get(orderId),
|
apiService.orders.get(orderId),
|
||||||
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
apiService.orders.getConfig(orderId).catch(() => ({ categories: [] })),
|
||||||
|
apiService.orders.getPayment(orderId).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { order, config };
|
return { order, config, payment: normalizePaymentDetails(payment) };
|
||||||
}, [orderId]);
|
}, [orderId]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -167,13 +180,14 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
|||||||
() => buildFormattedOrderString(normalizedSavedValues, config),
|
() => buildFormattedOrderString(normalizedSavedValues, config),
|
||||||
[normalizedSavedValues, config],
|
[normalizedSavedValues, config],
|
||||||
);
|
);
|
||||||
const savedEstimatedTotal = useMemo(
|
const savedEstimatedAmount = useMemo(
|
||||||
() =>
|
() => calculateEstimatedOrderTotal(normalizedSavedValues, config),
|
||||||
formatEstimatedTotal(
|
|
||||||
calculateEstimatedOrderTotal(normalizedSavedValues, config),
|
|
||||||
),
|
|
||||||
[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 =
|
const hasActiveChanges =
|
||||||
!!existingSubmission &&
|
!!existingSubmission &&
|
||||||
serializeSelections(normalizedDraftValues) !==
|
serializeSelections(normalizedDraftValues) !==
|
||||||
@@ -400,6 +414,34 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
|||||||
return <LoadingSkeleton rows={5} sections={2} />;
|
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 =
|
const canShowSubmittedResult =
|
||||||
!!existingSubmission && !isEditingSubmittedOrder;
|
!!existingSubmission && !isEditingSubmittedOrder;
|
||||||
const showSummaryView = isClosed || canShowSubmittedResult;
|
const showSummaryView = isClosed || canShowSubmittedResult;
|
||||||
@@ -413,148 +455,192 @@ export default function ParticipantView({ orderId }: { orderId: string }) {
|
|||||||
loadingRows={5}
|
loadingRows={5}
|
||||||
loadingSections={2}
|
loadingSections={2}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
<div style={{ position: "relative" }}>
|
||||||
{orderAccess?.is_owner && (
|
<Space direction="vertical" style={{ width: "100%" }} size={16}>
|
||||||
<Alert
|
{orderAccess?.is_owner && (
|
||||||
type="info"
|
<Alert
|
||||||
showIcon
|
type="info"
|
||||||
message={
|
showIcon
|
||||||
<Text>
|
message={
|
||||||
{"You're the owner of this order! "}
|
<Text>
|
||||||
<Link
|
{"You're the owner of this order! "}
|
||||||
href={`/order/${orderId}/admin`}
|
<Link
|
||||||
onClick={(event) => {
|
href={`/order/${orderId}/admin`}
|
||||||
event.preventDefault();
|
onClick={(event) => {
|
||||||
navigateTo(`/order/${orderId}/admin`);
|
event.preventDefault();
|
||||||
}}
|
navigateTo(`/order/${orderId}/admin`);
|
||||||
>
|
}}
|
||||||
Manage order
|
>
|
||||||
</Link>
|
Manage order
|
||||||
</Text>
|
</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 && (
|
<Card
|
||||||
<Alert
|
title="Your order"
|
||||||
type="warning"
|
extra={
|
||||||
showIcon
|
existingSubmission && !isClosed ? (
|
||||||
message="This order is closed. Submissions can no longer be changed."
|
canShowSubmittedResult ? (
|
||||||
/>
|
<Button
|
||||||
)}
|
type="primary"
|
||||||
|
onClick={() => setIsEditingSubmittedOrder(true)}
|
||||||
<ReadOnlyOrderOverviewCard order={liveOrder} config={config} />
|
>
|
||||||
|
Manage order
|
||||||
<Card
|
</Button>
|
||||||
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 }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={12}>
|
<Button
|
||||||
<ParticipantCategoryFields
|
onClick={() => {
|
||||||
categories={config.categories}
|
setIsEditingSubmittedOrder(false);
|
||||||
isClosed={isClosed}
|
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 && (
|
{config.categories.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<ParticipantDraftSummaryAlert
|
<ParticipantDraftSummaryAlert
|
||||||
hasExistingSubmission={!!existingSubmission}
|
hasExistingSubmission={!!existingSubmission}
|
||||||
hasActiveChanges={hasActiveChanges}
|
hasActiveChanges={hasActiveChanges}
|
||||||
savedOrderString={savedOrderString}
|
savedOrderString={savedOrderString}
|
||||||
savedEstimatedTotal={savedEstimatedTotal}
|
savedEstimatedTotal={savedEstimatedTotal}
|
||||||
draftOrderString={draftOrderString}
|
draftOrderString={draftOrderString}
|
||||||
draftEstimatedTotal={draftEstimatedTotal}
|
draftEstimatedTotal={draftEstimatedTotal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
existingSubmission
|
existingSubmission
|
||||||
? "Save updates to your submission"
|
? "Save updates to your submission"
|
||||||
: "Submit your order"
|
: "Submit your order"
|
||||||
}
|
}
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
htmlType="submit"
|
|
||||||
icon={<SaveOutlined />}
|
|
||||||
loading={submitting}
|
|
||||||
disabled={isClosed}
|
|
||||||
>
|
>
|
||||||
{existingSubmission ? "Update" : "Submit"}
|
<Button
|
||||||
</Button>
|
type="primary"
|
||||||
</Tooltip>
|
htmlType="submit"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={isClosed}
|
||||||
|
>
|
||||||
|
{existingSubmission ? "Update" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{existingSubmission && (
|
{existingSubmission && (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="Delete your current submission?"
|
title="Delete your current submission?"
|
||||||
description="This action cannot be undone."
|
description="This action cannot be undone."
|
||||||
okText="Delete"
|
okText="Delete"
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
okButtonProps={{ danger: true }}
|
okButtonProps={{ danger: true }}
|
||||||
onConfirm={deleteSubmission}
|
onConfirm={deleteSubmission}
|
||||||
disabled={isClosed}
|
disabled={isClosed}
|
||||||
>
|
>
|
||||||
<Tooltip title="Delete your current submission">
|
<Tooltip title="Delete your current submission">
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
disabled={isClosed}
|
disabled={isClosed}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
</Space>
|
</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>
|
</AsyncContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user