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
+32 -8
View File
@@ -994,11 +994,26 @@ field appears there when nobody's logged in). There's no persistent
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.
Customers isn't public-read) and only *then* swaps Card 1's own
"Passwort (für dein neues Konto)" field out for an inline login prompt
(password field + "Einloggen" button), gender-neutral copy, rendered
right under the same email field the shopper just typed into rather than
asking for it a second time in a separate field. `handleSubmit`'s own
`emailExists` handling (see "Checkout registration collisions" below) is
the fallback for the case this check was skipped or raced.
**Fixed 2026-07-24** — this login prompt used to render in a completely
separate block above the whole form (before `<form>` even opens), which on
a shopper who'd already scrolled down to reach Card 1's email field meant
the prompt popped in off-screen, above their current scroll position, with
no auto-scroll wired up for this common blur-triggered path (only the
submit-time fallback had one). Moved inline into Card 1 itself instead —
no scrolling needed in the common case since it now appears exactly where
the shopper is already looking. The submit-time fallback (see "Checkout
registration collisions" below) still scrolls it into view, now via a
`useEffect` watching `showLogin` rather than a synchronous call at the
`setShowLogin(true)` site — the prompt is conditionally rendered, so its
ref isn't attached to anything yet at that exact synchronous point.
- **`app/lib/customerAuth.ts`** (server-only) is the single place that
talks to Payload's `customers` collection — a second, fully separate
@@ -1080,13 +1095,22 @@ in the header itself, which stays visible above the panel throughout.
error, since Payload's own message text doesn't distinguish "duplicate"
from other email-field failures) rather than just surfacing a generic
error. `CheckoutContent.tsx`'s `handleSubmit` reacts by switching
straight to the login toggle with that email pre-filled and
scroll-into-view, instead of leaving the customer stuck with an error
and no obvious next step.
`showLogin` on (same inline prompt described above — reuses Card 1's
own email field, nothing to pre-fill) and scrolling it into view via a
`useEffect`, instead of leaving the customer stuck with an error and no
obvious next step. `handleLogin()` itself posts the live `email` field
value, not a separate `loginEmail` state — there's only ever one email
input on this form now.
- **`/konto/profil`** edits name + the one saved default address (deliberately
a single address, not a full address book — see the assistant's memory
note on optionally expanding this later), changes the password, shows
the email-verification banner, and has the GDPR export/delete section.
Its "Land" `<select>` used to hardcode Deutschland/Österreich/Schweiz
independently of `/checkout`'s own country list — **fixed 2026-07-24**:
`ProfileForm.tsx` now takes a `shippingCountries` prop (`page.tsx` fetches
`getShippingCountries()`, same Payload-configurable list `/checkout`
already reads), so a country added/removed in the admin reaches both
places instead of just one.
- **Cart sync**: `app/components/CartSync.tsx` (mounted once in
`app/layout.tsx`) watches the local cart via `useCart()` and
debounce-POSTs it to `/api/account/cart` on every change; the route
+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
+15 -4
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { Reveal } from "../../../components/Reveal";
import type { CustomerProfile } from "../../../lib/customerAuth";
import type { ShippingCountry } from "../../../lib/payload";
const inputClass =
"w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors";
@@ -21,7 +22,17 @@ function Field({
);
}
export function ProfileForm({ profile }: { profile: CustomerProfile }) {
export function ProfileForm({
profile,
shippingCountries,
}: {
profile: CustomerProfile;
/** Same admin-configurable list /checkout's own "Land" <select> reads
* (Payload's shipping-countries collection) this form used to hardcode
* its own Deutschland/Österreich/Schweiz options independently, so a
* country added/removed there never reached the profile page. */
shippingCountries: ShippingCountry[];
}) {
const router = useRouter();
const [deliveryMethod, setDeliveryMethod] = useState<"address" | "packstation">(profile.deliveryMethod ?? "address");
const [error, setError] = useState<string | null>(null);
@@ -147,9 +158,9 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) {
<label className="flex flex-col gap-2 items-start w-full sm:w-1/2">
<span className="text-label text-text-muted">Land</span>
<select name="country" defaultValue={profile.country ?? "Deutschland"} required className={`${inputClass} bg-bg-base`}>
<option>Deutschland</option>
<option>Österreich</option>
<option>Schweiz</option>
{shippingCountries.map((c) => (
<option key={c.name}>{c.name}</option>
))}
</select>
</label>
+3 -2
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import Link from "next/link";
import { Footer } from "../../components/Footer";
import { getSessionCustomer, getCustomerProfile } from "../../lib/customerAuth";
import { getShippingCountries } from "../../lib/payload";
import { ProfileForm } from "./components/ProfileForm";
import { PasswordForm } from "./components/PasswordForm";
import { VerificationBanner } from "./components/VerificationBanner";
@@ -21,7 +22,7 @@ export default async function KontoProfilPage({
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const profile = await getCustomerProfile(session.token);
const [profile, shippingCountries] = await Promise.all([getCustomerProfile(session.token), getShippingCountries()]);
if (!profile) redirect("/konto/login");
const { verified } = await searchParams;
@@ -34,7 +35,7 @@ export default async function KontoProfilPage({
Meine Bestellungen
</Link>
<VerificationBanner emailVerified={profile.emailVerified} justVerified={verified === "1" || verified === "0" ? verified : undefined} />
<ProfileForm profile={profile} />
<ProfileForm profile={profile} shippingCountries={shippingCountries} />
<PasswordForm email={profile.email} />
<AccountDataSection />
</div>