Out-of-stock UI, variant picker on marketing pages, server-side stock check

- ProductGrid/AddToCartInlineButton/AddToCartButton now show "Ausverkauft"
  and disable add-to-cart per variant (or product-level with no variants),
  derived from trackInventory/stock/allowBackorder via isOutOfStock().
- AddToCartButton (todo-cards Hero+Pricing, homepage spotlight) gains the
  same variant <select> AddToCartInlineButton already had — all three call
  sites already fetch full product data server-side.
- /api/checkout re-validates stock server-side (depth-in-defense, not just
  the disabled button), rejecting when trackInventory is on, allowBackorder
  is off, and requested qty exceeds stock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PL4zfTY1sXc8x5QS6FatM
This commit is contained in:
Marco
2026-07-22 17:58:10 +00:00
parent c5500bcc97
commit 39782eeab9
13 changed files with 205 additions and 59 deletions
+37 -10
View File
@@ -283,15 +283,26 @@ sides (the common no-variants case) still matches by simple equality —
every pre-existing call site that never passes a variant keeps working
unchanged.
**Where a variant gets picked**: `AddToCartInlineButton` renders a
`<select>` above the button when its `variants` prop is non-empty
(`ProductGrid.tsx`/`RelatedProducts.tsx` pass `product.variants` straight
through from `getProducts()`'s mapped `Product` type), defaulting to the
first variant. `AddToCartButton` (the marketing-page-specific one on
`/todo-cards` and the homepage spotlight) does **not** have a variant
picker — those reference one hardcoded product id directly with no
product data in scope, so if that specific product ever gets variants,
this button would need its own follow-up work.
**Where a variant gets picked**: both `AddToCartInlineButton`
(`/shop`, `/cart`'s related-products grid) and `AddToCartButton` (the
marketing-page-specific one on `/todo-cards`' Hero + Pricing panel and the
homepage spotlight) render a `<select>` above the button when their
`variants` prop is non-empty, defaulting to the first *in-stock* variant.
All five call sites already fetch the full product server-side
(`getProducts()`/`getProductBySlug()`/`getSpotlightProduct()`), so
`product.variants` and `product.outOfStock` are simply passed straight
through — no separate data-fetch needed for `AddToCartButton`'s two pages.
**Out-of-stock UI**: `app/lib/payload.ts`'s `isOutOfStock()` derives
`Product.outOfStock` (and each `variants[].outOfStock`) from
`trackInventory`/`stock`/`allowBackorder` — true only when inventory is
tracked, backorders aren't allowed, and `stock <= 0`. Both add-to-cart
buttons disable themselves and show "Ausverkauft" for whichever variant is
currently selected (or the plain product, when there are no variants);
`ProductGrid.tsx` additionally shows an "Ausverkauft" badge (replacing the
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.
**Pricing**: `app/lib/cartTotals.ts`'s `effectivePrice(entry, product)`
a selected variant's `priceOverride` wins over the base `product.price`
@@ -304,7 +315,11 @@ instead of reading `product.price` directly. The checkout route
too — same "never trust the client" reasoning as price re-derivation
generally: a `line.variant` naming something that doesn't exist on that
product (removed, or a tampered request) fails the whole checkout rather
than silently falling back to the base price.
than silently falling back to the base price. It also re-checks stock at
that same point — depth-in-defense, not just the disabled button UI above
— rejecting the order when the resolved product/variant has
`trackInventory` on, `allowBackorder` off, and less `stock` than the
requested quantity.
**Snapshotting**: `orders.items[].variantName` captures which variant was
picked at order time (same "snapshot, not a live relationship" reasoning
@@ -854,6 +869,18 @@ sees.
monitor in the existing "Content & API" group (`~/dev/README.md`'s
documented `sqlite3`-insert method, Kuma 1.x has no REST API for this).
`/shop` itself also has its own Kuma HTTP monitor ("einfach-produktiv Shop
(Produkte, Varianten, Lagerbestand)", same "Content & API" group) — added
once the shop grid started doing real work at render time (`fullyOutOfStock`
across a product's variants, `effectivePrice()`), not just listing static
content; `/api/health` alone only proves Payload is reachable, not that this
specific page still renders. The Payload jobs queue's own failure monitor
(`/api/health/jobs`, `hasError: true` in the last 24h) already covers all
five scheduled jobs generically by task-agnostic query — the four added this
session (low-stock digest, stale-unverified-accounts report, weekly revenue
report, expired-discount-code cleanup) needed no monitor changes of their
own; see the Payload README's "Jobs Queue" section.
## Tests
`npm run test:unit` (Vitest, `node` environment, no jsdom/Next.js runtime
+12
View File
@@ -112,6 +112,18 @@ export async function POST(request: Request) {
variant = product.variants?.find((v) => v.name === line.variant) ?? null;
if (!variant) return NextResponse.json({ ok: false, reason: "Eine gewählte Variante ist nicht mehr verfügbar." }, { status: 400 });
}
// Same depth-in-defense reasoning as the price re-check above — the
// storefront already disables "add to cart" for sold-out items, but a
// tampered/stale request could still submit one, so stock is
// re-validated here as the actual source of truth. Falls through
// (buyable) whenever trackInventory is off or backorders are allowed.
const stockSource = line.variant ? product.variants?.find((v) => v.name === line.variant) : product;
if (stockSource?.trackInventory && !stockSource.allowBackorder && (stockSource.stock ?? 0) < line.qty) {
return NextResponse.json(
{ ok: false, reason: `"${product.name}"${line.variant ? ` (${line.variant})` : ""} ist nicht mehr in ausreichender Menge verfügbar.` },
{ status: 400 },
);
}
const imageUrl = typeof product.image === "object" && product.image ? product.image.url : null;
items.push({
productId: product.id,
+1 -1
View File
@@ -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} variants={product.variants} />
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
</div>
</div>
))}
+65 -26
View File
@@ -17,6 +17,8 @@ export function AddToCartButton({
label,
className,
productId = "todo-karten",
outOfStock = false,
variants = [],
}: {
label: string;
className?: string;
@@ -24,16 +26,27 @@ export function AddToCartButton({
* ProductSpotlight passes the actual CMS-selected spotlight product's id
* explicitly, since that can now be a different product. */
productId?: string;
/** Product-level only meaningful when `variants` is empty, same split as
* AddToCartInlineButton. */
outOfStock?: 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 }[];
}) {
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();
useEffect(() => () => clearTimeout(timeoutRef.current), []);
const currentlyOutOfStock = variants.length > 0 ? (variants.find((v) => v.name === selectedVariant)?.outOfStock ?? false) : outOfStock;
function handleClick() {
addToCart(productId);
if (currentlyOutOfStock) return;
addToCart(productId, 1, selectedVariant);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
clearTimeout(timeoutRef.current);
@@ -55,33 +68,59 @@ 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 = added
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
: "";
const stateClasses = currentlyOutOfStock
? "opacity-60 cursor-not-allowed"
: added
? "border border-success! bg-success-subtle! hover:bg-success-subtle! text-success!"
: "";
const displayLabel = currentlyOutOfStock ? "Ausverkauft" : label;
return (
<button
ref={buttonRef}
type="button"
onClick={handleClick}
className={`${base} ${stateClasses}`}
>
{/* CSS-grid text-stack, not just swapping the button's text node
directly this button is inline-flex/content-sized (no w-full),
so "Hinzugefügt ✓" being shorter than most labels made the whole
button visibly shrink while showing the success state. Stacking
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. */}
<span className="relative grid">
<span className="invisible [grid-area:1/1]" aria-hidden="true">
{label}
<div className="flex flex-col gap-2">
{variants.length > 0 && (
<select
value={selectedVariant}
onChange={(e) => setSelectedVariant(e.target.value)}
className="w-full rounded-sm border border-border px-3 py-2 text-body-sm text-text-primary bg-bg-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
aria-label="Variante auswählen"
>
{variants.map((v) => (
<option key={v.name} value={v.name}>
{v.name}
{v.outOfStock ? " (ausverkauft)" : ""}
</option>
))}
</select>
)}
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={currentlyOutOfStock}
className={`${base} ${stateClasses}`}
>
{/* CSS-grid text-stack, not just swapping the button's text node
directly this button is inline-flex/content-sized (no w-full),
so "Hinzugefügt ✓" being shorter than most labels made the whole
button visibly shrink while showing the success state. Stacking
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. */}
<span className="relative grid">
<span className="invisible [grid-area:1/1]" aria-hidden="true">
{label}
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Hinzugefügt
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Ausverkauft
</span>
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : displayLabel}</span>
</span>
<span className="invisible [grid-area:1/1]" aria-hidden="true">
Hinzugefügt
</span>
<span className="[grid-area:1/1]">{added ? "Hinzugefügt ✓" : label}</span>
</span>
</button>
</button>
</div>
);
}
+31 -12
View File
@@ -20,26 +20,36 @@ export function AddToCartInlineButton({
id,
label = "In den Warenkorb",
className,
outOfStock = false,
variants = [],
}: {
id: string;
label?: string;
className?: string;
/** Optional products.variants (name + optional priceOverride). When
* non-empty, a variant must be picked (defaults to the first one) 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 }[];
/** Product-level only meaningful when `variants` is empty. A varianted
* product's buyability is entirely per-variant instead (see below). */
outOfStock?: 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 }[];
}) {
const [added, setAdded] = useState(false);
const [selectedVariant, setSelectedVariant] = useState(variants[0]?.name);
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();
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;
function handleClick() {
if (currentlyOutOfStock) return;
addToCart(id, 1, selectedVariant);
if (buttonRef.current) fly(buttonRef.current);
setAdded(true);
@@ -55,9 +65,11 @@ 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 = added
? "border-success bg-success-subtle"
: "border-border hover:border-brand";
const stateClasses = currentlyOutOfStock
? "border-border opacity-60 cursor-not-allowed"
: added
? "border-success bg-success-subtle"
: "border-border hover:border-brand";
return (
<div className="flex flex-col gap-2 w-full">
@@ -71,18 +83,25 @@ export function AddToCartInlineButton({
{variants.map((v) => (
<option key={v.name} value={v.name}>
{v.name}
{v.outOfStock ? " (ausverkauft)" : ""}
</option>
))}
</select>
)}
<button ref={buttonRef} type="button" onClick={handleClick} className={`${base} ${stateClasses}`}>
<button
ref={buttonRef}
type="button"
onClick={handleClick}
disabled={currentlyOutOfStock}
className={`${base} ${stateClasses}`}
>
<span
className={
"text-body-sm transition-colors " +
(added ? "font-semibold text-success" : "text-text-primary")
(currentlyOutOfStock ? "text-text-muted" : added ? "font-semibold text-success" : "text-text-primary")
}
>
{added ? "Hinzugefügt ✓" : label}
{currentlyOutOfStock ? "Ausverkauft" : 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
@@ -77,7 +77,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} />
<AddToCartButton label="In den Warenkorb" productId={product.id} outOfStock={product.outOfStock} variants={product.variants} />
{product.href && (
<Link
href={product.href}
+3
View File
@@ -12,6 +12,9 @@ const product = (overrides: Partial<RawProduct> = {}): RawProduct => ({
taxRatePercent: null,
bundleItems: null,
variants: null,
trackInventory: false,
stock: null,
allowBackorder: false,
...overrides,
});
+1
View File
@@ -18,6 +18,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
spotlightText: null,
spotlightImage: null,
variants: [],
outOfStock: false,
...overrides,
});
+26 -3
View File
@@ -170,7 +170,13 @@ export type Product = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: string | null;
variants: { name: string; priceOverride: number | null }[];
// Plain booleans, not the raw stock/threshold numbers — the public API
// has no reason to leak exact stock counts, callers only ever need
// "can this be bought right now". `outOfStock` on the product itself
// 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 }[];
};
type PayloadProduct = {
@@ -189,9 +195,21 @@ type PayloadProduct = {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: { url: string } | number | null;
variants: { name: string; priceOverride: number | null }[] | null;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
variants: { name: string; priceOverride: number | null; trackInventory: boolean; stock: number | null; allowBackorder: boolean }[] | null;
};
// A product/variant is only actually unbuyable when it opted into
// inventory tracking AND has zero stock AND backorders aren't allowed —
// the same three-condition check lib/inventory.ts's adjustStock() effectively
// mirrors from the other direction (it only ever touches stock when
// trackInventory is on in the first place).
function isOutOfStock(trackInventory: boolean, stock: number | null, allowBackorder: boolean): boolean {
return trackInventory && !allowBackorder && (stock ?? 0) <= 0;
}
// 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
@@ -213,7 +231,12 @@ export function mapPayloadProduct(product: PayloadProduct): Product {
spotlightText: product.spotlightText || null,
spotlightImage:
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
variants: product.variants ?? [],
outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder),
variants: (product.variants ?? []).map((v) => ({
name: v.name,
priceOverride: v.priceOverride,
outOfStock: isOutOfStock(v.trackInventory, v.stock, v.allowBackorder),
})),
};
}
+6
View File
@@ -11,6 +11,9 @@ export type RawProductVariant = {
name: string;
sku: string | null;
priceOverride: number | null;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
};
export type RawProduct = {
@@ -23,6 +26,9 @@ export type RawProduct = {
taxRatePercent: number | null;
bundleItems: { product: { id: number; name: string } | number; quantity: number }[] | null;
variants: RawProductVariant[] | null;
trackInventory: boolean;
stock: number | null;
allowBackorder: boolean;
};
export async function fetchProductsBySlug(): Promise<Map<string, RawProduct>> {
+17 -5
View File
@@ -28,6 +28,12 @@ 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);
// 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;
return (
<RevealItem
key={product.id}
@@ -39,12 +45,18 @@ export async function ProductGrid() {
alt={product.name}
fill
sizes="(min-width: 768px) 25vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
className={`object-cover transition-transform duration-500 group-hover:scale-105 ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{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}%
{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>
)
)}
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
@@ -82,7 +94,7 @@ export async function ProductGrid() {
equal-height lesson). */}
<div className="flex-1" />
<AddToCartInlineButton id={product.id} variants={product.variants} />
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
</div>
</RevealItem>
);
+2
View File
@@ -79,6 +79,8 @@ export async function Pricing() {
<AddToCartButton
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}
variants={product.variants}
/>
</div>
</Reveal>
+3 -1
View File
@@ -107,7 +107,9 @@ export async function TodoKartenHero() {
</p>
</div>
<AddToCartButton label="ToDo-Karten bestellen" />
{product && (
<AddToCartButton label="ToDo-Karten bestellen" outOfStock={product.outOfStock} variants={product.variants} />
)}
</div>
</Reveal>