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