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>
This commit is contained in:
Marco
2026-07-23 17:53:14 +00:00
parent 1212b9d115
commit 2d88fb86a1
10 changed files with 80 additions and 21 deletions
+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}