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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user