From 39782eeab9c79ad1c475be76484fb77d0e4c067f Mon Sep 17 00:00:00 2001
From: Marco
Date: Wed, 22 Jul 2026 17:58:10 +0000
Subject: [PATCH] Out-of-stock UI, variant picker on marketing pages,
server-side stock check
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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 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
Claude-Session: https://claude.ai/code/session_018PL4zfTY1sXc8x5QS6FatM
---
README.md | 47 +++++++---
app/api/checkout/route.ts | 12 +++
app/cart/components/RelatedProducts.tsx | 2 +-
app/components/AddToCartButton.tsx | 91 ++++++++++++++------
app/components/AddToCartInlineButton.tsx | 43 ++++++---
app/components/ProductSpotlight.tsx | 2 +-
app/lib/__tests__/bundleContents.test.ts | 3 +
app/lib/__tests__/cartTotals.test.ts | 1 +
app/lib/payload.ts | 29 ++++++-
app/lib/productsServer.ts | 6 ++
app/shop/components/ProductGrid.tsx | 22 +++--
app/todo-cards/components/Pricing.tsx | 2 +
app/todo-cards/components/TodoKartenHero.tsx | 4 +-
13 files changed, 205 insertions(+), 59 deletions(-)
diff --git a/README.md b/README.md
index 6ab3ee1..f700ab8 100644
--- a/README.md
+++ b/README.md
@@ -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
-`` 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 `` 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
diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts
index b71616f..4ec0f91 100644
--- a/app/api/checkout/route.ts
+++ b/app/api/checkout/route.ts
@@ -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,
diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx
index 535e973..fe5e72b 100644
--- a/app/cart/components/RelatedProducts.tsx
+++ b/app/cart/components/RelatedProducts.tsx
@@ -164,7 +164,7 @@ export function RelatedProducts() {
{product.name}
{formatPrice(product.price)}
-
+
))}
diff --git a/app/components/AddToCartButton.tsx b/app/components/AddToCartButton.tsx
index c296f88..5413f85 100644
--- a/app/components/AddToCartButton.tsx
+++ b/app/components/AddToCartButton.tsx
@@ -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 | undefined>(undefined);
const buttonRef = useRef(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 (
-
- {/* 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. */}
-
-
- {label}
+
+ {variants.length > 0 && (
+ 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) => (
+
+ {v.name}
+ {v.outOfStock ? " (ausverkauft)" : ""}
+
+ ))}
+
+ )}
+
+ {/* 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. */}
+
+
+ {label}
+
+
+ Hinzugefügt ✓
+
+
+ Ausverkauft
+
+ {added ? "Hinzugefügt ✓" : displayLabel}
-
- Hinzugefügt ✓
-
- {added ? "Hinzugefügt ✓" : label}
-
-
+
+
);
}
diff --git a/app/components/AddToCartInlineButton.tsx b/app/components/AddToCartInlineButton.tsx
index f9d584f..ad30928 100644
--- a/app/components/AddToCartInlineButton.tsx
+++ b/app/components/AddToCartInlineButton.tsx
@@ -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 | undefined>(undefined);
const buttonRef = useRef(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 (
@@ -71,18 +83,25 @@ export function AddToCartInlineButton({
{variants.map((v) => (
{v.name}
+ {v.outOfStock ? " (ausverkauft)" : ""}
))}
)}
-
+
- {added ? "Hinzugefügt ✓" : label}
+ {currentlyOutOfStock ? "Ausverkauft" : added ? "Hinzugefügt ✓" : label}
diff --git a/app/components/ProductSpotlight.tsx b/app/components/ProductSpotlight.tsx
index d6d6323..9bb4c2f 100644
--- a/app/components/ProductSpotlight.tsx
+++ b/app/components/ProductSpotlight.tsx
@@ -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. */}
-
+
{product.href && (
= {}): RawProduct => ({
taxRatePercent: null,
bundleItems: null,
variants: null,
+ trackInventory: false,
+ stock: null,
+ allowBackorder: false,
...overrides,
});
diff --git a/app/lib/__tests__/cartTotals.test.ts b/app/lib/__tests__/cartTotals.test.ts
index 024cfdd..30f931f 100644
--- a/app/lib/__tests__/cartTotals.test.ts
+++ b/app/lib/__tests__/cartTotals.test.ts
@@ -18,6 +18,7 @@ const product = (overrides: Partial = {}): Product => ({
spotlightText: null,
spotlightImage: null,
variants: [],
+ outOfStock: false,
...overrides,
});
diff --git a/app/lib/payload.ts b/app/lib/payload.ts
index 973725a..e7cd6e0 100644
--- a/app/lib/payload.ts
+++ b/app/lib/payload.ts
@@ -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),
+ })),
};
}
diff --git a/app/lib/productsServer.ts b/app/lib/productsServer.ts
index bc9f885..9b27cb2 100644
--- a/app/lib/productsServer.ts
+++ b/app/lib/productsServer.ts
@@ -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> {
diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx
index 8568437..f357de8 100644
--- a/app/shop/components/ProductGrid.tsx
+++ b/app/shop/components/ProductGrid.tsx
@@ -28,6 +28,12 @@ export async function ProductGrid() {
{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 (
- {discount !== null && (
-
- -{discount}%
+ {fullyOutOfStock ? (
+
+ Ausverkauft
+ ) : (
+ discount !== null && (
+
+ -{discount}%
+
+ )
)}
@@ -82,7 +94,7 @@ export async function ProductGrid() {
equal-height lesson). */}
-
+
);
diff --git a/app/todo-cards/components/Pricing.tsx b/app/todo-cards/components/Pricing.tsx
index fbee92e..58c42f9 100644
--- a/app/todo-cards/components/Pricing.tsx
+++ b/app/todo-cards/components/Pricing.tsx
@@ -79,6 +79,8 @@ export async function Pricing() {
diff --git a/app/todo-cards/components/TodoKartenHero.tsx b/app/todo-cards/components/TodoKartenHero.tsx
index 9265451..7632690 100644
--- a/app/todo-cards/components/TodoKartenHero.tsx
+++ b/app/todo-cards/components/TodoKartenHero.tsx
@@ -107,7 +107,9 @@ export async function TodoKartenHero() {
-
+ {product && (
+
+ )}