Detect checkout email collisions and show login state in the navbar

Registering with an email that already has an account previously just
failed with a generic error and no clear next step. registerCustomer()
now flags emailExists specifically, and checkout switches straight to the
login toggle (email pre-filled, scrolled into view) instead. The account
icon also gets a small underline while logged in, matching the nav links'
active-state styling — it was otherwise the only nav element that gave no
visual signal of session state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 08:26:00 +00:00
parent f0df359db4
commit ec75a480bd
4 changed files with 58 additions and 6 deletions
+15 -1
View File
@@ -287,7 +287,21 @@ without leaving the page.
server-rendered root layout) specifically so `app/layout.tsx` — otherwise
static/ISR-cacheable — doesn't get forced into per-request dynamic
rendering just to know one icon's href; briefly shows the logged-out
state on first paint until that fetch resolves.
state on first paint until that fetch resolves. The icon itself also
gets a small brand-colored underline while logged in — same visual
language as the desktop nav links' active-state indicator — since the
icon alone doesn't otherwise signal session state at a glance.
- **Checkout registration collisions**: if the email typed into Card 1
during inline registration already belongs to an existing account,
Payload's create call fails — `registerCustomer()` in `customerAuth.ts`
detects this specifically (`emailExists: true` on the returned
`AuthResult`, inferred from the field flagged in Payload's validation
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.
- **`/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
+17 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
@@ -67,6 +67,7 @@ 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("");
const [loginPassword, setLoginPassword] = useState("");
const [loginError, setLoginError] = useState<string | null>(null);
@@ -156,6 +157,16 @@ export function CheckoutContent({
});
const data = await res.json();
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.
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);
return;
@@ -234,7 +245,10 @@ export function CheckoutContent({
{/* 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). */}
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). */}
<div ref={accountGateRef} className="w-full">
{customerEmail ? (
<p className="text-body-sm text-text-primary">
Eingeloggt als <span className="font-bold">{customerEmail}</span>{" "}
@@ -278,6 +292,7 @@ export function CheckoutContent({
{loginError && <p className="text-label text-red-600 w-full">{loginError}</p>}
</div>
)}
</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">
+6 -1
View File
@@ -107,13 +107,18 @@ function AccountLink({ variant }: { variant: "icon" | "mobile" }) {
return (
<Link
href={href}
aria-label={loggedIn ? "Mein Konto" : "Anmelden"}
aria-label={loggedIn ? "Mein Konto (eingeloggt)" : "Anmelden"}
className="relative flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
>
<svg viewBox="0 0 24 24" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
<circle cx="12" cy="8" r="3.6" stroke="currentColor" strokeWidth="1.8" />
<path d="M4.5 20c1.2-4 4-6 7.5-6s6.3 2 7.5 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
{/* Only real "am I logged in?" signal on the site outside /konto
itself — same brand-colored underline language as the desktop
nav links' active-state indicator, so it reads as consistent
rather than a new visual idiom. */}
{loggedIn && <span aria-hidden className="absolute bottom-1 h-[2px] w-4 bg-brand rounded-full" />}
</Link>
);
}
+20 -2
View File
@@ -39,7 +39,9 @@ export type CustomerSummary = {
emailVerified: boolean;
};
export type AuthResult = { ok: true; token: string; customer: CustomerSummary } | { ok: false; reason: string };
export type AuthResult =
| { ok: true; token: string; customer: CustomerSummary }
| { ok: false; reason: string; emailExists?: boolean };
export async function registerCustomer(input: {
firstName: string;
@@ -58,7 +60,23 @@ export async function registerCustomer(input: {
if (!res.ok) {
const data = await res.json().catch(() => null);
const message: string | undefined = data?.errors?.[0]?.message;
return { ok: false, reason: message ?? "Diese E-Mail-Adresse ist bereits registriert." };
// Payload's own message for a duplicate unique field is a generic
// "The following field is invalid: email" — not distinguishable from
// any other email-field validation failure by content alone, but at
// this point the client's own type="email" + required already ruled
// out a malformed/missing address, so "email" being the flagged field
// here in practice only ever means one thing: this address is already
// registered. emailExists lets the checkout UI react to that
// specifically (switch to the login toggle) instead of just showing
// an error the customer has no clear next step for.
const emailExists = Boolean(message?.toLowerCase().includes("email"));
return {
ok: false,
reason: emailExists
? "Diese E-Mail-Adresse ist bereits registriert. Bitte logge dich stattdessen ein."
: (message ?? "Registrierung ist gerade nicht möglich."),
emailExists,
};
}
return loginCustomer(input);