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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user