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 (
@@ -192,7 +205,7 @@ export function BestellbestaetigungContent() { {entry.variant ? ` (${entry.variant})` : ""}

- {entry.qty} × {formatPrice(unitPrice)} inkl. MwSt. + {entry.qty} × {formatPrice(unitPrice)} inkl. {taxRate}% MwSt.

@@ -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
+ ) : ( +
{ + e.preventDefault(); + handleApplyDiscount(discountInput.trim()); + }} + className="flex flex-col gap-2 w-full" + > +
+ + setDiscountInput(e.target.value)} + placeholder="Rabattcode" + className="flex-1 min-w-0 border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors" + /> + +
+ {discountError &&

{discountError}

} + {discountLoading &&

Rabattcode wird geprüft…

} +
)} - {/* 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

- - + setFirstName(e.target.value)} placeholder="Max" autoComplete="given-name" required /> + setLastName(e.target.value)} placeholder="Mustermann" autoComplete="family-name" required />
{/* w-[calc(50%-0.5rem)] at sm: — exactly matches Vorname's actual rendered width in the 2-col row above (each half of @@ -354,7 +519,8 @@ export function CheckoutContent({ label="E-Mail-Adresse" name="email" type="email" - defaultValue={savedProfile?.email ?? customerEmail ?? undefined} + value={email} + onChange={(e) => setEmail(e.target.value)} placeholder="max@beispiel.de" autoComplete="email" required @@ -421,7 +587,8 @@ export function CheckoutContent({ label="Straße und Hausnummer" name="street" type="text" - defaultValue={savedProfile?.street ?? undefined} + value={street} + onChange={(e) => setStreet(e.target.value)} placeholder="Musterstraße 1" autoComplete="street-address" required @@ -433,7 +600,8 @@ export function CheckoutContent({ label="Packstationnummer" name="packstationNumber" type="text" - defaultValue={savedProfile?.packstationNumber ?? undefined} + value={packstationNumber} + onChange={(e) => setPackstationNumber(e.target.value)} inputMode="numeric" placeholder="123" autoComplete="off" @@ -443,7 +611,8 @@ export function CheckoutContent({ label="Postnummer" name="postNumber" type="text" - defaultValue={savedProfile?.postNumber ?? undefined} + value={postNumber} + onChange={(e) => setPostNumber(e.target.value)} inputMode="numeric" placeholder="1234567" autoComplete="off" @@ -452,14 +621,15 @@ export function CheckoutContent({ )}
- - + setZip(e.target.value)} placeholder="10115" autoComplete="postal-code" required /> + setCity(e.target.value)} placeholder="Berlin" autoComplete="address-level2" required />
+ +
+ + + + {hasDifferentShippingAddress && ( +
+

Lieferadresse

+
+ setShippingFirstName(e.target.value)} + placeholder="Max" + autoComplete="off" + required + /> + setShippingLastName(e.target.value)} + placeholder="Mustermann" + autoComplete="off" + required + /> +
+
+ Lieferart +
+ + +
+
+ {shippingDeliveryMethod === "address" ? ( + setShippingStreet(e.target.value)} + placeholder="Musterstraße 1" + autoComplete="off" + required + wrapperClassName="w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0" + /> + ) : ( +
+ setShippingPackstationNumber(e.target.value)} + inputMode="numeric" + placeholder="123" + autoComplete="off" + required + /> + setShippingPostNumber(e.target.value)} + inputMode="numeric" + placeholder="1234567" + autoComplete="off" + required + /> +
+ )} +
+ setShippingZip(e.target.value)} + placeholder="10115" + autoComplete="off" + required + /> + setShippingCity(e.target.value)} + placeholder="Berlin" + autoComplete="off" + required + /> +
+ +
+ )} + +
+
-

inkl. MwSt.

+
diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx index f260d2d..f8fb5f7 100644 --- a/app/checkout/page.tsx +++ b/app/checkout/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { CheckoutContent } from "./components/CheckoutContent"; import { TrustRow } from "../components/TrustRow"; import { Footer } from "../components/Footer"; -import { getShippingMethods, getPaymentMethods, getCartTrustBadges, getShippingSettings } from "../lib/payload"; +import { getShippingMethods, getPaymentMethods, getCartTrustBadges, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload"; import { getSessionCustomer, getCustomerProfile } from "../lib/customerAuth"; // robots: noindex — transactional page, same reasoning as /cart. @@ -16,11 +16,12 @@ export const metadata: Metadata = { }; export default async function CheckoutPage() { - const [shippingMethods, paymentMethods, trustBadges, shippingSettings, session] = await Promise.all([ + const [shippingMethods, paymentMethods, trustBadges, shippingSettings, defaultTaxRate, session] = await Promise.all([ getShippingMethods(), getPaymentMethods(), getCartTrustBadges(), getShippingSettings(), + getDefaultTaxRatePercent(), getSessionCustomer(), ]); // Full profile (incl. saved address) only fetched when a session exists @@ -35,6 +36,7 @@ export default async function CheckoutPage() { paymentMethods={paymentMethods} trustBadges={trustBadges} shippingSettings={shippingSettings} + defaultTaxRate={defaultTaxRate} customerEmail={session?.customer.email ?? null} savedProfile={profile} /> diff --git a/app/components/AddToCartButton.tsx b/app/components/AddToCartButton.tsx index 5413f85..a8ce6bf 100644 --- a/app/components/AddToCartButton.tsx +++ b/app/components/AddToCartButton.tsx @@ -18,6 +18,7 @@ export function AddToCartButton({ className, productId = "todo-karten", outOfStock = false, + lowStock = false, variants = [], }: { label: string; @@ -29,10 +30,13 @@ export function AddToCartButton({ /** Product-level — only meaningful when `variants` is empty, same split as * AddToCartInlineButton. */ outOfStock?: boolean; + /** Product-level low-stock hint, same "only meaningful without variants" + * split as outOfStock. */ + lowStock?: boolean; /** Optional — same shape/semantics as AddToCartInlineButton's own * `variants` prop; all three callers already fetch the full product * server-side, so this is just threaded straight through. */ - variants?: { name: string; priceOverride: number | null; outOfStock: boolean }[]; + variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean }[]; }) { const [added, setAdded] = useState(false); const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name); @@ -43,6 +47,7 @@ export function AddToCartButton({ useEffect(() => () => clearTimeout(timeoutRef.current), []); const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock; + const currentlyLowStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.lowStock ?? false) : lowStock; function handleClick() { if (currentlyOutOfStock) return; @@ -87,11 +92,14 @@ export function AddToCartButton({ {variants.map((v) => ( ))} )} + {currentlyLowStock && !currentlyOutOfStock && ( +

Nur noch wenige verfügbar

+ )} - - 7-Tage-Challenge - -
- -
- - -
+ {/* Fullscreen mobile panel — a sibling of
, deliberately NOT + nested inside it (same reason as NewsletterModal, see the + top-of-file comment: `mobileOpen` gives the header its own + backdrop-blur, which would make it a new containing block for any + `position: fixed` descendant and break the panel's fixed-to- + viewport positioning). Circular clip-path reveal expanding from + the hamburger's own corner (top-right) — the growing circle + naturally sweeps toward the opposite corner (bottom-left) last, + reading as the diagonal wipe this is going for without needing a + literal diagonal clip polygon. `vmax` (not %) for the radius so + full coverage holds regardless of viewport aspect ratio. */} + + {mobileOpen && ( + +
+ + + {/* md:hidden — these two duplicate the inline CTA pair that's + already visible in the header itself from md (768px) up (see + "Trailing controls" above); only genuinely missing below + that, where the inline pair is hidden and the panel is + these buttons' only way to reach them. No login/account CTA + here (removed — Nutzer-Entscheidung: that's already reachable + via the account icon in the header itself, outside this + panel, no need to duplicate it inside). */} + + + 7-Tage-Challenge + + +
+
+ )} +
+ setNewsletterOpen(false)} /> ); diff --git a/app/components/ProductSpotlight.tsx b/app/components/ProductSpotlight.tsx index 9bb4c2f..189ed12 100644 --- a/app/components/ProductSpotlight.tsx +++ b/app/components/ProductSpotlight.tsx @@ -2,8 +2,9 @@ import Image from "next/image"; import Link from "next/link"; import { AddToCartButton } from "./AddToCartButton"; import { Reveal } from "./Reveal"; -import { getSpotlightProduct, getShippingSettings } from "../lib/payload"; +import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload"; import { formatPrice, discountPercent } from "../lib/format"; +import { effectiveTaxRate } from "../lib/cartTotals"; /** * Product teaser for whichever product is marked `spotlight` in Payload @@ -22,11 +23,19 @@ import { formatPrice, discountPercent } from "../lib/format"; * see Products.ts), not duplicated here as hardcoded literals. */ export async function ProductSpotlight() { - const [product, shipping] = await Promise.all([getSpotlightProduct(), getShippingSettings()]); + const [product, shipping, defaultTaxRate] = await Promise.all([ + getSpotlightProduct(), + getShippingSettings(), + getDefaultTaxRatePercent(), + ]); if (!product) return null; const image = product.spotlightImage || product.image; const discount = discountPercent(product.price, product.compareAtPrice); + const taxRate = effectiveTaxRate(product, defaultTaxRate); + // Same "any vs. every" split as ProductGrid.tsx. + const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock; + const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock; return ( // id="spotlight" — the Navbar's "Shop" link becomes an anchor to this @@ -42,10 +51,20 @@ export async function ProductSpotlight() { sizes="(min-width: 768px) 380px, 100vw" className="object-cover transition-transform duration-500 group-hover:scale-105" /> - {discount !== null && ( + {fullyOutOfStock ? ( + + Ausverkauft + + ) : discount !== null ? ( -{discount}% + ) : ( + anyLowStock && ( + + Nur noch wenige verfügbar + + ) )} @@ -66,7 +85,7 @@ export async function ProductSpotlight() {

