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:
+1
-18
@@ -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
|
||||
|
||||
@@ -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}` },
|
||||
|
||||
@@ -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>` : ""}
|
||||
`);
|
||||
}
|
||||
@@ -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 || "",
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user