@ikas/code-components-mcp 1.4.0-beta.166 → 1.4.0-beta.168
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/framework.json +2 -2
- package/data/section-templates/add-to-cart/utils/cartLimits.ts +4 -3
- package/data/section-templates/cart-section/sub-components/CartItem/index.tsx +27 -0
- package/data/section-templates/cart-section/utils/cartLimits.ts +24 -0
- package/data/section-templates/cart-section/utils/cartLineLimits.ts +82 -0
- package/data/section-templates/header-section/sub-components/CartItem/index.tsx +27 -0
- package/data/section-templates/header-section/utils/cartLimits.ts +24 -0
- package/data/section-templates/header-section/utils/cartLineLimits.ts +82 -0
- package/data/section-templates/navigation/sub-components/CartItem/index.tsx +27 -0
- package/data/section-templates/navigation/utils/cartLimits.ts +24 -0
- package/data/section-templates/navigation/utils/cartLineLimits.ts +82 -0
- package/data/section-templates/product-detail-section/utils/cartLimits.ts +4 -3
- package/data/storefront-api.json +3 -3
- package/data/storefront-types.json +1 -1
- package/package.json +1 -1
package/data/framework.json
CHANGED
|
@@ -249,7 +249,7 @@
|
|
|
249
249
|
"async-data-patterns": {
|
|
250
250
|
"title": "Async Data & Loading Patterns",
|
|
251
251
|
"description": "How to handle async operations, loading states, and store data in ikas components",
|
|
252
|
-
"content": "## Async Data & Loading Patterns\n\n### 1. Store State is Null Until Initialized\n\n**Note:** Root component exports are automatically reactive via the ikas runtime's `autorun()` — no `observer()` needed. The examples below show `observer()` on **sub-components** that independently read store data.\n\nStore data (`cartStore.cart`, `customerStore.customer`) is `null` until the store initializes. Use null-safe access:\n\n```tsx\nimport { observer } from \"@ikas/component-utils\";\nimport { cartStore } from \"@ikas/bp-storefront\";\n\n// Sub-component: needs observer() for independent reactivity\nconst CartBadge = observer(function CartBadge() {\n // cart is null until initialized — use optional chaining\n const itemCount = cartStore.cart?.orderLineItems.length ?? 0;\n return <span className=\"cart-badge\">{itemCount}</span>;\n});\n```\n\n### 2. Loading Pattern for Async Operations\n\nUse `useState` for local loading flags. Always use `try/finally` to clear the loading state:\n\n```tsx\nimport { useState } from \"preact/hooks\";\nimport { addItemToCart } from \"@ikas/bp-storefront\";\n\nconst [isLoading, setIsLoading] = useState(false);\n\nconst handleAddToCart = async () => {\n if (isLoading) return; // prevent double-click\n setIsLoading(true);\n try {\n await addItemToCart(variant, product, 1);\n } finally {\n setIsLoading(false);\n }\n};\n\n// In JSX:\n<button disabled={isLoading} onClick={handleAddToCart}>\n {isLoading ? \"Adding...\" : \"Add to Cart\"}\n</button>\n```\n\n### 3. Cart Operation Results\n\n`addItemToCart` returns a result object. Check for validation errors:\n\n```tsx\nconst result = await addItemToCart(variant, product, 1);\nif (result.success) {\n // Item added successfully\n} else if (result.validationError === \"INSUFFICIENT_STOCK\") {\n // Not enough stock\n} else if (result.validationError === \"INVALID_PRODUCT_OPTION_VALUES\") {\n // Variant options not fully selected\n}\n```\n\n### 4. Form Submission Results\n\nForm submit functions return `boolean` and set status flags on the form:\n\n```tsx\nconst success = await submitLoginForm(loginForm);\nif (success) {\n // loginForm.isSuccess is true\n Router.navigate(\"/account\");\n} else {\n // loginForm.isFailure is true\n // loginForm.responseMessage has the error message\n console.log(loginForm.responseMessage);\n}\n```\n\n### 5. Observer Auto Re-rendering\n\nStore updates automatically trigger re-renders. Root components get this via `autorun()`, sub-components via `observer()`. You do NOT need manual state management for store-derived values:\n\n```tsx\n// WRONG — unnecessary useState for store data\nconst [cartItems, setCartItems] = useState([]);\nuseEffect(() => { setCartItems(cartStore.cart?.orderLineItems ?? []); }, [cartStore.cart]);\n\n// CORRECT — observer sub-component for independent reactivity\nconst CartList = observer(function CartList() {\n const items = cartStore.cart?.orderLineItems ?? [];\n return (\n <div>\n {items.map((item) => (\n <div key={item.id}>{item.variant?.name}</div>\n ))}\n </div>\n );\n});\n\n// Root export uses CartList — no observer needed on root\nexport function CartPage({ title }: Props) {\n return (\n <section>\n <h1>{title}</h1>\n <CartList />\n </section>\n );\n}\n\nexport default CartPage;\n```\n\n### 6. Pagination / Data Fetching\n\nList stores (blog, brand, category) use async pagination functions:\n\n```tsx\nimport { hasBlogListNextPage, getBlogListNextPage } from \"@ikas/bp-storefront\";\n\n// Check if more pages exist\nif (hasBlogListNextPage(blogList)) {\n // Fetch next page — mutates blogList in place, observer re-renders\n await getBlogListNextPage(blogList);\n}\n```\n\nThese functions mutate the list model in place. If your component uses `observer()`, it will re-render automatically when new data arrives.",
|
|
252
|
+
"content": "## Async Data & Loading Patterns\n\n### 1. Store State is Null Until Initialized\n\n**Note:** Root component exports are automatically reactive via the ikas runtime's `autorun()` — no `observer()` needed. The examples below show `observer()` on **sub-components** that independently read store data.\n\nStore data (`cartStore.cart`, `customerStore.customer`) is `null` until the store initializes. Use null-safe access:\n\n```tsx\nimport { observer } from \"@ikas/component-utils\";\nimport { cartStore } from \"@ikas/bp-storefront\";\n\n// Sub-component: needs observer() for independent reactivity\nconst CartBadge = observer(function CartBadge() {\n // cart is null until initialized — use optional chaining\n const itemCount = cartStore.cart?.orderLineItems.length ?? 0;\n return <span className=\"cart-badge\">{itemCount}</span>;\n});\n```\n\n### 2. Loading Pattern for Async Operations\n\nUse `useState` for local loading flags. Always use `try/finally` to clear the loading state:\n\n```tsx\nimport { useState } from \"preact/hooks\";\nimport { addItemToCart } from \"@ikas/bp-storefront\";\n\nconst [isLoading, setIsLoading] = useState(false);\n\nconst handleAddToCart = async () => {\n if (isLoading) return; // prevent double-click\n setIsLoading(true);\n try {\n await addItemToCart(variant, product, 1);\n } finally {\n setIsLoading(false);\n }\n};\n\n// In JSX:\n<button disabled={isLoading} onClick={handleAddToCart}>\n {isLoading ? \"Adding...\" : \"Add to Cart\"}\n</button>\n```\n\n### 3. Cart Operation Results\n\n`addItemToCart` returns a result object. Check for validation errors:\n\n```tsx\nconst result = await addItemToCart(variant, product, 1);\nif (result.success) {\n // Item added successfully\n} else if (result.validationError === \"INSUFFICIENT_STOCK\") {\n // Not enough stock\n} else if (result.validationError === \"INVALID_PRODUCT_OPTION_VALUES\") {\n // Variant options not fully selected\n}\n```\n\n### 4. Form Submission Results\n\nForm submit functions return `boolean` and set status flags on the form:\n\n```tsx\nconst success = await submitLoginForm(loginForm);\nif (success) {\n // loginForm.isSuccess is true\n Router.navigate(\"/account\");\n} else {\n // loginForm.isFailure is true\n // loginForm.responseMessage has the error message\n console.log(loginForm.responseMessage);\n}\n```\n\n### 5. Observer Auto Re-rendering\n\nStore updates automatically trigger re-renders. Root components get this via `autorun()`, sub-components via `observer()`. You do NOT need manual state management for store-derived values:\n\n```tsx\n// WRONG — unnecessary useState for store data\nconst [cartItems, setCartItems] = useState([]);\nuseEffect(() => { setCartItems(cartStore.cart?.orderLineItems ?? []); }, [cartStore.cart]);\n\n// CORRECT — observer sub-component for independent reactivity\nconst CartList = observer(function CartList() {\n const items = cartStore.cart?.orderLineItems ?? [];\n return (\n <div>\n {items.map((item) => (\n <div key={item.id}>{item.variant?.name}</div>\n ))}\n </div>\n );\n});\n\n// Root export uses CartList — no observer needed on root\nexport function CartPage({ title }: Props) {\n return (\n <section>\n <h1>{title}</h1>\n <CartList />\n </section>\n );\n}\n\nexport default CartPage;\n```\n\n### 6. Pagination / Data Fetching\n\nList stores (blog, brand, category) use async pagination functions:\n\n```tsx\nimport { hasBlogListNextPage, getBlogListNextPage } from \"@ikas/bp-storefront\";\n\n// Check if more pages exist\nif (hasBlogListNextPage(blogList)) {\n // Fetch next page — mutates blogList in place, observer re-renders\n await getBlogListNextPage(blogList);\n}\n```\n\nThese functions mutate the list model in place. If your component uses `observer()`, it will re-render automatically when new data arrives.\n\nTo batch-fetch full products by id — e.g. enriching cart lines with the `salesChannels` limits or per-variant `stock` they don't carry — use `bs_searchProductsById(baseStore, { productIds })`: one request for all ids. See the \"Enforcing Limits on Cart Lines\" section in the `cart-patterns` guide for the full pattern.",
|
|
253
253
|
"tags": [
|
|
254
254
|
"async",
|
|
255
255
|
"loading",
|
|
@@ -326,7 +326,7 @@
|
|
|
326
326
|
"cart-patterns": {
|
|
327
327
|
"title": "Cart Management Patterns (Serel Reference)",
|
|
328
328
|
"description": "Serel CartPage section with CartItem sub-component, OrderSummary/CouponCode/EmptyState local components, cartStore patterns, and checkout flow",
|
|
329
|
-
"content": "## Cart Management Patterns (Serel Reference)\n\nThe serel CartPage is a section that uses local internal components (OrderSummary, CouponCode, EmptyState) and the shared `CartItem` sub-component from `src/sub-components/CartItem/`. Unlike most sections, CartPage does NOT use IkasComponentRenderer — it directly composes its UI from sub-components.\n\n### CartPage Section Structure\n\n```tsx\nimport { cartStore } from \"@ikas/bp-storefront\";\nimport CartItem from \"../../sub-components/CartItem\";\nimport { Props } from \"./types\";\n\nexport function CartPage({ backgroundColor, emptyCartText, ...props }: Props) {\n const cart = cartStore.cart;\n const lineItems = cart?.orderLineItems ?? [];\n const isEmpty = lineItems.length === 0;\n\n if (isEmpty) return <EmptyState text={emptyCartText} />;\n\n return (\n <section className=\"cart-page\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"cart-page-inner\">\n <div className=\"cart-items\">\n {lineItems.map((item) => (\n <CartItem key={item.id} item={item} />\n ))}\n </div>\n <div className=\"cart-sidebar\">\n <CouponCode cart={cart} />\n <OrderSummary cart={cart} />\n </div>\n </div>\n </section>\n );\n}\nexport default CartPage;\n```\n\n### CartItem Sub-Component\n\nThe `CartItem` sub-component (in `src/sub-components/CartItem/`) displays a single line item with image, variant name, quantity controls, price, and remove button:\n\n```tsx\nimport {\n getOrderLineItemFormattedFinalPrice,\n getOrderLineItemFormattedUnitPrice,\n getIkasOrderLineVariantMainImage,\n getDefaultSrc,\n changeItemQuantity,\n removeItem,\n IkasOrderLineItem,\n} from \"@ikas/bp-storefront\";\nimport QuantitySelector from \"../QuantitySelector\";\n\ninterface Props { item: IkasOrderLineItem; }\n\nexport default function CartItem({ item }: Props) {\n const image = item.variant ? getIkasOrderLineVariantMainImage(item.variant) : null;\n\n return (\n <div className=\"cart-item\">\n {image && <img src={getDefaultSrc(image)} alt={item.variant?.name} />}\n <div className=\"cart-item-info\">\n <span>{item.variant?.name}</span>\n <span>{getOrderLineItemFormattedUnitPrice(item)}</span>\n </div>\n <QuantitySelector\n quantity={item.quantity}\n onIncrement={() => changeItemQuantity(item, item.quantity + 1)}\n onDecrement={() => changeItemQuantity(item, Math.max(1, item.quantity - 1))}\n />\n <span>{getOrderLineItemFormattedFinalPrice(item)}</span>\n <button onClick={() => removeItem(item)}>Remove</button>\n </div>\n );\n}\n```\n\n### OrderSummary Local Component\n\nDisplays cart totals using `getIkasOrderFormattedTotalFinalPrice(cart)` and `getIkasOrderFormattedTotalPrice(cart)`, plus a checkout button:\n\n```tsx\nimport { Router, getIkasOrderFormattedTotalFinalPrice } from \"@ikas/bp-storefront\";\n\nfunction OrderSummary({ cart }) {\n return (\n <div className=\"order-summary\">\n <span>Total: {getIkasOrderFormattedTotalFinalPrice(cart)}</span>\n <button onClick={() => Router.navigateToPage(\"CHECKOUT\")}>Checkout</button>\n </div>\n );\n}\n```\n\n### CouponCode Local Component\n\nCoupon code uses the standard form model. Get the form from `customerStore` via `getCouponCodeForm`, init it once, drive the input with `setCouponCodeFormCouponCode`, and submit/remove with the form helpers. Do NOT call `saveCart`/`apiSaveCart` or any deep `@ikas/bp-storefront/dist/...` import — the coupon has dedicated public exports:\n\n```tsx\nimport { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n getCouponCodeForm,\n initCouponCodeForm,\n setCouponCodeFormCouponCode,\n submitCouponCodeForm,\n removeCouponCodeForm,\n} from \"@ikas/bp-storefront\";\nimport { observer } from \"@ikas/component-utils\";\n\n// observer() because this sub-component reads the MobX coupon form\nconst CouponCode = observer(function CouponCode({ appliedCoupon }) {\n const couponForm = getCouponCodeForm(customerStore);\n\n useEffect(() => {\n initCouponCodeForm(couponForm);\n }, [couponForm]);\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n await submitCouponCodeForm(couponForm);\n };\n\n if (appliedCoupon) {\n return (\n <div className=\"coupon\">\n <span>{appliedCoupon}</span>\n <button type=\"button\" onClick={() => removeCouponCodeForm(couponForm)}>Remove</button>\n </div>\n );\n }\n\n return (\n <form className=\"coupon\" onSubmit={handleSubmit}>\n <input\n placeholder={couponForm.couponCode?.placeholder ?? \"Coupon code\"}\n value={couponForm.couponCode?.value ?? \"\"}\n onInput={(e) =>\n setCouponCodeFormCouponCode(couponForm, (e.target as HTMLInputElement).value)\n }\n />\n <button type=\"submit\" disabled={couponForm.isSubmitting}>\n {couponForm.isSubmitting ? \"Applying...\" : \"Apply\"}\n </button>\n {couponForm.isFailure && couponForm.responseMessage && (\n <p className=\"coupon-error\">{couponForm.responseMessage}</p>\n )}\n </form>\n );\n});\n```\n\n### cartStore Usage Patterns\n\nThe `cartStore` is a MobX store available globally. Root components are automatically reactive via autorun, so reads from `cartStore.cart` trigger re-renders:\n\n```tsx\nimport { cartStore } from \"@ikas/bp-storefront\";\n\n// Always null-safe:\nconst cart = cartStore.cart;\nconst lineItems = cart?.orderLineItems ?? [];\nconst itemCount = lineItems.length;\nconst isEmpty = itemCount === 0;\n```\n\n### Quantity Limits (min/max per cart)\n\nA product's sales channel can cap how many units go into one cart. The limits live on the product's `salesChannels`, matched to the active channel:\n\n```tsx\nimport { IkasStorefrontConfig } from \"@ikas/bp-storefront\";\n\nconst channel = product.salesChannels?.find((sc) => sc.id === IkasStorefrontConfig.salesChannelId);\nconst min = channel?.minQuantityPerCart ?? 1;\nconst max = channel?.maxQuantityPerCart; // null/undefined = no cap\n```\n\nApply these to the quantity stepper: seed the initial quantity to `min`, and cap increments at `max`. `addItemToCart(variant, product, qty)` clamps to the same limits internally only when it creates a **new** cart line — and silently, so enforce them in the UI too or the customer sees the number snap with no explanation.\n\n> **The internal clamp only runs when a NEW cart line is created.** If the variant already has a line in the cart, `addItemToCart` delegates to `changeItemQuantity(existing, existing.quantity + qty)` before the clamp is reached — and `changeItemQuantity` never clamps. Cart page / cart drawer steppers therefore get no SDK enforcement at all; enforce the limits in the UI (see \"Enforcing Limits on Cart Lines\" below).\n\n> `IkasStorefrontConfig.salesChannelId` is a property. The older `getSalesChannelId()` method does not exist in this SDK.\n\n### Cart Operation validationError (complete list)\n\n`addItemToCart` / `changeItemQuantity` return `IkasCartOperationResult` — `{ success, validationError?, response? }`. The `validationError` union has exactly three values, but at runtime only `addItemToCart` ever sets it (client-side pre-validation before the API call). `changeItemQuantity` does no client-side validation and never returns a `validationError` — branch on `success` (and `response.errorCodes` for details), not on `validationError`, for cart-line quantity changes:\n\n| Value | Meaning |\n|-------|---------|\n| `INSUFFICIENT_STOCK` | Not enough stock for the requested quantity |\n| `INVALID_PRODUCT_OPTION_VALUES` | Required product options not fully or validly selected |\n| `EMPTY_CART` | The operation needs a non-empty cart (declared in the type union, but no current code path emits it) |\n\nQuantity limits are never surfaced as a `validationError` — there is **no** `MAX_QUANTITY_PER_CART_*` / `MIN_QUANTITY_PER_CART_*` value beyond the three above. Note the distinction: legacy v1 themes branched on the GraphQL error code `MAX_QUANTITY_PER_CART_LIMIT_REACHED` (an `extensions.code` value, which the API client copies into `response.errorCodes` unfiltered), but that code is not a documented contract of this SDK — do not rely on it. Prevent limit violations in the stepper instead (see \"Quantity Limits\" above and \"Enforcing Limits on Cart Lines\" below).\n\n### Enforcing Limits on Cart Lines\n\nCart lines cannot tell you the limits: `IkasOrderLineItem.variant` is an `IkasOrderLineVariant`, which carries **neither** `salesChannels` nor `stock` — both live only on `IkasProduct` / `IkasProductVariant`. To enforce `minQuantityPerCart` / `maxQuantityPerCart` (or stock caps) on the cart page / drawer, batch-fetch the line products once with `bs_searchProductsById` and map the limits by product id:\n\n```tsx\nimport { baseStore, bs_searchProductsById, cartStore, IkasStorefrontConfig } from \"@ikas/bp-storefront\";\n\n// Collect the distinct productIds of the cart lines and fetch them in ONE request:\nconst productIds = [\n ...new Set(\n (cartStore.cart?.orderLineItems ?? [])\n .map((item) => item.variant.productId)\n .filter((id): id is string => !!id)\n ),\n];\nconst { products } = await bs_searchProductsById(baseStore, { productIds });\n\nconst limitsByProductId = new Map<string, { min: number; max: number | null }>();\nfor (const product of products ?? []) {\n const channel = product.salesChannels?.find((sc) => sc.id === IkasStorefrontConfig.salesChannelId);\n limitsByProductId.set(product.id, {\n min: channel?.minQuantityPerCart ?? 1,\n max: channel?.maxQuantityPerCart ?? null,\n });\n}\n\n// In the line's stepper:\nconst limits = limitsByProductId.get(item.variant.productId ?? \"\");\nconst atMax = limits?.max != null && item.quantity >= limits.max;\n// disable the + button when atMax; block decrements below limits.min the same way\n```\n\n`bs_searchProductsById` returns full `IkasProduct` objects, so `salesChannels` (already filtered to the active sales channel) and per-variant `stock` (summed over the sales channel's configured stock locations) come in the same response — one fetch covers both quantity limits and stock caps. Fetch once per cart open and cache by product id rather than fetching per line; when lines render independently, collect the ids over a tick and flush a single batched request.\n\n### Campaign Offer Lines in the Cart\n\nCampaign discounts surface as order-level adjustment rows, not as edits on the line items. Format the adjustment amount with `formatCurrency` — the root export that applies the merchant's currency format settings:\n\n```tsx\nimport { formatCurrency } from \"@ikas/bp-storefront\";\n\n{cart.orderAdjustments?.map((adj) => (\n <div key={adj.name} className=\"cart-adjustment\">\n <span>{adj.name}</span>\n <span>{formatCurrency(adj.amount, adj.currencyCode, adj.currencySymbol)}</span>\n </div>\n))}\n```\n\nWhen a campaign auto-adds a bundled or gift line (a \"buy 1 get N\" offer), `isOrderLineItemAutoCreated(cart, item)` returns `true` for that line. Use it to badge the line and hide its quantity/remove controls — the customer cannot edit an auto-created line directly:\n\n```tsx\nimport { isOrderLineItemAutoCreated } from \"@ikas/bp-storefront\";\n\n{lineItems.map((item) => (\n <CartItem key={item.id} item={item} readOnly={isOrderLineItemAutoCreated(cart, item)} />\n))}\n```\n\n### Key Pattern: Section Without IkasComponentRenderer\n\nCartPage demonstrates that not all sections need COMPONENT_LIST slots. Simple sections can directly compose sub-components and local components when the layout is fixed and does not need editor-level child component placement.",
|
|
329
|
+
"content": "## Cart Management Patterns (Serel Reference)\n\nThe serel CartPage is a section that uses local internal components (OrderSummary, CouponCode, EmptyState) and the shared `CartItem` sub-component from `src/sub-components/CartItem/`. Unlike most sections, CartPage does NOT use IkasComponentRenderer — it directly composes its UI from sub-components.\n\n### CartPage Section Structure\n\n```tsx\nimport { cartStore } from \"@ikas/bp-storefront\";\nimport CartItem from \"../../sub-components/CartItem\";\nimport { Props } from \"./types\";\n\nexport function CartPage({ backgroundColor, emptyCartText, ...props }: Props) {\n const cart = cartStore.cart;\n const lineItems = cart?.orderLineItems ?? [];\n const isEmpty = lineItems.length === 0;\n\n if (isEmpty) return <EmptyState text={emptyCartText} />;\n\n return (\n <section className=\"cart-page\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"cart-page-inner\">\n <div className=\"cart-items\">\n {lineItems.map((item) => (\n <CartItem key={item.id} item={item} />\n ))}\n </div>\n <div className=\"cart-sidebar\">\n <CouponCode cart={cart} />\n <OrderSummary cart={cart} />\n </div>\n </div>\n </section>\n );\n}\nexport default CartPage;\n```\n\n### CartItem Sub-Component\n\nThe `CartItem` sub-component (in `src/sub-components/CartItem/`) displays a single line item with image, variant name, quantity controls, price, and remove button:\n\n```tsx\nimport {\n getOrderLineItemFormattedFinalPrice,\n getOrderLineItemFormattedUnitPrice,\n getIkasOrderLineVariantMainImage,\n getDefaultSrc,\n changeItemQuantity,\n removeItem,\n IkasOrderLineItem,\n} from \"@ikas/bp-storefront\";\nimport QuantitySelector from \"../QuantitySelector\";\n\ninterface Props { item: IkasOrderLineItem; }\n\nexport default function CartItem({ item }: Props) {\n const image = item.variant ? getIkasOrderLineVariantMainImage(item.variant) : null;\n\n return (\n <div className=\"cart-item\">\n {image && <img src={getDefaultSrc(image)} alt={item.variant?.name} />}\n <div className=\"cart-item-info\">\n <span>{item.variant?.name}</span>\n <span>{getOrderLineItemFormattedUnitPrice(item)}</span>\n </div>\n <QuantitySelector\n quantity={item.quantity}\n onIncrement={() => changeItemQuantity(item, item.quantity + 1)}\n onDecrement={() => changeItemQuantity(item, Math.max(1, item.quantity - 1))}\n />\n <span>{getOrderLineItemFormattedFinalPrice(item)}</span>\n <button onClick={() => removeItem(item)}>Remove</button>\n </div>\n );\n}\n```\n\n### OrderSummary Local Component\n\nDisplays cart totals using `getIkasOrderFormattedTotalFinalPrice(cart)` and `getIkasOrderFormattedTotalPrice(cart)`, plus a checkout button:\n\n```tsx\nimport { Router, getIkasOrderFormattedTotalFinalPrice } from \"@ikas/bp-storefront\";\n\nfunction OrderSummary({ cart }) {\n return (\n <div className=\"order-summary\">\n <span>Total: {getIkasOrderFormattedTotalFinalPrice(cart)}</span>\n <button onClick={() => Router.navigateToPage(\"CHECKOUT\")}>Checkout</button>\n </div>\n );\n}\n```\n\n### CouponCode Local Component\n\nCoupon code uses the standard form model. Get the form from `customerStore` via `getCouponCodeForm`, init it once, drive the input with `setCouponCodeFormCouponCode`, and submit/remove with the form helpers. Do NOT call `saveCart`/`apiSaveCart` or any deep `@ikas/bp-storefront/dist/...` import — the coupon has dedicated public exports:\n\n```tsx\nimport { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n getCouponCodeForm,\n initCouponCodeForm,\n setCouponCodeFormCouponCode,\n submitCouponCodeForm,\n removeCouponCodeForm,\n} from \"@ikas/bp-storefront\";\nimport { observer } from \"@ikas/component-utils\";\n\n// observer() because this sub-component reads the MobX coupon form\nconst CouponCode = observer(function CouponCode({ appliedCoupon }) {\n const couponForm = getCouponCodeForm(customerStore);\n\n useEffect(() => {\n initCouponCodeForm(couponForm);\n }, [couponForm]);\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n await submitCouponCodeForm(couponForm);\n };\n\n if (appliedCoupon) {\n return (\n <div className=\"coupon\">\n <span>{appliedCoupon}</span>\n <button type=\"button\" onClick={() => removeCouponCodeForm(couponForm)}>Remove</button>\n </div>\n );\n }\n\n return (\n <form className=\"coupon\" onSubmit={handleSubmit}>\n <input\n placeholder={couponForm.couponCode?.placeholder ?? \"Coupon code\"}\n value={couponForm.couponCode?.value ?? \"\"}\n onInput={(e) =>\n setCouponCodeFormCouponCode(couponForm, (e.target as HTMLInputElement).value)\n }\n />\n <button type=\"submit\" disabled={couponForm.isSubmitting}>\n {couponForm.isSubmitting ? \"Applying...\" : \"Apply\"}\n </button>\n {couponForm.isFailure && couponForm.responseMessage && (\n <p className=\"coupon-error\">{couponForm.responseMessage}</p>\n )}\n </form>\n );\n});\n```\n\n### cartStore Usage Patterns\n\nThe `cartStore` is a MobX store available globally. Root components are automatically reactive via autorun, so reads from `cartStore.cart` trigger re-renders:\n\n```tsx\nimport { cartStore } from \"@ikas/bp-storefront\";\n\n// Always null-safe:\nconst cart = cartStore.cart;\nconst lineItems = cart?.orderLineItems ?? [];\nconst itemCount = lineItems.length;\nconst isEmpty = itemCount === 0;\n```\n\n### Quantity Limits (min/max per cart)\n\nA product's sales channel can cap how many units go into one cart. The limits live on the product's `salesChannels`, matched to the active channel:\n\n```tsx\nimport { IkasStorefrontConfig } from \"@ikas/bp-storefront\";\n\nconst channel = product.salesChannels?.find((sc) => sc.id === IkasStorefrontConfig.salesChannelId);\nconst min = channel?.minQuantityPerCart ?? 1;\nconst max = channel?.maxQuantityPerCart; // null/undefined = no cap\n```\n\nApply these to the quantity stepper: seed the initial quantity to `min`, and cap increments at `max`. `addItemToCart(variant, product, qty)` clamps to the same limits internally only when it creates a **new** cart line — and silently, so enforce them in the UI too or the customer sees the number snap with no explanation.\n\n> **The internal clamp only runs when a NEW cart line is created.** If the variant already has a line in the cart, `addItemToCart` delegates to `changeItemQuantity(existing, existing.quantity + qty)` before the clamp is reached — and `changeItemQuantity` never clamps. Cart page / cart drawer steppers therefore get no SDK enforcement at all; enforce the limits in the UI (see \"Enforcing Limits on Cart Lines\" below).\n\n> `IkasStorefrontConfig.salesChannelId` is a property. The older `getSalesChannelId()` method does not exist in this SDK.\n\n### Cart Operation validationError (complete list)\n\n`addItemToCart` / `changeItemQuantity` return `IkasCartOperationResult` — `{ success, validationError?, response? }`. The `validationError` union has exactly three values, but at runtime only `addItemToCart` ever sets it (client-side pre-validation before the API call). `changeItemQuantity` does no client-side validation and never returns a `validationError` — branch on `success` (and `response.errorCodes` for details), not on `validationError`, for cart-line quantity changes:\n\n| Value | Meaning |\n|-------|---------|\n| `INSUFFICIENT_STOCK` | Not enough stock for the requested quantity |\n| `INVALID_PRODUCT_OPTION_VALUES` | Required product options not fully or validly selected |\n| `EMPTY_CART` | The operation needs a non-empty cart (declared in the type union, but no current code path emits it) |\n\nQuantity limits are never surfaced as a `validationError` — there is **no** `MAX_QUANTITY_PER_CART_*` / `MIN_QUANTITY_PER_CART_*` value beyond the three above. They surface one level lower instead: when a mutation would push a line past `maxQuantityPerCart`, the API rejects it with the GraphQL error code `MAX_QUANTITY_PER_CART_LIMIT_REACHED` (an `extensions.code` value the API client copies into `response.errorCodes`). `addItemToCart` and `changeItemQuantity` go through the same mutation, so this is the reactive signal on the cart-line path — exactly where the SDK never clamps:\n\n```tsx\nimport { APIErrorCode, changeItemQuantity } from \"@ikas/bp-storefront\";\n\nconst result = await changeItemQuantity(item, qty);\nif (!result.success && result.response?.errorCodes?.includes(APIErrorCode.MAX_QUANTITY_PER_CART_LIMIT_REACHED)) {\n // The sales channel's per-cart cap rejected the quantity — tell the customer.\n}\n```\n\nTreat the error code as the fallback, not the UX: prevent limit violations proactively in the stepper (see \"Quantity Limits\" above and \"Enforcing Limits on Cart Lines\" below) so the customer never hits the rejection in the first place.\n\n### Enforcing Limits on Cart Lines\n\nCart lines cannot tell you the limits: `IkasOrderLineItem.variant` is an `IkasOrderLineVariant`, which carries **neither** `salesChannels` nor `stock` — both live only on `IkasProduct` / `IkasProductVariant`. To enforce `minQuantityPerCart` / `maxQuantityPerCart` (or stock caps) on the cart page / drawer, batch-fetch the line products once with `bs_searchProductsById` and map the limits by product id:\n\n```tsx\nimport { baseStore, bs_searchProductsById, cartStore, IkasStorefrontConfig } from \"@ikas/bp-storefront\";\n\n// Collect the distinct productIds of the cart lines and fetch them in ONE request:\nconst productIds = [\n ...new Set(\n (cartStore.cart?.orderLineItems ?? [])\n .map((item) => item.variant.productId)\n .filter((id): id is string => !!id)\n ),\n];\nconst { products } = await bs_searchProductsById(baseStore, { productIds });\n\nconst limitsByProductId = new Map<string, { min: number; max: number | null }>();\nfor (const product of products ?? []) {\n const channel = product.salesChannels?.find((sc) => sc.id === IkasStorefrontConfig.salesChannelId);\n limitsByProductId.set(product.id, {\n min: channel?.minQuantityPerCart ?? 1,\n max: channel?.maxQuantityPerCart ?? null,\n });\n}\n\n// In the line's stepper:\nconst limits = limitsByProductId.get(item.variant.productId ?? \"\");\nconst atMax = limits?.max != null && item.quantity >= limits.max;\n// disable the + button when atMax; block decrements below limits.min the same way\n```\n\n`bs_searchProductsById` returns full `IkasProduct` objects, so `salesChannels` (already filtered to the active sales channel) and per-variant `stock` (summed over the sales channel's configured stock locations) come in the same response — one fetch covers both quantity limits and stock caps. Fetch once per cart open and cache by product id rather than fetching per line; when lines render independently, collect the ids over a tick and flush a single batched request.\n\n### Campaign Offer Lines in the Cart\n\nCampaign discounts surface as order-level adjustment rows, not as edits on the line items. Format the adjustment amount with `formatCurrency` — the root export that applies the merchant's currency format settings:\n\n```tsx\nimport { formatCurrency } from \"@ikas/bp-storefront\";\n\n{cart.orderAdjustments?.map((adj) => (\n <div key={adj.name} className=\"cart-adjustment\">\n <span>{adj.name}</span>\n <span>{formatCurrency(adj.amount, adj.currencyCode, adj.currencySymbol)}</span>\n </div>\n))}\n```\n\nWhen a campaign auto-adds a bundled or gift line (a \"buy 1 get N\" offer), `isOrderLineItemAutoCreated(cart, item)` returns `true` for that line. Use it to badge the line and hide its quantity/remove controls — the customer cannot edit an auto-created line directly:\n\n```tsx\nimport { isOrderLineItemAutoCreated } from \"@ikas/bp-storefront\";\n\n{lineItems.map((item) => (\n <CartItem key={item.id} item={item} readOnly={isOrderLineItemAutoCreated(cart, item)} />\n))}\n```\n\n### Key Pattern: Section Without IkasComponentRenderer\n\nCartPage demonstrates that not all sections need COMPONENT_LIST slots. Simple sections can directly compose sub-components and local components when the layout is fixed and does not need editor-level child component placement.",
|
|
330
330
|
"tags": [
|
|
331
331
|
"cart",
|
|
332
332
|
"checkout",
|
|
@@ -8,9 +8,10 @@ export interface CartQuantityLimits {
|
|
|
8
8
|
/**
|
|
9
9
|
* Resolve the per-cart quantity limits for a product from its active sales
|
|
10
10
|
* channel. Falls back to a minimum of 1 and no maximum when the channel or its
|
|
11
|
-
* limits are not set. `addItemToCart` clamps to the same limits internally
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* limits are not set. `addItemToCart` clamps to the same limits internally only
|
|
12
|
+
* when it creates a NEW cart line — incrementing an existing line goes through
|
|
13
|
+
* `changeItemQuantity`, which never clamps — so enforcing these in the stepper
|
|
14
|
+
* is the only guard that covers both paths.
|
|
14
15
|
*/
|
|
15
16
|
export function getProductCartLimits(product: IkasProduct): CartQuantityLimits {
|
|
16
17
|
const channel = product.salesChannels?.find(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { observer } from "@ikas/component-utils";
|
|
2
|
+
import { useEffect, useState } from "preact/hooks";
|
|
2
3
|
import { TrashSVG, NoProductSVG } from "../icons";
|
|
3
4
|
import QuantitySelector from "../QuantitySelector";
|
|
4
5
|
import BundleProducts from "./components/BundleProducts";
|
|
@@ -19,6 +20,11 @@ import {
|
|
|
19
20
|
} from "@ikas/bp-storefront";
|
|
20
21
|
import { resolveAspectRatio, resolveObjectFit } from "../../utils/media";
|
|
21
22
|
import { cx } from "../../utils/cx";
|
|
23
|
+
import {
|
|
24
|
+
ensureCartLineLimits,
|
|
25
|
+
getCartLineLimits,
|
|
26
|
+
subscribeCartLineLimits,
|
|
27
|
+
} from "../../utils/cartLineLimits";
|
|
22
28
|
import type { AspectRatio, ObjectFit } from "../../global-types";
|
|
23
29
|
|
|
24
30
|
interface CartItemProps {
|
|
@@ -40,6 +46,23 @@ const CartItem = observer(function CartItem({
|
|
|
40
46
|
}: CartItemProps) {
|
|
41
47
|
const isPage = variant === "page";
|
|
42
48
|
|
|
49
|
+
// The line's IkasOrderLineVariant carries no salesChannels, so the per-cart
|
|
50
|
+
// quantity limits come from a batched product fetch shared across all lines.
|
|
51
|
+
const productId = item.variant?.productId ?? null;
|
|
52
|
+
const [, setLimitsVersion] = useState(0);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
// Subscribe BEFORE registering the id: a batch already in flight can
|
|
55
|
+
// resolve between render and this effect, and its emit must not be missed.
|
|
56
|
+
const unsubscribe = subscribeCartLineLimits(() => setLimitsVersion((v) => v + 1));
|
|
57
|
+
if (productId) {
|
|
58
|
+
ensureCartLineLimits([productId]);
|
|
59
|
+
// Re-check once for a result that landed before the subscription armed.
|
|
60
|
+
if (getCartLineLimits(productId)) setLimitsVersion((v) => v + 1);
|
|
61
|
+
}
|
|
62
|
+
return unsubscribe;
|
|
63
|
+
}, [productId]);
|
|
64
|
+
const limits = getCartLineLimits(productId);
|
|
65
|
+
|
|
43
66
|
// Lines a campaign added automatically (e.g. a "buy 1 get 1" gift) cannot be
|
|
44
67
|
// edited or removed on their own, so render them read-only with a badge.
|
|
45
68
|
const isAutoCreated =
|
|
@@ -180,6 +203,8 @@ const CartItem = observer(function CartItem({
|
|
|
180
203
|
<QuantitySelector
|
|
181
204
|
size="sm"
|
|
182
205
|
value={item.quantity}
|
|
206
|
+
min={limits?.min ?? 1}
|
|
207
|
+
max={limits?.max}
|
|
183
208
|
onChange={(qty) => changeItemQuantity(item, qty)}
|
|
184
209
|
/>
|
|
185
210
|
)}
|
|
@@ -227,6 +252,8 @@ const CartItem = observer(function CartItem({
|
|
|
227
252
|
<QuantitySelector
|
|
228
253
|
size="sm"
|
|
229
254
|
value={item.quantity}
|
|
255
|
+
min={limits?.min ?? 1}
|
|
256
|
+
max={limits?.max}
|
|
230
257
|
onChange={(qty) => changeItemQuantity(item, qty)}
|
|
231
258
|
/>
|
|
232
259
|
<button
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { IkasProduct, IkasStorefrontConfig } from "@ikas/bp-storefront";
|
|
2
|
+
|
|
3
|
+
export interface CartQuantityLimits {
|
|
4
|
+
min: number;
|
|
5
|
+
max?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the per-cart quantity limits for a product from its active sales
|
|
10
|
+
* channel. Falls back to a minimum of 1 and no maximum when the channel or its
|
|
11
|
+
* limits are not set. `addItemToCart` clamps to the same limits internally only
|
|
12
|
+
* when it creates a NEW cart line — incrementing an existing line goes through
|
|
13
|
+
* `changeItemQuantity`, which never clamps — so enforcing these in the stepper
|
|
14
|
+
* is the only guard that covers both paths.
|
|
15
|
+
*/
|
|
16
|
+
export function getProductCartLimits(product: IkasProduct): CartQuantityLimits {
|
|
17
|
+
const channel = product.salesChannels?.find(
|
|
18
|
+
(sc) => sc.id === IkasStorefrontConfig.salesChannelId,
|
|
19
|
+
);
|
|
20
|
+
return {
|
|
21
|
+
min: channel?.minQuantityPerCart ?? 1,
|
|
22
|
+
max: channel?.maxQuantityPerCart ?? undefined,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { baseStore, bs_searchProductsById } from "@ikas/bp-storefront";
|
|
2
|
+
import { CartQuantityLimits, getProductCartLimits } from "./cartLimits";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-cart quantity limits for cart lines.
|
|
6
|
+
*
|
|
7
|
+
* A cart line's `IkasOrderLineVariant` carries no `salesChannels`, so the
|
|
8
|
+
* min/max per-cart limits can only be read from the full `IkasProduct`. This
|
|
9
|
+
* store batch-fetches the line products with `bs_searchProductsById` (lines
|
|
10
|
+
* register their productId one by one; the ids are flushed as ONE request on
|
|
11
|
+
* the next tick) and maps the resolved limits by product id for the cart
|
|
12
|
+
* steppers to read. Each product is fetched at most once; results stay cached
|
|
13
|
+
* for the session.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const limitsByProductId = new Map<string, CartQuantityLimits>();
|
|
17
|
+
const requestedProductIds = new Set<string>();
|
|
18
|
+
const listeners = new Set<() => void>();
|
|
19
|
+
|
|
20
|
+
let pendingProductIds: string[] = [];
|
|
21
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
22
|
+
|
|
23
|
+
function emit() {
|
|
24
|
+
for (const listener of listeners) listener();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function flush() {
|
|
28
|
+
flushTimer = null;
|
|
29
|
+
const batch = pendingProductIds;
|
|
30
|
+
pendingProductIds = [];
|
|
31
|
+
if (!batch.length) return;
|
|
32
|
+
|
|
33
|
+
// bs_searchProductsById never throws — API/network failures resolve with
|
|
34
|
+
// isSuccess: false — so the retry unmark must live on the result check.
|
|
35
|
+
const result = await bs_searchProductsById(baseStore, { productIds: batch }).catch(
|
|
36
|
+
() => null,
|
|
37
|
+
);
|
|
38
|
+
if (!result?.isSuccess || !result.products) {
|
|
39
|
+
// Limits stay unenforced for now; unmark the batch so a later
|
|
40
|
+
// ensureCartLineLimits call (e.g. the next cart open) can retry.
|
|
41
|
+
for (const id of batch) requestedProductIds.delete(id);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const product of result.products) {
|
|
46
|
+
limitsByProductId.set(product.id, getProductCartLimits(product));
|
|
47
|
+
}
|
|
48
|
+
emit();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register cart-line productIds for limit resolution. Ids already requested
|
|
53
|
+
* are skipped; new ids are collected and fetched in a single batched request
|
|
54
|
+
* on the next tick, so per-line calls do not fan out into per-line requests.
|
|
55
|
+
*/
|
|
56
|
+
export function ensureCartLineLimits(productIds: (string | null | undefined)[]) {
|
|
57
|
+
const missing = productIds.filter(
|
|
58
|
+
(id): id is string => !!id && !requestedProductIds.has(id),
|
|
59
|
+
);
|
|
60
|
+
if (!missing.length) return;
|
|
61
|
+
|
|
62
|
+
for (const id of missing) {
|
|
63
|
+
requestedProductIds.add(id);
|
|
64
|
+
pendingProductIds.push(id);
|
|
65
|
+
}
|
|
66
|
+
if (!flushTimer) flushTimer = setTimeout(flush, 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolved limits for a product — undefined until the batch fetch lands. */
|
|
70
|
+
export function getCartLineLimits(
|
|
71
|
+
productId: string | null | undefined,
|
|
72
|
+
): CartQuantityLimits | undefined {
|
|
73
|
+
return productId ? limitsByProductId.get(productId) : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Subscribe to limit resolutions; returns the unsubscribe function. */
|
|
77
|
+
export function subscribeCartLineLimits(listener: () => void) {
|
|
78
|
+
listeners.add(listener);
|
|
79
|
+
return () => {
|
|
80
|
+
listeners.delete(listener);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { observer } from "@ikas/component-utils";
|
|
2
|
+
import { useEffect, useState } from "preact/hooks";
|
|
2
3
|
import { TrashSVG, NoProductSVG } from "../icons";
|
|
3
4
|
import QuantitySelector from "../QuantitySelector";
|
|
4
5
|
import BundleProducts from "./components/BundleProducts";
|
|
@@ -19,6 +20,11 @@ import {
|
|
|
19
20
|
} from "@ikas/bp-storefront";
|
|
20
21
|
import { resolveAspectRatio, resolveObjectFit } from "../../utils/media";
|
|
21
22
|
import { cx } from "../../utils/cx";
|
|
23
|
+
import {
|
|
24
|
+
ensureCartLineLimits,
|
|
25
|
+
getCartLineLimits,
|
|
26
|
+
subscribeCartLineLimits,
|
|
27
|
+
} from "../../utils/cartLineLimits";
|
|
22
28
|
import type { AspectRatio, ObjectFit } from "../../global-types";
|
|
23
29
|
|
|
24
30
|
interface CartItemProps {
|
|
@@ -40,6 +46,23 @@ const CartItem = observer(function CartItem({
|
|
|
40
46
|
}: CartItemProps) {
|
|
41
47
|
const isPage = variant === "page";
|
|
42
48
|
|
|
49
|
+
// The line's IkasOrderLineVariant carries no salesChannels, so the per-cart
|
|
50
|
+
// quantity limits come from a batched product fetch shared across all lines.
|
|
51
|
+
const productId = item.variant?.productId ?? null;
|
|
52
|
+
const [, setLimitsVersion] = useState(0);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
// Subscribe BEFORE registering the id: a batch already in flight can
|
|
55
|
+
// resolve between render and this effect, and its emit must not be missed.
|
|
56
|
+
const unsubscribe = subscribeCartLineLimits(() => setLimitsVersion((v) => v + 1));
|
|
57
|
+
if (productId) {
|
|
58
|
+
ensureCartLineLimits([productId]);
|
|
59
|
+
// Re-check once for a result that landed before the subscription armed.
|
|
60
|
+
if (getCartLineLimits(productId)) setLimitsVersion((v) => v + 1);
|
|
61
|
+
}
|
|
62
|
+
return unsubscribe;
|
|
63
|
+
}, [productId]);
|
|
64
|
+
const limits = getCartLineLimits(productId);
|
|
65
|
+
|
|
43
66
|
// Lines a campaign added automatically (e.g. a "buy 1 get 1" gift) cannot be
|
|
44
67
|
// edited or removed on their own, so render them read-only with a badge.
|
|
45
68
|
const isAutoCreated =
|
|
@@ -180,6 +203,8 @@ const CartItem = observer(function CartItem({
|
|
|
180
203
|
<QuantitySelector
|
|
181
204
|
size="sm"
|
|
182
205
|
value={item.quantity}
|
|
206
|
+
min={limits?.min ?? 1}
|
|
207
|
+
max={limits?.max}
|
|
183
208
|
onChange={(qty) => changeItemQuantity(item, qty)}
|
|
184
209
|
/>
|
|
185
210
|
)}
|
|
@@ -227,6 +252,8 @@ const CartItem = observer(function CartItem({
|
|
|
227
252
|
<QuantitySelector
|
|
228
253
|
size="sm"
|
|
229
254
|
value={item.quantity}
|
|
255
|
+
min={limits?.min ?? 1}
|
|
256
|
+
max={limits?.max}
|
|
230
257
|
onChange={(qty) => changeItemQuantity(item, qty)}
|
|
231
258
|
/>
|
|
232
259
|
<button
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { IkasProduct, IkasStorefrontConfig } from "@ikas/bp-storefront";
|
|
2
|
+
|
|
3
|
+
export interface CartQuantityLimits {
|
|
4
|
+
min: number;
|
|
5
|
+
max?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the per-cart quantity limits for a product from its active sales
|
|
10
|
+
* channel. Falls back to a minimum of 1 and no maximum when the channel or its
|
|
11
|
+
* limits are not set. `addItemToCart` clamps to the same limits internally only
|
|
12
|
+
* when it creates a NEW cart line — incrementing an existing line goes through
|
|
13
|
+
* `changeItemQuantity`, which never clamps — so enforcing these in the stepper
|
|
14
|
+
* is the only guard that covers both paths.
|
|
15
|
+
*/
|
|
16
|
+
export function getProductCartLimits(product: IkasProduct): CartQuantityLimits {
|
|
17
|
+
const channel = product.salesChannels?.find(
|
|
18
|
+
(sc) => sc.id === IkasStorefrontConfig.salesChannelId,
|
|
19
|
+
);
|
|
20
|
+
return {
|
|
21
|
+
min: channel?.minQuantityPerCart ?? 1,
|
|
22
|
+
max: channel?.maxQuantityPerCart ?? undefined,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { baseStore, bs_searchProductsById } from "@ikas/bp-storefront";
|
|
2
|
+
import { CartQuantityLimits, getProductCartLimits } from "./cartLimits";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-cart quantity limits for cart lines.
|
|
6
|
+
*
|
|
7
|
+
* A cart line's `IkasOrderLineVariant` carries no `salesChannels`, so the
|
|
8
|
+
* min/max per-cart limits can only be read from the full `IkasProduct`. This
|
|
9
|
+
* store batch-fetches the line products with `bs_searchProductsById` (lines
|
|
10
|
+
* register their productId one by one; the ids are flushed as ONE request on
|
|
11
|
+
* the next tick) and maps the resolved limits by product id for the cart
|
|
12
|
+
* steppers to read. Each product is fetched at most once; results stay cached
|
|
13
|
+
* for the session.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const limitsByProductId = new Map<string, CartQuantityLimits>();
|
|
17
|
+
const requestedProductIds = new Set<string>();
|
|
18
|
+
const listeners = new Set<() => void>();
|
|
19
|
+
|
|
20
|
+
let pendingProductIds: string[] = [];
|
|
21
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
22
|
+
|
|
23
|
+
function emit() {
|
|
24
|
+
for (const listener of listeners) listener();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function flush() {
|
|
28
|
+
flushTimer = null;
|
|
29
|
+
const batch = pendingProductIds;
|
|
30
|
+
pendingProductIds = [];
|
|
31
|
+
if (!batch.length) return;
|
|
32
|
+
|
|
33
|
+
// bs_searchProductsById never throws — API/network failures resolve with
|
|
34
|
+
// isSuccess: false — so the retry unmark must live on the result check.
|
|
35
|
+
const result = await bs_searchProductsById(baseStore, { productIds: batch }).catch(
|
|
36
|
+
() => null,
|
|
37
|
+
);
|
|
38
|
+
if (!result?.isSuccess || !result.products) {
|
|
39
|
+
// Limits stay unenforced for now; unmark the batch so a later
|
|
40
|
+
// ensureCartLineLimits call (e.g. the next cart open) can retry.
|
|
41
|
+
for (const id of batch) requestedProductIds.delete(id);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const product of result.products) {
|
|
46
|
+
limitsByProductId.set(product.id, getProductCartLimits(product));
|
|
47
|
+
}
|
|
48
|
+
emit();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register cart-line productIds for limit resolution. Ids already requested
|
|
53
|
+
* are skipped; new ids are collected and fetched in a single batched request
|
|
54
|
+
* on the next tick, so per-line calls do not fan out into per-line requests.
|
|
55
|
+
*/
|
|
56
|
+
export function ensureCartLineLimits(productIds: (string | null | undefined)[]) {
|
|
57
|
+
const missing = productIds.filter(
|
|
58
|
+
(id): id is string => !!id && !requestedProductIds.has(id),
|
|
59
|
+
);
|
|
60
|
+
if (!missing.length) return;
|
|
61
|
+
|
|
62
|
+
for (const id of missing) {
|
|
63
|
+
requestedProductIds.add(id);
|
|
64
|
+
pendingProductIds.push(id);
|
|
65
|
+
}
|
|
66
|
+
if (!flushTimer) flushTimer = setTimeout(flush, 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolved limits for a product — undefined until the batch fetch lands. */
|
|
70
|
+
export function getCartLineLimits(
|
|
71
|
+
productId: string | null | undefined,
|
|
72
|
+
): CartQuantityLimits | undefined {
|
|
73
|
+
return productId ? limitsByProductId.get(productId) : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Subscribe to limit resolutions; returns the unsubscribe function. */
|
|
77
|
+
export function subscribeCartLineLimits(listener: () => void) {
|
|
78
|
+
listeners.add(listener);
|
|
79
|
+
return () => {
|
|
80
|
+
listeners.delete(listener);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { observer } from "@ikas/component-utils";
|
|
2
|
+
import { useEffect, useState } from "preact/hooks";
|
|
2
3
|
import { TrashSVG, NoProductSVG } from "../icons";
|
|
3
4
|
import QuantitySelector from "../QuantitySelector";
|
|
4
5
|
import BundleProducts from "./components/BundleProducts";
|
|
@@ -19,6 +20,11 @@ import {
|
|
|
19
20
|
} from "@ikas/bp-storefront";
|
|
20
21
|
import { resolveAspectRatio, resolveObjectFit } from "../../utils/media";
|
|
21
22
|
import { cx } from "../../utils/cx";
|
|
23
|
+
import {
|
|
24
|
+
ensureCartLineLimits,
|
|
25
|
+
getCartLineLimits,
|
|
26
|
+
subscribeCartLineLimits,
|
|
27
|
+
} from "../../utils/cartLineLimits";
|
|
22
28
|
import type { AspectRatio, ObjectFit } from "../../global-types";
|
|
23
29
|
|
|
24
30
|
interface CartItemProps {
|
|
@@ -40,6 +46,23 @@ const CartItem = observer(function CartItem({
|
|
|
40
46
|
}: CartItemProps) {
|
|
41
47
|
const isPage = variant === "page";
|
|
42
48
|
|
|
49
|
+
// The line's IkasOrderLineVariant carries no salesChannels, so the per-cart
|
|
50
|
+
// quantity limits come from a batched product fetch shared across all lines.
|
|
51
|
+
const productId = item.variant?.productId ?? null;
|
|
52
|
+
const [, setLimitsVersion] = useState(0);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
// Subscribe BEFORE registering the id: a batch already in flight can
|
|
55
|
+
// resolve between render and this effect, and its emit must not be missed.
|
|
56
|
+
const unsubscribe = subscribeCartLineLimits(() => setLimitsVersion((v) => v + 1));
|
|
57
|
+
if (productId) {
|
|
58
|
+
ensureCartLineLimits([productId]);
|
|
59
|
+
// Re-check once for a result that landed before the subscription armed.
|
|
60
|
+
if (getCartLineLimits(productId)) setLimitsVersion((v) => v + 1);
|
|
61
|
+
}
|
|
62
|
+
return unsubscribe;
|
|
63
|
+
}, [productId]);
|
|
64
|
+
const limits = getCartLineLimits(productId);
|
|
65
|
+
|
|
43
66
|
// Lines a campaign added automatically (e.g. a "buy 1 get 1" gift) cannot be
|
|
44
67
|
// edited or removed on their own, so render them read-only with a badge.
|
|
45
68
|
const isAutoCreated =
|
|
@@ -180,6 +203,8 @@ const CartItem = observer(function CartItem({
|
|
|
180
203
|
<QuantitySelector
|
|
181
204
|
size="sm"
|
|
182
205
|
value={item.quantity}
|
|
206
|
+
min={limits?.min ?? 1}
|
|
207
|
+
max={limits?.max}
|
|
183
208
|
onChange={(qty) => changeItemQuantity(item, qty)}
|
|
184
209
|
/>
|
|
185
210
|
)}
|
|
@@ -227,6 +252,8 @@ const CartItem = observer(function CartItem({
|
|
|
227
252
|
<QuantitySelector
|
|
228
253
|
size="sm"
|
|
229
254
|
value={item.quantity}
|
|
255
|
+
min={limits?.min ?? 1}
|
|
256
|
+
max={limits?.max}
|
|
230
257
|
onChange={(qty) => changeItemQuantity(item, qty)}
|
|
231
258
|
/>
|
|
232
259
|
<button
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { IkasProduct, IkasStorefrontConfig } from "@ikas/bp-storefront";
|
|
2
|
+
|
|
3
|
+
export interface CartQuantityLimits {
|
|
4
|
+
min: number;
|
|
5
|
+
max?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the per-cart quantity limits for a product from its active sales
|
|
10
|
+
* channel. Falls back to a minimum of 1 and no maximum when the channel or its
|
|
11
|
+
* limits are not set. `addItemToCart` clamps to the same limits internally only
|
|
12
|
+
* when it creates a NEW cart line — incrementing an existing line goes through
|
|
13
|
+
* `changeItemQuantity`, which never clamps — so enforcing these in the stepper
|
|
14
|
+
* is the only guard that covers both paths.
|
|
15
|
+
*/
|
|
16
|
+
export function getProductCartLimits(product: IkasProduct): CartQuantityLimits {
|
|
17
|
+
const channel = product.salesChannels?.find(
|
|
18
|
+
(sc) => sc.id === IkasStorefrontConfig.salesChannelId,
|
|
19
|
+
);
|
|
20
|
+
return {
|
|
21
|
+
min: channel?.minQuantityPerCart ?? 1,
|
|
22
|
+
max: channel?.maxQuantityPerCart ?? undefined,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { baseStore, bs_searchProductsById } from "@ikas/bp-storefront";
|
|
2
|
+
import { CartQuantityLimits, getProductCartLimits } from "./cartLimits";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-cart quantity limits for cart lines.
|
|
6
|
+
*
|
|
7
|
+
* A cart line's `IkasOrderLineVariant` carries no `salesChannels`, so the
|
|
8
|
+
* min/max per-cart limits can only be read from the full `IkasProduct`. This
|
|
9
|
+
* store batch-fetches the line products with `bs_searchProductsById` (lines
|
|
10
|
+
* register their productId one by one; the ids are flushed as ONE request on
|
|
11
|
+
* the next tick) and maps the resolved limits by product id for the cart
|
|
12
|
+
* steppers to read. Each product is fetched at most once; results stay cached
|
|
13
|
+
* for the session.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const limitsByProductId = new Map<string, CartQuantityLimits>();
|
|
17
|
+
const requestedProductIds = new Set<string>();
|
|
18
|
+
const listeners = new Set<() => void>();
|
|
19
|
+
|
|
20
|
+
let pendingProductIds: string[] = [];
|
|
21
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
22
|
+
|
|
23
|
+
function emit() {
|
|
24
|
+
for (const listener of listeners) listener();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function flush() {
|
|
28
|
+
flushTimer = null;
|
|
29
|
+
const batch = pendingProductIds;
|
|
30
|
+
pendingProductIds = [];
|
|
31
|
+
if (!batch.length) return;
|
|
32
|
+
|
|
33
|
+
// bs_searchProductsById never throws — API/network failures resolve with
|
|
34
|
+
// isSuccess: false — so the retry unmark must live on the result check.
|
|
35
|
+
const result = await bs_searchProductsById(baseStore, { productIds: batch }).catch(
|
|
36
|
+
() => null,
|
|
37
|
+
);
|
|
38
|
+
if (!result?.isSuccess || !result.products) {
|
|
39
|
+
// Limits stay unenforced for now; unmark the batch so a later
|
|
40
|
+
// ensureCartLineLimits call (e.g. the next cart open) can retry.
|
|
41
|
+
for (const id of batch) requestedProductIds.delete(id);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const product of result.products) {
|
|
46
|
+
limitsByProductId.set(product.id, getProductCartLimits(product));
|
|
47
|
+
}
|
|
48
|
+
emit();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register cart-line productIds for limit resolution. Ids already requested
|
|
53
|
+
* are skipped; new ids are collected and fetched in a single batched request
|
|
54
|
+
* on the next tick, so per-line calls do not fan out into per-line requests.
|
|
55
|
+
*/
|
|
56
|
+
export function ensureCartLineLimits(productIds: (string | null | undefined)[]) {
|
|
57
|
+
const missing = productIds.filter(
|
|
58
|
+
(id): id is string => !!id && !requestedProductIds.has(id),
|
|
59
|
+
);
|
|
60
|
+
if (!missing.length) return;
|
|
61
|
+
|
|
62
|
+
for (const id of missing) {
|
|
63
|
+
requestedProductIds.add(id);
|
|
64
|
+
pendingProductIds.push(id);
|
|
65
|
+
}
|
|
66
|
+
if (!flushTimer) flushTimer = setTimeout(flush, 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolved limits for a product — undefined until the batch fetch lands. */
|
|
70
|
+
export function getCartLineLimits(
|
|
71
|
+
productId: string | null | undefined,
|
|
72
|
+
): CartQuantityLimits | undefined {
|
|
73
|
+
return productId ? limitsByProductId.get(productId) : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Subscribe to limit resolutions; returns the unsubscribe function. */
|
|
77
|
+
export function subscribeCartLineLimits(listener: () => void) {
|
|
78
|
+
listeners.add(listener);
|
|
79
|
+
return () => {
|
|
80
|
+
listeners.delete(listener);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -8,9 +8,10 @@ export interface CartQuantityLimits {
|
|
|
8
8
|
/**
|
|
9
9
|
* Resolve the per-cart quantity limits for a product from its active sales
|
|
10
10
|
* channel. Falls back to a minimum of 1 and no maximum when the channel or its
|
|
11
|
-
* limits are not set. `addItemToCart` clamps to the same limits internally
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* limits are not set. `addItemToCart` clamps to the same limits internally only
|
|
12
|
+
* when it creates a NEW cart line — incrementing an existing line goes through
|
|
13
|
+
* `changeItemQuantity`, which never clamps — so enforcing these in the stepper
|
|
14
|
+
* is the only guard that covers both paths.
|
|
14
15
|
*/
|
|
15
16
|
export function getProductCartLimits(product: IkasProduct): CartQuantityLimits {
|
|
16
17
|
const channel = product.salesChannels?.find(
|
package/data/storefront-api.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-08-
|
|
2
|
+
"generatedAt": "2026-08-19T12:35:16.476Z",
|
|
3
3
|
"functions": [
|
|
4
4
|
{
|
|
5
5
|
"name": "apiListProductBrand",
|
|
@@ -12756,7 +12756,7 @@
|
|
|
12756
12756
|
"name": "changeItemQuantity",
|
|
12757
12757
|
"displayName": "changeQuantityOnCart",
|
|
12758
12758
|
"signature": "async function changeItemQuantity(item: IkasOrderLineItem, quantity: number, offers?: IkasProductOffer[], product?: IkasProduct): Promise<IkasCartOperationResult>",
|
|
12759
|
-
"description": "Change the quantity of an item in the cart.\nUse quantity=0 to remove the item.",
|
|
12759
|
+
"description": "Change the quantity of an item in the cart.\nUse quantity=0 to remove the item.\n\nPerforms no client-side clamping or validation: a quantity above the sales channel's\n`maxQuantityPerCart` is rejected by the API with the `MAX_QUANTITY_PER_CART_LIMIT_REACHED`\ncode in `result.response.errorCodes` (never as a `validationError`). Prevent violations in\nthe stepper and treat the error code as the fallback.",
|
|
12760
12760
|
"params": [
|
|
12761
12761
|
{
|
|
12762
12762
|
"name": "item",
|
|
@@ -12777,7 +12777,7 @@
|
|
|
12777
12777
|
],
|
|
12778
12778
|
"returns": "Result with success flag",
|
|
12779
12779
|
"returnType": "Promise<IkasCartOperationResult>",
|
|
12780
|
-
"example": "```typescript\nimport { cartStore, changeItemQuantity } from \"@ikas/bp-storefront\";\n\n// Increment quantity\nasync function handleIncrement(itemId: string) {\n const item = cartStore.cart?.orderLineItems.find(i => i.id === itemId);\n if (item) {\n const result = await changeItemQuantity(item, item.quantity + 1);\n if (!result.success) {\n showToast(\"Failed to update quantity\");\n }\n }\n}\n\n// Decrement quantity\nasync function handleDecrement(itemId: string) {\n const item = cartStore.cart?.orderLineItems.find(i => i.id === itemId);\n if (item && item.quantity > 1) {\n await changeItemQuantity(item, item.quantity - 1);\n }\n}\n```",
|
|
12780
|
+
"example": "```typescript\nimport { APIErrorCode, cartStore, changeItemQuantity } from \"@ikas/bp-storefront\";\n\n// Increment quantity\nasync function handleIncrement(itemId: string) {\n const item = cartStore.cart?.orderLineItems.find(i => i.id === itemId);\n if (item) {\n const result = await changeItemQuantity(item, item.quantity + 1);\n if (!result.success) {\n const limitHit = result.response?.errorCodes?.includes(\n APIErrorCode.MAX_QUANTITY_PER_CART_LIMIT_REACHED\n );\n showToast(limitHit ? \"Per-cart quantity limit reached\" : \"Failed to update quantity\");\n }\n }\n}\n\n// Decrement quantity\nasync function handleDecrement(itemId: string) {\n const item = cartStore.cart?.orderLineItems.find(i => i.id === itemId);\n if (item && item.quantity > 1) {\n await changeItemQuantity(item, item.quantity - 1);\n }\n}\n```",
|
|
12781
12781
|
"categories": [
|
|
12782
12782
|
"Cart"
|
|
12783
12783
|
],
|