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