Add standalone registration page; fix 2 merkliste bugs; honor login redirect

New /konto/registrieren (+ RegisterForm.tsx) — until now an account
could only be created inline during checkout, which stopped making
sense the moment the Wishlist gave accounts a non-purchase use case.
LoginForm now also honors ?redirect= (previously ignored, always
landing on /konto/bestellungen regardless of where the login was
triggered from, e.g. WishlistButton's login-gate) and links to the
new registration page instead of implying "only via checkout".

Fixed: removing an item from the wishlist didn't disappear from
/konto/merkliste — that page was a Server Component with a fixed
render, not reactive to the client-side toggle. New MerklisteGrid.tsx
(Client Component) uses useWishlist()'s live state as the actual
source of truth for which products are still shown.

Fixed: AddToCartInlineButton looked broken on that same page —
`className="w-full"` was passed to it, but its `className` prop
*replaces* the whole default styling (`?? default`, not a merge),
throwing away all the button's actual styling. Its default is
already `w-full`; no override needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-30 22:59:23 +00:00
parent bce5a9f82c
commit fc8a1d4201
6 changed files with 271 additions and 75 deletions
+16 -7
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Reveal } from "../../../components/Reveal";
import { mergeServerCartIntoLocal } from "../../../lib/cart";
@@ -9,6 +9,12 @@ import { dispatchAuthChanged } from "../../../lib/auth";
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
// WishlistButton (and anything else login-gated) sends the shopper back
// here with ?redirect=<where they were>, e.g. a product page they
// wanted to heart — previously ignored, always landing on
// /konto/bestellungen regardless of where the login was triggered from.
const redirectTo = searchParams.get("redirect") || "/konto/bestellungen";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -32,7 +38,7 @@ export function LoginForm() {
}
await mergeServerCartIntoLocal();
dispatchAuthChanged();
router.push("/konto/bestellungen");
router.push(redirectTo);
router.refresh();
} catch {
setError("Login ist gerade nicht möglich.");
@@ -81,11 +87,14 @@ export function LoginForm() {
Passwort vergessen?
</Link>
<p className="text-body-sm text-text-muted">
Noch kein Konto? Einfach beim{" "}
<Link href="/checkout" className="underline hover:text-brand transition-colors">
nächsten Einkauf
</Link>{" "}
anlegen.
Noch kein Konto?{" "}
<Link
href={`/konto/registrieren${redirectTo !== "/konto/bestellungen" ? `?redirect=${encodeURIComponent(redirectTo)}` : ""}`}
className="underline hover:text-brand transition-colors"
>
Jetzt anlegen
</Link>
.
</p>
</Reveal>
);
+7 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { LoginForm } from "./components/LoginForm";
import { Footer } from "../../components/Footer";
@@ -16,7 +17,12 @@ export default function KontoLoginPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<LoginForm />
{/* Suspense required — LoginForm uses useSearchParams() (?redirect=,
e.g. from WishlistButton's login-gate) which opts any consumer
into client-side rendering unless wrapped. */}
<Suspense fallback={null}>
<LoginForm />
</Suspense>
</main>
<Footer />
</>
@@ -0,0 +1,88 @@
"use client";
import Image from "next/image";
import { RevealGroup, RevealItem } from "../../../components/Reveal";
import { formatPrice, discountPercent } from "../../../lib/format";
import { AddToCartInlineButton } from "../../../components/AddToCartInlineButton";
import { WishlistButton } from "../../../components/WishlistButton";
import { useWishlist } from "../../../lib/useWishlist";
import { effectiveTaxRate } from "../../../lib/cartTotals";
import type { Product } from "../../../lib/payload";
// Client Component so removing an item (WishlistButton toggling it off)
// disappears from this grid immediately — the server-rendered initial
// list alone doesn't react to that client-side toggle at all (the parent
// page.tsx is a Server Component, its render is fixed at request time).
// useWishlist()'s live `items` is the actual source of truth for which
// products are still wishlisted; `initialProducts` only supplies the
// display data (name/image/price) for whatever numericIds are currently
// wishlisted, since the wishlist itself only stores product ids.
export function MerklisteGrid({
initialProducts,
defaultTaxRate,
kleinunternehmer,
}: {
initialProducts: Product[];
defaultTaxRate: number;
kleinunternehmer: boolean;
}) {
const { items } = useWishlist();
const wishlistedIds = new Set(items.map((i) => i.productId));
// Preserve the wishlist's own order (most-recently-added-first, via
// `items`) rather than initialProducts' own order.
const productsByNumericId = new Map(initialProducts.map((p) => [p.numericId, p]));
const visibleProducts = items.map((i) => productsByNumericId.get(i.productId)).filter((p): p is Product => Boolean(p));
if (visibleProducts.length === 0) {
return <p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>;
}
return (
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{visibleProducts.map((product) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
return (
<RevealItem key={product.id} className="bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full">
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{fullyOutOfStock && (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
)}
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
{product.name}
</p>
<div className="flex flex-col gap-1 items-start">
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
</div>
{/* No className override — AddToCartInlineButton's `className`
prop REPLACES its whole default styling (`?? defaultClass`,
not a merge), so passing just "w-full" here previously threw
away all the button's actual styling. Its default is
already `w-full`. */}
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</RevealItem>
);
})}
</RevealGroup>
);
}
+4 -67
View File
@@ -1,15 +1,11 @@
import type { Metadata } from "next";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { formatPrice, discountPercent } from "../../lib/format";
import { getSessionCustomer, getWishlist } from "../../lib/customerAuth";
import { getProductsByIds, getWishlistEnabled, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { effectiveTaxRate } from "../../lib/cartTotals";
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
import { WishlistButton } from "../../components/WishlistButton";
import { MerklisteGrid } from "./components/MerklisteGrid";
// robots: noindex — account area, same reasoning as /konto/bestellungen.
export const metadata: Metadata = {
@@ -37,13 +33,7 @@ export default async function KontoMerklistePage() {
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const products = await getProductsByIds(wishlistItems.map((i) => i.productId));
// Preserve the wishlist's own most-recently-added-first order rather
// than whatever order the products query happens to return in.
const productsByNumericId = new Map(products.map((p) => [p.numericId, p]));
const orderedProducts = wishlistItems
.map((item) => productsByNumericId.get(item.productId))
.filter((p): p is NonNullable<typeof p> => Boolean(p));
const initialProducts = await getProductsByIds(wishlistItems.map((i) => i.productId));
return (
<>
@@ -53,60 +43,7 @@ export default async function KontoMerklistePage() {
Meine Merkliste
</p>
{orderedProducts.length === 0 ? (
<p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>
) : (
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{orderedProducts.map((product) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
return (
<RevealItem
key={product.id}
className="bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full"
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{fullyOutOfStock && (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
)}
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
{product.name}
</p>
<div className="flex flex-col gap-1 items-start">
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
</div>
<AddToCartInlineButton
id={product.id}
outOfStock={product.outOfStock}
maxQty={product.maxQty}
variants={product.variants}
className="w-full"
/>
</div>
</RevealItem>
);
})}
</RevealGroup>
)}
<MerklisteGrid initialProducts={initialProducts} defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
<div className="flex gap-6 self-center sm:self-start">
<Link href="/konto/bestellungen" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Reveal } from "../../../components/Reveal";
import { mergeServerCartIntoLocal } from "../../../lib/cart";
import { dispatchAuthChanged } from "../../../lib/auth";
// Standalone registration, independent of checkout — until the Wishlist
// shipped, the only way to get a customer account was the inline
// registration step inside checkout (see api/checkout/route.ts), which
// made sense when an account only ever existed to hold an order. That
// stopped being true the moment a shopper could want an account just to
// save products to a Wishlist without buying anything yet.
export function RegisterForm() {
const router = useRouter();
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect") || "/konto/bestellungen";
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/account/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ firstName, lastName, email, password }),
});
const data = await res.json();
if (!data.ok) {
setError(data.reason || "Registrierung fehlgeschlagen.");
setLoading(false);
return;
}
await mergeServerCartIntoLocal();
dispatchAuthChanged();
router.push(redirectTo);
router.refresh();
} catch {
setError("Registrierung ist gerade nicht möglich.");
setLoading(false);
}
}
return (
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-20 px-[var(--layout-padding-x)] w-full max-w-[26rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Konto anlegen
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 items-start w-full">
<div className="flex gap-4 w-full">
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Vorname</span>
<input
type="text"
required
autoComplete="given-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Nachname</span>
<input
type="text"
required
autoComplete="family-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
</div>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">E-Mail-Adresse</span>
<input
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Passwort</span>
<input
type="password"
required
minLength={8}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
</label>
{error && <p className="text-label text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className={`w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary ${loading ? "opacity-70 pointer-events-none" : ""}`}
>
{loading ? "Einen Moment…" : "Konto anlegen"}
</button>
</form>
<p className="text-body-sm text-text-muted">
Schon ein Konto?{" "}
<Link
href={`/konto/login${redirectTo !== "/konto/bestellungen" ? `?redirect=${encodeURIComponent(redirectTo)}` : ""}`}
className="underline hover:text-brand transition-colors"
>
Einloggen
</Link>
.
</p>
</Reveal>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { RegisterForm } from "./components/RegisterForm";
import { Footer } from "../../components/Footer";
// robots: noindex — account area, same reasoning as /konto/login.
export const metadata: Metadata = {
title: "Konto anlegen",
description: "Lege ein Konto bei einfach produktiv an.",
robots: {
index: false,
follow: true,
},
};
export default function KontoRegistrierenPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
{/* Suspense required — RegisterForm uses useSearchParams() (?redirect=),
same reasoning as /konto/login's own page. */}
<Suspense fallback={null}>
<RegisterForm />
</Suspense>
</main>
<Footer />
</>
);
}