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:
+112
-35
@@ -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, "&").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>`;
|
||||
// `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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user