Generate invoice PDFs attached to order confirmation, and send emails on order status changes
Invoice PDFs (§14 UStG line items, tenant-configurable VAT rate) are now generated at checkout and attached to the confirmation email, plus available on demand from the order-detail page. Payload-side, orders now also email the customer on shipped/cancelled/return_requested/returned, with Stornorechnung/Gutschrift correction PDFs attached for the latter two so the original invoice's immutable number stays honest.
This commit is contained in:
@@ -116,7 +116,12 @@ function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerT
|
||||
</body>`;
|
||||
}
|
||||
|
||||
export type OrderConfirmationItem = { productName: string; quantity: number; unitPrice: number };
|
||||
export type OrderConfirmationItem = {
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
export type OrderConfirmationData = {
|
||||
orderNumber: string;
|
||||
createdAt: string;
|
||||
@@ -132,8 +137,13 @@ export const SAMPLE_ORDER: OrderConfirmationData = {
|
||||
orderNumber: "#EP-0001-A7K2",
|
||||
createdAt: new Date().toISOString(),
|
||||
items: [
|
||||
{ productName: "ToDo-Karten – Set", quantity: 1, unitPrice: 12.9 },
|
||||
{ productName: "Wochenplaner – Überblick", quantity: 2, unitPrice: 14.9 },
|
||||
{
|
||||
productName: "ToDo-Karten – Set",
|
||||
quantity: 1,
|
||||
unitPrice: 12.9,
|
||||
imageUrl: "https://payload.mk360.de/api/media/file/product-todo-karten.png",
|
||||
},
|
||||
{ productName: "Wochenplaner – Überblick", quantity: 2, unitPrice: 14.9, imageUrl: null },
|
||||
],
|
||||
subtotal: 42.7,
|
||||
shippingCost: 0,
|
||||
@@ -146,7 +156,14 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
const rows = order.items
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span></td>
|
||||
<td width="52" style="padding:10px 0;border-bottom:1px solid ${BORDER};">
|
||||
${
|
||||
item.imageUrl
|
||||
? `<img src="${item.imageUrl}" width="44" height="44" alt="" style="display:block;width:44px;height:44px;border-radius:6px;object-fit:cover;border:1px solid ${BORDER};" />`
|
||||
: `<div style="width:44px;height:44px;border-radius:6px;background:${BG_MUTED};"></div>`
|
||||
}
|
||||
</td>
|
||||
<td style="padding:10px 0 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span></td>
|
||||
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;font-size:14px;color:${TEXT_PRIMARY};">${formatPrice(item.quantity * item.unitPrice)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
@@ -158,7 +175,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
const body = `
|
||||
${paragraphs(template.bodyText, "center")}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;background:${BG_MUTED};border-radius:8px;padding:20px;">
|
||||
<tr><td colspan="2" style="padding-bottom:10px;font-size:12px;color:${TEXT_MUTED};">Bestellnummer <strong style="color:${TEXT_PRIMARY};">${escapeHtml(order.orderNumber)}</strong> · ${formatDate(order.createdAt)}</td></tr>
|
||||
<tr><td colspan="3" style="padding-bottom:10px;font-size:12px;color:${TEXT_MUTED};">Bestellnummer <strong style="color:${TEXT_PRIMARY};">${escapeHtml(order.orderNumber)}</strong> · ${formatDate(order.createdAt)}</td></tr>
|
||||
${rows}
|
||||
</table>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;">
|
||||
@@ -177,6 +194,34 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
return emailShell("✓", escapeHtml(template.heading), body, template.footerText);
|
||||
}
|
||||
|
||||
// Icon shown per status — matches the Payload-side send exactly (see
|
||||
// STATUS_EMAIL in the backend repo's src/collections/Orders.ts), kept here
|
||||
// only for the Live Preview approximation (the real send happens from
|
||||
// Payload itself, not this repo — see that file's own comment on why the
|
||||
// two aren't pixel-identical, same established gap as password-reset).
|
||||
export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
|
||||
"order-shipped": "→",
|
||||
"order-cancelled": "✕",
|
||||
"order-return-requested": "↩",
|
||||
"order-returned": "✓",
|
||||
};
|
||||
|
||||
export function renderOrderStatusHtml(template: EmailTemplateContent, icon: string, orderNumber: string, orderUrl: string): string {
|
||||
const body = `
|
||||
${paragraphs(template.bodyText, "center")}
|
||||
<p style="text-align:center;font-size:13px;color:${TEXT_MUTED};margin:0 0 4px;">Bestellnummer ${escapeHtml(orderNumber)}</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
|
||||
<tr>
|
||||
<td style="background:${BRAND};border-radius:6px;">
|
||||
<a href="${orderUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Bestellung ansehen</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`;
|
||||
|
||||
return emailShell(icon, escapeHtml(template.heading), body, template.footerText);
|
||||
}
|
||||
|
||||
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string): string {
|
||||
const body = `
|
||||
${paragraphs(template.bodyText, "center")}
|
||||
|
||||
Reference in New Issue
Block a user