From 2d88fb86a1c22dc27e420e55def3af84e8d72151 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 23 Jul 2026 17:53:14 +0000 Subject: [PATCH] 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 --- app/cart/components/CartContent.tsx | 15 +++++++++- app/cart/components/RelatedProducts.tsx | 2 +- app/components/AddToCartButton.tsx | 29 ++++++++++++++------ app/components/AddToCartInlineButton.tsx | 28 ++++++++++++++----- app/components/ProductSpotlight.tsx | 2 +- app/lib/__tests__/cartTotals.test.ts | 1 + app/lib/payload.ts | 19 ++++++++++++- app/shop/components/ProductGrid.tsx | 2 +- app/todo-cards/components/Pricing.tsx | 1 + app/todo-cards/components/TodoKartenHero.tsx | 2 +- 10 files changed, 80 insertions(+), 21 deletions(-) diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx index f080b56..a15c646 100644 --- a/app/cart/components/CartContent.tsx +++ b/app/cart/components/CartContent.tsx @@ -191,6 +191,19 @@ export function CartContent({ const lowStock = entry.variant ? (product.variants.find((v) => v.name === entry.variant)?.lowStock ?? false) : product.lowStock; + // Same per-line resolution as lowStock above — caps how high + // the quantity stepper below can go, instead of only finding + // out at checkout that this many aren't actually available + // (api/checkout/route.ts's own stock check stays as the + // authoritative server-side guard). null (no cap) falls back + // to the stepper's original fixed 1-9 range; at least 1 is + // always offered even if maxQty is somehow lower than the + // qty already in this line, so the remove (×) button stays + // the only way down, never an empty diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx index 845624f..821baa8 100644 --- a/app/cart/components/RelatedProducts.tsx +++ b/app/cart/components/RelatedProducts.tsx @@ -198,7 +198,7 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })

{anyLowStock ? "Nur noch wenige verfügbar" : null}

- + ); diff --git a/app/components/AddToCartButton.tsx b/app/components/AddToCartButton.tsx index 0c3c784..4722ba9 100644 --- a/app/components/AddToCartButton.tsx +++ b/app/components/AddToCartButton.tsx @@ -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 | undefined>(undefined); const buttonRef = useRef(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. */} diff --git a/app/components/AddToCartInlineButton.tsx b/app/components/AddToCartInlineButton.tsx index 83e5d35..1c5ffa5 100644 --- a/app/components/AddToCartInlineButton.tsx +++ b/app/components/AddToCartInlineButton.tsx @@ -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 | undefined>(undefined); const buttonRef = useRef(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}`} > - {currentlyOutOfStock ? "Ausverkauft" : added ? "Hinzugefügt ✓" : label} + {currentlyOutOfStock ? "Ausverkauft" : limitReached ? "Maximale Menge im Warenkorb" : added ? "Hinzugefügt ✓" : label} diff --git a/app/components/ProductSpotlight.tsx b/app/components/ProductSpotlight.tsx index ba7dd51..801939f 100644 --- a/app/components/ProductSpotlight.tsx +++ b/app/components/ProductSpotlight.tsx @@ -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. */} - + {product.href && ( = {}): Product => ({ variants: [], outOfStock: false, lowStock: false, + maxQty: null, taxRatePercent: null, ...overrides, }); diff --git a/app/lib/payload.ts b/app/lib/payload.ts index 151dc22..35a46de 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -179,13 +179,21 @@ export type Product = { // Derived, like outOfStock — no raw stock count/threshold leaked, callers // only ever need "should a low-stock hint show for this right now". lowStock: boolean; + // Unlike outOfStock/lowStock, this DOES expose the real number — it's + // the cap the add-to-cart controls (AddToCartButton/AddToCartInlineButton, + // CartContent's quantity stepper) need client-side to stop a shopper from + // putting more in the cart than checkout would actually accept, instead + // of only finding out at the very last step (api/checkout/route.ts's own + // stock check, which stays as the authoritative server-side guard). null + // means "no cap" — backorder allowed or inventory not tracked. + maxQty: number | null; // Per-product override — null means "use the tenant's default rate" // (CompanySettings.taxRatePercent, fetched separately since it's behind // an admin-only secret, see getCompanySettings()). Display-only on the // storefront; the actual rate used for order totals is resolved and // snapshotted server-side at checkout (api/checkout/route.ts). taxRatePercent: number | null; - variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean }[]; + variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[]; }; type PayloadProduct = { @@ -237,6 +245,13 @@ function isLowStock(trackInventory: boolean, stock: number | null, threshold: nu return trackInventory && threshold != null && stock != null && stock > 0 && stock <= threshold; } +// null (no cap) whenever backorder is allowed or inventory isn't tracked — +// only a hard-tracked, non-backorderable stock count actually limits what a +// shopper can add to their cart. +function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowBackorder: boolean): number | null { + return trackInventory && !allowBackorder ? (stock ?? 0) : null; +} + // 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 @@ -260,12 +275,14 @@ export function mapPayloadProduct(product: PayloadProduct): Product { typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null, outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder), lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold), + maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder), taxRatePercent: product.taxRatePercent ?? null, variants: (product.variants ?? []).map((v) => ({ name: v.name, priceOverride: v.priceOverride, outOfStock: isOutOfStock(v.trackInventory, v.stock, v.allowBackorder), lowStock: isLowStock(v.trackInventory, v.stock, v.lowStockThreshold), + maxQty: maxPurchasableQty(v.trackInventory, v.stock, v.allowBackorder), })), }; } diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx index 17c33b9..c251471 100644 --- a/app/shop/components/ProductGrid.tsx +++ b/app/shop/components/ProductGrid.tsx @@ -110,7 +110,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 617a8ea..a6e9d8b 100644 --- a/app/todo-cards/components/Pricing.tsx +++ b/app/todo-cards/components/Pricing.tsx @@ -103,6 +103,7 @@ export async function Pricing() { 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} + maxQty={product.maxQty} variants={product.variants} /> diff --git a/app/todo-cards/components/TodoKartenHero.tsx b/app/todo-cards/components/TodoKartenHero.tsx index 7212a01..4eaf14a 100644 --- a/app/todo-cards/components/TodoKartenHero.tsx +++ b/app/todo-cards/components/TodoKartenHero.tsx @@ -119,7 +119,7 @@ export async function TodoKartenHero() { {product && ( - + )}