diff --git a/README.md b/README.md index a77d973..a059a7f 100644 --- a/README.md +++ b/README.md @@ -244,9 +244,16 @@ check against Payload's public API, unlike most content on this site. An account is required to buy — there is no guest checkout. Registration happens inline in `/checkout`'s "1. Rechnungsadresse" card (a password -field appears there when nobody's logged in); returning customers can -instead expand a small "Schon Kundin? Einloggen" toggle in the same place -without leaving the page. +field appears there when nobody's logged in). There's no persistent +"already a customer? log in" prompt — that was gendered ("Schon Kundin?") +and shown to every logged-out visitor regardless of relevance. Instead, +the email field's `onBlur` calls `/api/account/check-email` +(`checkEmailExists()` in `customerAuth.ts`, service-secret authenticated — +Customers isn't public-read) and only *then* swaps Card 1's password field +out for an inline login form, gender-neutral copy, pre-filled with the +email just typed. `handleSubmit`'s own `emailExists` handling (see +"Checkout registration collisions" below) is the fallback for the case +this check was skipped or raced. - **`app/lib/customerAuth.ts`** (server-only) is the single place that talks to Payload's `customers` collection — a second, fully separate @@ -413,9 +420,25 @@ dependency here for `LivePostContent.tsx`). (`getEmailTemplate()` in `app/lib/payload.ts`, `draft` unset) — a Live Preview edit never affects a live customer email until actually saved. - `npx payload run src/seed-email-templates.ts` (Payload repo) seeds - sensible defaults for both rows; `sendOrderConfirmationEmail()` also has - a hardcoded fallback for the rare case a fresh install's order arrives - before that seed has run. + defaults for both rows — deliberately on-brand and a little playful + ("Geschafft!" / "Kein Drama.", not generic transactional-email + boilerplate), matching this site's voice elsewhere (see e.g. the + testimonial copy). Editable in the admin afterward regardless. + `sendOrderConfirmationEmail()` also has a hardcoded fallback for the + rare case a fresh install's order arrives before that seed has run. +- **`emailShell()`'s visual design deliberately echoes `/bestellbestaetigung`** + (the on-screen order confirmation page) rather than reading as a generic + transactional email: same warm cream background/brand color as + `globals.css`'s `--color-*` tokens (hardcoded here as literal hex — email + clients don't resolve `var()` either), a circular brand-tinted icon + (✓ for order-confirmation, ✉ for password-reset) echoing that page's own + success-icon treatment, a thin brand-colored divider under the heading, + and a Georgia/serif heading font as the closest reliably-available + approximation of the site's Playfair Display (most email clients strip + `@font-face`/external font requests, so an actual web font isn't an + option here). Not shared code with the React page — this is plain + inline-styled HTML built for email-client compatibility (nested + ``s, no flexbox) — just matched by eye. ### GDPR self-service diff --git a/app/api/account/check-email/route.ts b/app/api/account/check-email/route.ts new file mode 100644 index 0000000..ecd13d2 --- /dev/null +++ b/app/api/account/check-email/route.ts @@ -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 }); +} diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx index 4ae1352..4bc9143 100644 --- a/app/checkout/components/CheckoutContent.tsx +++ b/app/checkout/components/CheckoutContent.tsx @@ -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) { + 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({

Fast geschafft! Nur noch ein paar Angaben.

- {/* 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. */}
{customerEmail ? (

@@ -256,42 +285,50 @@ export function CheckoutContent({ Abmelden

- ) : !showLogin ? ( -

- Schon Kundin?{" "} - -

- ) : ( -
- setLoginEmail(e.target.value)} - autoComplete="email" - wrapperClassName="w-full sm:w-56" - /> - setLoginPassword(e.target.value)} - autoComplete="current-password" - wrapperClassName="w-full sm:w-56" - /> + ) : showLogin ? ( +
+

+ Für diese E-Mail-Adresse existiert bereits ein Konto — bitte einloggen, um fortzufahren. +

+
+ setLoginEmail(e.target.value)} + autoComplete="email" + wrapperClassName="w-full sm:w-56" + /> + setLoginPassword(e.target.value)} + autoComplete="current-password" + wrapperClassName="w-full sm:w-56" + /> + +
+ {loginError &&

{loginError}

} - {loginError &&

{loginError}

}
- )} + ) : null}
@@ -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 diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts index 7f5abb1..e23dc96 100644 --- a/app/lib/customerAuth.ts +++ b/app/lib/customerAuth.ts @@ -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 { + 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; diff --git a/app/lib/emailTemplates.ts b/app/lib/emailTemplates.ts index 4c1b04c..431fe37 100644 --- a/app/lib/emailTemplates.ts +++ b/app/lib/emailTemplates.ts @@ -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) => `

${escapeHtml(p).replace(/\n/g, "
")}

`) + .map( + (p) => + `

${escapeHtml(p).replace(/\n/g, "
")}

`, + ) .join(""); } @@ -36,14 +64,56 @@ function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } -function emailShell(bodyHtml: string): string { - return `
-

einfach produktiv.

- ${bodyHtml} -

- einfach produktiv · admin@mk360.de -

-
`; +// `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 ` +
+ + + + + + + + + +
+ einfach produktiv. +
+ + + + + + + + + + + + + +
+ + + + +
+ ${icon} +
+
+ ${headingHtml} +
+
+
${bodyHtml}
+
+ ${footerText ? `

${escapeHtml(footerText)}

` : ""} +

einfach produktiv · admin@mk360.de

+
+`; } 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) => ` - ${escapeHtml(item.productName)} × ${item.quantity} - ${formatPrice(item.quantity * item.unitPrice)} + ${escapeHtml(item.productName)} × ${item.quantity} + ${formatPrice(item.quantity * item.unitPrice)} `, ) .join(""); - const summaryRow = (label: string, value: string) => - `${label}${value}`; + const summaryRow = (label: string, value: string, color = TEXT_PRIMARY) => + `${label}${value}`; - return emailShell(` -

${escapeHtml(template.heading)}

- ${paragraphs(template.bodyText)} - - + const body = ` + ${paragraphs(template.bodyText, "center")} +
Bestellnummer ${escapeHtml(order.orderNumber)} · ${formatDate(order.createdAt)}
+ ${rows}
Bestellnummer ${escapeHtml(order.orderNumber)} · ${formatDate(order.createdAt)}
- +
${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))}
- - +
Gesamtsumme${formatPrice(order.total)}
+ + + +
Gesamtsumme${formatPrice(order.total)}
- ${template.footerText ? `

${escapeHtml(template.footerText)}

` : ""} - `); + `; + + return emailShell("✓", escapeHtml(template.heading), body, template.footerText); } export function renderPasswordResetHtml(template: EmailTemplateContent, resetUrl: string): string { - return emailShell(` -

${escapeHtml(template.heading)}

- ${paragraphs(template.bodyText)} -

- Neues Passwort vergeben -

-

Falls der Button nicht funktioniert: ${resetUrl}

-

Der Link ist 1 Stunde gültig.

- ${template.footerText ? `

${escapeHtml(template.footerText)}

` : ""} - `); + const body = ` + ${paragraphs(template.bodyText, "center")} + + + + +
+ Neues Passwort vergeben +
+

${resetUrl}

+

Der Link ist 1 Stunde gültig.

+ `; + + return emailShell("✉", escapeHtml(template.heading), body, template.footerText); } diff --git a/app/lib/orderEmail.ts b/app/lib/orderEmail.ts index 2c15a40..fb0d499 100644 --- a/app/lib/orderEmail.ts +++ b/app/lib/orderEmail.ts @@ -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 { 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, };