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:
@@ -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 <select>.
|
||||
const maxQty = entry.variant
|
||||
? (product.variants.find((v) => v.name === entry.variant)?.maxQty ?? null)
|
||||
: product.maxQty;
|
||||
const qtyOptions = Array.from({ length: Math.max(1, Math.min(9, maxQty ?? 9)) }, (_, n) => n + 1);
|
||||
return (
|
||||
<div key={lineKey} className="w-full">
|
||||
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
|
||||
@@ -238,7 +251,7 @@ export function CartContent({
|
||||
onChange={(e) => setQuantity(product.id, Number(e.target.value), entry.variant)}
|
||||
className="border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
>
|
||||
{Array.from({ length: 9 }, (_, n) => n + 1).map((n) => (
|
||||
{qtyOptions.map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -198,7 +198,7 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })
|
||||
<p className="min-h-[1.05rem] text-label font-bold text-warning">
|
||||
{anyLowStock ? "Nur noch wenige verfügbar" : null}
|
||||
</p>
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -20,6 +20,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
|
||||
variants: [],
|
||||
outOfStock: false,
|
||||
lowStock: false,
|
||||
maxQty: null,
|
||||
taxRatePercent: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
+18
-1
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function ProductGrid() {
|
||||
equal-height lesson). */}
|
||||
<div className="flex-1" />
|
||||
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} variants={product.variants} />
|
||||
<AddToCartInlineButton id={product.id} outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
</div>
|
||||
</RevealItem>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function TodoKartenHero() {
|
||||
</div>
|
||||
|
||||
{product && (
|
||||
<AddToCartButton label="ToDo-Karten bestellen" outOfStock={product.outOfStock} variants={product.variants} />
|
||||
<AddToCartButton label="ToDo-Karten bestellen" outOfStock={product.outOfStock} maxQty={product.maxQty} variants={product.variants} />
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
Reference in New Issue
Block a user