import type { InventoryInfo, Product } from 'brainerce'; /** * KIT stock, for every surface that shows availability. * * A `KIT` is one purchasable product assembled from other catalog products, and * it carries **no `inventory` row of its own** — the component that runs out * first decides how many kits can be sold, and the API reports that as * `product.kitAvailable`: * * - `null` → unlimited (every component is untracked) * - `0` → not sellable (a component is out, or the kit has none) * - a number → that many kits * - `undefined` → not a kit; the field is absent on SIMPLE / VARIABLE * * ⛔ `null` and `undefined` mean OPPOSITE things here. Treating `null` as "no * stock" renders an unlimited kit as sold out, which is the same bug as the one * this file exists to fix, only inverted. * * Why a helper rather than `product.inventory` at each call site: reading * `inventory` alone gave every kit two contradictory answers on one page — a red * "Out of stock" badge (because `!inventory`) beside an ENABLED add-to-cart * button (because `inventory?.canPurchase !== false` is `true` when inventory is * null). Both now read the same resolved value. */ /** The subset of a product this module needs. Keeps it usable with list rows. */ type StockSource = Pick & { inventory?: InventoryInfo | null; kitAvailable?: number | null; }; /** * Build the `InventoryInfo` a kit would have if it had one. * * Mirrors the shape the backend synthesizes for the sales-channel read, so a * storefront cannot disagree with the API about the same kit. */ export function kitInventory(kitAvailable: number | null): InventoryInfo { const unlimited = kitAvailable === null; const available = kitAvailable ?? 0; return { total: available, reserved: 0, available, trackingMode: unlimited ? 'UNLIMITED' : 'TRACKED', inStock: unlimited || available > 0, canPurchase: unlimited || available > 0, // A kit is never backorderable: its components decide, not the kit. backorderMode: 'NONE', }; } /** * The stock signal to render for a product — kit or not. * * Pass the selected variant's inventory as the second argument on a product * page; it wins for VARIABLE products and is irrelevant for a kit, which takes * no variant at all. */ export function resolveStockInfo( product: StockSource | null | undefined, variantInventory?: InventoryInfo | null ): InventoryInfo | null { if (variantInventory) return variantInventory; if (!product) return null; if (product.type === 'KIT') { // `kitAvailable` first. Some reads also synthesize an `inventory` block for // kits, so fall back to it before giving up — and only treat the kit as // unresolved (`null`) when neither field arrived. if (product.kitAvailable !== undefined) return kitInventory(product.kitAvailable); return product.inventory ?? null; } return product.inventory ?? null; } /** * Can this product go in the cart right now? * * `!== false` rather than `=== true` on purpose: a missing stock signal must not * disable the button on a store that does not track inventory at all. */ export function canPurchaseProduct( product: StockSource | null | undefined, variantInventory?: InventoryInfo | null ): boolean { return resolveStockInfo(product, variantInventory)?.canPurchase !== false; }