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:
Marco
2026-07-22 08:14:08 +00:00
parent adca6e0f64
commit f0df359db4
26 changed files with 865 additions and 81 deletions
+1 -18
View File
@@ -1,21 +1,4 @@
import nodemailer from "nodemailer";
// Server-only. Deliberately its own SMTP transport, independent of
// Payload's (which now also sends mail — see Customers.ts's verification
// email) — the whole point of this module is alerting when something is
// broken, and if Payload itself is what's broken, routing the alert
// through it could mean the alert never arrives. Same Hostinger account
// either way (already proven working via Diun's update notifications),
// just a second, separate connection to it.
const transport = nodemailer.createTransport({
host: "smtp.hostinger.com",
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER || "",
pass: process.env.SMTP_PASSWORD || "",
},
});
import { transport } from "./mailer";
// Fire-and-forget by design — callers should not await this in a way that
// blocks or fails the actual error response the customer sees. Wrap
+46
View File
@@ -90,6 +90,52 @@ export async function loginCustomer(input: { email: string; password: string }):
};
}
// Always resolves — never throws or returns a distinguishable "email not
// found" shape. Mirrors Payload's own forgot-password operation, which
// fails silently on a non-existent email specifically to avoid leaking
// which addresses are registered (see auth/operations/forgotPassword.js);
// the caller (app/api/account/forgot-password/route.ts) must preserve that
// by always responding the same way regardless of this call's outcome.
export async function requestPasswordReset(email: string): Promise<void> {
await fetch(`${PAYLOAD_URL}/api/customers/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
}).catch(() => {
// Best-effort — same "never reveal anything" reasoning as above.
});
}
// Payload's reset-password operation logs the customer in on success (see
// auth/operations/resetPassword.js) and returns the same {token, user}
// shape as login — reused here so the frontend route can set the session
// cookie immediately, no separate login step needed after a reset.
export async function resetPassword(token: string, newPassword: string): Promise<AuthResult> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password: newPassword }),
});
if (!res.ok) return { ok: false, reason: "Der Link ist ungültig oder abgelaufen." };
const data: {
token: string;
user: { id: number; customerNumber: string; firstName: string; lastName: string; email: string; emailVerified: boolean };
} = await res.json();
return {
ok: true,
token: data.token,
customer: {
id: data.user.id,
customerNumber: data.user.customerNumber,
firstName: data.user.firstName,
lastName: data.user.lastName,
email: data.user.email,
emailVerified: data.user.emailVerified,
},
};
}
export async function getCustomerFromToken(token: string): Promise<CustomerSummary | null> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/me`, {
headers: { Authorization: `JWT ${token}` },
+118
View File
@@ -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, "&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>` : ""}
`);
}
+19
View File
@@ -0,0 +1,19 @@
import nodemailer from "nodemailer";
// Server-only. This app's own SMTP connection, deliberately independent of
// Payload's (which also sends mail now — see Customers.ts's verification/
// password-reset emails on the Payload side) — critical-error alerts in
// particular need to still go out even if Payload itself is what's broken.
// Same Hostinger account either way (already proven working via Diun's
// update notifications), just a second, separate connection to it. Shared
// by alertAdmin.ts and orderEmail.ts so there's exactly one transport
// instance, not one per call site.
export const transport = nodemailer.createTransport({
host: "smtp.hostinger.com",
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER || "",
pass: process.env.SMTP_PASSWORD || "",
},
});
+28
View File
@@ -0,0 +1,28 @@
import { transport } from "./mailer";
import { getEmailTemplate } from "./payload";
import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./emailTemplates";
// Called from app/api/checkout/route.ts right after a successful
// createOrder() — fire-and-forget, must never block or fail the checkout
// response itself. A missing/unpublished template falls back to a plain
// default so a first-ever order isn't silently un-confirmed just because
// nobody's visited the Payload admin yet (mirrors seed-email-templates.ts's
// defaults, kept in sync by hand — there are only two places this wording
// lives, seeding it is a one-time setup step, not a runtime dependency).
export async function sendOrderConfirmationEmail(order: OrderConfirmationData, customerEmail: string): Promise<boolean> {
const template = (await getEmailTemplate("order-confirmation")) ?? {
subject: "Deine Bestellung bei einfach produktiv",
heading: "Vielen Dank für deine Bestellung!",
bodyText: "Wir haben deine Bestellung erhalten und bereiten sie für den Versand vor.",
footerText: null,
};
const html = renderOrderConfirmationHtml(template, order);
await transport.sendMail({
from: '"einfach produktiv" <admin@mk360.de>',
to: customerEmail,
subject: template.subject,
html,
});
return true;
}
+33
View File
@@ -603,3 +603,36 @@ export async function getLegalPage(type: LegalPageType, options?: { draft?: bool
: null,
};
}
export type EmailTemplateType = "order-confirmation" | "password-reset";
type PayloadEmailTemplate = {
type: EmailTemplateType;
subject: string;
heading: string;
bodyText: string;
footerText: string | null;
};
// draft:true is used by app/email-preview/[type]/page.tsx (Live Preview,
// see EmailTemplates.ts in the Payload repo); the real send (orderEmail.ts,
// Customers.ts's forgotPassword hook) always reads the published version.
export async function getEmailTemplate(
type: EmailTemplateType,
options?: { draft?: boolean },
): Promise<PayloadEmailTemplate | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[type][equals]": type,
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/email-templates?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
if (!res.ok) {
console.error(`getEmailTemplate: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { docs?: PayloadEmailTemplate[] } = await res.json();
return data.docs?.[0] ?? null;
}