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
+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>