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
+1
View File
@@ -20,6 +20,7 @@ const product = (overrides: Partial<Product> = {}): Product => ({
variants: [],
outOfStock: false,
lowStock: false,
maxQty: null,
taxRatePercent: null,
...overrides,
});
+18 -1
View File
@@ -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),
})),
};
}