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