Add password reset, order confirmation email with editable templates, and fix missing account entry points

Password reset uses Payload's built-in forgot/reset-password flow,
customized to link to this app instead of the Payload admin. Order
confirmation email and the password-reset email's wording both come from
a new Payload email-templates collection, editable without a deploy and
previewable via Live Preview at /email-preview/[type] (same mechanism as
Posts/LegalPages/Testimonials, sample data instead of a real document).

Also: order numbers get a random suffix (prevents guessing, motivated by
a considered-and-deferred guest order-lookup feature); the discount code
field only shows in the cart when a code is actually active (codes now
apply via a ?code= link instead of manual entry); and three navigation
gaps found while testing — no reachable login link with an empty cart, no
logout link anywhere, no way back from profile to order history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 08:14:08 +00:00
parent adca6e0f64
commit f0df359db4
26 changed files with 865 additions and 81 deletions
+31 -31
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
@@ -38,9 +39,9 @@ export function CartContent({
const cart = useCart();
const products = useProducts();
const discount = useDiscount();
const [discountInput, setDiscountInput] = useState("");
const [discountError, setDiscountError] = useState<string | null>(null);
const [discountLoading, setDiscountLoading] = useState(false);
const searchParams = useSearchParams();
// While the /api/products fetch is still pending, treat a non-empty
// cart as "loading" rather than "empty" — the old hardcoded PRODUCTS
// lookup was synchronous, so this distinction didn't exist before;
@@ -59,8 +60,7 @@ export function CartContent({
: shippingCost;
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
async function handleApplyDiscount() {
const code = discountInput.trim();
async function handleApplyDiscount(code: string) {
if (!code) return;
setDiscountLoading(true);
setDiscountError(null);
@@ -73,7 +73,6 @@ export function CartContent({
const data = await res.json();
if (data.valid) {
applyDiscount({ code: code.toUpperCase(), type: data.type, value: data.value });
setDiscountInput("");
} else {
setDiscountError(data.reason || "Dieser Code ist ungültig.");
}
@@ -84,6 +83,20 @@ export function CartContent({
}
}
// No manual input field anymore (see the Rabattcode section below) —
// codes are shared as a direct link instead (e.g. "/cart?code=SAVE10"),
// auto-applied once on arrival. Only fires while nothing's applied yet
// and there's actually something in the cart to validate a minimum-order
// value against.
useEffect(() => {
const code = searchParams.get("code");
if (code && !discount && items.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time sync from the URL's ?code= param to app state on arrival, via an async server validation call, not a render-cascade
handleApplyDiscount(code);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- only ever re-run when the cart finishes loading or the URL's code param itself changes, not on every discount/handleApplyDiscount identity change
}, [searchParams, items.length]);
return (
<>
{/* Page header */}
@@ -233,10 +246,15 @@ export function CartContent({
</div>
)}
{/* Rabattcode — cart-only, /checkout only displays the
already-applied result (see lib/discount.ts, shared via
{/* Rabattcode — no manual input anymore (Nutzer-Entscheidung:
kein offenes Eingabefeld für jede:n Besucher:in), nur noch
sichtbar wenn tatsächlich ein Code aktiv ist. Codes kommen
jetzt ausschließlich über einen Link mit vorausgefülltem
Code (siehe die useEffect oben), nicht mehr durch manuelle
Eingabe hier. /checkout zeigt weiterhin nur das bereits
angewendete Ergebnis (see lib/discount.ts, shared via
localStorage the same way the cart itself is). */}
{discount ? (
{discount && (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center w-full">
<span className="text-body-sm text-success">Rabattcode ({discount.code})</span>
@@ -251,30 +269,12 @@ export function CartContent({
Entfernen
</button>
</div>
) : (
<div className="flex flex-col gap-2 w-full">
<div className="flex gap-2 w-full">
<label className="sr-only" htmlFor="discount-code">Rabattcode</label>
<input
id="discount-code"
type="text"
value={discountInput}
onChange={(e) => setDiscountInput(e.target.value)}
placeholder="Rabattcode"
className="flex-1 min-w-0 border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
/>
<button
type="button"
onClick={handleApplyDiscount}
disabled={discountLoading || !discountInput.trim()}
className="shrink-0 px-4 py-2 rounded-sm border border-border text-body-sm font-bold text-text-primary hover:border-brand hover:text-brand disabled:opacity-50 disabled:hover:border-border disabled:hover:text-text-primary transition-colors"
>
{discountLoading ? "…" : "Anwenden"}
</button>
</div>
{discountError && <p className="text-label text-red-600">{discountError}</p>}
</div>
)}
{/* Feedback for a code that arrived via URL (?code=...) but
turned out invalid/expired — surfaced even though there's
no input field to attach it to anymore. */}
{!discount && discountError && <p className="text-label text-red-600 w-full">{discountError}</p>}
{!discount && discountLoading && <p className="text-label text-text-muted w-full">Rabattcode wird geprüft</p>}
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center w-full">
+15 -6
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { CartContent } from "./components/CartContent";
import { RelatedProducts } from "./components/RelatedProducts";
import { TrustRow } from "../components/TrustRow";
@@ -38,12 +39,20 @@ export default async function CartPage() {
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<CartContent
trustBadges={trustBadges}
shippingCost={defaultShipping?.price ?? 0}
freeShippingThreshold={freeShippingThreshold}
shippingSettings={shipping}
/>
{/* Suspense required — CartContent uses useSearchParams() (?code=
auto-apply, see its own comment) which opts any consumer into
client-side rendering unless wrapped. fallback={null}: the cart
itself is entirely client-rendered from localStorage anyway
(see CartContent's own productsLoading handling), so there's no
meaningful server-rendered content this would flash away from. */}
<Suspense fallback={null}>
<CartContent
trustBadges={trustBadges}
shippingCost={defaultShipping?.price ?? 0}
freeShippingThreshold={freeShippingThreshold}
shippingSettings={shipping}
/>
</Suspense>
<RelatedProducts />
<TrustRow />
</main>