a935357e70
DEFAULT_LEGAL_FOOTER_LINES' email was "admin@mk360.de" — the same domain as the real send address, which read as a hardcoded real value in the preview rather than an obvious placeholder. Now "kontakt@musterfirma.de", matching "Musterstraße 12"'s already-fake spirit. Mirrors the same fix already made on the Payload backend's copy of this constant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
315 lines
15 KiB
TypeScript
315 lines
15 KiB
TypeScript
import { formatPrice, formatDate } from "./format";
|
||
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
}
|
||
|
||
// 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 TMG-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.
|
||
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="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;
|
||
taxRatePercent: number;
|
||
};
|
||
export type OrderConfirmationData = {
|
||
orderNumber: string;
|
||
createdAt: string;
|
||
items: OrderConfirmationItem[];
|
||
subtotal: number;
|
||
shippingCost: number;
|
||
discountAmount: number;
|
||
discountCode: string | null;
|
||
total: number;
|
||
};
|
||
|
||
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,
|
||
};
|
||
|
||
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)} <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>`,
|
||
)
|
||
.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 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>
|
||
</table>
|
||
`;
|
||
|
||
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": "✓",
|
||
};
|
||
|
||
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));
|
||
}
|