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
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { checkEmailExists } from "../../../lib/customerAuth";
import { checkRateLimit, getClientIp } from "../../../lib/rateLimit";
// Called on blur from the checkout email field (CheckoutContent.tsx) —
// lets the form switch to login mode as soon as an existing account is
// detected, instead of only after a failed registration attempt. Same
// exposure as the registration-collision case already had (both reveal
// "this email has an account"), so rate-limited the same way rather than
// treated as a new problem.
export async function POST(request: Request) {
if (!checkRateLimit(`check-email:${getClientIp(request)}`, { limit: 20, windowMs: 15 * 60 * 1000 })) {
return NextResponse.json({ exists: false }, { status: 429 });
}
const body = await request.json().catch(() => null);
const email = typeof body?.email === "string" ? body.email : "";
if (!email) return NextResponse.json({ exists: false });
const exists = await checkEmailExists(email);
return NextResponse.json({ exists });
}
+75 -37
View File
@@ -119,6 +119,33 @@ export function CheckoutContent({
router.refresh();
}
// Checks whether the email just typed into Card 1 already has an
// account — proactively, on blur, rather than only finding out after a
// registration attempt fails (see handleSubmit's own emailExists
// handling, which still applies as a fallback if this check is skipped
// or the account was created in the meantime). Silent when there's no
// match — the account gate simply stays empty; nothing is shown "just
// in case", only once actually relevant.
async function handleEmailBlur(e: React.FocusEvent<HTMLInputElement>) {
const email = e.target.value.trim();
if (!email || customerEmail || showLogin) return;
try {
const res = await fetch("/api/account/check-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (data.exists) {
setLoginEmail(email);
setShowLogin(true);
}
} catch {
// Best-effort — worst case, the registration attempt itself catches
// the collision at submit time instead (handleSubmit).
}
}
// Always goes through /api/checkout now — that route re-prices
// everything server-side, re-validates+redeems a discount code exactly
// once (see its own comment), registers a new account inline when no
@@ -242,12 +269,14 @@ export function CheckoutContent({
<p className="text-body text-text-muted">Fast geschafft! Nur noch ein paar Angaben.</p>
</div>
{/* Account gate — an account is required to buy, so this either
confirms the active session or offers "already a customer?"
login inline (registration itself happens as part of the main
form submit below, via the password field in Card 1). Ref used
to scroll this into view if a checkout submit turns out to
collide with an existing account (see handleSubmit). */}
{/* Account gate — an account is required to buy. Not a persistent
"already a customer? log in" prompt anymore (removed —
Nutzer-Entscheidung: don't show it unless actually relevant):
stays empty by default, and only shows the login form once
handleEmailBlur (Card 1's email field) or handleSubmit's own
emailExists fallback actually detects that the typed email
belongs to an existing account. Ref used to scroll this into
view when that happens after a submit attempt specifically. */}
<div ref={accountGateRef} className="w-full">
{customerEmail ? (
<p className="text-body-sm text-text-primary">
@@ -256,42 +285,50 @@ export function CheckoutContent({
Abmelden
</button>
</p>
) : !showLogin ? (
<p className="text-body-sm text-text-primary">
Schon Kundin?{" "}
<button type="button" onClick={() => setShowLogin(true)} className="underline font-bold hover:text-brand transition-colors">
Hier einloggen
</button>
</p>
) : (
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-end w-full sm:w-auto">
<FormField
label="E-Mail-Adresse"
type="email"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
autoComplete="email"
wrapperClassName="w-full sm:w-56"
/>
<FormField
label="Passwort"
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
autoComplete="current-password"
wrapperClassName="w-full sm:w-56"
/>
) : showLogin ? (
<div className="flex flex-col gap-3 items-start w-full">
<p className="text-body-sm text-text-primary">
Für diese E-Mail-Adresse existiert bereits ein Konto bitte einloggen, um fortzufahren.
</p>
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-end w-full sm:w-auto">
<FormField
label="E-Mail-Adresse"
type="email"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
autoComplete="email"
wrapperClassName="w-full sm:w-56"
/>
<FormField
label="Passwort"
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
autoComplete="current-password"
wrapperClassName="w-full sm:w-56"
/>
<button
type="button"
onClick={handleLogin}
disabled={loggingIn}
className={`px-5 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${loggingIn ? "opacity-70 pointer-events-none" : ""}`}
>
{loggingIn ? "…" : "Einloggen"}
</button>
</div>
{loginError && <p className="text-label text-red-600 w-full">{loginError}</p>}
<button
type="button"
onClick={handleLogin}
disabled={loggingIn}
className={`px-5 py-3 rounded-sm bg-brand hover:bg-brand-hover font-bold text-body-sm text-text-primary transition-colors ${loggingIn ? "opacity-70 pointer-events-none" : ""}`}
onClick={() => {
setShowLogin(false);
setLoginError(null);
}}
className="text-label text-text-muted underline hover:text-text-primary transition-colors"
>
{loggingIn ? "…" : "Einloggen"}
Andere E-Mail-Adresse verwenden
</button>
{loginError && <p className="text-label text-red-600 w-full">{loginError}</p>}
</div>
)}
) : null}
</div>
</Reveal>
@@ -321,6 +358,7 @@ export function CheckoutContent({
placeholder="max@beispiel.de"
autoComplete="email"
required
onBlur={handleEmailBlur}
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
/>
{/* Only needed for the inline-registration path — an existing
+17
View File
@@ -43,6 +43,23 @@ export type AuthResult =
| { ok: true; token: string; customer: CustomerSummary }
| { ok: false; reason: string; emailExists?: boolean };
// Called from app/api/account/check-email/route.ts — lets the checkout
// form detect an existing account *before* a submit attempt fails (see
// CheckoutContent.tsx's email field onBlur), not just after. Service-secret
// authenticated (same reasoning as the other service calls in this file) —
// Customers' read access isn't public, and there's no customer session yet
// at this point either way.
export async function checkEmailExists(email: string): Promise<boolean> {
const params = new URLSearchParams({ "where[email][equals]": email, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/customers?${params}`, {
headers: { "x-order-service-secret": SERVICE_SECRET },
cache: "no-store",
});
if (!res.ok) return false;
const data: { docs?: unknown[] } = await res.json();
return (data.docs?.length ?? 0) > 0;
}
export async function registerCustomer(input: {
firstName: string;
lastName: string;
+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);
}
+3 -3
View File
@@ -11,9 +11,9 @@ import { renderOrderConfirmationHtml, type OrderConfirmationData } from "./email
// 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.",
subject: "Bestellt! Deine Ruhe kann kommen 🎉",
heading: "Geschafft!",
bodyText: "Deine Bestellung ist bei uns eingetrudelt — wir kümmern uns schon liebevoll darum, sie für dich zu packen.",
footerText: null,
};