Make checkout's login prompt reactive instead of persistent, and give the confirmation email real style and voice

The always-visible "Schon Kundin?" toggle was gendered and shown to every
logged-out visitor regardless of relevance. Card 1's email field now
checks on blur (/api/account/check-email) whether that address already
has an account, and only then swaps in a gender-neutral login form,
pre-filled — the collision check in handleSubmit stays as a fallback.

The order-confirmation and password-reset emails also got a real visual
pass: same warm background/brand color/circular success-icon treatment as
the on-screen /bestellbestaetigung page, serif heading, thin brand
divider, table-based layout for email-client compatibility. Copy is
on-brand and a little playful now instead of generic transactional
boilerplate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 08:38:41 +00:00
parent ec75a480bd
commit fa02d95dff
6 changed files with 258 additions and 81 deletions
+112 -35
View File
@@ -13,6 +13,17 @@ import { formatPrice, formatDate } from "./format";
// `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;
@@ -21,14 +32,31 @@ export type EmailTemplateContent = {
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): string {
function paragraphs(text: string, align: "center" | "left" = "left"): 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>`)
.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("");
}
@@ -36,14 +64,56 @@ 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>`;
// `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.
function emailShell(icon: string, headingHtml: string, bodyHtml: string, footerText: string | null): 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>` : ""}
<p style="margin:0;font-size:12px;color:${TEXT_MUTED};">einfach produktiv · admin@mk360.de</p>
</td>
</tr>
</table>
</body>`;
}
export type OrderConfirmationItem = { productName: string; quantity: number; unitPrice: number };
@@ -76,43 +146,50 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
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>
<td style="padding:10px 0;border-bottom:1px solid ${BORDER};font-size:14px;color:${TEXT_PRIMARY};">${escapeHtml(item.productName)} <span style="color:${TEXT_MUTED};">× ${item.quantity}</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) =>
`<tr><td style="padding:4px 0;">${label}</td><td style="padding:4px 0;text-align:right;">${value}</td></tr>`;
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>`;
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>
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="2" 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 style="width:100%;border-collapse:collapse;font-size:14px;">
<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)}`) : ""}
${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 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 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>
${template.footerText ? `<p style="margin-top:24px;font-size:14px;color:#6b6b66;">${escapeHtml(template.footerText)}</p>` : ""}
`);
`;
return emailShell("✓", escapeHtml(template.heading), body, template.footerText);
}
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>` : ""}
`);
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);
}