Fix navbar/discount/invoice bugs from manual QA, add VAT breakdown, shipping-address override, checkout persistence, redesigned mobile menu
Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns
Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
included, broken down per rate when a cart spans more than one
(new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown
Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).
Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.
Checkout:
- Optional deviating shipping address (separate from the billing address
used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
address differs from billing
Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).
Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.
Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -195,16 +195,16 @@ Applied in `/cart` only (`/checkout` displays the already-applied result,
|
||||
no second input) — real server-side validation, not just a client-side
|
||||
check against Payload's public API, unlike most content on this site.
|
||||
|
||||
- **No manual input field anymore** — the Rabattcode section on `/cart`
|
||||
(`CartContent.tsx`) only renders at all when a code is actually applied;
|
||||
there's no open "enter a code" box for every visitor (Nutzer-Entscheidung:
|
||||
less visual noise, and codes are meant to be shared as marketing links,
|
||||
not guessed/typed in). Instead, `?code=SAVE10` on the `/cart` URL
|
||||
auto-applies once on arrival (a `useEffect` reading `useSearchParams()` —
|
||||
requires `/cart`'s `page.tsx` to wrap `CartContent` in `<Suspense>`, a
|
||||
Next.js requirement for any `useSearchParams()` consumer). A code that
|
||||
arrives via the URL but turns out invalid/expired still shows an inline
|
||||
error, just without an input box to attach it to.
|
||||
- **Manual input field on `/cart`** (`CartContent.tsx`) — a text field +
|
||||
"Anwenden" button, shown whenever no code is currently applied; once
|
||||
applied, the field is replaced by a read-only result + "Entfernen" link
|
||||
(reverted an earlier no-manual-input decision). `?code=SAVE10` on the
|
||||
`/cart` URL still auto-applies once on arrival (a `useEffect` reading
|
||||
`useSearchParams()` — requires `/cart`'s `page.tsx` to wrap `CartContent`
|
||||
in `<Suspense>`, a Next.js requirement for any `useSearchParams()`
|
||||
consumer), so a marketing link still works without the shopper typing
|
||||
anything. A code that arrives via the URL but turns out invalid/expired
|
||||
shows the same inline error the manual field uses.
|
||||
- **`app/lib/discountServer.ts`** (server-only, imported exclusively by the
|
||||
two route handlers below — never by a `"use client"` component, same
|
||||
reasoning as Live Preview's `next/headers` lesson above) talks to
|
||||
@@ -270,6 +270,70 @@ check against Payload's public API, unlike most content on this site.
|
||||
labelled "zahlungspflichtig" but nothing actually captures a payment
|
||||
yet. See `project_backend_checkout_plan` in the assistant's own memory.
|
||||
|
||||
### VAT display
|
||||
|
||||
Every price shown storefront-wide says "inkl. X% MwSt." with the *actual*
|
||||
resolved rate (`app/lib/cartTotals.ts`'s `effectiveTaxRate(product,
|
||||
defaultRate)` — a product's own `taxRatePercent` override if set,
|
||||
otherwise the tenant default from `company-settings`), not a generic
|
||||
"inkl. MwSt." disclosure — `getDefaultTaxRatePercent()` in
|
||||
`app/lib/payload.ts` is a separate, ISR-cached (60s) fetch of just that one
|
||||
number, deliberately not `getCompanySettings()` itself (that one is
|
||||
`cache: "no-store"` for its invoice-generation callers, where always-fresh
|
||||
bank details matter; the display rate only needs the same freshness every
|
||||
other public catalog fetch already has).
|
||||
|
||||
A cart/checkout/order-confirmation *total* additionally shows the actual
|
||||
€ amount of VAT included, not just a percentage — `app/lib/taxBreakdown.ts`'s
|
||||
`computeTaxBreakdown()` (extracted out of what used to be independently
|
||||
duplicated `groupByTaxRate()` logic in `invoicePdf.tsx`/
|
||||
`correctionInvoicePdf.tsx`, now shared by both the PDFs and this display)
|
||||
groups line items by their effective rate and reports each group's actual
|
||||
tax amount; `app/components/VatBreakdown.tsx` renders a single "enthält
|
||||
X% MwSt.: Y €" line when the cart/order has one rate, or one line per rate
|
||||
when it spans more than one. Used on `/cart`, `/checkout`, `/bestellbestaetigung`,
|
||||
the order-confirmation email (`emailTemplates.ts`'s `renderOrderConfirmationHtml`,
|
||||
which already carried per-item `taxRatePercent` but didn't render it
|
||||
before), and both account order pages (see "Orders & customer accounts"
|
||||
below) — the account order list additionally shows up to 4 product
|
||||
thumbnails per order row (`getProductImagesByIds()` in `app/lib/payload.ts`,
|
||||
a plain product-id → image-url lookup separate from the slug-keyed
|
||||
catalog, since an order only ever snapshots a numeric product id).
|
||||
|
||||
### Checkout state persistence
|
||||
|
||||
`app/lib/checkoutDraft.ts` — `localStorage` under `ep_checkout_draft`,
|
||||
plain read/write functions (not `useSyncExternalStore` like `cart.ts`/
|
||||
`discount.ts`: `CheckoutContent` is this draft's only reader, no
|
||||
cross-component subscription to keep in sync). Every address-card field
|
||||
(name, email, delivery method, street/Packstation, PLZ/Ort/Land, the
|
||||
shipping-address-override fields below, newsletter opt-in) plus the
|
||||
selected shipping/payment method is now a controlled input backed by this
|
||||
draft, restored on mount (a `useEffect`-deferred read, same SSR/hydration-
|
||||
mismatch avoidance as `BestellbestaetigungContent`'s own sessionStorage
|
||||
read) and cleared on a completed purchase. `password` is deliberately
|
||||
excluded — stays a plain uncontrolled, unpersisted input.
|
||||
|
||||
### Optional deviating shipping address
|
||||
|
||||
A checkbox in "1. Rechnungsadresse" ("Abweichende Lieferadresse
|
||||
verwenden") reveals a second address section (own name + delivery method +
|
||||
street/Packstation/PLZ/Ort/Land) — when used, the order's original address
|
||||
fields stay the **billing** address (used for the invoice's "An" block
|
||||
regardless), and the `shipping*`-prefixed fields (`hasDifferentShippingAddress`,
|
||||
`shippingFirstName`/`shippingLastName`/`shippingDeliveryMethod`/
|
||||
`shippingStreet`/`shippingPackstationNumber`/`shippingPostNumber`/
|
||||
`shippingZip`/`shippingCity`/`shippingCountry` — mirrored 1:1 on the
|
||||
Payload `orders` collection, see the Payload README) determine where the
|
||||
order actually ships. `/api/checkout/route.ts` validates the override the
|
||||
same way it already validated the primary address (required fields,
|
||||
street-xor-Packstation depending on the chosen delivery method). The
|
||||
invoice PDF shows a third "Lieferadresse" address block alongside Von/An
|
||||
when set (see "Invoice PDFs" below); `/konto/bestellungen/[orderNumber]`
|
||||
shows both addresses too, relabeling the first one "Rechnungsadresse"
|
||||
instead of "Lieferadresse" only once there's an actual second address to
|
||||
distinguish it from.
|
||||
|
||||
### Product variants
|
||||
|
||||
A cart line's identity is `(id, variant)` together, not `id` alone —
|
||||
@@ -304,6 +368,21 @@ discount badge, never both) once *every* variant of a product is out —
|
||||
one sold-out variant among several just reads as such in the picker
|
||||
itself, not as a misleading blanket badge.
|
||||
|
||||
**Low-stock warning**: `app/lib/payload.ts`'s `isLowStock()` derives
|
||||
`Product.lowStock` (and each `variants[].lowStock`) from `trackInventory`/
|
||||
`stock`/`lowStockThreshold` the same way `outOfStock` is derived — true
|
||||
only when inventory is tracked, stock is above zero (out-of-stock has its
|
||||
own distinct badge, the two never combine), and at/below the product's own
|
||||
`lowStockThreshold`. Neither raw `stock` nor `lowStockThreshold` is
|
||||
exposed in the public `Product` type, only this derived boolean — the
|
||||
public API has no reason to leak exact counts. Shown as a "Nur noch wenige
|
||||
verfügbar" pill (a new `--color-warning` token in `globals.css`, distinct
|
||||
from the brand-colored discount badge so the two never read as the same
|
||||
thing) on `ProductGrid.tsx`/`ProductSpotlight.tsx`, a `"(nur noch wenige)"`
|
||||
variant-select suffix, and a text hint under `AddToCartButton`/
|
||||
`AddToCartInlineButton` — same component/prop shape as `outOfStock`
|
||||
throughout.
|
||||
|
||||
**Pricing**: `app/lib/cartTotals.ts`'s `effectivePrice(entry, product)` —
|
||||
a selected variant's `priceOverride` wins over the base `product.price`
|
||||
(falling back to it when unset or no variant selected). Every cart/
|
||||
@@ -409,16 +488,31 @@ inbox, not only in `/konto/bestellungen`.
|
||||
same caveat as the rest of `company-settings`' seller data below.
|
||||
- **Per-tax-rate summary**: line items are grouped by their own
|
||||
snapshotted `taxRatePercent` (see the Payload README's "Per-product tax
|
||||
rates" section) and the summary prints one "Netto (X%)" / "zzgl. X%
|
||||
MwSt." pair per distinct rate actually present in that order — a plain
|
||||
single pair in the common case (one rate for the whole order), a real
|
||||
multi-rate breakdown the moment a product with a different rate is
|
||||
involved. The order-level discount/shipping are distributed
|
||||
proportionally across each rate group before computing net/tax, so the
|
||||
grouped totals still reconcile exactly to `order.total`.
|
||||
rates" section) via the shared `app/lib/taxBreakdown.ts` (see "VAT
|
||||
display" above) and the summary prints one plain "Netto" / "zzgl. X%
|
||||
MwSt." pair per distinct rate actually present in that order — no `%`
|
||||
after "Netto" itself anymore, since the rate is already stated on the
|
||||
"zzgl." line directly below it. A plain single pair in the common case
|
||||
(one rate for the whole order), a real multi-rate breakdown the moment a
|
||||
product with a different rate is involved. The order-level discount/
|
||||
shipping are distributed proportionally across each rate group before
|
||||
computing net/tax, so the grouped totals still reconcile exactly to
|
||||
`order.total`.
|
||||
- **Product thumbnails**: each item row shows a small product image —
|
||||
resolved from the order-confirmation data's already-available
|
||||
`imageUrl` for the checkout-time attachment, or via
|
||||
`getProductImagesByIds()` (see "VAT display" above) for the on-demand
|
||||
re-download route, since a stored order item only snapshots a numeric
|
||||
product id, not an image URL.
|
||||
- **Bundle contents**: an item row for a bundle product also shows the
|
||||
small muted `bundleContents` sub-line snapshotted at order time (see
|
||||
the Payload README's "Product bundles" section).
|
||||
- **Shipping address**: when `order.hasDifferentShippingAddress` is set
|
||||
(see "Optional deviating shipping address" above), a third
|
||||
"Lieferadresse" address block joins Von/An (three ~30%-width columns
|
||||
instead of two ~45%-width ones) — otherwise unchanged, two columns as
|
||||
before. `USt-IdNr.` no longer repeats in a header meta box — it already
|
||||
lives in the footer, printing it twice was redundant.
|
||||
- **`app/lib/invoiceData.ts`** — `generateInvoicePdf(order, seller)` /
|
||||
`generateCorrectionInvoicePdf(kind, order, seller)`, the render
|
||||
entrypoints every caller below goes through. `seller` (`company-settings`
|
||||
@@ -454,7 +548,13 @@ inbox, not only in `/konto/bestellungen`.
|
||||
always reproduces the identical document. Deliberately not persisted to
|
||||
disk/S3/Media — the underlying data is already durable in Postgres, and
|
||||
deterministic regeneration needs no cleanup or storage cost, same
|
||||
reasoning already applied to the original invoice.
|
||||
reasoning already applied to the original invoice. Also gained product
|
||||
thumbnails (same resolution approach as the original invoice) and, for a
|
||||
Stornorechnung specifically, an explicit "Versand" summary line — it was
|
||||
previously only folded silently into the tax-rate groups' scaled gross
|
||||
amounts, with no line stating how much of the reversed total was
|
||||
shipping. A Gutschrift never shows this line, since it never reverses
|
||||
shipping in the first place (see the reasoning below).
|
||||
- **`company-settings`** (Payload collection, structured seller data —
|
||||
name/address/`vatId`/`taxRatePercent`/`bankDetails`) is fetched via
|
||||
`getCompanySettings()`/`getSellerForInvoice()`, authenticated the same
|
||||
@@ -553,8 +653,9 @@ this check was skipped or raced.
|
||||
`cancelled`/`return_requested`/`returned`) is maintained by hand in the
|
||||
Payload admin for the shipping states — no shipping-carrier API
|
||||
integration.
|
||||
- **`Navbar.tsx`'s `AccountLink`** (account icon, desktop; "Anmelden"/"Mein
|
||||
Konto" text link, mobile drawer) is the only *always*-reachable way into
|
||||
- **`Navbar.tsx`'s `AccountLink`** (account icon, always visible in the
|
||||
header itself — not duplicated inside the mobile fullscreen menu, see
|
||||
"Mobile navigation" below) is the only *always*-reachable way into
|
||||
`/konto/*` — added after discovering there previously wasn't one:
|
||||
`/checkout`'s own login toggle only renders once the cart already has
|
||||
items (its empty-cart state is an early return with no such toggle), and
|
||||
@@ -565,10 +666,34 @@ this check was skipped or raced.
|
||||
server-rendered root layout) specifically so `app/layout.tsx` — otherwise
|
||||
static/ISR-cacheable — doesn't get forced into per-request dynamic
|
||||
rendering just to know one icon's href; briefly shows the logged-out
|
||||
state on first paint until that fetch resolves. The icon itself also
|
||||
state on first paint until that fetch resolves. Re-fetches on an
|
||||
`ep-auth-changed` `window` event (`app/lib/auth.ts`'s `dispatchAuthChanged()`,
|
||||
called by every login/logout/checkout-registration call site) — the icon
|
||||
otherwise never noticed a login/logout until a hard reload, since
|
||||
`router.refresh()` only re-runs Server Components, not an
|
||||
already-mounted Client Component's effects, and this Navbar lives in the
|
||||
root layout and never unmounts across navigations. The icon itself also
|
||||
gets a small brand-colored underline while logged in — same visual
|
||||
language as the desktop nav links' active-state indicator — since the
|
||||
icon alone doesn't otherwise signal session state at a glance.
|
||||
|
||||
### Mobile navigation
|
||||
|
||||
Below `lg` (1024px), the hamburger opens a **fullscreen** panel
|
||||
(`Navbar.tsx`, `motion.div` from the `motion/react` package already used
|
||||
elsewhere in this app for `NewsletterModal`/`VersandModal`) — not an
|
||||
in-flow accordion pushed under the header like before. A circular
|
||||
`clip-path` reveal (`circle(0vmax at 100% 0%)` → `circle(150vmax at 100%
|
||||
0%)`, `vmax` rather than `%` so full coverage holds regardless of aspect
|
||||
ratio) expands from the hamburger's own top-right corner, sweeping toward
|
||||
the opposite corner last. Nav links fade/rise in with a per-item stagger
|
||||
once the reveal has visibly opened up. The panel is a **sibling** of
|
||||
`<header>`, not a child — `mobileOpen` gives the header its own
|
||||
`backdrop-blur`, which would make it a new CSS containing block for any
|
||||
`position: fixed` descendant and break the panel's fixed-to-viewport
|
||||
positioning (same class of bug documented on `NewsletterModal`). No
|
||||
login/account CTA inside the panel — that's reachable via the account icon
|
||||
in the header itself, which stays visible above the panel throughout.
|
||||
- **Checkout registration collisions**: if the email typed into Card 1
|
||||
during inline registration already belongs to an existing account,
|
||||
Payload's create call fails — `registerCustomer()` in `customerAuth.ts`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<OrderSnapshot | null>(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 (
|
||||
<div key={lineKey} className="flex gap-4 items-center w-full">
|
||||
@@ -192,7 +205,7 @@ export function BestellbestaetigungContent() {
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. MwSt.</span>
|
||||
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. {taxRate}% MwSt.</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary whitespace-nowrap">
|
||||
@@ -244,7 +257,7 @@ export function BestellbestaetigungContent() {
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">inkl. MwSt.</p>
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<BestellbestaetigungContent />
|
||||
<BestellbestaetigungContent defaultTaxRate={defaultTaxRate} />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -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<string | null>(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({
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-body-sm text-text-primary">{formatPrice(unitPrice)}</span>
|
||||
<span className="text-label text-text-muted">inkl. MwSt.</span>
|
||||
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,15 +275,12 @@ export function CartContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-success">Rabattcode ({discount.code})</span>
|
||||
@@ -278,12 +295,36 @@ export function CartContent({
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleApplyDiscount(discountInput.trim());
|
||||
}}
|
||||
className="flex flex-col gap-2 w-full"
|
||||
>
|
||||
<div className="flex gap-2 w-full">
|
||||
<label className="sr-only" htmlFor="cart-discount-code">Rabattcode</label>
|
||||
<input
|
||||
id="cart-discount-code"
|
||||
type="text"
|
||||
value={discountInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!discountInput.trim() || discountLoading}
|
||||
className="shrink-0 rounded-sm border border-border px-4 py-2 text-body-sm font-bold text-text-primary hover:border-brand hover:text-brand transition-colors disabled:opacity-50"
|
||||
>
|
||||
Anwenden
|
||||
</button>
|
||||
</div>
|
||||
{discountError && <p className="text-label text-red-600">{discountError}</p>}
|
||||
{discountLoading && <p className="text-label text-text-muted">Rabattcode wird geprüft…</p>}
|
||||
</form>
|
||||
)}
|
||||
{/* 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 && <p className="text-label text-red-600 w-full">{discountError}</p>}
|
||||
{!discount && discountLoading && <p className="text-label text-text-muted w-full">Rabattcode wird geprüft…</p>}
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
@@ -326,7 +367,7 @@ export function CartContent({
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">inkl. MwSt.</p>
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
|
||||
@@ -164,7 +164,7 @@ export function RelatedProducts() {
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} lowStock={product.lowStock} variants={product.variants} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+4
-2
@@ -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}
|
||||
/>
|
||||
</Suspense>
|
||||
<RelatedProducts />
|
||||
|
||||
@@ -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<string | null>(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
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField label="Vorname" name="firstName" type="text" defaultValue={savedProfile?.firstName} placeholder="Max" autoComplete="given-name" required />
|
||||
<FormField label="Nachname" name="lastName" type="text" defaultValue={savedProfile?.lastName} placeholder="Mustermann" autoComplete="family-name" required />
|
||||
<FormField label="Vorname" name="firstName" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} placeholder="Max" autoComplete="given-name" required />
|
||||
<FormField label="Nachname" name="lastName" type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} placeholder="Mustermann" autoComplete="family-name" required />
|
||||
</div>
|
||||
{/* 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({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField label="PLZ" name="zip" type="text" defaultValue={savedProfile?.zip ?? undefined} placeholder="10115" autoComplete="postal-code" required />
|
||||
<FormField label="Ort" name="city" type="text" defaultValue={savedProfile?.city ?? undefined} placeholder="Berlin" autoComplete="address-level2" required />
|
||||
<FormField label="PLZ" name="zip" type="text" value={zip} onChange={(e) => setZip(e.target.value)} placeholder="10115" autoComplete="postal-code" required />
|
||||
<FormField label="Ort" name="city" type="text" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Berlin" autoComplete="address-level2" required />
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select
|
||||
name="country"
|
||||
defaultValue={savedProfile?.country ?? "Deutschland"}
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
required
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
@@ -468,10 +638,150 @@ export function CheckoutContent({
|
||||
<option>Schweiz</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<label className="flex gap-3 items-start w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasDifferentShippingAddress}
|
||||
onChange={(e) => setHasDifferentShippingAddress(e.target.checked)}
|
||||
className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-body-sm text-text-primary">Abweichende Lieferadresse verwenden</span>
|
||||
</label>
|
||||
|
||||
{hasDifferentShippingAddress && (
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<p className="font-semibold text-body-sm text-text-primary">Lieferadresse</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField
|
||||
label="Vorname"
|
||||
type="text"
|
||||
value={shippingFirstName}
|
||||
onChange={(e) => setShippingFirstName(e.target.value)}
|
||||
placeholder="Max"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Nachname"
|
||||
type="text"
|
||||
value={shippingLastName}
|
||||
onChange={(e) => setShippingLastName(e.target.value)}
|
||||
placeholder="Mustermann"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-[calc(50%-0.5rem)] flex flex-col gap-2 items-start">
|
||||
<span className="text-label text-text-muted">Lieferart</span>
|
||||
<div className="flex w-full rounded-sm border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShippingDeliveryMethod("address")}
|
||||
aria-pressed={shippingDeliveryMethod === "address"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold transition-colors ${
|
||||
shippingDeliveryMethod === "address"
|
||||
? "bg-brand text-text-primary"
|
||||
: "text-text-muted hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
Lieferadresse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShippingDeliveryMethod("packstation")}
|
||||
aria-pressed={shippingDeliveryMethod === "packstation"}
|
||||
className={`flex-1 py-3 text-body-sm font-bold border-l border-border transition-colors ${
|
||||
shippingDeliveryMethod === "packstation"
|
||||
? "bg-brand text-text-primary"
|
||||
: "text-text-muted hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
Packstation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{shippingDeliveryMethod === "address" ? (
|
||||
<FormField
|
||||
label="Straße und Hausnummer"
|
||||
type="text"
|
||||
value={shippingStreet}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField
|
||||
label="Packstationnummer"
|
||||
type="text"
|
||||
value={shippingPackstationNumber}
|
||||
onChange={(e) => setShippingPackstationNumber(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="123"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Postnummer"
|
||||
type="text"
|
||||
value={shippingPostNumber}
|
||||
onChange={(e) => setShippingPostNumber(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="1234567"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
||||
<FormField
|
||||
label="PLZ"
|
||||
type="text"
|
||||
value={shippingZip}
|
||||
onChange={(e) => setShippingZip(e.target.value)}
|
||||
placeholder="10115"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
<FormField
|
||||
label="Ort"
|
||||
type="text"
|
||||
value={shippingCity}
|
||||
onChange={(e) => setShippingCity(e.target.value)}
|
||||
placeholder="Berlin"
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 items-start w-full">
|
||||
<span className="text-label text-text-muted">Land</span>
|
||||
<select
|
||||
value={shippingCountry}
|
||||
onChange={(e) => setShippingCountry(e.target.value)}
|
||||
required
|
||||
className="w-full border border-border rounded-sm px-4 py-3 text-body-sm text-text-primary outline-none focus:border-brand transition-colors bg-bg-base"
|
||||
>
|
||||
<option>Deutschland</option>
|
||||
<option>Österreich</option>
|
||||
<option>Schweiz</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<label className="flex gap-3 items-start w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="newsletterOptIn"
|
||||
checked={newsletterOptIn}
|
||||
onChange={(e) => setNewsletterOptIn(e.target.checked)}
|
||||
className="size-5 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="flex flex-col gap-1 text-body-sm text-text-primary">
|
||||
@@ -583,6 +893,7 @@ export function CheckoutContent({
|
||||
|
||||
{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 (
|
||||
<div key={lineKey} className="flex gap-4 items-center w-full">
|
||||
@@ -595,7 +906,7 @@ export function CheckoutContent({
|
||||
{entry.variant ? ` (${entry.variant})` : ""}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. MwSt.</span>
|
||||
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. {taxRate}% MwSt.</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary whitespace-nowrap">{formatPrice(entry.qty * unitPrice)}</p>
|
||||
@@ -670,7 +981,7 @@ export function CheckoutContent({
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">inkl. MwSt.</p>
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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) => (
|
||||
<option key={v.name} value={v.name}>
|
||||
{v.name}
|
||||
{v.outOfStock ? " (ausverkauft)" : ""}
|
||||
{v.outOfStock ? " (ausverkauft)" : v.lowStock ? " (nur noch wenige)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{currentlyLowStock && !currentlyOutOfStock && (
|
||||
<p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>
|
||||
)}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
|
||||
@@ -21,6 +21,7 @@ export function AddToCartInlineButton({
|
||||
label = "In den Warenkorb",
|
||||
className,
|
||||
outOfStock = false,
|
||||
lowStock = false,
|
||||
variants = [],
|
||||
}: {
|
||||
id: string;
|
||||
@@ -29,12 +30,15 @@ export function AddToCartInlineButton({
|
||||
/** Product-level — only meaningful when `variants` is empty. A varianted
|
||||
* product's buyability is entirely per-variant instead (see below). */
|
||||
outOfStock?: boolean;
|
||||
/** Product-level low-stock hint, same "only meaningful without variants"
|
||||
* split as outOfStock. */
|
||||
lowStock?: boolean;
|
||||
/** Optional — products.variants (name + optional priceOverride + its own
|
||||
* outOfStock). When non-empty, a variant must be picked (defaults to the
|
||||
* first *in-stock* one, or just the first if all are out) before "add to
|
||||
* cart" is enabled — the selected variant's name is snapshotted onto the
|
||||
* cart line and, later, the order itself. */
|
||||
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);
|
||||
@@ -47,6 +51,7 @@ export function AddToCartInlineButton({
|
||||
// Whichever is actually being offered right now — the selected variant's
|
||||
// own flag if there are variants, otherwise the plain product-level one.
|
||||
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;
|
||||
@@ -83,11 +88,14 @@ export function AddToCartInlineButton({
|
||||
{variants.map((v) => (
|
||||
<option key={v.name} value={v.name}>
|
||||
{v.name}
|
||||
{v.outOfStock ? " (ausverkauft)" : ""}
|
||||
{v.outOfStock ? " (ausverkauft)" : v.lowStock ? " (nur noch wenige)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{currentlyLowStock && !currentlyOutOfStock && (
|
||||
<p className="text-label font-bold text-warning">Nur noch wenige verfügbar</p>
|
||||
)}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
|
||||
+168
-124
@@ -4,7 +4,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
import { AUTH_CHANGED_EVENT } from "../lib/auth";
|
||||
import { NewsletterModal } from "./NewsletterModal";
|
||||
import { useCartFly } from "./CartFly";
|
||||
|
||||
@@ -82,28 +84,29 @@ function isNavLinkActive(href: string, pathname: string, activeSection: string):
|
||||
// and reading the session cookie there (next/headers' cookies()) would
|
||||
// force the entire site into per-request dynamic rendering just for this.
|
||||
// `loggedIn === null` is the brief "not checked yet" state on first paint.
|
||||
function AccountLink({ variant }: { variant: "icon" | "mobile" }) {
|
||||
function AccountLink() {
|
||||
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/account/me")
|
||||
.then((res) => setLoggedIn(res.ok))
|
||||
.catch(() => setLoggedIn(false));
|
||||
function checkAuth() {
|
||||
fetch("/api/account/me")
|
||||
.then((res) => setLoggedIn(res.ok))
|
||||
.catch(() => setLoggedIn(false));
|
||||
}
|
||||
checkAuth();
|
||||
// Navbar lives in the root layout and never unmounts across
|
||||
// navigations, so this effect only ever runs once on its own —
|
||||
// router.refresh() (called after login/logout) re-fetches Server
|
||||
// Component data but doesn't re-run an already-mounted Client
|
||||
// Component's effects. AUTH_CHANGED_EVENT is dispatched explicitly by
|
||||
// every login/logout call site (see dispatchAuthChanged() in
|
||||
// ../lib/auth) so this stays in sync without a hard reload.
|
||||
window.addEventListener(AUTH_CHANGED_EVENT, checkAuth);
|
||||
return () => window.removeEventListener(AUTH_CHANGED_EVENT, checkAuth);
|
||||
}, []);
|
||||
|
||||
const href = loggedIn ? "/konto/bestellungen" : "/konto/login";
|
||||
|
||||
if (variant === "mobile") {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
|
||||
>
|
||||
{loggedIn ? "Mein Konto" : "Anmelden"}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
@@ -327,6 +330,32 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [mobileOpen]);
|
||||
|
||||
// Background scroll lock while the fullscreen panel is open — same
|
||||
// wheel/touchmove interception as NewsletterModal.tsx (see that
|
||||
// component's own comment on why this approach over overflow:hidden or
|
||||
// position:fixed on body). Needed now that the panel actually covers the
|
||||
// viewport instead of pushing page content down in normal flow.
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
|
||||
const isInsidePanel = (target: EventTarget | null) =>
|
||||
target instanceof Node && !!panelRef.current?.contains(target);
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!isInsidePanel(e.target)) e.preventDefault();
|
||||
};
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!isInsidePanel(e.target)) e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", onWheel, { passive: false });
|
||||
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
return () => {
|
||||
document.removeEventListener("wheel", onWheel);
|
||||
document.removeEventListener("touchmove", onTouchMove);
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
const closeMobile = () => setMobileOpen(false);
|
||||
|
||||
return (
|
||||
@@ -345,18 +374,11 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
// position itself.
|
||||
<>
|
||||
<header
|
||||
// min-h-, not a fixed h- — the fixed height used to cap this flex
|
||||
// column at exactly 6.25rem regardless of content. The top row below
|
||||
// has shrink-0 (always fills all 6.25rem on its own), so the mobile
|
||||
// drawer panel — the column's only other child — had zero room left.
|
||||
// Flex items default to `min-height: auto` (their own content size),
|
||||
// which normally would've still forced the column taller — but the
|
||||
// panel's own `overflow-hidden` (needed for its open/close animation)
|
||||
// resets that automatic minimum to 0 per spec, so flexbox was free to
|
||||
// crush it to a literal 0px box instead of pushing the header taller.
|
||||
// The hamburger icon itself was never the problem — it toggled to "X"
|
||||
// correctly; the panel it opens was rendering at zero height beneath it.
|
||||
className={`sticky top-0 z-50 w-full min-h-[6.25rem] flex flex-col transition-[background-color,backdrop-filter] duration-300 ${
|
||||
// The mobile panel used to be a child of this element and needed the
|
||||
// header itself to be a flexible column that could grow for it — it's
|
||||
// now a fixed-position sibling instead (see that panel's own comment
|
||||
// on why), so this is back to a plain fixed-height bar.
|
||||
className={`sticky top-0 z-50 w-full h-[6.25rem] transition-[background-color,backdrop-filter] duration-300 ${
|
||||
scrolled || mobileOpen
|
||||
? "bg-bg-base/80 backdrop-blur-md"
|
||||
: "bg-bg-base"
|
||||
@@ -453,7 +475,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
(below lg). Grouped so spacing stays consistent as individual
|
||||
children hide/show across the three breakpoint tiers. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<AccountLink variant="icon" />
|
||||
<AccountLink />
|
||||
<CartLink />
|
||||
|
||||
{/* CTA buttons — inline from md (768px) up, i.e. through both
|
||||
@@ -513,104 +535,126 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer panel — toggleable below lg (see hamburger above).
|
||||
`grid-rows-[0fr]/[1fr]` (not a guessed max-h-[Nrem]) is what
|
||||
actually animates the open/close height — a CSS grid row sized in
|
||||
`fr` units transitions smoothly between 0 and its content's real
|
||||
height with no fixed cap to guess/outgrow, the modern replacement
|
||||
for the old max-height-accordion trick. `overflow-hidden` has to
|
||||
live on the *inner* div, not this one — collapsing a 0fr grid row
|
||||
already clips its content on its own, and this outer element is
|
||||
also where the opacity/translate fade below is applied, which
|
||||
must NOT itself be clipped (a translateY sliding a hidden/clipped
|
||||
element in doesn't read as a fade-in, just a hard cut). */}
|
||||
<div
|
||||
id="mobile-nav-panel"
|
||||
ref={panelRef}
|
||||
className={`lg:hidden grid w-full transition-[grid-template-rows] duration-300 ease-in-out ${
|
||||
mobileOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`overflow-hidden transition-[opacity,transform] duration-300 ease-out ${
|
||||
mobileOpen ? "opacity-100 translate-y-0 delay-100" : "opacity-0 -translate-y-2"
|
||||
}`}
|
||||
>
|
||||
<nav className="flex flex-col gap-6 px-8 pt-2 pb-6">
|
||||
{navLinks.map((link) => {
|
||||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||||
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
|
||||
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
|
||||
return link.href.startsWith("#") ? (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={resolvedHref}
|
||||
onClick={
|
||||
isHomeAnchor
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
closeMobile();
|
||||
const el = document.getElementById(link.href.slice(1));
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
|
||||
smoothScrollTo(top);
|
||||
}
|
||||
history.replaceState(null, "", link.href);
|
||||
}
|
||||
: closeMobile
|
||||
}
|
||||
className="min-h-11 flex flex-col justify-center gap-1 text-h4 font-semibold text-text-primary w-fit"
|
||||
>
|
||||
{link.label}
|
||||
<span
|
||||
className={`h-[2px] bg-brand transition-opacity duration-200 ${
|
||||
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={closeMobile}
|
||||
className="min-h-11 flex items-center text-h4 font-semibold text-text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex flex-col gap-3 px-8 pb-8">
|
||||
{/* 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 drawer is
|
||||
these buttons' only way to reach them. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closeMobile();
|
||||
setNewsletterOpen(true);
|
||||
}}
|
||||
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
|
||||
>
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
onClick={closeMobile}
|
||||
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
</Link>
|
||||
<div onClick={closeMobile}>
|
||||
<AccountLink variant="mobile" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Fullscreen mobile panel — a sibling of <header>, 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. */}
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
id="mobile-nav-panel"
|
||||
ref={panelRef}
|
||||
className="lg:hidden fixed inset-0 z-40 bg-bg-base overflow-y-auto"
|
||||
initial={{ clipPath: "circle(0vmax at 100% 0%)" }}
|
||||
animate={{ clipPath: "circle(150vmax at 100% 0%)" }}
|
||||
exit={{ clipPath: "circle(0vmax at 100% 0%)" }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="flex flex-col min-h-full pt-[6.25rem]">
|
||||
<nav className="flex flex-col flex-1 items-center justify-center gap-6 px-8 py-10 text-center">
|
||||
{navLinks.map((link, i) => {
|
||||
const isActive = isNavLinkActive(link.href, pathname, activeSection);
|
||||
const isHomeAnchor = link.href.startsWith("#") && pathname === "/";
|
||||
const resolvedHref = link.href.startsWith("#") && pathname !== "/" ? `/${link.href}` : link.href;
|
||||
// Staggered fade+rise entrance, timed to land after the
|
||||
// clip-path reveal has visibly opened up — same
|
||||
// "fancy but restrained" register as the rest of this
|
||||
// codebase's motion usage (Reveal.tsx et al.), not a
|
||||
// separate animation language just for this panel.
|
||||
const linkMotionProps = {
|
||||
initial: { opacity: 0, y: 12 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { delay: 0.15 + i * 0.05, duration: 0.3, ease: "easeOut" as const },
|
||||
};
|
||||
return link.href.startsWith("#") ? (
|
||||
<motion.div key={link.href} {...linkMotionProps}>
|
||||
<Link
|
||||
href={resolvedHref}
|
||||
onClick={
|
||||
isHomeAnchor
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
closeMobile();
|
||||
const el = document.getElementById(link.href.slice(1));
|
||||
if (el) {
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - NAVBAR_HEIGHT;
|
||||
smoothScrollTo(top);
|
||||
}
|
||||
history.replaceState(null, "", link.href);
|
||||
}
|
||||
: closeMobile
|
||||
}
|
||||
className="min-h-11 flex flex-col justify-center gap-1 text-h-feature font-semibold text-text-primary w-fit"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{link.label}
|
||||
<span
|
||||
className={`h-[2px] bg-brand transition-opacity duration-200 ${
|
||||
isActive ? "w-10 opacity-100" : "w-10 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key={link.href} {...linkMotionProps}>
|
||||
<Link
|
||||
href={link.href}
|
||||
onClick={closeMobile}
|
||||
className="min-h-11 flex items-center text-h-feature font-semibold text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 + navLinks.length * 0.05, duration: 0.3, ease: "easeOut" }}
|
||||
className="flex flex-col gap-3 px-8 pb-16 pt-10"
|
||||
>
|
||||
{/* 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). */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closeMobile();
|
||||
setNewsletterOpen(true);
|
||||
}}
|
||||
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm border border-[#868686] text-h4 font-bold text-text-primary hover:border-brand hover:text-brand active:scale-[0.97] transition-all"
|
||||
>
|
||||
Newsletter
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
onClick={closeMobile}
|
||||
className="md:hidden min-h-11 flex items-center justify-center px-6 py-4 rounded-sm bg-brand text-h4 font-bold text-text-primary hover:bg-brand-hover active:scale-[0.97] transition-all"
|
||||
>
|
||||
7-Tage-Challenge
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
|
||||
Ausverkauft
|
||||
</span>
|
||||
) : discount !== null ? (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
</span>
|
||||
) : (
|
||||
anyLowStock && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
|
||||
Nur noch wenige verfügbar
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -66,7 +85,7 @@ export async function ProductSpotlight() {
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
|
||||
<p className="text-label text-text-muted">inkl. {taxRate}% MwSt. zzgl. Versand</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
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. */}
|
||||
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} variants={product.variants} />
|
||||
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} lowStock={product.lowStock} variants={product.variants} />
|
||||
{product.href && (
|
||||
<Link
|
||||
href={product.href}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
// Native size of the hand-drawn underline texture (icon-merke-dir-underline.png,
|
||||
// exported from the Figma "label-underline" node) — used as the SSR/pre-hydration
|
||||
// fallback width before the label's actual rendered width is measured.
|
||||
const UNDERLINE_NATIVE_WIDTH = 136;
|
||||
|
||||
// Blockquote "Merke dir:" label + sparkle icon + underline (see RichText.tsx's
|
||||
// "quote" case). Split out as its own client component because the underline
|
||||
// needs to be stretched to match the label's actual rendered width — a fixed
|
||||
// width only ever matched the one label length it was eyeballed against,
|
||||
// leaving the underline too short (overflowing labels) or too long (sparse-
|
||||
// looking short labels) for anything else.
|
||||
export function QuoteLabel({ label }: { label: string }) {
|
||||
const labelRef = useRef<HTMLSpanElement>(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 (
|
||||
<div className="relative flex items-center gap-2 shrink-0">
|
||||
<span className="relative flex h-7 w-6 items-center justify-center shrink-0">
|
||||
<Image alt="" src="/icon-sparkle-merke-dir.png" fill sizes="24px" className="object-contain" />
|
||||
</span>
|
||||
<span
|
||||
ref={labelRef}
|
||||
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/* 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. */}
|
||||
<Image
|
||||
alt=""
|
||||
src="/icon-merke-dir-underline.png"
|
||||
width={UNDERLINE_NATIVE_WIDTH}
|
||||
height={23}
|
||||
className="absolute left-8 top-[2.1875rem] h-[1.4375rem] object-fill pointer-events-none"
|
||||
style={{ width: underlineWidth }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="relative flex h-7 w-6 items-center justify-center shrink-0">
|
||||
<Image alt="" src="/icon-sparkle-merke-dir.png" fill sizes="24px" className="object-contain" />
|
||||
</span>
|
||||
<span
|
||||
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
|
||||
style={{ fontFamily: "var(--font-caveat)" }}
|
||||
>
|
||||
{quoteLabel}
|
||||
</span>
|
||||
</div>
|
||||
{/* Hand-drawn underline image, not a plain bar — exported
|
||||
straight from the Figma node (label-underline). */}
|
||||
<Image
|
||||
alt=""
|
||||
src="/icon-merke-dir-underline.png"
|
||||
width={136}
|
||||
height={23}
|
||||
className="absolute left-8 top-[2.1875rem] w-[8.5rem] h-[1.4375rem] object-cover pointer-events-none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{quoteLabel && <QuoteLabel label={quoteLabel} />}
|
||||
<div className="w-px self-stretch bg-brand shrink-0" />
|
||||
{/* Lexical's real QuoteNode holds flat text/linebreak children
|
||||
directly, NOT nested paragraphs — pressing Enter inside a
|
||||
|
||||
@@ -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 (
|
||||
<p className="text-label text-text-muted">
|
||||
enthält {g.rate}% MwSt.: {formatPrice(g.tax)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-label text-text-muted">enthält MwSt.:</p>
|
||||
{groups.map((g) => (
|
||||
<p key={g.rate} className="text-label text-text-muted pl-2">
|
||||
{g.rate}%: {formatPrice(g.tax)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Lieferadresse</p>
|
||||
{/* 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. */}
|
||||
<p className="text-label text-text-muted">{order.hasDifferentShippingAddress ? "Rechnungsadresse" : "Lieferadresse"}</p>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.customerFirstName} {order.customerLastName}
|
||||
</p>
|
||||
@@ -82,14 +96,33 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{order.hasDifferentShippingAddress && (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<p className="text-label text-text-muted">Lieferadresse</p>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingFirstName} {order.shippingLastName}
|
||||
</p>
|
||||
<p className="text-body-sm text-text-primary">{shippingAddress}</p>
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{order.shippingZip} {order.shippingCity}, {order.shippingCountry}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full bg-bg-base border border-border rounded-md p-6 flex flex-col gap-3">
|
||||
{order.items.map((item, i) => (
|
||||
{order.items.map((item, i) => {
|
||||
const imageUrl = imagesByProductId.get(item.product);
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-4 w-full">
|
||||
<div className="relative size-16 shrink-0 rounded-sm overflow-hidden bg-bg-muted">
|
||||
{imageUrl && <Image src={imageUrl} alt="" fill sizes="64px" className="object-cover" />}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-0.5">
|
||||
<p className="text-body-sm text-text-primary">
|
||||
{item.quantity} × {item.productName}
|
||||
{item.variantName ? ` (${item.variantName})` : ""}
|
||||
</p>
|
||||
<p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>
|
||||
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
|
||||
{item.returnQuantity > 0 && (
|
||||
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
|
||||
@@ -97,7 +130,8 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
</div>
|
||||
<p className="text-body-sm text-text-primary">{formatPrice(item.quantity * item.unitPrice)}</p>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
@@ -123,12 +157,15 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<div className="flex items-center w-full">
|
||||
<span className="font-semibold text-h4 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Gesamtsumme
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="font-semibold text-h4 text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
|
||||
Gesamtsumme
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
|
||||
</div>
|
||||
<VatBreakdown groups={taxBreakdown} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<p className="text-body text-text-muted">Du hast noch keine Bestellung aufgegeben.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{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 (
|
||||
<Link
|
||||
key={order.orderNumber}
|
||||
href={`/konto/bestellungen/${encodeURIComponent(order.orderNumber)}`}
|
||||
className="flex flex-wrap items-center gap-4 w-full bg-bg-base border border-border rounded-md p-6 hover:border-brand transition-colors"
|
||||
>
|
||||
<div className="flex -space-x-2 shrink-0">
|
||||
{thumbnails.map((url, i) =>
|
||||
url ? (
|
||||
<div key={i} className="relative size-11 rounded-sm overflow-hidden border-2 border-bg-base">
|
||||
<Image src={url} alt="" fill sizes="44px" className="object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="size-11 rounded-sm bg-bg-muted border-2 border-bg-base" />
|
||||
),
|
||||
)}
|
||||
{overflow > 0 && (
|
||||
<div className="relative size-11 rounded-sm border-2 border-bg-base bg-bg-muted flex items-center justify-center">
|
||||
<span className="text-label font-bold text-text-muted">+{overflow}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-label text-text-muted">Bestellnummer</p>
|
||||
<p className="font-bold text-body-sm text-text-primary">{order.orderNumber}</p>
|
||||
@@ -66,7 +93,8 @@ export default async function KontoBestellungenPage() {
|
||||
<p className="font-bold text-body-sm text-text-primary">{formatPrice(order.total)}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,6 +19,8 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
spotlightImage: null,
|
||||
variants: [],
|
||||
outOfStock: false,
|
||||
lowStock: false,
|
||||
taxRatePercent: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<CheckoutDraft> | 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
|
||||
}
|
||||
}
|
||||
@@ -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<number, number>();
|
||||
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
|
||||
<Text style={styles.metaLabel}>Datum</Text>
|
||||
<Text style={styles.metaValue}>{formatDate(order.correctionInvoiceIssuedAt)}</Text>
|
||||
</View>
|
||||
<View style={styles.metaBox}>
|
||||
<Text style={styles.metaLabel}>USt-IdNr.</Text>
|
||||
<Text style={styles.metaValue}>{seller.vatId}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
<View style={styles.colImage} />
|
||||
<Text style={[styles.colName, styles.headerCell]}>Artikel</Text>
|
||||
<Text style={[styles.colQty, styles.headerCell]}>Menge</Text>
|
||||
<Text style={[styles.colPrice, styles.headerCell]}>Einzelpreis</Text>
|
||||
@@ -237,6 +237,9 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
|
||||
</View>
|
||||
{lines.map(({ item, effectiveQuantity }, i) => (
|
||||
<View style={[styles.tableRow, i % 2 === 1 ? styles.tableRowAlt : {}]} key={i}>
|
||||
<View style={styles.colImage}>
|
||||
{item.imageUrl && <Image src={item.imageUrl} style={styles.itemImage} />}
|
||||
</View>
|
||||
<View style={styles.colName}>
|
||||
<Text>
|
||||
{item.productName}
|
||||
@@ -253,10 +256,22 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
|
||||
|
||||
<View style={styles.summary}>
|
||||
<View style={styles.summaryBox}>
|
||||
{/* 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 && (
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Versand</Text>
|
||||
<Text style={styles.summaryValue}>-{formatPrice(order.shippingCost)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{rateGroups.map((g) => (
|
||||
<React.Fragment key={g.rate}>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Netto ({g.rate}%)</Text>
|
||||
<Text style={styles.summaryLabel}>Netto</Text>
|
||||
<Text style={styles.summaryValue}>-{formatPrice(g.net)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
|
||||
+19
-2
@@ -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<CustomerOrder[]> {
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
`<tr><td style="padding:4px 0;font-size:14px;color:${color};">${label}</td><td style="padding:4px 0;text-align:right;font-size:14px;color:${color};">${value}</td></tr>`;
|
||||
|
||||
const vatRow = (label: string, value: string) =>
|
||||
`<tr><td style="padding-top:4px;font-size:12px;color:${TEXT_MUTED};">${label}</td><td style="padding-top:4px;text-align:right;font-size:12px;color:${TEXT_MUTED};">${value}</td></tr>`;
|
||||
|
||||
// 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")}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:16px;background:${BG_MUTED};border-radius:8px;padding:20px;">
|
||||
@@ -234,6 +256,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
<td style="font-family:${FONT_SERIF};font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">Gesamtsumme</td>
|
||||
<td style="text-align:right;font-weight:700;font-size:17px;color:${TEXT_PRIMARY};">${formatPrice(order.total)}</td>
|
||||
</tr>
|
||||
${taxRows}
|
||||
</table>
|
||||
`;
|
||||
|
||||
|
||||
+63
-24
@@ -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<number, number>();
|
||||
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 (
|
||||
<Document>
|
||||
@@ -210,7 +236,7 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
|
||||
|
||||
<View style={styles.body}>
|
||||
<View style={styles.addressRow}>
|
||||
<View style={styles.addressBlock}>
|
||||
<View style={addressBlockStyle}>
|
||||
<Text style={styles.addressLabel}>Von</Text>
|
||||
<Text style={styles.addressLine}>{seller.sellerName}</Text>
|
||||
<Text style={styles.addressLine}>{seller.sellerStreet}</Text>
|
||||
@@ -219,7 +245,7 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
|
||||
</Text>
|
||||
<Text style={styles.addressLine}>{seller.sellerCountry}</Text>
|
||||
</View>
|
||||
<View style={styles.addressBlock}>
|
||||
<View style={addressBlockStyle}>
|
||||
<Text style={styles.addressLabel}>An</Text>
|
||||
<Text style={styles.addressLine}>
|
||||
{order.customerFirstName} {order.customerLastName}
|
||||
@@ -230,6 +256,19 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
|
||||
</Text>
|
||||
<Text style={styles.addressLine}>{order.country}</Text>
|
||||
</View>
|
||||
{order.hasDifferentShippingAddress && (
|
||||
<View style={addressBlockStyle}>
|
||||
<Text style={styles.addressLabel}>Lieferadresse</Text>
|
||||
<Text style={styles.addressLine}>
|
||||
{order.shippingFirstName} {order.shippingLastName}
|
||||
</Text>
|
||||
<Text style={styles.addressLine}>{shippingLine}</Text>
|
||||
<Text style={styles.addressLine}>
|
||||
{order.shippingZip} {order.shippingCity}
|
||||
</Text>
|
||||
<Text style={styles.addressLine}>{order.shippingCountry}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
@@ -245,10 +284,6 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
|
||||
<Text style={styles.metaLabel}>Bestellnummer</Text>
|
||||
<Text style={styles.metaValue}>{order.orderNumber}</Text>
|
||||
</View>
|
||||
<View style={styles.metaBox}>
|
||||
<Text style={styles.metaLabel}>USt-IdNr.</Text>
|
||||
<Text style={styles.metaValue}>{seller.vatId}</Text>
|
||||
</View>
|
||||
{paid && (
|
||||
<View style={styles.paidBadge}>
|
||||
<Text style={styles.paidBadgeText}>✓ Bereits beglichen ({order.paymentMethodTitle})</Text>
|
||||
@@ -258,6 +293,7 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
|
||||
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
<View style={styles.colImage} />
|
||||
<Text style={[styles.colName, styles.headerCell]}>Artikel</Text>
|
||||
<Text style={[styles.colQty, styles.headerCell]}>Menge</Text>
|
||||
<Text style={[styles.colPrice, styles.headerCell]}>Einzelpreis</Text>
|
||||
@@ -265,6 +301,9 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
|
||||
</View>
|
||||
{order.items.map((item, i) => (
|
||||
<View style={[styles.tableRow, i % 2 === 1 ? styles.tableRowAlt : {}]} key={i}>
|
||||
<View style={styles.colImage}>
|
||||
{item.imageUrl && <Image src={item.imageUrl} style={styles.itemImage} />}
|
||||
</View>
|
||||
<View style={styles.colName}>
|
||||
<Text>
|
||||
{item.productName}
|
||||
@@ -294,7 +333,7 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
|
||||
{rateGroups.map((g) => (
|
||||
<React.Fragment key={g.rate}>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Netto ({g.rate}%)</Text>
|
||||
<Text style={styles.summaryLabel}>Netto</Text>
|
||||
<Text style={styles.summaryValue}>{formatPrice(g.net)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CreatedOrder
|
||||
zip: input.zip,
|
||||
city: input.city,
|
||||
country: input.country,
|
||||
hasDifferentShippingAddress: input.hasDifferentShippingAddress ?? false,
|
||||
shippingFirstName: input.shippingFirstName,
|
||||
shippingLastName: input.shippingLastName,
|
||||
shippingDeliveryMethod: input.shippingDeliveryMethod,
|
||||
shippingStreet: input.shippingStreet,
|
||||
shippingPackstationNumber: input.shippingPackstationNumber,
|
||||
shippingPostNumber: input.shippingPostNumber,
|
||||
shippingZip: input.shippingZip,
|
||||
shippingCity: input.shippingCity,
|
||||
shippingCountry: input.shippingCountry,
|
||||
newsletterOptIn: input.newsletterOptIn,
|
||||
items: input.items.map((i) => ({
|
||||
product: i.productId,
|
||||
|
||||
+74
-2
@@ -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<Product | null> {
|
||||
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<Map<number, string>> {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const map = new Map<number, string>();
|
||||
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<CompanySettings | null> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<number, number>();
|
||||
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);
|
||||
}
|
||||
@@ -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() {
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
|
||||
{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 (
|
||||
<RevealItem
|
||||
key={product.id}
|
||||
@@ -51,10 +57,14 @@ export async function ProductGrid() {
|
||||
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
|
||||
Ausverkauft
|
||||
</span>
|
||||
) : discount !== null ? (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
</span>
|
||||
) : (
|
||||
discount !== null && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-brand px-2.5 py-1 text-label font-bold text-text-primary">
|
||||
-{discount}%
|
||||
anyLowStock && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-warning px-2.5 py-1 text-label font-bold text-text-on-dark">
|
||||
Nur noch wenige verfügbar
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
@@ -72,7 +82,7 @@ export async function ProductGrid() {
|
||||
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
|
||||
)}
|
||||
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
|
||||
<span className="text-label text-text-muted">inkl. MwSt.</span>
|
||||
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
|
||||
</p>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
|
||||
@@ -94,7 +104,7 @@ export async function ProductGrid() {
|
||||
equal-height lesson). */}
|
||||
<div className="flex-1" />
|
||||
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} lowStock={product.lowStock} variants={product.variants} />
|
||||
</div>
|
||||
</RevealItem>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<section className="w-full bg-bg-base px-[var(--layout-padding-x)] py-8">
|
||||
@@ -66,7 +72,7 @@ export async function Pricing() {
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<p className="text-label text-text-muted">inkl. MwSt. zzgl. Versand</p>
|
||||
<p className="text-label text-text-muted">inkl. {taxRate}% MwSt. zzgl. Versand</p>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
@@ -99,7 +105,7 @@ export async function TodoKartenHero() {
|
||||
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
|
||||
)}
|
||||
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<p className="text-label text-text-muted">inkl. MwSt.</p>
|
||||
<p className="text-label text-text-muted">inkl. {taxRate}% MwSt.</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-label text-text-muted">
|
||||
@@ -108,7 +114,7 @@ export async function TodoKartenHero() {
|
||||
</div>
|
||||
|
||||
{product && (
|
||||
<AddToCartButton label="ToDo-Karten bestellen" outOfStock={product.outOfStock} variants={product.variants} />
|
||||
<AddToCartButton label="ToDo-Karten bestellen" outOfStock={product.outOfStock} lowStock={product.lowStock} variants={product.variants} />
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
Reference in New Issue
Block a user