Compare commits

...

2 Commits

Author SHA1 Message Date
Marco e48107470a Add optional Firma/USt-IdNr. fields to checkout and profile
B2B checkout fields, split out from the e-invoicing migration and
picked back up now that it's shipped. Both fields are independently
optional, format-validated (shared regex in lib/vatId.ts, mirrored
server-side in api/checkout and api/account/profile), persisted in
the checkout draft, and saved as a customer profile default that
pre-fills future checkouts. Order/customer snapshot fields land in a
companion Payload backend commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 17:53:21 +00:00
Marco 2d88fb86a1 Cap add-to-cart quantity at actual remaining stock
Stock was only checked at checkout; a shopper could add more of a
product to the cart than was actually in stock and only find out at
the last step. Product/variant now carry a real maxQty, and
AddToCartButton/AddToCartInlineButton/the cart's quantity stepper all
disable or cap once the cart already holds that many.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 17:53:14 +00:00
18 changed files with 199 additions and 22 deletions
+10 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getSessionCustomer, updateCustomerProfile } from "../../../lib/customerAuth";
import { normalizeVatId, isValidVatId } from "../../../lib/vatId";
export async function GET() {
const session = await getSessionCustomer();
@@ -12,7 +13,7 @@ export async function PATCH(request: Request) {
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const body = await request.json().catch(() => null);
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country } = body ?? {};
const { firstName, lastName, deliveryMethod, street, packstationNumber, postNumber, zip, city, country, companyName, vatId } = body ?? {};
if (
typeof firstName !== "string" ||
!firstName ||
@@ -34,6 +35,12 @@ export async function PATCH(request: Request) {
if (deliveryMethod === "packstation" && (!packstationNumber || !postNumber)) {
return NextResponse.json({ ok: false, reason: "Bitte Packstation- und Postnummer angeben." }, { status: 400 });
}
// Both independently optional (see Customers.ts's own comment) — only
// format-checked when actually provided, same as the backend field itself.
const normalizedVatId = typeof vatId === "string" && vatId ? normalizeVatId(vatId) : undefined;
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { status: 400 });
}
const result = await updateCustomerProfile(session.token, session.customer.id, {
firstName,
@@ -45,6 +52,8 @@ export async function PATCH(request: Request) {
zip,
city,
country,
companyName: typeof companyName === "string" && companyName ? companyName : undefined,
vatId: normalizedVatId,
});
return NextResponse.json(result, { status: result.ok ? 200 : 400 });
}
+14
View File
@@ -8,6 +8,7 @@ import { fetchProductsBySlug } from "../../lib/productsServer";
import { describeBundleContents } from "../../lib/bundleContents";
import { sendCriticalAlert } from "../../lib/alertAdmin";
import { sendOrderConfirmationEmail } from "../../lib/orderEmail";
import { normalizeVatId, isValidVatId } from "../../lib/vatId";
// Plain float arithmetic on money (quantity × unitPrice summed across
// lines, a percent discount, subtracting/adding those together) drifts
@@ -30,6 +31,8 @@ type CheckoutBody = {
lastName: string;
email: string;
password?: string;
companyName?: string;
vatId?: string;
deliveryMethod: "address" | "packstation";
street?: string;
packstationNumber?: string;
@@ -85,6 +88,15 @@ 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 });
}
// Optional — only format-checked when actually provided, same "never
// trust the client" reasoning as every other checkout field re-validated
// here. Normalized the same way Orders.ts's own field does (uppercase +
// trim), so the snapshot on the order matches what would've been
// accepted directly through the Payload admin.
const normalizedVatId = body.vatId ? normalizeVatId(body.vatId) : undefined;
if (normalizedVatId && !isValidVatId(normalizedVatId)) {
return NextResponse.json({ ok: false, reason: "Ungültiges USt-IdNr.-Format (z. B. DE123456789)." }, { 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 });
@@ -201,6 +213,8 @@ export async function POST(request: Request) {
customerFirstName: body.firstName,
customerLastName: body.lastName,
customerEmail: body.email,
companyName: body.companyName || undefined,
vatId: normalizedVatId,
deliveryMethod: body.deliveryMethod,
street: body.street,
packstationNumber: body.packstationNumber,
+14 -1
View File
@@ -191,6 +191,19 @@ export function CartContent({
const lowStock = entry.variant
? (product.variants.find((v) => v.name === entry.variant)?.lowStock ?? false)
: product.lowStock;
// Same per-line resolution as lowStock above — caps how high
// the quantity stepper below can go, instead of only finding
// out at checkout that this many aren't actually available
// (api/checkout/route.ts's own stock check stays as the
// authoritative server-side guard). null (no cap) falls back
// to the stepper's original fixed 1-9 range; at least 1 is
// always offered even if maxQty is somehow lower than the
// qty already in this line, so the remove (×) button stays
// the only way down, never an empty <select>.
const maxQty = entry.variant
? (product.variants.find((v) => v.name === entry.variant)?.maxQty ?? null)
: product.maxQty;
const qtyOptions = Array.from({ length: Math.max(1, Math.min(9, maxQty ?? 9)) }, (_, n) => n + 1);
return (
<div key={lineKey} className="w-full">
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
@@ -238,7 +251,7 @@ export function CartContent({
onChange={(e) => setQuantity(product.id, Number(e.target.value), entry.variant)}
className="border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
>
{Array.from({ length: 9 }, (_, n) => n + 1).map((n) => (
{qtyOptions.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
+1 -1
View File
@@ -198,7 +198,7 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })
<p className="min-h-[1.05rem] text-label font-bold text-warning">
{anyLowStock ? "Nur noch wenige verfügbar" : null}
</p>
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
</div>
</div>
);
@@ -102,6 +102,13 @@ export function CheckoutContent({
const [firstName, setFirstName] = useState(savedProfile?.firstName ?? "");
const [lastName, setLastName] = useState(savedProfile?.lastName ?? "");
const [email, setEmail] = useState(savedProfile?.email ?? customerEmail ?? "");
// Optional B2B fields — sit right next to Rechnungsadresse (not a
// separately gated "order as a business" toggle) since each is
// independently optional (see Customers.ts/Orders.ts's own comment on
// why neither implies the other). Prefilled from the saved profile, same
// as every other Card 1 field.
const [companyName, setCompanyName] = useState(savedProfile?.companyName ?? "");
const [vatId, setVatId] = useState(savedProfile?.vatId ?? "");
// Always a plain street address — a Packstation isn't a valid Rechnungs-
// adresse (an invoice needs a real postal address). Packstation is only
// ever offered on the separate, optional shipping-address override below.
@@ -143,6 +150,8 @@ export function CheckoutContent({
if (draft.firstName) setFirstName(draft.firstName);
if (draft.lastName) setLastName(draft.lastName);
if (draft.email) setEmail(draft.email);
if (draft.companyName) setCompanyName(draft.companyName);
if (draft.vatId) setVatId(draft.vatId);
if (draft.street) setStreet(draft.street);
if (draft.zip) setZip(draft.zip);
if (draft.city) setCity(draft.city);
@@ -171,6 +180,8 @@ export function CheckoutContent({
firstName,
lastName,
email,
companyName,
vatId,
street,
zip,
city,
@@ -194,6 +205,8 @@ export function CheckoutContent({
firstName,
lastName,
email,
companyName,
vatId,
street,
zip,
city,
@@ -317,6 +330,8 @@ export function CheckoutContent({
firstName,
lastName,
email,
companyName: companyName || undefined,
vatId: vatId || undefined,
// 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).
@@ -516,6 +531,32 @@ export function CheckoutContent({
<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>
{/* Optional B2B fields — both independently optional (see
Orders.ts's own comment: a sole proprietor might give a VAT
ID with no separate "company name", and vice versa), so
neither is required just because the other is filled in. */}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<FormField
label="Firma (optional)"
name="companyName"
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="Muster GmbH"
autoComplete="organization"
/>
<FormField
label="USt-IdNr. (optional)"
name="vatId"
type="text"
value={vatId}
onChange={(e) => setVatId(e.target.value)}
placeholder="DE123456789"
autoComplete="off"
pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}"
title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789."
/>
</div>
{/* w-[calc(50%-0.5rem)] at sm: — exactly matches Vorname's
actual rendered width in the 2-col row above (each half of
a gap-4 flex row), instead of stretching full-width. */}
+21 -8
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { addToCart } from "../lib/cart";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
const FEEDBACK_MS = 2000;
@@ -18,6 +18,7 @@ export function AddToCartButton({
className,
productId = "todo-karten",
outOfStock = false,
maxQty = null,
variants = [],
}: {
label: string;
@@ -29,23 +30,31 @@ export function AddToCartButton({
/** Product-level — only meaningful when `variants` is empty, same split as
* AddToCartInlineButton. */
outOfStock?: boolean;
/** Product-level cap on total cart quantity — only meaningful when
* `variants` is empty, same split as `outOfStock`. null means no cap. */
maxQty?: number | null;
/** 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; lowStock: boolean }[];
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
}) {
const [added, setAdded] = useState(false);
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const buttonRef = useRef<HTMLButtonElement>(null);
const { fly } = useCartFly();
const cart = useCart();
useEffect(() => () => clearTimeout(timeoutRef.current), []);
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
const currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
const qtyInCart = cart.find((i) => i.id === productId && i.variant === selectedVariant)?.qty ?? 0;
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
const disabled = currentlyOutOfStock || limitReached;
function handleClick() {
if (currentlyOutOfStock) return;
if (disabled) return;
addToCart(productId, 1, selectedVariant);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
@@ -68,12 +77,12 @@ export function AddToCartButton({
// a solid bright-green button read as too loud here. `border` (width) is
// added here too since `base` has none by default, unlike
// AddToCartInlineButton's own base which already carries a plain border.
const stateClasses = currentlyOutOfStock
const stateClasses = disabled
? "opacity-60 cursor-not-allowed"
: added
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
: "";
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : label;
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : label;
return (
// Low stock is deliberately NOT surfaced here as its own text line
@@ -106,7 +115,7 @@ export function AddToCartButton({
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={currentlyOutOfStock}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
{/* CSS-grid text-stack, not just swapping the button's text node
@@ -116,8 +125,9 @@ export function AddToCartButton({
both possible texts in the same grid cell (both invisible ones
still contribute to sizing) reserves width for whichever is
wider, so the button's box never changes size either way. Now
also reserves space for "Ausverkauft" — the widest of the three
wins regardless of which is showing. */}
also reserves space for "Ausverkauft"/"Maximale Menge im
Warenkorb" — the widest of the four wins regardless of which is
showing. */}
<span className="relative grid">
<span className="invisible [grid-area:1/1]" aria-hidden="true">
{label}
@@ -128,6 +138,9 @@ export function AddToCartButton({
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Ausverkauft
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Maximale Menge im Warenkorb
</span>
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
</span>
</button>
+21 -7
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { addToCart } from "../lib/cart";
import { addToCart, useCart } from "../lib/cart";
import { useCartFly } from "./CartFly";
// Exported so consumers like RelatedProducts.tsx can delay their own
@@ -21,6 +21,7 @@ export function AddToCartInlineButton({
label = "In den Warenkorb",
className,
outOfStock = false,
maxQty = null,
variants = [],
}: {
id: string;
@@ -29,27 +30,40 @@ 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 cap on total cart quantity — only meaningful when
* `variants` is empty, same split as `outOfStock`. null means no cap
* (backorder allowed / inventory untracked). See lib/payload.ts's
* maxPurchasableQty(). */
maxQty?: number | null;
/** 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; lowStock: boolean }[];
variants?: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
}) {
const [added, setAdded] = useState(false);
const [selectedVariant, setSelectedVariant] = useState(variants.find((v) => !v.outOfStock)?.name ?? variants[0]?.name);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const buttonRef = useRef<HTMLButtonElement>(null);
const { fly } = useCartFly();
const cart = useCart();
useEffect(() => () => clearTimeout(timeoutRef.current), []);
// 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 currentMaxQty = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.maxQty ?? null) : maxQty;
// How much of this exact (id, variant) line is already sitting in the
// cart — capped adds mean "In den Warenkorb" must go disabled once this
// reaches currentMaxQty, not just when the product is fully sold out.
const qtyInCart = cart.find((i) => i.id === id && i.variant === selectedVariant)?.qty ?? 0;
const limitReached = currentMaxQty != null && qtyInCart >= currentMaxQty;
const disabled = currentlyOutOfStock || limitReached;
function handleClick() {
if (currentlyOutOfStock) return;
if (disabled) return;
addToCart(id, 1, selectedVariant);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
@@ -65,7 +79,7 @@ export function AddToCartInlineButton({
// anymore (it's a trailing `!` now), so two conflicting utilities like
// border-border/border-success both being present would silently race on
// CSS source order instead of one cleanly winning.
const stateClasses = currentlyOutOfStock
const stateClasses = disabled
? "border-border opacity-60 cursor-not-allowed"
: added
? "border-success bg-success-subtle"
@@ -97,16 +111,16 @@ export function AddToCartInlineButton({
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={currentlyOutOfStock}
disabled={disabled}
className={`${base} ${stateClasses}`}
>
<span
className={
"text-body-sm transition-colors " +
(currentlyOutOfStock ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
(disabled ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
}
>
{currentlyOutOfStock ? "Ausverkauft" : added ? "Hinzugefügt ✓" : label}
{currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label}
</span>
<Image alt="" src="/icon-cart-outline.png" width={32} height={30} className="h-[1.875rem] w-8 object-contain" />
</button>
+1 -1
View File
@@ -105,7 +105,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} maxQty={product.maxQty} variants={product.variants} />
{product.href && (
<Link
href={product.href}
@@ -45,6 +45,8 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) {
zip: String(form.get("zip") ?? ""),
city: String(form.get("city") ?? ""),
country: String(form.get("country") ?? ""),
companyName: String(form.get("companyName") ?? "") || undefined,
vatId: String(form.get("vatId") ?? "") || undefined,
};
try {
@@ -83,6 +85,22 @@ export function ProfileForm({ profile }: { profile: CustomerProfile }) {
<Field label="Nachname" name="lastName" type="text" defaultValue={profile.lastName} required />
</div>
{/* Optional B2B fields — prefills /checkout's own Firma/USt-IdNr.
fields, same "profile default, order keeps its own snapshot"
split as the address fields below (see Customers.ts). */}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<Field label="Firma (optional)" name="companyName" type="text" defaultValue={profile.companyName ?? ""} />
<Field
label="USt-IdNr. (optional)"
name="vatId"
type="text"
defaultValue={profile.vatId ?? ""}
placeholder="DE123456789"
pattern="[A-Za-z]{2}[A-Za-z0-9]{2,12}"
title="EU-Format: 2 Buchstaben Länderpräfix + bis zu 12 alphanumerische Zeichen, z. B. DE123456789."
/>
</div>
<div className="w-full flex flex-col gap-2 items-start">
<span className="text-label text-text-muted">Lieferart</span>
<div className="flex w-full max-w-sm rounded-sm border border-border overflow-hidden">
+1
View File
@@ -20,6 +20,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
variants: [],
outOfStock: false,
lowStock: false,
maxQty: null,
taxRatePercent: null,
...overrides,
});
+5
View File
@@ -14,6 +14,11 @@ export type CheckoutDraft = {
firstName: string;
lastName: string;
email: string;
// Optional B2B fields — see CheckoutContent.tsx's own comment on why
// they sit here (right next to the Rechnungsadresse fields, not a
// separate persisted concept).
companyName: string;
vatId: string;
// Rechnungsadresse is always a plain street address now — no
// deliveryMethod/packstationNumber/postNumber here, only on the
// shipping* override fields below (see CheckoutContent.tsx).
+10
View File
@@ -199,6 +199,10 @@ export type CustomerAddress = {
zip: string | null;
city: string | null;
country: string | null;
// Optional B2B profile default — see Customers.ts's own comment. Prefills
// /checkout's Firma/USt-IdNr. fields for a returning customer.
companyName: string | null;
vatId: string | null;
};
export type CustomerProfile = CustomerSummary & CustomerAddress;
@@ -217,6 +221,8 @@ type PayloadCustomerMe = {
zip: string | null;
city: string | null;
country: string | null;
companyName: string | null;
vatId: string | null;
cart: { product: number; productSlug: string; quantity: number; variantName: string | null }[] | null;
};
@@ -243,6 +249,8 @@ export async function getCustomerProfile(token: string): Promise<CustomerProfile
zip: u.zip,
city: u.city,
country: u.country,
companyName: u.companyName,
vatId: u.vatId,
};
}
@@ -259,6 +267,8 @@ export async function updateCustomerProfile(
zip: string;
city: string;
country: string;
companyName?: string;
vatId?: string;
},
): Promise<{ ok: true } | { ok: false; reason: string }> {
const res = await fetch(`${PAYLOAD_URL}/api/customers/${customerId}`, {
+6
View File
@@ -34,6 +34,10 @@ export type CreateOrderInput = {
customerFirstName: string;
customerLastName: string;
customerEmail: string;
// Optional B2B snapshot fields — see Orders.ts's own comment on why both
// are independently optional.
companyName?: string;
vatId?: string;
deliveryMethod: "address" | "packstation";
street?: string;
packstationNumber?: string;
@@ -87,6 +91,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
customerFirstName: input.customerFirstName,
customerLastName: input.customerLastName,
customerEmail: input.customerEmail,
companyName: input.companyName,
vatId: input.vatId,
deliveryMethod: input.deliveryMethod,
street: input.street,
packstationNumber: input.packstationNumber,
+18 -1
View File
@@ -179,13 +179,21 @@ export type Product = {
// 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;
// Unlike outOfStock/lowStock, this DOES expose the real number — it's
// the cap the add-to-cart controls (AddToCartButton/AddToCartInlineButton,
// CartContent's quantity stepper) need client-side to stop a shopper from
// putting more in the cart than checkout would actually accept, instead
// of only finding out at the very last step (api/checkout/route.ts's own
// stock check, which stays as the authoritative server-side guard). null
// means "no cap" — backorder allowed or inventory not tracked.
maxQty: number | null;
// 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 }[];
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
};
type PayloadProduct = {
@@ -237,6 +245,13 @@ function isLowStock(trackInventory: boolean, stock: number | null, threshold: nu
return trackInventory && threshold != null && stock != null && stock > 0 && stock <= threshold;
}
// null (no cap) whenever backorder is allowed or inventory isn't tracked —
// only a hard-tracked, non-backorderable stock count actually limits what a
// shopper can add to their cart.
function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowBackorder: boolean): number | null {
return trackInventory && !allowBackorder ? (stock ?? 0) : null;
}
// 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
@@ -260,12 +275,14 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
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),
maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder),
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),
maxQty: maxPurchasableQty(v.trackInventory, v.stock, v.allowBackorder),
})),
};
}
+15
View File
@@ -0,0 +1,15 @@
// Mirrors the backend's own USt-IdNr. validation exactly (Orders.ts/
// Customers.ts/CompanySettings.ts in the Payload repo) — kept as a plain
// client+server-safe helper here since this repo's frontend needs the same
// check twice (checkout's instant client-side pattern + api/checkout's own
// server-side re-validation, same "never trust the client" reasoning as
// every other checkout field).
const VAT_ID_PATTERN = /^[A-Z]{2}[A-Z0-9]{2,12}$/;
export function normalizeVatId(value: string): string {
return value.toUpperCase().trim();
}
export function isValidVatId(value: string): boolean {
return VAT_ID_PATTERN.test(value);
}
+1 -1
View File
@@ -110,7 +110,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} maxQty={product.maxQty} variants={product.variants} />
</div>
</RevealItem>
);
+1
View File
@@ -103,6 +103,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}
maxQty={product.maxQty}
variants={product.variants}
/>
</div>
+1 -1
View File
@@ -119,7 +119,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} maxQty={product.maxQty} variants={product.variants} />
)}
</div>
</Reveal>