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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user