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
+146 -21
View File
@@ -195,16 +195,16 @@ Applied in `/cart` only (`/checkout` displays the already-applied result,
no second input) — real server-side validation, not just a client-side
check against Payload's public API, unlike most content on this site.
- **No manual input field anymore** — the Rabattcode section on `/cart`
(`CartContent.tsx`) only renders at all when a code is actually applied;
there's no open "enter a code" box for every visitor (Nutzer-Entscheidung:
less visual noise, and codes are meant to be shared as marketing links,
not guessed/typed in). Instead, `?code=SAVE10` on the `/cart` URL
auto-applies once on arrival (a `useEffect` reading `useSearchParams()`
requires `/cart`'s `page.tsx` to wrap `CartContent` in `<Suspense>`, a
Next.js requirement for any `useSearchParams()` consumer). A code that
arrives via the URL but turns out invalid/expired still shows an inline
error, just without an input box to attach it to.
- **Manual input field on `/cart`** (`CartContent.tsx`) — a text field +
"Anwenden" button, shown whenever no code is currently applied; once
applied, the field is replaced by a read-only result + "Entfernen" link
(reverted an earlier no-manual-input decision). `?code=SAVE10` on the
`/cart` URL still auto-applies once on arrival (a `useEffect` reading
`useSearchParams()` — requires `/cart`'s `page.tsx` to wrap `CartContent`
in `<Suspense>`, a Next.js requirement for any `useSearchParams()`
consumer), so a marketing link still works without the shopper typing
anything. A code that arrives via the URL but turns out invalid/expired
shows the same inline error the manual field uses.
- **`app/lib/discountServer.ts`** (server-only, imported exclusively by the
two route handlers below — never by a `"use client"` component, same
reasoning as Live Preview's `next/headers` lesson above) talks to
@@ -270,6 +270,70 @@ check against Payload's public API, unlike most content on this site.
labelled "zahlungspflichtig" but nothing actually captures a payment
yet. See `project_backend_checkout_plan` in the assistant's own memory.
### VAT display
Every price shown storefront-wide says "inkl. X% MwSt." with the *actual*
resolved rate (`app/lib/cartTotals.ts`'s `effectiveTaxRate(product,
defaultRate)` — a product's own `taxRatePercent` override if set,
otherwise the tenant default from `company-settings`), not a generic
"inkl. MwSt." disclosure — `getDefaultTaxRatePercent()` in
`app/lib/payload.ts` is a separate, ISR-cached (60s) fetch of just that one
number, deliberately not `getCompanySettings()` itself (that one is
`cache: "no-store"` for its invoice-generation callers, where always-fresh
bank details matter; the display rate only needs the same freshness every
other public catalog fetch already has).
A cart/checkout/order-confirmation *total* additionally shows the actual
€ amount of VAT included, not just a percentage — `app/lib/taxBreakdown.ts`'s
`computeTaxBreakdown()` (extracted out of what used to be independently
duplicated `groupByTaxRate()` logic in `invoicePdf.tsx`/
`correctionInvoicePdf.tsx`, now shared by both the PDFs and this display)
groups line items by their effective rate and reports each group's actual
tax amount; `app/components/VatBreakdown.tsx` renders a single "enthält
X% MwSt.: Y €" line when the cart/order has one rate, or one line per rate
when it spans more than one. Used on `/cart`, `/checkout`, `/bestellbestaetigung`,
the order-confirmation email (`emailTemplates.ts`'s `renderOrderConfirmationHtml`,
which already carried per-item `taxRatePercent` but didn't render it
before), and both account order pages (see "Orders & customer accounts"
below) — the account order list additionally shows up to 4 product
thumbnails per order row (`getProductImagesByIds()` in `app/lib/payload.ts`,
a plain product-id → image-url lookup separate from the slug-keyed
catalog, since an order only ever snapshots a numeric product id).
### Checkout state persistence
`app/lib/checkoutDraft.ts``localStorage` under `ep_checkout_draft`,
plain read/write functions (not `useSyncExternalStore` like `cart.ts`/
`discount.ts`: `CheckoutContent` is this draft's only reader, no
cross-component subscription to keep in sync). Every address-card field
(name, email, delivery method, street/Packstation, PLZ/Ort/Land, the
shipping-address-override fields below, newsletter opt-in) plus the
selected shipping/payment method is now a controlled input backed by this
draft, restored on mount (a `useEffect`-deferred read, same SSR/hydration-
mismatch avoidance as `BestellbestaetigungContent`'s own sessionStorage
read) and cleared on a completed purchase. `password` is deliberately
excluded — stays a plain uncontrolled, unpersisted input.
### Optional deviating shipping address
A checkbox in "1. Rechnungsadresse" ("Abweichende Lieferadresse
verwenden") reveals a second address section (own name + delivery method +
street/Packstation/PLZ/Ort/Land) — when used, the order's original address
fields stay the **billing** address (used for the invoice's "An" block
regardless), and the `shipping*`-prefixed fields (`hasDifferentShippingAddress`,
`shippingFirstName`/`shippingLastName`/`shippingDeliveryMethod`/
`shippingStreet`/`shippingPackstationNumber`/`shippingPostNumber`/
`shippingZip`/`shippingCity`/`shippingCountry` — mirrored 1:1 on the
Payload `orders` collection, see the Payload README) determine where the
order actually ships. `/api/checkout/route.ts` validates the override the
same way it already validated the primary address (required fields,
street-xor-Packstation depending on the chosen delivery method). The
invoice PDF shows a third "Lieferadresse" address block alongside Von/An
when set (see "Invoice PDFs" below); `/konto/bestellungen/[orderNumber]`
shows both addresses too, relabeling the first one "Rechnungsadresse"
instead of "Lieferadresse" only once there's an actual second address to
distinguish it from.
### Product variants
A cart line's identity is `(id, variant)` together, not `id` alone —
@@ -304,6 +368,21 @@ discount badge, never both) once *every* variant of a product is out —
one sold-out variant among several just reads as such in the picker
itself, not as a misleading blanket badge.
**Low-stock warning**: `app/lib/payload.ts`'s `isLowStock()` derives
`Product.lowStock` (and each `variants[].lowStock`) from `trackInventory`/
`stock`/`lowStockThreshold` the same way `outOfStock` is derived — true
only when inventory is tracked, stock is above zero (out-of-stock has its
own distinct badge, the two never combine), and at/below the product's own
`lowStockThreshold`. Neither raw `stock` nor `lowStockThreshold` is
exposed in the public `Product` type, only this derived boolean — the
public API has no reason to leak exact counts. Shown as a "Nur noch wenige
verfügbar" pill (a new `--color-warning` token in `globals.css`, distinct
from the brand-colored discount badge so the two never read as the same
thing) on `ProductGrid.tsx`/`ProductSpotlight.tsx`, a `"(nur noch wenige)"`
variant-select suffix, and a text hint under `AddToCartButton`/
`AddToCartInlineButton` — same component/prop shape as `outOfStock`
throughout.
**Pricing**: `app/lib/cartTotals.ts`'s `effectivePrice(entry, product)`
a selected variant's `priceOverride` wins over the base `product.price`
(falling back to it when unset or no variant selected). Every cart/
@@ -409,16 +488,31 @@ inbox, not only in `/konto/bestellungen`.
same caveat as the rest of `company-settings`' seller data below.
- **Per-tax-rate summary**: line items are grouped by their own
snapshotted `taxRatePercent` (see the Payload README's "Per-product tax
rates" section) and the summary prints one "Netto (X%)" / "zzgl. X%
MwSt." pair per distinct rate actually present in that order — a plain
single pair in the common case (one rate for the whole order), a real
multi-rate breakdown the moment a product with a different rate is
involved. The order-level discount/shipping are distributed
proportionally across each rate group before computing net/tax, so the
grouped totals still reconcile exactly to `order.total`.
rates" section) via the shared `app/lib/taxBreakdown.ts` (see "VAT
display" above) and the summary prints one plain "Netto" / "zzgl. X%
MwSt." pair per distinct rate actually present in that order — no `%`
after "Netto" itself anymore, since the rate is already stated on the
"zzgl." line directly below it. A plain single pair in the common case
(one rate for the whole order), a real multi-rate breakdown the moment a
product with a different rate is involved. The order-level discount/
shipping are distributed proportionally across each rate group before
computing net/tax, so the grouped totals still reconcile exactly to
`order.total`.
- **Product thumbnails**: each item row shows a small product image —
resolved from the order-confirmation data's already-available
`imageUrl` for the checkout-time attachment, or via
`getProductImagesByIds()` (see "VAT display" above) for the on-demand
re-download route, since a stored order item only snapshots a numeric
product id, not an image URL.
- **Bundle contents**: an item row for a bundle product also shows the
small muted `bundleContents` sub-line snapshotted at order time (see
the Payload README's "Product bundles" section).
- **Shipping address**: when `order.hasDifferentShippingAddress` is set
(see "Optional deviating shipping address" above), a third
"Lieferadresse" address block joins Von/An (three ~30%-width columns
instead of two ~45%-width ones) — otherwise unchanged, two columns as
before. `USt-IdNr.` no longer repeats in a header meta box — it already
lives in the footer, printing it twice was redundant.
- **`app/lib/invoiceData.ts`** — `generateInvoicePdf(order, seller)` /
`generateCorrectionInvoicePdf(kind, order, seller)`, the render
entrypoints every caller below goes through. `seller` (`company-settings`
@@ -454,7 +548,13 @@ inbox, not only in `/konto/bestellungen`.
always reproduces the identical document. Deliberately not persisted to
disk/S3/Media — the underlying data is already durable in Postgres, and
deterministic regeneration needs no cleanup or storage cost, same
reasoning already applied to the original invoice.
reasoning already applied to the original invoice. Also gained product
thumbnails (same resolution approach as the original invoice) and, for a
Stornorechnung specifically, an explicit "Versand" summary line — it was
previously only folded silently into the tax-rate groups' scaled gross
amounts, with no line stating how much of the reversed total was
shipping. A Gutschrift never shows this line, since it never reverses
shipping in the first place (see the reasoning below).
- **`company-settings`** (Payload collection, structured seller data —
name/address/`vatId`/`taxRatePercent`/`bankDetails`) is fetched via
`getCompanySettings()`/`getSellerForInvoice()`, authenticated the same
@@ -553,8 +653,9 @@ this check was skipped or raced.
`cancelled`/`return_requested`/`returned`) is maintained by hand in the
Payload admin for the shipping states — no shipping-carrier API
integration.
- **`Navbar.tsx`'s `AccountLink`** (account icon, desktop; "Anmelden"/"Mein
Konto" text link, mobile drawer) is the only *always*-reachable way into
- **`Navbar.tsx`'s `AccountLink`** (account icon, always visible in the
header itself — not duplicated inside the mobile fullscreen menu, see
"Mobile navigation" below) is the only *always*-reachable way into
`/konto/*` — added after discovering there previously wasn't one:
`/checkout`'s own login toggle only renders once the cart already has
items (its empty-cart state is an early return with no such toggle), and
@@ -565,10 +666,34 @@ this check was skipped or raced.
server-rendered root layout) specifically so `app/layout.tsx` — otherwise
static/ISR-cacheable — doesn't get forced into per-request dynamic
rendering just to know one icon's href; briefly shows the logged-out
state on first paint until that fetch resolves. The icon itself also
state on first paint until that fetch resolves. Re-fetches on an
`ep-auth-changed` `window` event (`app/lib/auth.ts`'s `dispatchAuthChanged()`,
called by every login/logout/checkout-registration call site) — the icon
otherwise never noticed a login/logout until a hard reload, since
`router.refresh()` only re-runs Server Components, not an
already-mounted Client Component's effects, and this Navbar lives in the
root layout and never unmounts across navigations. The icon itself also
gets a small brand-colored underline while logged in — same visual
language as the desktop nav links' active-state indicator — since the
icon alone doesn't otherwise signal session state at a glance.
### Mobile navigation
Below `lg` (1024px), the hamburger opens a **fullscreen** panel
(`Navbar.tsx`, `motion.div` from the `motion/react` package already used
elsewhere in this app for `NewsletterModal`/`VersandModal`) — not an
in-flow accordion pushed under the header like before. A circular
`clip-path` reveal (`circle(0vmax at 100% 0%)``circle(150vmax at 100%
0%)`, `vmax` rather than `%` so full coverage holds regardless of aspect
ratio) expands from the hamburger's own top-right corner, sweeping toward
the opposite corner last. Nav links fade/rise in with a per-item stagger
once the reveal has visibly opened up. The panel is a **sibling** of
`<header>`, not a child — `mobileOpen` gives the header its own
`backdrop-blur`, which would make it a new CSS containing block for any
`position: fixed` descendant and break the panel's fixed-to-viewport
positioning (same class of bug documented on `NewsletterModal`). No
login/account CTA inside the panel — that's reachable via the account icon
in the header itself, which stays visible above the panel throughout.
- **Checkout registration collisions**: if the email typed into Card 1
during inline registration already belongs to an existing account,
Payload's create call fails — `registerCustomer()` in `customerAuth.ts`