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
+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