From 43944d8cc89a23aed15003b138117e17f3cf36a4 Mon Sep 17 00:00:00 2001
From: Marco
Date: Wed, 22 Jul 2026 22:52:15 +0000
Subject: [PATCH] Fix navbar/discount/invoice bugs from manual QA, add VAT
breakdown, shipping-address override, checkout persistence, redesigned mobile
menu
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
README.md | 167 ++++++--
.../[orderNumber]/correction-invoice/route.ts | 4 +-
.../orders/[orderNumber]/invoice/route.ts | 17 +-
app/api/checkout/route.ts | 44 +++
.../components/BestellbestaetigungContent.tsx | 21 +-
app/bestellbestaetigung/page.tsx | 7 +-
app/cart/components/CartContent.tsx | 75 +++-
app/cart/components/RelatedProducts.tsx | 2 +-
app/cart/page.tsx | 6 +-
app/checkout/components/CheckoutContent.tsx | 357 ++++++++++++++++--
app/checkout/page.tsx | 6 +-
app/components/AddToCartButton.tsx | 12 +-
app/components/AddToCartInlineButton.tsx | 12 +-
app/components/Navbar.tsx | 292 ++++++++------
app/components/ProductSpotlight.tsx | 29 +-
app/components/QuoteLabel.tsx | 56 +++
app/components/RichText.tsx | 27 +-
app/components/VatBreakdown.tsx | 28 ++
.../components/LiveEmailPreviewClient.tsx | 2 +-
app/globals.css | 4 +
app/konto/bestellungen/[orderNumber]/page.tsx | 55 ++-
app/konto/bestellungen/page.tsx | 32 +-
app/konto/components/LogoutButton.tsx | 2 +
app/konto/login/components/LoginForm.tsx | 2 +
app/lib/__tests__/cartTotals.test.ts | 2 +
app/lib/auth.ts | 10 +
app/lib/cartTotals.ts | 9 +
app/lib/checkoutDraft.ts | 67 ++++
app/lib/correctionInvoicePdf.tsx | 55 ++-
app/lib/customerAuth.ts | 21 +-
app/lib/emailTemplates.ts | 23 ++
app/lib/invoicePdf.tsx | 87 +++--
app/lib/orderEmail.ts | 21 ++
app/lib/orderServer.ts | 24 ++
app/lib/payload.ts | 76 +++-
app/lib/taxBreakdown.ts | 36 ++
app/shop/components/ProductGrid.tsx | 24 +-
app/todo-cards/components/Pricing.tsx | 13 +-
app/todo-cards/components/TodoKartenHero.tsx | 14 +-
39 files changed, 1435 insertions(+), 306 deletions(-)
create mode 100644 app/components/QuoteLabel.tsx
create mode 100644 app/components/VatBreakdown.tsx
create mode 100644 app/lib/auth.ts
create mode 100644 app/lib/checkoutDraft.ts
create mode 100644 app/lib/taxBreakdown.ts
diff --git a/README.md b/README.md
index f700ab8..0549f9b 100644
--- a/README.md
+++ b/README.md
@@ -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 ``, 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 ``, 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
+``, 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`
diff --git a/app/api/account/orders/[orderNumber]/correction-invoice/route.ts b/app/api/account/orders/[orderNumber]/correction-invoice/route.ts
index f480d58..e8d4351 100644
--- a/app/api/account/orders/[orderNumber]/correction-invoice/route.ts
+++ b/app/api/account/orders/[orderNumber]/correction-invoice/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
import { generateCorrectionInvoicePdf, getSellerForInvoice } from "../../../../../lib/invoiceData";
+import { getProductImagesByIds } from "../../../../../lib/payload";
// On-demand download for "Stornorechnung/Gutschrift herunterladen" on
// /konto/bestellungen/[orderNumber]. The real document was generated once
@@ -22,6 +23,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
const kind = order.status === "returned" ? "gutschrift" : "storno";
const seller = await getSellerForInvoice();
+ const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
const pdf = await generateCorrectionInvoicePdf(
kind,
{
@@ -39,7 +41,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
zip: order.zip,
city: order.city,
country: order.country,
- items: order.items,
+ items: order.items.map((item) => ({ ...item, imageUrl: imagesByProductId.get(item.product) ?? null })),
subtotal: order.subtotal,
shippingCost: order.shippingCost,
discountAmount: order.discountAmount,
diff --git a/app/api/account/orders/[orderNumber]/invoice/route.ts b/app/api/account/orders/[orderNumber]/invoice/route.ts
index 7f03129..4955948 100644
--- a/app/api/account/orders/[orderNumber]/invoice/route.ts
+++ b/app/api/account/orders/[orderNumber]/invoice/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, getCustomerOrderDetail } from "../../../../../lib/customerAuth";
import { generateInvoicePdf, getSellerForInvoice } from "../../../../../lib/invoiceData";
+import { getProductImagesByIds } from "../../../../../lib/payload";
// On-demand download for "Rechnung herunterladen" on
// /konto/bestellungen/[orderNumber] — reuses the exact same render call as
@@ -19,6 +20,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
}
const seller = await getSellerForInvoice();
+ // On-demand re-download has no imageUrl snapshot to fall back to like
+ // the checkout-time attachment does (order.items only stores a numeric
+ // product id, see CustomerOrderItem) — resolved fresh here instead.
+ const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product));
const pdf = await generateInvoicePdf(
{
orderNumber: order.orderNumber,
@@ -33,8 +38,18 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
zip: order.zip,
city: order.city,
country: order.country,
+ hasDifferentShippingAddress: order.hasDifferentShippingAddress,
+ shippingFirstName: order.shippingFirstName,
+ shippingLastName: order.shippingLastName,
+ shippingDeliveryMethod: order.shippingDeliveryMethod,
+ shippingStreet: order.shippingStreet,
+ shippingPackstationNumber: order.shippingPackstationNumber,
+ shippingPostNumber: order.shippingPostNumber,
+ shippingZip: order.shippingZip,
+ shippingCity: order.shippingCity,
+ shippingCountry: order.shippingCountry,
paymentMethodTitle: order.paymentMethodTitle,
- items: order.items,
+ items: order.items.map((item) => ({ ...item, imageUrl: imagesByProductId.get(item.product) ?? null })),
subtotal: order.subtotal,
shippingCost: order.shippingCost,
discountAmount: order.discountAmount,
diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts
index 4ec0f91..01e6f54 100644
--- a/app/api/checkout/route.ts
+++ b/app/api/checkout/route.ts
@@ -25,6 +25,16 @@ type CheckoutBody = {
zip: string;
city: string;
country: string;
+ hasDifferentShippingAddress?: boolean;
+ shippingFirstName?: string;
+ shippingLastName?: string;
+ shippingDeliveryMethod?: "address" | "packstation";
+ shippingStreet?: string;
+ shippingPackstationNumber?: string;
+ shippingPostNumber?: string;
+ shippingZip?: string;
+ shippingCity?: string;
+ shippingCountry?: string;
newsletterOptIn: boolean;
};
@@ -63,6 +73,20 @@ export async function POST(request: Request) {
if (body.deliveryMethod === "packstation" && (!body.packstationNumber || !body.postNumber)) {
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
}
+ if (body.hasDifferentShippingAddress) {
+ if (!body.shippingFirstName || !body.shippingLastName || !body.shippingZip || !body.shippingCity || !body.shippingCountry) {
+ return NextResponse.json({ ok: false, reason: "Bitte alle Felder der Lieferadresse ausfüllen." }, { status: 400 });
+ }
+ if (body.shippingDeliveryMethod === "address" && !body.shippingStreet) {
+ return NextResponse.json({ ok: false, reason: "Bitte Straße und Hausnummer der Lieferadresse angeben." }, { status: 400 });
+ }
+ if (body.shippingDeliveryMethod === "packstation" && (!body.shippingPackstationNumber || !body.shippingPostNumber)) {
+ return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer der Lieferadresse angeben." }, { status: 400 });
+ }
+ if (body.shippingDeliveryMethod !== "address" && body.shippingDeliveryMethod !== "packstation") {
+ return NextResponse.json({ ok: false, reason: "Lieferart der Lieferadresse ist ungültig." }, { status: 400 });
+ }
+ }
// Auth: an existing session wins; otherwise this checkout submit doubles
// as inline registration ("Konto Pflicht, Registrierung direkt im
@@ -171,6 +195,16 @@ export async function POST(request: Request) {
zip: body.zip,
city: body.city,
country: body.country,
+ hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
+ shippingFirstName: body.shippingFirstName,
+ shippingLastName: body.shippingLastName,
+ shippingDeliveryMethod: body.shippingDeliveryMethod,
+ shippingStreet: body.shippingStreet,
+ shippingPackstationNumber: body.shippingPackstationNumber,
+ shippingPostNumber: body.shippingPostNumber,
+ shippingZip: body.shippingZip,
+ shippingCity: body.shippingCity,
+ shippingCountry: body.shippingCountry,
newsletterOptIn: Boolean(body.newsletterOptIn),
items,
subtotal,
@@ -217,6 +251,16 @@ export async function POST(request: Request) {
zip: body.zip,
city: body.city,
country: body.country,
+ hasDifferentShippingAddress: Boolean(body.hasDifferentShippingAddress),
+ shippingFirstName: body.shippingFirstName,
+ shippingLastName: body.shippingLastName,
+ shippingDeliveryMethod: body.shippingDeliveryMethod,
+ shippingStreet: body.shippingStreet,
+ shippingPackstationNumber: body.shippingPackstationNumber,
+ shippingPostNumber: body.shippingPostNumber,
+ shippingZip: body.shippingZip,
+ shippingCity: body.shippingCity,
+ shippingCountry: body.shippingCountry,
paymentMethodTitle: paymentMethod.title,
items: items.map((i) => ({
productName: i.productName,
diff --git a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
index 32fc171..e6b6d14 100644
--- a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
+++ b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
@@ -5,10 +5,12 @@ import Link from "next/link";
import Image from "next/image";
import type { CartItem } from "../../lib/cart";
import { useProducts } from "../../lib/products";
-import { computeCartTotals, effectivePrice } from "../../lib/cartTotals";
+import { computeCartTotals, effectivePrice, effectiveTaxRate } from "../../lib/cartTotals";
+import { computeTaxBreakdown } from "../../lib/taxBreakdown";
import { formatPrice, formatDate } from "../../lib/format";
import { Reveal } from "../../components/Reveal";
import { CheckoutSteps } from "../../components/CheckoutSteps";
+import { VatBreakdown } from "../../components/VatBreakdown";
import { ORDER_KEY, type OrderSnapshot } from "../../lib/order";
// Rejects (rather than silently patching with fallback values) anything
@@ -39,7 +41,7 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null {
}
}
-export function BestellbestaetigungContent() {
+export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate: number }) {
const products = useProducts();
const [order, setOrder] = useState(null);
const [checked, setChecked] = useState(false);
@@ -105,6 +107,16 @@ export function BestellbestaetigungContent() {
type: "fixed",
value: order.discountAmount,
});
+ const taxBreakdown = computeTaxBreakdown(
+ items.map(({ entry, product }) => ({
+ quantity: entry.qty,
+ unitPrice: effectivePrice(entry, product),
+ taxRatePercent: effectiveTaxRate(product, defaultTaxRate),
+ })),
+ subtotal,
+ order.discountAmount,
+ order.shippingCost,
+ );
return (
<>
@@ -180,6 +192,7 @@ export function BestellbestaetigungContent() {
{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 (
@@ -244,7 +257,7 @@ export function BestellbestaetigungContent() {
{formatPrice(total)}
-
inkl. MwSt.
+
diff --git a/app/bestellbestaetigung/page.tsx b/app/bestellbestaetigung/page.tsx
index 56a4478..f475adb 100644
--- a/app/bestellbestaetigung/page.tsx
+++ b/app/bestellbestaetigung/page.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { BestellbestaetigungContent } from "./components/BestellbestaetigungContent";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
+import { getDefaultTaxRatePercent } from "../lib/payload";
// robots: noindex — transactional page, same reasoning as /cart and
// /checkout (this one doubles as a receipt, not something to surface in
@@ -15,11 +16,13 @@ export const metadata: Metadata = {
},
};
-export default function BestellbestaetigungPage() {
+export default async function BestellbestaetigungPage() {
+ const defaultTaxRate = await getDefaultTaxRatePercent();
+
return (
<>
-
+
diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx
index 686f2ad..56df4bd 100644
--- a/app/cart/components/CartContent.tsx
+++ b/app/cart/components/CartContent.tsx
@@ -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(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({
{formatPrice(product.compareAtPrice!)}
)}
{formatPrice(unitPrice)}
- inkl. MwSt.
+ inkl. {taxRate}% MwSt.
@@ -255,15 +275,12 @@ export function CartContent({
)}
- {/* 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 ? (
Rabattcode ({discount.code})
@@ -278,12 +295,36 @@ export function CartContent({
Entfernen
+ ) : (
+
)}
- {/* 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 &&
{discountError}
}
- {!discount && discountLoading &&
Rabattcode wird geprüft…
}
@@ -326,7 +367,7 @@ export function CartContent({
{formatPrice(total)}
-
inkl. MwSt.
+
{formatPrice(product.price)}
-
+
))}
diff --git a/app/cart/page.tsx b/app/cart/page.tsx
index 85c4973..25b54c5 100644
--- a/app/cart/page.tsx
+++ b/app/cart/page.tsx
@@ -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}
/>
diff --git a/app/checkout/components/CheckoutContent.tsx b/app/checkout/components/CheckoutContent.tsx
index 30d594a..341bc84 100644
--- a/app/checkout/components/CheckoutContent.tsx
+++ b/app/checkout/components/CheckoutContent.tsx
@@ -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(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