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
+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),
})),
};
}