Payment details integration
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m57s

This commit is contained in:
sim committed 2026-10-20 21:58:20 +02:00
1 parent f2cbc07738
commit 9e25c6da04
14 files changed
+1069 -148

No files matched your search

+36
View File
@@ -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>
+14
View File
@@ -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);
}
+178
View File
@@ -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);
+21
View 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;
+20 -1
View File
@@ -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
+27 -1
View File
@@ -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)}
+228 -142
View File
@@ -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>
);
}