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