{formatPrice(product.compareAtPrice!)}

)}

{formatPrice(product.price)}

-

inkl. MwSt. zzgl. Versand

+

inkl. {taxRate}% MwSt. zzgl. Versand

Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands @@ -77,7 +96,7 @@ export async function ProductSpotlight() { (matches Tools/Blog above/below), same as AddToCartButton's own default styling/ring-offset, so no override is needed here. */} - + {product.href && ( (null); + const [underlineWidth, setUnderlineWidth] = useState(UNDERLINE_NATIVE_WIDTH); + + useLayoutEffect(() => { + const el = labelRef.current; + if (!el) return; + const measure = () => setUnderlineWidth(el.offsetWidth); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [label]); + + return ( +

+ + + + + {label} + + {/* object-fill (not cover) — the box's height stays fixed, only the + width tracks the label, so the texture stretches horizontally to + match rather than getting cropped. */} + +
+ ); +} diff --git a/app/components/RichText.tsx b/app/components/RichText.tsx index 2e00a3c..6fbac34 100644 --- a/app/components/RichText.tsx +++ b/app/components/RichText.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; -import Image from "next/image"; import type { TOCSection } from "./SectionTOC"; +import { QuoteLabel } from "./QuoteLabel"; // Minimal Lexical JSON → JSX renderer for Payload's richText fields. // Deliberately small and dependency-free (matches the project's existing @@ -149,30 +149,7 @@ function renderNode(node: LexicalNode, key: string, quoteLabel: string): ReactNo {/* Label/icon/underline are optional (Posts.quoteLabel) — if empty, only the divider + quote text render. The blockquote itself is never optional, just this framing around it. */} - {quoteLabel && ( - <> -
- - - - - {quoteLabel} - -
- {/* Hand-drawn underline image, not a plain bar — exported - straight from the Figma node (label-underline). */} - - - )} + {quoteLabel && }
{/* Lexical's real QuoteNode holds flat text/linebreak children directly, NOT nested paragraphs — pressing Enter inside a diff --git a/app/components/VatBreakdown.tsx b/app/components/VatBreakdown.tsx new file mode 100644 index 0000000..c2ca214 --- /dev/null +++ b/app/components/VatBreakdown.tsx @@ -0,0 +1,28 @@ +import { formatPrice } from "../lib/format"; +import type { TaxBreakdownGroup } from "../lib/taxBreakdown"; + +// The actual amount of VAT included in a total — not just a disclosure +// that VAT is included (see cartTotals.ts's effectiveTaxRate() for the +// "which %" shown next to each line item elsewhere). One line per rate +// when a cart/order spans more than one; a single line otherwise. +export function VatBreakdown({ groups }: { groups: TaxBreakdownGroup[] }) { + if (groups.length === 0) return null; + if (groups.length === 1) { + const [g] = groups; + return ( +

+ enthält {g.rate}% MwSt.: {formatPrice(g.tax)} +

+ ); + } + return ( +
+

enthält MwSt.:

+ {groups.map((g) => ( +

+ {g.rate}%: {formatPrice(g.tax)} +

+ ))} +
+ ); +} diff --git a/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx b/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx index 3f01d8c..7f26de2 100644 --- a/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx +++ b/app/email-preview/[type]/components/LiveEmailPreviewClient.tsx @@ -47,7 +47,7 @@ export function LiveEmailPreviewClient({ data, ORDER_STATUS_EMAIL_ICON[type] ?? "✓", SAMPLE_ORDER.orderNumber, - `https://einfach-produktiv.mk360.de/konto/bestellungen/${SAMPLE_ORDER.orderNumber}`, + `https://einfach-produktiv.mk360.de/konto/bestellungen/${encodeURIComponent(SAMPLE_ORDER.orderNumber)}`, null, ); diff --git a/app/globals.css b/app/globals.css index 49611e5..748ef40 100644 --- a/app/globals.css +++ b/app/globals.css @@ -38,6 +38,10 @@ --color-toc-active-border: #f6a701; --color-success: #2f8f4e; --color-success-subtle: #e8f4ea; + /* Low-stock warning — distinct from --color-brand's golden yellow (used + for the discount badge) so the two pills never read as the same thing. */ + --color-warning: #c2410c; + --color-warning-subtle: #fdf1e9; /* Radius */ --radius-xs: 0.25rem; diff --git a/app/konto/bestellungen/[orderNumber]/page.tsx b/app/konto/bestellungen/[orderNumber]/page.tsx index 9564a0d..fbaf782 100644 --- a/app/konto/bestellungen/[orderNumber]/page.tsx +++ b/app/konto/bestellungen/[orderNumber]/page.tsx @@ -1,10 +1,14 @@ import type { Metadata } from "next"; import { redirect, notFound } from "next/navigation"; import Link from "next/link"; +import Image from "next/image"; import { Reveal } from "../../../components/Reveal"; import { Footer } from "../../../components/Footer"; +import { VatBreakdown } from "../../../components/VatBreakdown"; import { formatPrice, formatDate } from "../../../lib/format"; import { getSessionCustomer, getCustomerOrderDetail, customerOrderAction } from "../../../lib/customerAuth"; +import { getProductImagesByIds } from "../../../lib/payload"; +import { computeTaxBreakdown } from "../../../lib/taxBreakdown"; import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking"; import { OrderActionButton } from "./components/OrderActionButton"; import { OrderStatusBadge } from "../../components/OrderStatusBadge"; @@ -26,7 +30,13 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`; + const shippingAddress = + order.shippingDeliveryMethod === "packstation" + ? `Packstation ${order.shippingPackstationNumber} · Postnummer ${order.shippingPostNumber}` + : order.shippingStreet; const action = customerOrderAction(order.status); + const imagesByProductId = await getProductImagesByIds(order.items.map((item) => item.product)); + const taxBreakdown = computeTaxBreakdown(order.items, order.subtotal, order.discountAmount, order.shippingCost); return ( <> @@ -72,7 +82,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr )}
-

Lieferadresse

+ {/* Labeled "Rechnungsadresse" only once there's an actual + second (shipping) address to distinguish it from — the + common case (no override) keeps the original "Lieferadresse" + label, since that's exactly what this address still is. */} +

{order.hasDifferentShippingAddress ? "Rechnungsadresse" : "Lieferadresse"}

{order.customerFirstName} {order.customerLastName}

@@ -82,14 +96,33 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr

+ {order.hasDifferentShippingAddress && ( +
+

Lieferadresse

+

+ {order.shippingFirstName} {order.shippingLastName} +

+

{shippingAddress}

+

+ {order.shippingZip} {order.shippingCity}, {order.shippingCountry} +

+
+ )} +
- {order.items.map((item, i) => ( + {order.items.map((item, i) => { + const imageUrl = imagesByProductId.get(item.product); + return (
+
+ {imageUrl && } +

{item.quantity} × {item.productName} {item.variantName ? ` (${item.variantName})` : ""}

+

inkl. {item.taxRatePercent}% MwSt.

{item.bundleContents &&

{item.bundleContents}

} {item.returnQuantity > 0 && (

davon {item.returnQuantity} zurückgesendet

@@ -97,7 +130,8 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr

{formatPrice(item.quantity * item.unitPrice)}

- ))} + ); + })}
@@ -123,12 +157,15 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
-
- - Gesamtsumme - - - {formatPrice(order.total)} +
+
+ + Gesamtsumme + + + {formatPrice(order.total)} +
+
diff --git a/app/konto/bestellungen/page.tsx b/app/konto/bestellungen/page.tsx index 6bc41dc..ffa9672 100644 --- a/app/konto/bestellungen/page.tsx +++ b/app/konto/bestellungen/page.tsx @@ -1,13 +1,20 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import Link from "next/link"; +import Image from "next/image"; import { Reveal } from "../../components/Reveal"; import { Footer } from "../../components/Footer"; import { formatPrice, formatDate } from "../../lib/format"; import { getSessionCustomer, getCustomerOrders } from "../../lib/customerAuth"; +import { getProductImagesByIds } from "../../lib/payload"; import { OrderStatusBadge } from "../components/OrderStatusBadge"; import { LogoutButton } from "../components/LogoutButton"; +// Caps how many of an order's items get a thumbnail before folding the +// rest into a "+N" pill — a row here is one line in a list, not a full +// receipt (that's the detail page), so it stays a glance-able preview. +const MAX_THUMBNAILS = 4; + // robots: noindex — account area, same reasoning as /checkout. export const metadata: Metadata = { title: "Meine Bestellungen", @@ -23,6 +30,7 @@ export default async function KontoBestellungenPage() { if (!session) redirect("/konto/login"); const orders = await getCustomerOrders(session.token, session.customer.id); + const imagesByProductId = await getProductImagesByIds(orders.flatMap((order) => order.productIds)); return ( <> @@ -39,12 +47,31 @@ export default async function KontoBestellungenPage() {

Du hast noch keine Bestellung aufgegeben.

) : (
- {orders.map((order) => ( + {orders.map((order) => { + const thumbnails = order.productIds.slice(0, MAX_THUMBNAILS).map((id) => imagesByProductId.get(id)); + const overflow = order.productIds.length - MAX_THUMBNAILS; + return ( +
+ {thumbnails.map((url, i) => + url ? ( +
+ +
+ ) : ( +
+ ), + )} + {overflow > 0 && ( +
+ +{overflow} +
+ )} +

Bestellnummer

{order.orderNumber}

@@ -66,7 +93,8 @@ export default async function KontoBestellungenPage() {

{formatPrice(order.total)}

- ))} + ); + })}
)} diff --git a/app/konto/components/LogoutButton.tsx b/app/konto/components/LogoutButton.tsx index 8283450..ad5f88d 100644 --- a/app/konto/components/LogoutButton.tsx +++ b/app/konto/components/LogoutButton.tsx @@ -1,12 +1,14 @@ "use client"; import { useRouter } from "next/navigation"; +import { dispatchAuthChanged } from "../../lib/auth"; export function LogoutButton() { const router = useRouter(); async function handleLogout() { await fetch("/api/account/logout", { method: "POST" }); + dispatchAuthChanged(); router.push("/"); router.refresh(); } diff --git a/app/konto/login/components/LoginForm.tsx b/app/konto/login/components/LoginForm.tsx index 6450446..96d30fc 100644 --- a/app/konto/login/components/LoginForm.tsx +++ b/app/konto/login/components/LoginForm.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import Link from "next/link"; import { Reveal } from "../../../components/Reveal"; import { mergeServerCartIntoLocal } from "../../../lib/cart"; +import { dispatchAuthChanged } from "../../../lib/auth"; export function LoginForm() { const router = useRouter(); @@ -30,6 +31,7 @@ export function LoginForm() { return; } await mergeServerCartIntoLocal(); + dispatchAuthChanged(); router.push("/konto/bestellungen"); router.refresh(); } catch { diff --git a/app/lib/__tests__/cartTotals.test.ts b/app/lib/__tests__/cartTotals.test.ts index 30f931f..ebd9fff 100644 --- a/app/lib/__tests__/cartTotals.test.ts +++ b/app/lib/__tests__/cartTotals.test.ts @@ -19,6 +19,8 @@ const product = (overrides: Partial = {}): Product => ({ spotlightImage: null, variants: [], outOfStock: false, + lowStock: false, + taxRatePercent: null, ...overrides, }); diff --git a/app/lib/auth.ts b/app/lib/auth.ts new file mode 100644 index 0000000..cb802e1 --- /dev/null +++ b/app/lib/auth.ts @@ -0,0 +1,10 @@ +// Fired by every client-side call site that logs a customer in or out, so +// already-mounted Client Components (e.g. Navbar's AccountLink, which never +// unmounts across navigations) can re-check /api/account/me without needing +// a hard reload. router.refresh() alone doesn't do this — it only re-runs +// Server Components. +export const AUTH_CHANGED_EVENT = "ep-auth-changed"; + +export function dispatchAuthChanged() { + window.dispatchEvent(new Event(AUTH_CHANGED_EVENT)); +} diff --git a/app/lib/cartTotals.ts b/app/lib/cartTotals.ts index 1bd1145..8028884 100644 --- a/app/lib/cartTotals.ts +++ b/app/lib/cartTotals.ts @@ -20,6 +20,15 @@ export function effectivePrice(entry: { variant?: string }, product: Product): n return variant?.priceOverride ?? product.price; } +// A product's own taxRatePercent override wins over the tenant's default +// rate — mirrors api/checkout/route.ts's server-side snapshot logic +// (`product.taxRatePercent ?? defaultTaxRate`), kept in sync deliberately +// since this is only ever used for display, never for the actual charged +// amount. +export function effectiveTaxRate(product: Product, defaultRate: number): number { + return product.taxRatePercent ?? defaultRate; +} + // Split out from computeCartTotals() below because callers need a subtotal // figure *before* they can decide a shipping cost (e.g. checking it against // a free-shipping threshold) — which computeCartTotals itself takes as an diff --git a/app/lib/checkoutDraft.ts b/app/lib/checkoutDraft.ts new file mode 100644 index 0000000..6e6d05b --- /dev/null +++ b/app/lib/checkoutDraft.ts @@ -0,0 +1,67 @@ +"use client"; + +// Survives navigating away from /checkout and back (e.g. to double-check +// something in /cart) — same localStorage approach as lib/cart.ts/ +// lib/discount.ts, but plain read/write functions rather than +// useSyncExternalStore: CheckoutContent is this draft's only reader, so +// there's no cross-component subscription to keep in sync the way the cart +// needs (Navbar + CartContent + CheckoutContent all read it at once). +// Deliberately excludes `password` — that field stays a plain uncontrolled +// input, never persisted. +const DRAFT_KEY = "ep_checkout_draft"; + +export type CheckoutDraft = { + firstName: string; + lastName: string; + email: string; + deliveryMethod: "address" | "packstation"; + street: string; + packstationNumber: string; + postNumber: string; + 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; + shippingMethodId: number | null; + paymentMethodId: number | null; +}; + +export function readCheckoutDraft(): Partial | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(DRAFT_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +export function writeCheckoutDraft(draft: CheckoutDraft) { + try { + window.localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); + } catch { + // localStorage unavailable (private browsing etc.) — the draft just + // doesn't persist this time, same best-effort fallback as + // lib/order.ts's sessionStorage write. + } +} + +// Called once an order actually completes (handleSubmit) — a leftover +// draft from a finished purchase would otherwise pre-fill the next one. +export function clearCheckoutDraft() { + try { + window.localStorage.removeItem(DRAFT_KEY); + } catch { + // ignore + } +} diff --git a/app/lib/correctionInvoicePdf.tsx b/app/lib/correctionInvoicePdf.tsx index a386242..84b17ce 100644 --- a/app/lib/correctionInvoicePdf.tsx +++ b/app/lib/correctionInvoicePdf.tsx @@ -1,6 +1,7 @@ import React from "react"; -import { Document, Page, View, Text, StyleSheet, renderToBuffer } from "@react-pdf/renderer"; +import { Document, Page, View, Text, Image, StyleSheet, renderToBuffer } from "@react-pdf/renderer"; import { formatDate } from "./format"; +import { computeTaxBreakdown } from "./taxBreakdown"; // Frontend port of the Payload backend's src/lib/correctionInvoicePdf.tsx // — the *real* Stornorechnung/Gutschrift is generated and emailed from @@ -47,6 +48,8 @@ const styles = StyleSheet.create({ tableHeader: { flexDirection: "row", backgroundColor: BG_MUTED, paddingVertical: 8, paddingHorizontal: 10 }, tableRow: { flexDirection: "row", paddingVertical: 8, paddingHorizontal: 10, borderTopWidth: 1, borderTopColor: BORDER }, tableRowAlt: { backgroundColor: BG_MUTED }, + colImage: { width: 28 }, + itemImage: { width: 28, height: 28, borderRadius: 3 }, colName: { flex: 3 }, colQty: { flex: 1, textAlign: "right" }, colPrice: { flex: 1, textAlign: "right" }, @@ -88,6 +91,7 @@ export type CorrectionInvoiceItem = { bundleContents?: string | null; variantName?: string | null; returnQuantity?: number; + imageUrl?: string | null; }; export type CorrectionInvoiceOrder = { @@ -149,20 +153,19 @@ function groupByTaxRate( order: CorrectionInvoiceOrder, defaultRate: number, ): { rate: number; net: number; tax: number; gross: number }[] { - const groups = new Map(); - for (const { item, effectiveQuantity } of lines) { - const rate = item.taxRatePercent ?? defaultRate; - const lineGross = effectiveQuantity * item.unitPrice; - groups.set(rate, (groups.get(rate) ?? 0) + lineGross); - } - const scale = kind === "storno" && order.subtotal > 0 ? (order.subtotal - order.discountAmount + order.shippingCost) / order.subtotal : 1; - return Array.from(groups.entries()) - .map(([rate, lineGross]) => { - const gross = lineGross * scale; - const net = gross / (1 + rate / 100); - return { rate, net, tax: gross - net, gross }; - }) - .sort((a, b) => b.rate - a.rate); + // Gutschrift excludes shipping/discount entirely — passing 0 for both + // collapses computeTaxBreakdown's scale factor to 1, same as the old + // kind==='storno' ? ... : 1 branch did explicitly. + return computeTaxBreakdown( + lines.map(({ item, effectiveQuantity }) => ({ + quantity: effectiveQuantity, + unitPrice: item.unitPrice, + taxRatePercent: item.taxRatePercent ?? defaultRate, + })), + order.subtotal, + kind === "storno" ? order.discountAmount : 0, + kind === "storno" ? order.shippingCost : 0, + ); } function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionInvoiceKind; order: CorrectionInvoiceOrder; seller: InvoiceSeller }) { @@ -222,14 +225,11 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn Datum {formatDate(order.correctionInvoiceIssuedAt)} - - USt-IdNr. - {seller.vatId} - + Artikel Menge Einzelpreis @@ -237,6 +237,9 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn {lines.map(({ item, effectiveQuantity }, i) => ( + + {item.imageUrl && } + {item.productName} @@ -253,10 +256,22 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn + {/* Storno reverses the full original invoice, shipping + included (see this file's top-of-file comment on why the + two kinds differ) — shown as its own line instead of + silently folded into the tax-rate groups below, same as + the original invoice's own Versand row. Gutschrift never + reverses shipping, so this never renders for it. */} + {kind === "storno" && order.shippingCost > 0 && ( + + Versand + -{formatPrice(order.shippingCost)} + + )} {rateGroups.map((g) => ( - Netto ({g.rate}%) + Netto -{formatPrice(g.net)} diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts index 6af3627..d02ab1b 100644 --- a/app/lib/customerAuth.ts +++ b/app/lib/customerAuth.ts @@ -399,6 +399,11 @@ export type CustomerOrder = { total: number; status: string; itemCount: number; + /** Raw product relationship ids, in item order — depth=0 keeps them as + * plain numbers, not populated objects. Callers resolve these to image + * URLs separately via payload.ts's getProductImagesByIds(), not here — + * this file already deliberately doesn't fetch from lib/payload.ts. */ + productIds: number[]; }; export async function getCustomerOrders(token: string, customerId: number): Promise { @@ -413,14 +418,16 @@ export async function getCustomerOrders(token: string, customerId: number): Prom cache: "no-store", }); if (!res.ok) return []; - const data: { docs?: { orderNumber: string; createdAt: string; total: number; status: string; items: unknown[] }[] } = - await res.json(); + const data: { + docs?: { orderNumber: string; createdAt: string; total: number; status: string; items: { product: number }[] }[]; + } = await res.json(); return (data.docs ?? []).map((doc) => ({ orderNumber: doc.orderNumber, createdAt: doc.createdAt, total: doc.total, status: doc.status, itemCount: doc.items.length, + productIds: doc.items.map((item) => item.product), })); } @@ -442,6 +449,16 @@ export type CustomerOrderDetail = CustomerOrder & { zip: string; city: string; country: string; + hasDifferentShippingAddress: boolean; + shippingFirstName: string | null; + shippingLastName: string | null; + shippingDeliveryMethod: "address" | "packstation" | null; + shippingStreet: string | null; + shippingPackstationNumber: string | null; + shippingPostNumber: string | null; + shippingZip: string | null; + shippingCity: string | null; + shippingCountry: string | null; subtotal: number; shippingCost: number; shippingMethodTitle: string; diff --git a/app/lib/emailTemplates.ts b/app/lib/emailTemplates.ts index 5262837..c0403ca 100644 --- a/app/lib/emailTemplates.ts +++ b/app/lib/emailTemplates.ts @@ -1,4 +1,5 @@ import { formatPrice, formatDate } from "./format"; +import { computeTaxBreakdown } from "./taxBreakdown"; import type { CompanySettings } from "./payload"; // Pure string-building functions, no server-only or client-only imports — @@ -218,6 +219,27 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde const summaryRow = (label: string, value: string, color = TEXT_PRIMARY) => `${label}${value}`; + const vatRow = (label: string, value: string) => + `${label}${value}`; + + // Actual VAT amount included in the total, broken down per rate when the + // order spans more than one — mirrors /bestellbestaetigung's own + // VatBreakdown component (not shared code, this file is plain + // inline-styled HTML for email-client compatibility, see the top-of-file + // comment) and the same lib/taxBreakdown.ts math the invoice PDF uses. + const taxBreakdown = computeTaxBreakdown( + order.items.map((item) => ({ quantity: item.quantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent })), + order.subtotal, + order.discountAmount, + order.shippingCost, + ); + const taxRows = + taxBreakdown.length <= 1 + ? taxBreakdown[0] + ? vatRow(`enthält ${taxBreakdown[0].rate}% MwSt.`, formatPrice(taxBreakdown[0].tax)) + : "" + : taxBreakdown.map((g) => vatRow(`davon ${g.rate}% MwSt.`, formatPrice(g.tax))).join(""); + const body = ` ${paragraphs(template.bodyText, "center")} @@ -234,6 +256,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde + ${taxRows}
Gesamtsumme ${formatPrice(order.total)}
`; diff --git a/app/lib/invoicePdf.tsx b/app/lib/invoicePdf.tsx index 65d9374..878d4f5 100644 --- a/app/lib/invoicePdf.tsx +++ b/app/lib/invoicePdf.tsx @@ -1,6 +1,7 @@ import React from "react"; -import { Document, Page, View, Text, StyleSheet, renderToBuffer } from "@react-pdf/renderer"; +import { Document, Page, View, Text, Image, StyleSheet, renderToBuffer } from "@react-pdf/renderer"; import { formatDate } from "./format"; +import { computeTaxBreakdown } from "./taxBreakdown"; // Generated synchronously in the checkout request (see app/lib/orderEmail.ts) // and attached to the order-confirmation email, plus available on-demand via @@ -37,8 +38,12 @@ const styles = StyleSheet.create({ wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 }, kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 }, body: { padding: 32, paddingBottom: 90 }, - addressRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 }, + addressRow: { flexDirection: "row", flexWrap: "wrap", justifyContent: "space-between", rowGap: 12, marginBottom: 24 }, addressBlock: { width: "45%" }, + // Narrower variant for when a 3rd (shipping) block joins Von/An — three + // of these plus the row's own space-between still fit a Page's width + // without any block cramping its text. + addressBlockThird: { width: "30%" }, addressLabel: { fontSize: 8, color: TEXT_MUTED, marginBottom: 4, textTransform: "uppercase" }, addressLine: { fontSize: 10, lineHeight: 1.5 }, metaRow: { flexDirection: "row", gap: 10, marginBottom: 16, flexWrap: "wrap" }, @@ -51,6 +56,8 @@ const styles = StyleSheet.create({ tableHeader: { flexDirection: "row", backgroundColor: BG_MUTED, paddingVertical: 8, paddingHorizontal: 10 }, tableRow: { flexDirection: "row", paddingVertical: 8, paddingHorizontal: 10, borderTopWidth: 1, borderTopColor: BORDER }, tableRowAlt: { backgroundColor: BG_MUTED }, + colImage: { width: 28 }, + itemImage: { width: 28, height: 28, borderRadius: 3 }, colName: { flex: 3 }, colQty: { flex: 1, textAlign: "right" }, colPrice: { flex: 1, textAlign: "right" }, @@ -85,7 +92,15 @@ function formatPrice(amount: number): string { return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount); } -export type InvoiceItem = { productName: string; quantity: number; unitPrice: number; taxRatePercent: number; bundleContents?: string | null; variantName?: string | null }; +export type InvoiceItem = { + productName: string; + quantity: number; + unitPrice: number; + taxRatePercent: number; + bundleContents?: string | null; + variantName?: string | null; + imageUrl?: string | null; +}; export type InvoiceOrder = { orderNumber: string; @@ -100,6 +115,20 @@ export type InvoiceOrder = { zip: string; city: string; country: string; + // Optional package destination distinct from the "An" recipient above — + // when set, the invoice shows both addresses (billing stays "An", this + // becomes its own "Lieferadresse" block) instead of implying the order + // shipped to the billing address, which is only true when this is unset. + hasDifferentShippingAddress?: boolean; + shippingFirstName?: string | null; + shippingLastName?: string | null; + shippingDeliveryMethod?: "address" | "packstation" | null; + shippingStreet?: string | null; + shippingPackstationNumber?: string | null; + shippingPostNumber?: string | null; + shippingZip?: string | null; + shippingCity?: string | null; + shippingCountry?: string | null; paymentMethodTitle: string; items: InvoiceItem[]; subtotal: number; @@ -172,20 +201,12 @@ function isPaidImmediately(paymentMethodTitle: string): boolean { // rates. Falls back to the seller's default rate for any line that // predates this field (older orders had no per-item snapshot). function groupByTaxRate(order: InvoiceOrder, defaultRate: number): { rate: number; net: number; tax: number; gross: number }[] { - const groups = new Map(); - for (const item of order.items) { - const rate = item.taxRatePercent ?? defaultRate; - const lineGross = item.quantity * item.unitPrice; - groups.set(rate, (groups.get(rate) ?? 0) + lineGross); - } - const scale = order.subtotal > 0 ? (order.subtotal - order.discountAmount + order.shippingCost) / order.subtotal : 1; - return Array.from(groups.entries()) - .map(([rate, lineGross]) => { - const gross = lineGross * scale; - const net = gross / (1 + rate / 100); - return { rate, net, tax: gross - net, gross }; - }) - .sort((a, b) => b.rate - a.rate); + return computeTaxBreakdown( + order.items.map((item) => ({ quantity: item.quantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent ?? defaultRate })), + order.subtotal, + order.discountAmount, + order.shippingCost, + ); } // Exported (not just used internally by renderInvoicePdf below) so @@ -199,6 +220,11 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller const paid = isPaidImmediately(order.paymentMethodTitle); const deliveryLine = order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`; + const shippingLine = + order.shippingDeliveryMethod === "packstation" + ? `Packstation ${order.shippingPackstationNumber} · Postnummer ${order.shippingPostNumber}` + : order.shippingStreet; + const addressBlockStyle = order.hasDifferentShippingAddress ? styles.addressBlockThird : styles.addressBlock; return ( @@ -210,7 +236,7 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller - + Von {seller.sellerName} {seller.sellerStreet} @@ -219,7 +245,7 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
{seller.sellerCountry}
- + An {order.customerFirstName} {order.customerLastName} @@ -230,6 +256,19 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller {order.country} + {order.hasDifferentShippingAddress && ( + + Lieferadresse + + {order.shippingFirstName} {order.shippingLastName} + + {shippingLine} + + {order.shippingZip} {order.shippingCity} + + {order.shippingCountry} + + )} @@ -245,10 +284,6 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller Bestellnummer {order.orderNumber} - - USt-IdNr. - {seller.vatId} - {paid && ( ✓ Bereits beglichen ({order.paymentMethodTitle}) @@ -258,6 +293,7 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller + Artikel Menge Einzelpreis @@ -265,6 +301,9 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller {order.items.map((item, i) => ( + + {item.imageUrl && } + {item.productName} @@ -294,7 +333,7 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller {rateGroups.map((g) => ( - Netto ({g.rate}%) + Netto {formatPrice(g.net)} diff --git a/app/lib/orderEmail.ts b/app/lib/orderEmail.ts index ba8238d..22a8a1d 100644 --- a/app/lib/orderEmail.ts +++ b/app/lib/orderEmail.ts @@ -20,6 +20,16 @@ export type OrderConfirmationEmailData = OrderConfirmationData & { zip: string; city: string; country: string; + hasDifferentShippingAddress?: boolean; + shippingFirstName?: string | null; + shippingLastName?: string | null; + shippingDeliveryMethod?: "address" | "packstation" | null; + shippingStreet?: string | null; + shippingPackstationNumber?: string | null; + shippingPostNumber?: string | null; + shippingZip?: string | null; + shippingCity?: string | null; + shippingCountry?: string | null; paymentMethodTitle: string; }; @@ -65,6 +75,16 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa zip: order.zip, city: order.city, country: order.country, + hasDifferentShippingAddress: order.hasDifferentShippingAddress ?? false, + 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.map((i) => ({ productName: i.productName, @@ -73,6 +93,7 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa taxRatePercent: i.taxRatePercent, bundleContents: i.bundleContents ?? null, variantName: i.variantName ?? null, + imageUrl: i.imageUrl ?? null, })), subtotal: order.subtotal, shippingCost: order.shippingCost, diff --git a/app/lib/orderServer.ts b/app/lib/orderServer.ts index b9eacc6..42259f9 100644 --- a/app/lib/orderServer.ts +++ b/app/lib/orderServer.ts @@ -41,6 +41,20 @@ export type CreateOrderInput = { zip: string; city: string; country: string; + // Optional package destination distinct from the billing address above + // — mirrors Orders.ts's own shipping*/hasDifferentShippingAddress + // fields exactly, just camelCased the same way the rest of this input + // type already is. + hasDifferentShippingAddress?: boolean; + shippingFirstName?: string; + shippingLastName?: string; + shippingDeliveryMethod?: "address" | "packstation"; + shippingStreet?: string; + shippingPackstationNumber?: string; + shippingPostNumber?: string; + shippingZip?: string; + shippingCity?: string; + shippingCountry?: string; newsletterOptIn: boolean; items: OrderItemInput[]; subtotal: number; @@ -80,6 +94,16 @@ export async function createOrder(input: CreateOrderInput): Promise ({ product: i.productId, diff --git a/app/lib/payload.ts b/app/lib/payload.ts index e7cd6e0..26c1271 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -176,7 +176,16 @@ export type Product = { // only matters for a product with no variants; a varianted product's // buyability is entirely per-variant (see each variant's own flag). outOfStock: boolean; - variants: { name: string; priceOverride: number | null; outOfStock: boolean }[]; + // Derived, like outOfStock — no raw stock count/threshold leaked, callers + // only ever need "should a low-stock hint show for this right now". + lowStock: boolean; + // Per-product override — null means "use the tenant's default rate" + // (CompanySettings.taxRatePercent, fetched separately since it's behind + // an admin-only secret, see getCompanySettings()). Display-only on the + // storefront; the actual rate used for order totals is resolved and + // snapshotted server-side at checkout (api/checkout/route.ts). + taxRatePercent: number | null; + variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean }[]; }; type PayloadProduct = { @@ -198,7 +207,18 @@ type PayloadProduct = { trackInventory: boolean; stock: number | null; allowBackorder: boolean; - variants: { name: string; priceOverride: number | null; trackInventory: boolean; stock: number | null; allowBackorder: boolean }[] | null; + lowStockThreshold: number | null; + taxRatePercent: number | null; + variants: + | { + name: string; + priceOverride: number | null; + trackInventory: boolean; + stock: number | null; + allowBackorder: boolean; + lowStockThreshold: number | null; + }[] + | null; }; // A product/variant is only actually unbuyable when it opted into @@ -210,6 +230,13 @@ function isOutOfStock(trackInventory: boolean, stock: number | null, allowBackor return trackInventory && !allowBackorder && (stock ?? 0) <= 0; } +// Below the threshold but not already out of stock — out-of-stock gets its +// own distinct "Ausverkauft" badge, a low-stock one on top of that would be +// redundant/contradictory. +function isLowStock(trackInventory: boolean, stock: number | null, threshold: number | null): boolean { + return trackInventory && threshold != null && stock != null && stock > 0 && stock <= threshold; +} + // Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in // one place instead of duplicating the same field mapping, which is // exactly the kind of drift this session's Shipping Settings work was @@ -232,10 +259,13 @@ export function mapPayloadProduct(product: PayloadProduct): Product { spotlightImage: typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null, outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder), + lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold), + taxRatePercent: product.taxRatePercent ?? null, variants: (product.variants ?? []).map((v) => ({ name: v.name, priceOverride: v.priceOverride, outOfStock: isOutOfStock(v.trackInventory, v.stock, v.allowBackorder), + lowStock: isLowStock(v.trackInventory, v.stock, v.lowStockThreshold), })), }; } @@ -266,6 +296,28 @@ export async function getProductBySlug(slug: string): Promise { return products.find((p) => p.id === slug) ?? null; } +// For account order pages — Orders.items only snapshots a numeric +// `product` relationship id (see CustomerOrderItem in lib/customerAuth.ts), +// not an image URL, unlike the checkout/email/invoice paths that resolve +// the image once at order-creation/send time. depth=1 + a single `in` +// query is a plain product-id → image-url lookup, deliberately separate +// from getProducts()'s slug-keyed catalog (an order can reference a +// product that's since been deactivated/deleted, and slugs aren't even +// the key an order item stores). +export async function getProductImagesByIds(ids: number[]): Promise> { + const uniqueIds = [...new Set(ids)]; + const map = new Map(); + if (uniqueIds.length === 0) return map; + const params = new URLSearchParams({ "where[id][in]": uniqueIds.join(","), depth: "1", limit: String(uniqueIds.length) }); + const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 } }); + if (!res.ok) return map; + const data: { docs?: { id: number; image: { url: string } | number | null }[] } = await res.json(); + for (const doc of data.docs ?? []) { + if (typeof doc.image === "object" && doc.image) map.set(doc.id, doc.image.url); + } + return map; +} + // Derived from getProducts() (same 60s-ISR-cached fetch every other // discovery surface already uses) instead of its own separate Payload // query — also what lets the auto-spotlight rule below just be a plain @@ -707,3 +759,23 @@ export async function getCompanySettings(): Promise { const data: { docs?: CompanySettings[] } = await res.json(); return data.docs?.[0] ?? null; } + +// A separate, ISR-cached fetch (unlike getCompanySettings()'s deliberate +// cache: "no-store", where invoice generation needs always-fresh bank +// details/legal footer text) — the storefront's "inkl. X% MwSt." display +// rate only needs the same 60s freshness every other public catalog fetch +// here already has, and only ever needs the one number, not the seller's +// bank details/register info. +export async function getDefaultTaxRatePercent(): Promise { + const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" }); + const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, { + headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" }, + next: { revalidate: 60 }, + }); + if (!res.ok) { + console.error(`getDefaultTaxRatePercent: Payload returned ${res.status} ${res.statusText}`); + return 19; + } + const data: { docs?: { taxRatePercent: number }[] } = await res.json(); + return data.docs?.[0]?.taxRatePercent ?? 19; +} diff --git a/app/lib/taxBreakdown.ts b/app/lib/taxBreakdown.ts new file mode 100644 index 0000000..b659b9a --- /dev/null +++ b/app/lib/taxBreakdown.ts @@ -0,0 +1,36 @@ +// Previously duplicated as groupByTaxRate() independently inside +// invoicePdf.tsx and correctionInvoicePdf.tsx (and, on the Payload backend, +// their own copies) — pulled out so the storefront's own MwSt. breakdowns +// (checkout summary, order confirmation page/email, account order pages) +// can share the exact same math instead of a fourth hand-rolled version +// drifting out of sync with what the actual invoices say. + +export type TaxBreakdownLine = { quantity: number; unitPrice: number; taxRatePercent: number }; +export type TaxBreakdownGroup = { rate: number; net: number; tax: number; gross: number }; + +// Groups line items by their effective VAT rate, then scales each group's +// gross total by however much shipping/discount moved the grand total away +// from the raw item subtotal — proportional to that group's own share of +// the subtotal, not a flat split. Passing discountAmount=0 and +// shippingCost=0 (e.g. a Gutschrift, which excludes both) collapses `scale` +// to 1, i.e. no adjustment at all. +export function computeTaxBreakdown( + items: TaxBreakdownLine[], + subtotal: number, + discountAmount: number, + shippingCost: number, +): TaxBreakdownGroup[] { + const groups = new Map(); + for (const item of items) { + const lineGross = item.quantity * item.unitPrice; + groups.set(item.taxRatePercent, (groups.get(item.taxRatePercent) ?? 0) + lineGross); + } + const scale = subtotal > 0 ? (subtotal - discountAmount + shippingCost) / subtotal : 1; + return Array.from(groups.entries()) + .map(([rate, lineGross]) => { + const gross = lineGross * scale; + const net = gross / (1 + rate / 100); + return { rate, net, tax: gross - net, gross }; + }) + .sort((a, b) => b.rate - a.rate); +} diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx index f357de8..5ea1f91 100644 --- a/app/shop/components/ProductGrid.tsx +++ b/app/shop/components/ProductGrid.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import Image from "next/image"; -import { getProducts, getShippingSettings } from "../../lib/payload"; +import { getProducts, getShippingSettings, getDefaultTaxRatePercent } from "../../lib/payload"; +import { effectiveTaxRate } from "../../lib/cartTotals"; import { formatPrice, discountPercent } from "../../lib/format"; import { RevealGroup, RevealItem } from "../../components/Reveal"; import { AddToCartInlineButton } from "../../components/AddToCartInlineButton"; @@ -12,7 +13,7 @@ import { AddToCartInlineButton } from "../../components/AddToCartInlineButton"; // gives faster first paint and no loading flash. export async function ProductGrid() { - const [allProducts, shipping] = await Promise.all([getProducts(), getShippingSettings()]); + const [allProducts, shipping, defaultTaxRate] = await Promise.all([getProducts(), getShippingSettings(), getDefaultTaxRatePercent()]); const products = allProducts.filter((p) => p.active); if (products.length === 0) { @@ -28,12 +29,17 @@ export async function ProductGrid() { {products.map((product) => { const discount = discountPercent(product.price, product.compareAtPrice); + const taxRate = effectiveTaxRate(product, defaultTaxRate); // A varianted product only reads as "ausverkauft" overall once // every one of its variants is — a single sold-out variant just // shows as such in the picker itself (AddToCartInlineButton), // not as a blanket badge that would misleadingly suggest the // whole product is unavailable while other variants still are. const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock; + // Mirrors fullyOutOfStock's "any vs. every" split — a varianted + // product reads as low-stock as soon as one variant is, since a + // shopper landing on the grid hasn't picked a variant yet. + const anyLowStock = product.variants.length > 0 ? product.variants.some((v) => v.lowStock) : product.lowStock; return ( Ausverkauft + ) : discount !== null ? ( + + -{discount}% + ) : ( - discount !== null && ( - - -{discount}% + anyLowStock && ( + + Nur noch wenige verfügbar ) )} @@ -72,7 +82,7 @@ export async function ProductGrid() { {formatPrice(product.compareAtPrice!)} )} {formatPrice(product.price)} - inkl. MwSt. + inkl. {taxRate}% MwSt.

Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands @@ -94,7 +104,7 @@ export async function ProductGrid() { equal-height lesson). */}

- +
); diff --git a/app/todo-cards/components/Pricing.tsx b/app/todo-cards/components/Pricing.tsx index 58c42f9..dd3ad1e 100644 --- a/app/todo-cards/components/Pricing.tsx +++ b/app/todo-cards/components/Pricing.tsx @@ -1,8 +1,9 @@ import Image from "next/image"; import { AddToCartButton } from "../../components/AddToCartButton"; import { Reveal } from "../../components/Reveal"; -import { getProductBySlug, getShippingSettings } from "../../lib/payload"; +import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent } from "../../lib/payload"; import { formatPrice, discountPercent } from "../../lib/format"; +import { effectiveTaxRate } from "../../lib/cartTotals"; const bullets = [ "50 ToDo-Karten", @@ -17,9 +18,14 @@ const bullets = [ // reasoning; the bullet list stays hand-written since it's spec detail, // not something the Products collection models. export async function Pricing() { - const [product, shipping] = await Promise.all([getProductBySlug("todo-karten"), getShippingSettings()]); + const [product, shipping, defaultTaxRate] = await Promise.all([ + getProductBySlug("todo-karten"), + getShippingSettings(), + getDefaultTaxRatePercent(), + ]); if (!product) return null; const discount = discountPercent(product.price, product.compareAtPrice); + const taxRate = effectiveTaxRate(product, defaultTaxRate); return (
@@ -66,7 +72,7 @@ export async function Pricing() {

{formatPrice(product.compareAtPrice!)}

)}

{formatPrice(product.price)}

-

inkl. MwSt. zzgl. Versand

+

inkl. {taxRate}% MwSt. zzgl. Versand

Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands @@ -80,6 +86,7 @@ export async function Pricing() { label="In den Warenkorb" className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted" outOfStock={product.outOfStock} + lowStock={product.lowStock} variants={product.variants} />

diff --git a/app/todo-cards/components/TodoKartenHero.tsx b/app/todo-cards/components/TodoKartenHero.tsx index 7632690..fd564d6 100644 --- a/app/todo-cards/components/TodoKartenHero.tsx +++ b/app/todo-cards/components/TodoKartenHero.tsx @@ -2,8 +2,9 @@ import Link from "next/link"; import Image from "next/image"; import { AddToCartButton } from "../../components/AddToCartButton"; import { Reveal } from "../../components/Reveal"; -import { getProductBySlug, getShippingSettings } from "../../lib/payload"; +import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent } from "../../lib/payload"; import { formatPrice, discountPercent } from "../../lib/format"; +import { effectiveTaxRate } from "../../lib/cartTotals"; const checklist = [ "Klarer Fokus auf das, was wirklich zählt", @@ -18,8 +19,13 @@ const checklist = [ // §1 Abs.1 Nr.8 EGBGB's delivery-date disclosure needs to sit next to every // buy button, not just one of them. export async function TodoKartenHero() { - const [product, shipping] = await Promise.all([getProductBySlug("todo-karten"), getShippingSettings()]); + const [product, shipping, defaultTaxRate] = await Promise.all([ + getProductBySlug("todo-karten"), + getShippingSettings(), + getDefaultTaxRatePercent(), + ]); const discount = product ? discountPercent(product.price, product.compareAtPrice) : null; + const taxRate = product ? effectiveTaxRate(product, defaultTaxRate) : null; return (
@@ -99,7 +105,7 @@ export async function TodoKartenHero() {

{formatPrice(product.compareAtPrice!)}

)}

{formatPrice(product.price)}

-

inkl. MwSt.

+

inkl. {taxRate}% MwSt.

)}

@@ -108,7 +114,7 @@ export async function TodoKartenHero() {

{product && ( - + )}