Files
einfach-produktiv/app/lib/emailTemplates.ts
T
Marco f0df359db4 Add password reset, order confirmation email with editable templates, and fix missing account entry points
Password reset uses Payload's built-in forgot/reset-password flow,
customized to link to this app instead of the Payload admin. Order
confirmation email and the password-reset email's wording both come from
a new Payload email-templates collection, editable without a deploy and
previewable via Live Preview at /email-preview/[type] (same mechanism as
Posts/LegalPages/Testimonials, sample data instead of a real document).

Also: order numbers get a random suffix (prevents guessing, motivated by
a considered-and-deferred guest order-lookup feature); the discount code
field only shows in the cart when a code is actually active (codes now
apply via a ?code= link instead of manual entry); and three navigation
gaps found while testing — no reachable login link with an empty cart, no
logout link anywhere, no way back from profile to order history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:14:08 +00:00

119 lines
5.2 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";
// 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.
export type EmailTemplateContent = {
subject: string;
heading: string;
bodyText: string;
footerText: string | null;
};
const BRAND = "#f6a701";
const TEXT_PRIMARY = "#1a1a18";
const BORDER = "#e5e0d8";
function paragraphs(text: string): string {
return text
.split(/\n{2,}/)
.map((p) => `<p style="margin:0 0 16px;line-height:1.6;">${escapeHtml(p).replace(/\n/g, "<br/>")}</p>`)
.join("");
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function emailShell(bodyHtml: string): string {
return `<div style="max-width:600px;margin:0 auto;padding:32px 24px;font-family:Arial,Helvetica,sans-serif;color:${TEXT_PRIMARY};">
<p style="margin:0 0 24px;font-weight:700;font-size:18px;">einfach produktiv.</p>
${bodyHtml}
<p style="margin-top:32px;padding-top:16px;border-top:1px solid ${BORDER};font-size:12px;color:#6b6b66;">
einfach produktiv · admin@mk360.de
</p>
</div>`;
}
export type OrderConfirmationItem = { productName: string; quantity: number; unitPrice: 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 },
{ productName: "Wochenplaner Überblick", quantity: 2, unitPrice: 14.9 },
],
subtotal: 42.7,
shippingCost: 0,
discountAmount: 5,
discountCode: "WILLKOMMEN10",
total: 37.7,
};
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData): string {
const rows = order.items
.map(
(item) => `<tr>
<td style="padding:8px 0;border-bottom:1px solid ${BORDER};">${escapeHtml(item.productName)} × ${item.quantity}</td>
<td style="padding:8px 0;border-bottom:1px solid ${BORDER};text-align:right;white-space:nowrap;">${formatPrice(item.quantity * item.unitPrice)}</td>
</tr>`,
)
.join("");
const summaryRow = (label: string, value: string) =>
`<tr><td style="padding:4px 0;">${label}</td><td style="padding:4px 0;text-align:right;">${value}</td></tr>`;
return emailShell(`
<h1 style="margin:0 0 16px;font-size:22px;">${escapeHtml(template.heading)}</h1>
${paragraphs(template.bodyText)}
<table style="width:100%;border-collapse:collapse;margin:24px 0;font-size:14px;">
<tr><td colspan="2" style="padding-bottom:8px;font-size:12px;color:#6b6b66;">Bestellnummer ${escapeHtml(order.orderNumber)} · ${formatDate(order.createdAt)}</td></tr>
${rows}
</table>
<table style="width:100%;border-collapse:collapse;font-size:14px;">
${summaryRow("Zwischensumme", formatPrice(order.subtotal))}
${order.discountAmount > 0 ? summaryRow(`Rabattcode${order.discountCode ? ` (${escapeHtml(order.discountCode)})` : ""}`, `-${formatPrice(order.discountAmount)}`) : ""}
${summaryRow("Versand", order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost))}
</table>
<table style="width:100%;border-collapse:collapse;margin-top:8px;padding-top:8px;border-top:1px solid ${BORDER};font-size:16px;font-weight:700;">
<tr><td>Gesamtsumme</td><td style="text-align:right;">${formatPrice(order.total)}</td></tr>
</table>
${template.footerText ? `<p style="margin-top:24px;font-size:14px;color:#6b6b66;">${escapeHtml(template.footerText)}</p>` : ""}
`);
}
export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string): string {
return emailShell(`
<h1 style="margin:0 0 16px;font-size:22px;">${escapeHtml(template.heading)}</h1>
${paragraphs(template.bodyText)}
<p style="margin:24px 0;">
<a href="${resetUrl}" style="display:inline-block;padding:12px 24px;background:${BRAND};color:${TEXT_PRIMARY};font-weight:700;text-decoration:none;border-radius:4px;">Neues Passwort vergeben</a>
</p>
<p style="font-size:13px;color:#6b6b66;">Falls der Button nicht funktioniert: ${resetUrl}</p>
<p style="font-size:13px;color:#6b6b66;">Der Link ist 1 Stunde gültig.</p>
${template.footerText ? `<p style="margin-top:24px;font-size:14px;color:#6b6b66;">${escapeHtml(template.footerText)}</p>` : ""}
`);
}