Redesign invoice PDFs, add correction-invoice downloads, return reasons, and per-product tax/bundle support
Invoice + Stornorechnung/Gutschrift PDFs get a modern header-band layout, a "bereits beglichen" badge for immediately-paid orders, labelled bank details, and a per-tax-rate summary breakdown. Correction invoices can now be re-downloaded from the account (regenerated deterministically, not stored as files, same approach as the original invoice). Return requests capture a reason. Products can define bundles (bundleItems) and a per-product VAT rate override, both snapshotted onto order items.
This commit is contained in:
+28
-11
@@ -64,11 +64,18 @@ function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Live-Preview-only fallback (no real order/invoice-settings fetch there,
|
||||
// see /email-preview/[type]) — the actual send always passes the real
|
||||
// "<sellerName> · <sellerEmail>" from invoice-settings (see orderEmail.ts).
|
||||
export const DEFAULT_COMPANY_LINE = "einfach produktiv · admin@mk360.de";
|
||||
|
||||
// `icon`: a single glyph rendered inside the brand-tinted circle up top —
|
||||
// "✓" for order-confirmation, "✉" for password-reset. Same circular
|
||||
// treatment as the confirmation page's own success icon and its delivery-
|
||||
// status panel icon.
|
||||
function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerText: string | null): string {
|
||||
// status panel icon. `companyLine`: sourced from Payload's invoice-settings
|
||||
// (sellerName/sellerEmail), not hardcoded — same admin-editable business
|
||||
// data the invoice PDFs already use, see orderEmail.ts.
|
||||
function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerText: string | null, companyLine: string): string {
|
||||
return `<body style="margin:0;padding:32px 16px;background:${BG_BASE};font-family:${FONT_SANS};">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;margin:0 auto;">
|
||||
<tr>
|
||||
@@ -109,7 +116,7 @@ function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerT
|
||||
<tr>
|
||||
<td style="padding-top:24px;text-align:center;">
|
||||
${footerText ? `<p style="margin:0 0 8px;font-size:13px;color:${TEXT_MUTED};">${escapeHtml(footerText)}</p>` : ""}
|
||||
<p style="margin:0;font-size:12px;color:${TEXT_MUTED};">einfach produktiv · admin@mk360.de</p>
|
||||
<p style="margin:0;font-size:12px;color:${TEXT_MUTED};">${escapeHtml(companyLine)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -121,6 +128,8 @@ export type OrderConfirmationItem = {
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
imageUrl?: string | null;
|
||||
bundleContents?: string | null;
|
||||
taxRatePercent: number;
|
||||
};
|
||||
export type OrderConfirmationData = {
|
||||
orderNumber: string;
|
||||
@@ -142,8 +151,10 @@ export const SAMPLE_ORDER: OrderConfirmationData = {
|
||||
quantity: 1,
|
||||
unitPrice: 12.9,
|
||||
imageUrl: "https://payload.mk360.de/api/media/file/product-todo-karten.png",
|
||||
bundleContents: null,
|
||||
taxRatePercent: 19,
|
||||
},
|
||||
{ productName: "Wochenplaner – Überblick", quantity: 2, unitPrice: 14.9, imageUrl: null },
|
||||
{ productName: "Wochenplaner – Überblick", quantity: 2, unitPrice: 14.9, imageUrl: null, taxRatePercent: 19 },
|
||||
],
|
||||
subtotal: 42.7,
|
||||
shippingCost: 0,
|
||||
@@ -152,7 +163,7 @@ export const SAMPLE_ORDER: OrderConfirmationData = {
|
||||
total: 37.7,
|
||||
};
|
||||
|
||||
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData): string {
|
||||
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData, companyLine: string): string {
|
||||
const rows = order.items
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
@@ -163,7 +174,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
: `<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 10px 12px;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</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>`,
|
||||
)
|
||||
@@ -191,7 +202,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
</table>
|
||||
`;
|
||||
|
||||
return emailShell("✓", escapeHtml(template.heading), body, template.footerText);
|
||||
return emailShell("✓", escapeHtml(template.heading), body, template.footerText, companyLine);
|
||||
}
|
||||
|
||||
// Icon shown per status — matches the Payload-side send exactly (see
|
||||
@@ -206,7 +217,13 @@ export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
|
||||
"order-returned": "✓",
|
||||
};
|
||||
|
||||
export function renderOrderStatusHtml(template: EmailTemplateContent, icon: string, orderNumber: string, orderUrl: string): string {
|
||||
export function renderOrderStatusHtml(
|
||||
template: EmailTemplateContent,
|
||||
icon: string,
|
||||
orderNumber: string,
|
||||
orderUrl: string,
|
||||
companyLine: 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>
|
||||
@@ -219,10 +236,10 @@ export function renderOrderStatusHtml(template: EmailTemplateContent, icon: stri
|
||||
</table>
|
||||
`;
|
||||
|
||||
return emailShell(icon, escapeHtml(template.heading), body, template.footerText);
|
||||
return emailShell(icon, escapeHtml(template.heading), body, template.footerText, companyLine);
|
||||
}
|
||||
|
||||
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string): string {
|
||||
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string, companyLine: string): string {
|
||||
const body = `
|
||||
${paragraphs(template.bodyText, "center")}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
|
||||
@@ -236,5 +253,5 @@ export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl
|
||||
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};">Der Link ist 1 Stunde gültig.</p>
|
||||
`;
|
||||
|
||||
return emailShell("✉", escapeHtml(template.heading), body, template.footerText);
|
||||
return emailShell("✉", escapeHtml(template.heading), body, template.footerText, companyLine);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user