Fix checkout login-prompt placement and unify profile country select

Login prompt (email already has an account) now renders inline under
Card 1's own email field instead of a separate block above the whole
form — no scrolling needed in the common case, and no more re-typing
the email into a second field. The submit-time fallback still scrolls
it into view via a useEffect, now that the target is conditionally
rendered.

/konto/profil's "Land" select was still hardcoded to Deutschland/
Österreich/Schweiz independently of /checkout's own Payload-configurable
shipping-countries list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-24 17:44:04 +00:00
parent 36bfa3bd84
commit f0aec851d3
4 changed files with 145 additions and 82 deletions
+95 -68
View File
@@ -142,11 +142,30 @@ export function CheckoutContent({
const [purchaseError, setPurchaseError] = useState<string | null>(null);
const [purchasing, setPurchasing] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const accountGateRef = useRef<HTMLDivElement>(null);
const [loginEmail, setLoginEmail] = useState("");
// Scroll target for the submit-time emailExists fallback below — the
// common case (blur-triggered, see handleEmailBlur) needs no scroll at
// all, since the inline login prompt already renders right where the
// shopper is looking (under Card 1's own email field); this ref only
// matters if a shopper filled the whole form and hit submit from further
// down the page without ever blurring the email field first (e.g.
// browser autofill).
const loginGateRef = useRef<HTMLDivElement>(null);
const [loginPassword, setLoginPassword] = useState("");
const [loginError, setLoginError] = useState<string | null>(null);
const [loggingIn, setLoggingIn] = useState(false);
// Scrolls the inline login prompt into view once it actually exists in
// the DOM — can't do this synchronously right where setShowLogin(true)
// is called (handleEmailBlur/handleSubmit below): the prompt is
// conditionally rendered on `showLogin`, so loginGateRef.current is
// still null until after the next render flushes. Effect fires post-
// render instead, once the ref is actually attached. A no-op on the
// common blur-triggered path in practice — the prompt renders right
// under the email field the shopper is already looking at, already in
// view — but still correct/harmless there too.
useEffect(() => {
if (showLogin) loginGateRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [showLogin]);
// Per-field inline validation, populated on blur (see each field's own
// onBlur below) — surfaces the same plausibility checks the pattern/
// required attributes already declare, immediately instead of only at
@@ -416,7 +435,13 @@ export function CheckoutContent({
const res = await fetch("/api/account/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: loginEmail, password: loginPassword }),
// Card 1's own `email` state, not a separate re-typed value — the
// login prompt now renders inline right under that same field (see
// "1. Rechnungsadresse" below), so asking for the email a second
// time would just be redundant. Whatever's currently in the field
// is authoritative; the server rejects it the normal way if it
// doesn't match an account.
body: JSON.stringify({ email, password: loginPassword }),
});
const data = await res.json();
if (!data.ok) {
@@ -458,7 +483,6 @@ export function CheckoutContent({
});
const data = await res.json();
if (data.exists) {
setLoginEmail(email);
setShowLogin(true);
}
} catch {
@@ -522,13 +546,15 @@ export function CheckoutContent({
if (!data.ok) {
// The email typed into Card 1 already belongs to an existing
// account — registering was never going to work here. Switch
// straight to the login toggle with that email pre-filled instead
// of just showing an error with no clear next step; the customer
// only needs to add their password and resubmit.
// straight to the login prompt instead of just showing an error
// with no clear next step; the customer only needs to add their
// password and resubmit. The scroll-into-view (for a shopper who
// filled the whole form and hit submit without ever blurring the
// email field, e.g. autofill) happens in the loginGateRef effect
// below, not here — this div doesn't exist in the DOM yet at this
// exact point, `showLogin` only flips it in after the next render.
if (data.emailExists) {
setLoginEmail(body.email);
setShowLogin(true);
accountGateRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
setPurchaseError(data.reason || "Die Bestellung konnte nicht abgeschlossen werden.");
setPurchasing(false);
@@ -611,67 +637,24 @@ 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. 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 ? (
{/* Only the passive "already logged in" state stays up here — the
reactive "this email already has an account, please log in"
prompt used to render in this same spot too, which meant it
could pop in well above wherever the shopper had scrolled to
fill in Card 1's email field, mobile especially (no scroll-to
was even wired up for the common case — only the submit-time
fallback further down had one). Moved inline right under Card
1's own email field instead (see "1. Rechnungsadresse" below) —
it now appears exactly where the shopper's attention already
is, no scrolling needed either way. */}
{customerEmail && (
<p className="text-body-sm text-text-primary">
Eingeloggt als <span className="font-bold">{customerEmail}</span>{" "}
<button type="button" onClick={handleLogout} className="underline hover:text-brand transition-colors">
Abmelden
</button>
</p>
) : 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={() => {
setShowLogin(false);
setLoginError(null);
}}
className="text-label text-text-muted underline hover:text-text-primary transition-colors"
>
Andere E-Mail-Adresse verwenden
</button>
</div>
) : null}
</div>
)}
</Reveal>
<form onSubmit={handleSubmit} className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
@@ -782,9 +765,53 @@ export function CheckoutContent({
error={fieldErrors.email}
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
/>
{/* Only needed for the inline-registration path — an existing
session already has an account, no password to collect. */}
{!customerEmail && (
{/* Not needed once already logged in (existing session, no
password to collect) — and swapped for the inline login
prompt below instead of the "create a new account" password
field the moment handleEmailBlur/handleSubmit's own
emailExists fallback detects the typed email already
belongs to an account. Rendered right under the email field
itself (previously a separate block all the way at the top
of the page, above the form — easy to lose track of once
scrolled down to fill in Card 1, especially on mobile,
since nothing auto-scrolled to it on the common
blur-triggered path either). */}
{!customerEmail && (showLogin ? (
<div ref={loginGateRef} className="flex flex-col gap-2 w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0">
<p className="text-label text-text-muted">
Für diese E-Mail-Adresse existiert bereits ein Konto.
</p>
<FormField
label="Passwort"
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
autoComplete="current-password"
wrapperClassName="w-full"
/>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleLogin}
disabled={loggingIn}
className={`px-5 py-2.5 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>
<button
type="button"
onClick={() => {
setShowLogin(false);
setLoginError(null);
}}
className="text-label text-text-muted underline hover:text-text-primary transition-colors"
>
Andere E-Mail-Adresse verwenden
</button>
</div>
{loginError && <p className="text-label text-red-600">{loginError}</p>}
</div>
) : (
<div className="flex flex-col gap-2 w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0">
<FormField
label="Passwort (für dein neues Konto)"
@@ -822,7 +849,7 @@ export function CheckoutContent({
einsehen und bei Bedarf stornieren oder zurücksenden kannst.
</p>
</div>
)}
))}
{/* Always a plain street address — Packstation isn't a valid
Rechnungsadresse (an invoice needs a real postal address).
Packstation is only ever offered below, in the optional