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>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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>` : ""}
|
||||
`);
|
||||
}
|
||||
Reference in New Issue
Block a user