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
@@ -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) => `
`;
+// `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 `
+
+ `;
+
+ 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,
};