Fix navbar/discount/invoice bugs from manual QA, add VAT breakdown, shipping-address override, checkout persistence, redesigned mobile menu

Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
  event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
  number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns

Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
  a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
  included, broken down per rate when a cart spans more than one
  (new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown

Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).

Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.

Checkout:
- Optional deviating shipping address (separate from the billing address
  used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
  selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
  address differs from billing

Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).

Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.

Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-22 22:52:15 +00:00
parent 7a9fed6f95
commit 43944d8cc8
39 changed files with 1435 additions and 306 deletions
+334 -23
View File
@@ -1,18 +1,22 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCart, clearCart, mergeServerCartIntoLocal } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { useDiscount, clearDiscount } from "../../lib/discount";
import { computeSubtotal, computeCartTotals, effectivePrice } from "../../lib/cartTotals";
import { computeSubtotal, computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
import { computeTaxBreakdown } from "../../lib/taxBreakdown";
import { formatPrice } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
import { VatBreakdown } from "../../components/VatBreakdown";
import { CheckoutSteps } from "../../components/CheckoutSteps";
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
import { dispatchAuthChanged } from "../../lib/auth";
import { readCheckoutDraft, writeCheckoutDraft, clearCheckoutDraft } from "../../lib/checkoutDraft";
import type { ShippingMethod, PaymentMethod, TrustBadge, ShippingSettings } from "../../lib/payload";
import type { CustomerProfile } from "../../lib/customerAuth";
@@ -37,6 +41,7 @@ export function CheckoutContent({
paymentMethods,
trustBadges,
shippingSettings,
defaultTaxRate,
customerEmail,
savedProfile,
}: {
@@ -47,6 +52,8 @@ export function CheckoutContent({
* "shippingSettings", not "shipping", since that name is already the
* local computed shipping-cost value below. */
shippingSettings: ShippingSettings;
/** Tenant's default VAT rate, same role as CartContent's own prop. */
defaultTaxRate: number;
/** From the checkout page's own session read (app/lib/customerAuth.ts) —
* null means no account is logged in yet, which flips "1. Rechnungsadresse"
* into inline-registration mode (password field shown, account created on
@@ -73,6 +80,135 @@ export function CheckoutContent({
const [loginError, setLoginError] = useState<string | null>(null);
const [loggingIn, setLoggingIn] = useState(false);
// Address-card fields — controlled (unlike before) so they can be
// persisted via lib/checkoutDraft.ts and restored after navigating away
// from /checkout and back. Initial values still come from savedProfile
// only (server-safe); a saved draft, if any, overwrites them in the
// hydration effect below rather than here, to avoid an SSR/hydration
// mismatch the same way BestellbestaetigungContent's own browser-only
// read does.
const [firstName, setFirstName] = useState(savedProfile?.firstName ?? "");
const [lastName, setLastName] = useState(savedProfile?.lastName ?? "");
const [email, setEmail] = useState(savedProfile?.email ?? customerEmail ?? "");
const [street, setStreet] = useState(savedProfile?.street ?? "");
const [packstationNumber, setPackstationNumber] = useState(savedProfile?.packstationNumber ?? "");
const [postNumber, setPostNumber] = useState(savedProfile?.postNumber ?? "");
const [zip, setZip] = useState(savedProfile?.zip ?? "");
const [city, setCity] = useState(savedProfile?.city ?? "");
const [country, setCountry] = useState(savedProfile?.country ?? "Deutschland");
// Optional package destination distinct from the billing address above —
// no savedProfile fallback (a customer's saved profile has only ever had
// one address), just an empty draft-only section.
const [hasDifferentShippingAddress, setHasDifferentShippingAddress] = useState(false);
const [shippingFirstName, setShippingFirstName] = useState("");
const [shippingLastName, setShippingLastName] = useState("");
const [shippingDeliveryMethod, setShippingDeliveryMethod] = useState<"address" | "packstation">("address");
const [shippingStreet, setShippingStreet] = useState("");
const [shippingPackstationNumber, setShippingPackstationNumber] = useState("");
const [shippingPostNumber, setShippingPostNumber] = useState("");
const [shippingZip, setShippingZip] = useState("");
const [shippingCity, setShippingCity] = useState("");
const [shippingCountry, setShippingCountry] = useState("Deutschland");
const [newsletterOptIn, setNewsletterOptIn] = useState(false);
// Flips true only after the hydration effect's setState calls have
// actually landed in a render — gates the write-back effect below so it
// never fires with the pre-hydration defaults first and briefly
// clobbers a real saved draft with them (both effects otherwise run in
// the same post-mount flush, before hydration's setState is reflected
// in either effect's closure).
const [draftHydrated, setDraftHydrated] = useState(false);
useEffect(() => {
// One-time sync from browser-only localStorage to app state on mount —
// same reasoning as CartContent.tsx's URL-code auto-apply and
// BestellbestaetigungContent's own sessionStorage read, not a
// render-cascade: this can't run any earlier (no localStorage on the
// server) and never re-runs after mount ([] deps).
/* eslint-disable react-hooks/set-state-in-effect */
const draft = readCheckoutDraft();
if (draft) {
if (draft.firstName) setFirstName(draft.firstName);
if (draft.lastName) setLastName(draft.lastName);
if (draft.email) setEmail(draft.email);
if (draft.deliveryMethod) setDeliveryMethod(draft.deliveryMethod);
if (draft.street) setStreet(draft.street);
if (draft.packstationNumber) setPackstationNumber(draft.packstationNumber);
if (draft.postNumber) setPostNumber(draft.postNumber);
if (draft.zip) setZip(draft.zip);
if (draft.city) setCity(draft.city);
if (draft.country) setCountry(draft.country);
if (typeof draft.hasDifferentShippingAddress === "boolean") setHasDifferentShippingAddress(draft.hasDifferentShippingAddress);
if (draft.shippingFirstName) setShippingFirstName(draft.shippingFirstName);
if (draft.shippingLastName) setShippingLastName(draft.shippingLastName);
if (draft.shippingDeliveryMethod) setShippingDeliveryMethod(draft.shippingDeliveryMethod);
if (draft.shippingStreet) setShippingStreet(draft.shippingStreet);
if (draft.shippingPackstationNumber) setShippingPackstationNumber(draft.shippingPackstationNumber);
if (draft.shippingPostNumber) setShippingPostNumber(draft.shippingPostNumber);
if (draft.shippingZip) setShippingZip(draft.shippingZip);
if (draft.shippingCity) setShippingCity(draft.shippingCity);
if (draft.shippingCountry) setShippingCountry(draft.shippingCountry);
if (typeof draft.newsletterOptIn === "boolean") setNewsletterOptIn(draft.newsletterOptIn);
if (draft.shippingMethodId != null) setShippingMethodId(draft.shippingMethodId);
if (draft.paymentMethodId != null) setPaymentMethodId(draft.paymentMethodId);
}
setDraftHydrated(true);
/* eslint-enable react-hooks/set-state-in-effect */
}, []);
useEffect(() => {
if (!draftHydrated) return;
writeCheckoutDraft({
firstName,
lastName,
email,
deliveryMethod,
street,
packstationNumber,
postNumber,
zip,
city,
country,
hasDifferentShippingAddress,
shippingFirstName,
shippingLastName,
shippingDeliveryMethod,
shippingStreet,
shippingPackstationNumber,
shippingPostNumber,
shippingZip,
shippingCity,
shippingCountry,
newsletterOptIn,
shippingMethodId,
paymentMethodId,
});
}, [
draftHydrated,
firstName,
lastName,
email,
deliveryMethod,
street,
packstationNumber,
postNumber,
zip,
city,
country,
hasDifferentShippingAddress,
shippingFirstName,
shippingLastName,
shippingDeliveryMethod,
shippingStreet,
shippingPackstationNumber,
shippingPostNumber,
shippingZip,
shippingCity,
shippingCountry,
newsletterOptIn,
shippingMethodId,
paymentMethodId,
]);
const productsLoading = products.length === 0 && cart.length > 0;
const items = cart
.map((entry) => ({ entry, product: products.find((p) => p.id === entry.id) }))
@@ -86,6 +222,16 @@ export function CheckoutContent({
subtotal >= selectedShipping.freeShippingThreshold;
const shipping = items.length === 0 || freeShipping ? 0 : selectedShipping?.price ?? 0;
const { totalSavings, discountAmount, total } = computeCartTotals(items, shipping, discount);
const taxBreakdown = computeTaxBreakdown(
items.map(({ entry, product }) => ({
quantity: entry.qty,
unitPrice: effectivePrice(entry, product),
taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
})),
subtotal,
discountAmount,
shipping,
);
// Logs into an existing account inline, without leaving /checkout —
// router.refresh() re-runs the page's Server Component, which re-reads
@@ -107,6 +253,7 @@ export function CheckoutContent({
return;
}
await mergeServerCartIntoLocal();
dispatchAuthChanged();
router.refresh();
} catch {
setLoginError("Login ist gerade nicht möglich.");
@@ -116,6 +263,7 @@ export function CheckoutContent({
async function handleLogout() {
await fetch("/api/account/logout", { method: "POST" });
dispatchAuthChanged();
router.refresh();
}
@@ -162,18 +310,31 @@ export function CheckoutContent({
shippingMethodId,
paymentMethodId,
discountCode: discount?.code ?? null,
firstName: String(form.get("firstName") ?? ""),
lastName: String(form.get("lastName") ?? ""),
email: String(form.get("email") ?? ""),
firstName,
lastName,
email,
// Deliberately still read from FormData, not state — password is the
// one address-card field that stays uncontrolled/unpersisted (see
// lib/checkoutDraft.ts's own comment on why).
password: customerEmail ? undefined : String(form.get("password") ?? ""),
deliveryMethod,
street: String(form.get("street") ?? "") || undefined,
packstationNumber: String(form.get("packstationNumber") ?? "") || undefined,
postNumber: String(form.get("postNumber") ?? "") || undefined,
zip: String(form.get("zip") ?? ""),
city: String(form.get("city") ?? ""),
country: String(form.get("country") ?? ""),
newsletterOptIn: form.get("newsletterOptIn") === "on",
street: street || undefined,
packstationNumber: packstationNumber || undefined,
postNumber: postNumber || undefined,
zip,
city,
country,
hasDifferentShippingAddress,
shippingFirstName: hasDifferentShippingAddress ? shippingFirstName : undefined,
shippingLastName: hasDifferentShippingAddress ? shippingLastName : undefined,
shippingDeliveryMethod: hasDifferentShippingAddress ? shippingDeliveryMethod : undefined,
shippingStreet: hasDifferentShippingAddress ? shippingStreet || undefined : undefined,
shippingPackstationNumber: hasDifferentShippingAddress ? shippingPackstationNumber || undefined : undefined,
shippingPostNumber: hasDifferentShippingAddress ? shippingPostNumber || undefined : undefined,
shippingZip: hasDifferentShippingAddress ? shippingZip : undefined,
shippingCity: hasDifferentShippingAddress ? shippingCity : undefined,
shippingCountry: hasDifferentShippingAddress ? shippingCountry : undefined,
newsletterOptIn,
};
try {
@@ -216,6 +377,10 @@ export function CheckoutContent({
}
clearCart();
clearDiscount();
clearCheckoutDraft();
// Guest checkout with a password creates+logs into a new account
// server-side — Navbar needs to know even though it isn't remounting.
dispatchAuthChanged();
router.push("/bestellbestaetigung");
} catch {
setPurchaseError("Die Bestellung konnte gerade nicht abgeschlossen werden.");
@@ -344,8 +509,8 @@ export function CheckoutContent({
1. Rechnungsadresse
</p>
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField label="Vorname" name="firstName" type="text" defaultValue={savedProfile?.firstName} placeholder="Max" autoComplete="given-name" required />
<FormField label="Nachname" name="lastName" type="text" defaultValue={savedProfile?.lastName} placeholder="Mustermann" autoComplete="family-name" required />
<FormField label="Vorname" name="firstName" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Max" autoComplete="given-name" required />
<FormField label="Nachname" name="lastName" type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Mustermann" autoComplete="family-name" required />
</div>
{/* w-[calc(50%-0.5rem)] at sm: — exactly matches Vorname's
actual rendered width in the 2-col row above (each half of
@@ -354,7 +519,8 @@ export function CheckoutContent({
label="E-Mail-Adresse"
name="email"
type="email"
defaultValue={savedProfile?.email ?? customerEmail ?? undefined}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="max@beispiel.de"
autoComplete="email"
required
@@ -421,7 +587,8 @@ export function CheckoutContent({
label="Straße und Hausnummer"
name="street"
type="text"
defaultValue={savedProfile?.street ?? undefined}
value={street}
onChange={(e) => setStreet(e.target.value)}
placeholder="Musterstraße 1"
autoComplete="street-address"
required
@@ -433,7 +600,8 @@ export function CheckoutContent({
label="Packstationnummer"
name="packstationNumber"
type="text"
defaultValue={savedProfile?.packstationNumber ?? undefined}
value={packstationNumber}
onChange={(e) => setPackstationNumber(e.target.value)}
inputMode="numeric"
placeholder="123"
autoComplete="off"
@@ -443,7 +611,8 @@ export function CheckoutContent({
label="Postnummer"
name="postNumber"
type="text"
defaultValue={savedProfile?.postNumber ?? undefined}
value={postNumber}
onChange={(e) => setPostNumber(e.target.value)}
inputMode="numeric"
placeholder="1234567"
autoComplete="off"
@@ -452,14 +621,15 @@ export function CheckoutContent({
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField label="PLZ" name="zip" type="text" defaultValue={savedProfile?.zip ?? undefined} placeholder="10115" autoComplete="postal-code" required />
<FormField label="Ort" name="city" type="text" defaultValue={savedProfile?.city ?? undefined} placeholder="Berlin" autoComplete="address-level2" required />
<FormField label="PLZ" name="zip" type="text" value={zip} onChange={(e) => setZip(e.target.value)} placeholder="10115" autoComplete="postal-code" required />
<FormField label="Ort" name="city" type="text" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Berlin" autoComplete="address-level2" required />
</div>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Land</span>
<select
name="country"
defaultValue={savedProfile?.country ?? "Deutschland"}
value={country}
onChange={(e) => setCountry(e.target.value)}
required
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 bg-bg-base"
>
@@ -468,10 +638,150 @@ export function CheckoutContent({
<option>Schweiz</option>
</select>
</label>
<div className="h-px bg-border w-full" />
<label className="flex gap-3 items-start w-full cursor-pointer">
<input
type="checkbox"
checked={hasDifferentShippingAddress}
onChange={(e) => setHasDifferentShippingAddress(e.target.checked)}
className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="text-body-sm text-text-primary">Abweichende Lieferadresse verwenden</span>
</label>
{hasDifferentShippingAddress && (
<div className="flex flex-col gap-4 items-start w-full">
<p className="font-semibold text-body-sm text-text-primary">Lieferadresse</p>
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField
label="Vorname"
type="text"
value={shippingFirstName}
onChange={(e) => setShippingFirstName(e.target.value)}
placeholder="Max"
autoComplete="off"
required
/>
<FormField
label="Nachname"
type="text"
value={shippingLastName}
onChange={(e) => setShippingLastName(e.target.value)}
placeholder="Mustermann"
autoComplete="off"
required
/>
</div>
<div className="w-full sm:w-[calc(50%-0.5rem)] flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full rounded-sm border border-border overflow-hidden">
<button
type="button"
onClick={() => setShippingDeliveryMethod("address")}
aria-pressed={shippingDeliveryMethod === "address"}
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${
shippingDeliveryMethod === "address"
? "bg-brand text-text-primary"
: "text-text-muted hover:text-text-primary"
}`}
>
Lieferadresse
</button>
<button
type="button"
onClick={() => setShippingDeliveryMethod("packstation")}
aria-pressed={shippingDeliveryMethod === "packstation"}
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${
shippingDeliveryMethod === "packstation"
? "bg-brand text-text-primary"
: "text-text-muted hover:text-text-primary"
}`}
>
Packstation
</button>
</div>
</div>
{shippingDeliveryMethod === "address" ? (
<FormField
label="Straße und Hausnummer"
type="text"
value={shippingStreet}
onChange={(e) => setShippingStreet(e.target.value)}
placeholder="Musterstraße 1"
autoComplete="off"
required
wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0"
/>
) : (
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField
label="Packstationnummer"
type="text"
value={shippingPackstationNumber}
onChange={(e) => setShippingPackstationNumber(e.target.value)}
inputMode="numeric"
placeholder="123"
autoComplete="off"
required
/>
<FormField
label="Postnummer"
type="text"
value={shippingPostNumber}
onChange={(e) => setShippingPostNumber(e.target.value)}
inputMode="numeric"
placeholder="1234567"
autoComplete="off"
required
/>
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField
label="PLZ"
type="text"
value={shippingZip}
onChange={(e) => setShippingZip(e.target.value)}
placeholder="10115"
autoComplete="off"
required
/>
<FormField
label="Ort"
type="text"
value={shippingCity}
onChange={(e) => setShippingCity(e.target.value)}
placeholder="Berlin"
autoComplete="off"
required
/>
</div>
<label className="flex flex-col gap-2 items-start w-full">
<span className="text-label text-text-muted">Land</span>
<select
value={shippingCountry}
onChange={(e) => setShippingCountry(e.target.value)}
required
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 bg-bg-base"
>
<option>Deutschland</option>
<option>Österreich</option>
<option>Schweiz</option>
</select>
</label>
</div>
)}
<div className="h-px bg-border w-full" />
<label className="flex gap-3 items-start w-full cursor-pointer">
<input
type="checkbox"
name="newsletterOptIn"
checked={newsletterOptIn}
onChange={(e) => setNewsletterOptIn(e.target.checked)}
className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="flex flex-col gap-1 text-body-sm text-text-primary">
@@ -583,6 +893,7 @@ export function CheckoutContent({
{items.map(({ entry, product }) => {
const unitPrice = effectivePrice(entry, product);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
const lineKey = entry.variant ? `${product.id}::${entry.variant}` : product.id;
return (
<div key={lineKey} className="flex gap-4 items-center w-full">
@@ -595,7 +906,7 @@ export function CheckoutContent({
{entry.variant ? ` (${entry.variant})` : ""}
</p>
<p className="text-label text-text-muted">
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. MwSt.</span>
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. {taxRate}% MwSt.</span>
</p>
</div>
<p className="text-body-sm text-text-primary whitespace-nowrap">{formatPrice(entry.qty * unitPrice)}</p>
@@ -670,7 +981,7 @@ export function CheckoutContent({
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
</div>
<p className="text-label text-text-muted">inkl. MwSt.</p>
<VatBreakdown groups={taxBreakdown} />
</div>
</div>