Files
einfach-produktiv/app/lib/emailTemplates.ts
T
Marco f0b2d21989 Send a dedicated email for a switched Überweisung payment, not the full order-confirmation
New sendPaymentSwitchedEmail() — sent instead of sendOrderConfirmationEmail()
when confirmPaymentEmail.ts sees order.paymentSwitchedAt set. Resending the
full order-confirmation (with its invoice re-attached) after a payment-method
switch read like a brand-new purchase; this is a short, dedicated "Zahlung
erhalten" confirmation instead, matching the order-status-email shape.

Also bumps @einfach-produktiv/invoicing to 0.2.8, fixing a font-path bug
that broke every invoice PDF render inside the Payload backend specifically.
2026-07-30 11:04:27 +00:00

402 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { formatPrice, formatDate } from "./format";
import { computeTaxBreakdown } from "@einfach-produktiv/invoicing";
import type { CompanySettings } from "./payload";
// Pure string-building functions, no server-only or client-only imports —
// used both server-side for the actual email send (app/lib/orderEmail.ts,
// app/api/checkout/route.ts) and client-side for the Live Preview page
// (app/email-preview/[type]/page.tsx's client component), so a Live
// Preview edit and the real sent email are guaranteed to render
// identically — same function, same input shape, just different data
// (real order vs. SAMPLE_ORDER below).
//
// Inline-styled HTML, not Tailwind classes or a <style> block — most email
// clients strip external/embedded CSS and only reliably honor inline
// `style` attributes. Structure (this file) is fixed; only the wording
// (heading/bodyText/footerText, edited in Payload's email-templates
// collection) is admin-editable — see that collection's own comment for why.
//
// Visual language deliberately echoes /bestellbestaetigung (the on-screen
// confirmation page, app/bestellbestaetigung/components/BestellbestaetigungContent.tsx)
// rather than being a generic transactional-email template: same brand
// color/warm cream background, the same circular success-icon treatment,
// the same thin brand-colored divider under the headline, a serif display
// heading. Not literally shared code (that component is React+Tailwind,
// this is plain inline-styled HTML for email-client compatibility — no
// external fonts/CSS, no flexbox reliance), just matched by eye so a
// customer doesn't get a starkly different "brand voice" between the page
// they just saw and the email that follows it.
export type EmailTemplateContent = {
subject: string;
heading: string;
bodyText: string;
footerText: string | null;
};
// Same hex values as app/globals.css's --color-* tokens — kept as literal
// constants here rather than imported, since this file has no build-time
// access to CSS custom properties (email clients wouldn't resolve `var()`
// either, even if it did).
const BRAND = "#f6a701";
const BG_BASE = "#f8f5f1";
const BG_MUTED = "#f3efe9";
const TEXT_PRIMARY = "#1a1a18";
const TEXT_MUTED = "#6b6b69";
const BORDER = "#e5e0d8";
const SUCCESS = "#2f8f4e";
// Georgia, not a web font — most email clients strip @font-face/external
// font requests, so this is the closest reliably-available serif to
// Playfair Display/Lora's editorial feel rather than an attempt to load
// the real thing.
const FONT_SERIF = "Georgia,'Times New Roman',serif";
const FONT_SANS = "-apple-system,Helvetica,Arial,sans-serif";
function paragraphs(text: string, align: "center" | "left" = "left"): string {
return text
.split(/\n{2,}/)
.map(
(p) =>
`<p style="margin:0 0 12px;line-height:1.6;font-size:15px;color:${TEXT_MUTED};text-align:${align};">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`,
)
.join("");
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Vorkasse (Überweisung/manual) instruction — the invoice PDF already
// shows this same information (see @einfach-produktiv/invoicing's own
// unpaidNoticeText), but a customer often only glances at the email body
// itself, not the attached PDF, so it's repeated here in plain text too.
// Own full-width block, margin-top matching the other section gaps in
// this template (16px) — not squeezed into the narrow Gesamtsumme table
// like an initial draft of the invoice version was before that got
// widened per feedback.
function vorkasseNotice(orderNumber: string, seller: CompanySettings | null, hasOnlinePaymentOption: boolean): string {
const bankLine = seller && (seller.iban || seller.bic)
? [seller.bankName, seller.iban && `IBAN ${seller.iban}`, seller.bic && `BIC ${seller.bic}`].filter(Boolean).join(" · ")
: null;
return `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:20px;background:${BG_MUTED};border-radius:8px;padding:16px 20px;">
<tr>
<td style="font-size:13px;line-height:1.6;color:${TEXT_MUTED};text-align:center;">
<p style="margin:0 0 8px;">Bitte überweise den Rechnungsbetrag unter Angabe der Bestellnummer ${escapeHtml(orderNumber)} auf ${bankLine ? "folgende Bankverbindung:" : "die dir genannte Bankverbindung."}</p>
${bankLine ? `<p style="margin:0 0 12px;font-weight:700;color:${TEXT_PRIMARY};">${escapeHtml(bankLine)}</p>` : ""}
<p style="margin:0;">Deine Bestellung wird nach Zahlungseingang bearbeitet (in der Regel innerhalb von 12 Werktagen).</p>
${hasOnlinePaymentOption ? `<p style="margin:8px 0 0;">Zahlungsart geändert? Solange die Überweisung noch nicht bei uns eingegangen ist, kannst du in deinem Konto jederzeit auf Kreditkarte/PayPal umsteigen.</p>` : ""}
</td>
</tr>
</table>
`;
}
// Live-Preview-only fallback (no real order/company-settings fetch there,
// see /email-preview/[type]) — the actual send always passes the real
// seller (company-settings) through buildLegalFooterLines() below. Email
// deliberately doesn't match the real admin@mk360.de send address, so this
// never reads as a hardcoded real value in the preview — it's a visibly
// fake placeholder, same spirit as "Musterstraße 12".
export const DEFAULT_LEGAL_FOOTER_LINES: string[] = [
"einfach produktiv",
"Musterstraße 12",
"12345 Musterstadt",
"E-Mail: kontakt@musterfirma.de",
];
// Every business email needs an Anbieterkennzeichnung (§5 DDG-equivalent
// minimum for business correspondence: full name, postal address, contact,
// plus VAT ID once assigned) — not just a friendly "brand · email" line.
// Built from the same company-settings fields the invoice PDFs already
// render (app/lib/invoicePdf.tsx), so there is exactly one source of truth
// for this business's legal identity.
//
// registerCourt/registerNumber/managingDirector are conditionally required
// on the Payload side (CompanySettings.ts, gated by legalForm) — only
// appended here when actually present, so a sole proprietorship's footer
// stays exactly as short as before this field set existed. Keep this in
// sync with the Payload backend's own copy in src/lib/sellerInfo.ts.
// `shareCapital` deliberately does NOT appear here even though it's a
// company-settings field — see CompanySettings.ts's own comment: it's a
// voluntary disclosure, not something safe to auto-inject into every
// outgoing email regardless of whether the business actually wants that
// disclosure made.
export function buildLegalFooterLines(seller: CompanySettings | null): string[] {
if (!seller) return DEFAULT_LEGAL_FOOTER_LINES;
const lines = [
seller.sellerName,
seller.sellerStreet,
`${seller.sellerZip} ${seller.sellerCity}${seller.sellerCountry && seller.sellerCountry !== "Deutschland" ? `, ${seller.sellerCountry}` : ""}`,
`E-Mail: ${seller.sellerEmail}`,
];
if (seller.vatId) lines.push(`USt-IdNr.: ${seller.vatId}`);
if (seller.registerCourt && seller.registerNumber) lines.push(`${seller.registerCourt} · ${seller.registerNumber}`);
if (seller.managingDirector) lines.push(`Geschäftsführung: ${seller.managingDirector}`);
return lines;
}
// `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. `footerLines`: the legal Anbieterkennzeichnung from
// buildLegalFooterLines() above — required on every email, not optional.
function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerText: string | null, footerLines: 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>
<td style="padding-bottom:20px;text-align:center;">
<span style="font-family:${FONT_SERIF};font-weight:700;font-size:15px;color:${TEXT_PRIMARY};letter-spacing:0.02em;">einfach produktiv.</span>
</td>
</tr>
<tr>
<td style="background:#ffffff;border:1px solid ${BORDER};border-radius:12px;padding:40px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align:center;padding-bottom:20px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto;">
<tr>
<td width="56" height="56" style="width:56px;height:56px;background:${BRAND}1a;border-radius:50%;text-align:center;vertical-align:middle;font-size:24px;color:${BRAND};">
${icon}
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="text-align:center;padding-bottom:8px;">
<span style="font-family:${FONT_SERIF};font-weight:700;font-size:26px;color:${TEXT_PRIMARY};">${headingHtml}</span>
</td>
</tr>
<tr>
<td style="text-align:center;padding-bottom:20px;">
<div style="width:32px;height:2px;background:${BRAND};margin:0 auto;"></div>
</td>
</tr>
<tr>
<td>${bodyHtml}</td>
</tr>
</table>
</td>
</tr>
<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>` : ""}
${footerLines.map((line) => `<p style="margin:0;font-size:11px;line-height:1.6;color:${TEXT_MUTED};">${escapeHtml(line)}</p>`).join("")}
</td>
</tr>
</table>
</body>`;
}
export type OrderConfirmationItem = {
productName: string;
quantity: number;
unitPrice: number;
imageUrl?: string | null;
bundleContents?: string | null;
variantName?: string | null;
taxRatePercent: number;
sku?: string | null;
};
export type OrderConfirmationData = {
orderNumber: string;
createdAt: string;
items: OrderConfirmationItem[];
subtotal: number;
shippingCost: number;
discountAmount: number;
discountCode: string | null;
total: number;
// Explicit boolean set by each caller (checkout route's manual branch:
// true; the Stripe webhook path: always false, since only a *paid*
// Stripe order ever reaches this send) — not derived from
// paymentMethodTitle here, since that string ("Online-Zahlung",
// "Kreditkarte", "Überweisung (Vorkasse)", ...) is exactly the kind of
// fragile thing a payment-methods rename already broke once this
// session (see @einfach-produktiv/invoicing's isPaidImmediately()).
isManualPayment: boolean;
// Only meaningful when isManualPayment is true — whether at least one
// Stripe-backed payment method is currently active, so the Vorkasse
// notice can mention the option to switch instead of promising it
// unconditionally. Set by the checkout route from the same
// getPaymentMethods() call it already makes.
hasOnlinePaymentOption?: boolean;
};
export const SAMPLE_ORDER: OrderConfirmationData = {
orderNumber: "#EP-0001-A7K2",
createdAt: new Date().toISOString(),
items: [
{
productName: "ToDo-Karten Set",
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, taxRatePercent: 19 },
],
subtotal: 42.7,
shippingCost: 0,
discountAmount: 5,
discountCode: "WILLKOMMEN10",
total: 37.7,
isManualPayment: false,
};
// Second preview fixture — demonstrates the Vorkasse/Überweisung branch
// (vorkasseNotice(), incl. the "switch to Kreditkarte/PayPal" mention)
// that SAMPLE_ORDER's own isManualPayment: false never shows. Used by
// LiveEmailPreviewClient.tsx's toggle, not by any real send — a real
// order-confirmation email always computes both flags live per order.
export const SAMPLE_ORDER_MANUAL: OrderConfirmationData = {
...SAMPLE_ORDER,
isManualPayment: true,
hasOnlinePaymentOption: true,
};
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData, seller: CompanySettings | null): string {
const rows = order.items
.map(
(item) => `<tr>
<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)}${item.variantName ? ` (${escapeHtml(item.variantName)})` : ""} <span style="color:${TEXT_MUTED};">× ${item.quantity}</span>${item.bundleContents ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">${escapeHtml(item.bundleContents)}</span>` : ""}${item.sku ? `<br/><span style="font-size:12px;color:${TEXT_MUTED};">Art.-Nr. ${escapeHtml(item.sku)}</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>`,
)
.join("");
const summaryRow = (label: string, value: string, color = TEXT_PRIMARY) =>
`<tr><td style="padding:4px 0;font-size:14px;color:${color};">${label}</td><td style="padding:4px 0;text-align:right;font-size:14px;color:${color};">${value}</td></tr>`;
const vatRow = (label: string, value: string) =>
`<tr><td style="padding-top:4px;font-size:12px;color:${TEXT_MUTED};">${label}</td><td style="padding-top:4px;text-align:right;font-size:12px;color:${TEXT_MUTED};">${value}</td></tr>`;
// Actual VAT amount included in the total, broken down per rate when the
// order spans more than one — mirrors /bestellbestaetigung's own
// VatBreakdown component (not shared code, this file is plain
// inline-styled HTML for email-client compatibility, see the top-of-file
// comment) and the same lib/taxBreakdown.ts math the invoice PDF uses.
const taxBreakdown = computeTaxBreakdown(
order.items.map((item) => ({ quantity: item.quantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent })),
order.subtotal,
order.discountAmount,
order.shippingCost,
);
const taxRows =
taxBreakdown.length <= 1
? taxBreakdown[0]
? vatRow(`enthält ${taxBreakdown[0].rate}% MwSt.`, formatPrice(taxBreakdown[0].tax))
: ""
: taxBreakdown.map((g) => vatRow(`davon ${g.rate}% MwSt.`, formatPrice(g.tax))).join("");
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="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;">
${summaryRow("Zwischensumme", formatPrice(order.subtotal))}
${order.discountAmount > 0 ? summaryRow(`Rabattcode${order.discountCode ? ` (${escapeHtml(order.discountCode)})` : ""}`, `-${formatPrice(order.discountAmount)}`, SUCCESS) : ""}
${summaryRow("Versand", order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost))}
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:12px;padding-top:12px;border-top:1px solid ${BORDER};">
<tr>
<td style="font-family:${FONT_SERIF};font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">Gesamtsumme</td>
<td style="text-align:right;font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">${formatPrice(order.total)}</td>
</tr>
${taxRows}
</table>
${order.isManualPayment ? vorkasseNotice(order.orderNumber, seller, Boolean(order.hasOnlinePaymentOption)) : ""}
`;
return emailShell("✓", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
}
// 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": "✓",
"order-tracking-added": "📦",
"order-tracking-corrected": "📦",
"order-delivered": "🎉",
"payment-method-switched": "💳",
};
export function renderOrderStatusHtml(
template: EmailTemplateContent,
icon: string,
orderNumber: string,
orderUrl: string,
seller: CompanySettings | null,
): 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, buildLegalFooterLines(seller));
}
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string, seller: CompanySettings | null): string {
const body = `
${paragraphs(template.bodyText, "center")}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
<tr>
<td style="background:${BRAND};border-radius:6px;">
<a href="${resetUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">Neues Passwort vergeben</a>
</td>
</tr>
</table>
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};word-break:break-all;">${resetUrl}</p>
<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, buildLegalFooterLines(seller));
}
// Not a Payload-editable template like the others above (see alertAdmin.ts's
// own comment on why this one send path exists outside the email-templates
// collection) — wording is fixed in code, but it still goes through the
// same emailShell()/buildLegalFooterLines() so the resend-verification email
// carries the same branding and legally required footer as every other
// email this app sends.
export function renderVerificationEmailHtml(firstName: string, verifyUrl: string, seller: CompanySettings | null): string {
const body = `
${paragraphs(`Hallo ${firstName},\n\nbitte bestätige deine E-Mail-Adresse für dein Konto bei einfach produktiv.`, "center")}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px auto 8px;">
<tr>
<td style="background:${BRAND};border-radius:6px;">
<a href="${verifyUrl}" style="display:inline-block;padding:13px 28px;font-weight:700;font-size:15px;color:${TEXT_PRIMARY};text-decoration:none;">E-Mail-Adresse bestätigen</a>
</td>
</tr>
</table>
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};word-break:break-all;">${verifyUrl}</p>
<p style="text-align:center;font-size:12px;color:${TEXT_MUTED};">Der Link ist 24 Stunden gültig.</p>
`;
return emailShell("✉", "Bitte bestätige deine E-Mail-Adresse", body, null, buildLegalFooterLines(seller));
}