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 && ( - + )}