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