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
+58 -17
View File
@@ -7,10 +7,12 @@ import Image from "next/image";
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
import { useProducts } from "../../lib/products";
import { useDiscount, applyDiscount, 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, discountPercent } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { VersandModal } from "../../components/VersandModal";
import { VatBreakdown } from "../../components/VatBreakdown";
import { FreeShippingBanner } from "./FreeShippingBanner";
import type { TrustBadge, ShippingSettings } from "../../lib/payload";
@@ -19,6 +21,7 @@ export function CartContent({
shippingCost,
freeShippingThreshold,
shippingSettings,
defaultTaxRate,
}: {
trustBadges: TrustBadge[];
/** Price of the default (first active, i.e. Standard) ShippingMethod — an
@@ -34,11 +37,16 @@ export function CartContent({
* "shippingSettings", not "shipping" — that name is already the local
* computed shipping-cost value below. */
shippingSettings: ShippingSettings;
/** Tenant's default VAT rate (Company Settings), for products that don't
* override taxRatePercent themselves — see lib/cartTotals.ts's
* effectiveTaxRate(). */
defaultTaxRate: number;
}) {
const [versandOpen, setVersandOpen] = useState(false);
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();
@@ -59,6 +67,16 @@ export function CartContent({
? 0
: shippingCost;
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,
);
async function handleApplyDiscount(code: string) {
if (!code) return;
@@ -73,6 +91,7 @@ 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.");
}
@@ -150,6 +169,7 @@ export function CartContent({
{items.map(({ entry, product }, i) => {
const discount = discountPercent(product.price, product.compareAtPrice);
const unitPrice = effectivePrice(entry, product);
const taxRate = effectiveTaxRate(product, defaultTaxRate);
// (id, variant) together, not id alone — two lines for the
// same product with different variants need distinct React
// keys/element ids and must each only affect their own line
@@ -184,7 +204,7 @@ export function CartContent({
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-body-sm text-text-primary">{formatPrice(unitPrice)}</span>
<span className="text-label text-text-muted">inkl. MwSt.</span>
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
</p>
</div>
</div>
@@ -255,15 +275,12 @@ export function CartContent({
</div>
)}
{/* 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 && (
{/* Rabattcode — manual input when nothing's applied yet;
once active, just the result + "Entfernen" (also reached
via a direct link with a prefilled code, see the useEffect
above). /checkout mirrors this exact block, sharing state
through lib/discount.ts's localStorage store. */}
{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>
@@ -278,12 +295,36 @@ export function CartContent({
Entfernen
</button>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
handleApplyDiscount(discountInput.trim());
}}
className="flex flex-col gap-2 w-full"
>
<div className="flex gap-2 w-full">
<label className="sr-only" htmlFor="cart-discount-code">Rabattcode</label>
<input
id="cart-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="submit"
disabled={!discountInput.trim() || discountLoading}
className="shrink-0 rounded-sm border border-border px-4 py-2 text-body-sm font-bold text-text-primary hover:border-brand hover:text-brand transition-colors disabled:opacity-50"
>
Anwenden
</button>
</div>
{discountError && <p className="text-label text-red-600">{discountError}</p>}
{discountLoading && <p className="text-label text-text-muted">Rabattcode wird geprüft</p>}
</form>
)}
{/* 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">
@@ -326,7 +367,7 @@ export function CartContent({
<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>
<Link
+1 -1
View File
@@ -164,7 +164,7 @@ export function RelatedProducts() {
{product.name}
</p>
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} lowStock={product.lowStock} variants={product.variants} />
</div>
</div>
))}
+4 -2
View File
@@ -4,7 +4,7 @@ import { CartContent } from "./components/CartContent";
import { RelatedProducts } from "./components/RelatedProducts";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getCartTrustBadges, getShippingMethods, getShippingSettings } from "../lib/payload";
import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
// robots: noindex — transactional page (mirrors a specific shopper's cart
// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing
@@ -19,10 +19,11 @@ export const metadata: Metadata = {
};
export default async function CartPage() {
const [trustBadges, shippingMethods, shipping] = await Promise.all([
const [trustBadges, shippingMethods, shipping, defaultTaxRate] = await Promise.all([
getCartTrustBadges(),
getShippingMethods(),
getShippingSettings(),
getDefaultTaxRatePercent(),
]);
// The cart doesn't ask which shipping method the shopper wants yet
@@ -51,6 +52,7 @@ export default async function CartPage() {
shippingCost={defaultShipping?.price ?? 0}
freeShippingThreshold={freeShippingThreshold}
shippingSettings={shipping}
defaultTaxRate={defaultTaxRate}
/>
</Suspense>
<RelatedProducts />