@ikas/code-components-mcp 1.4.0-beta.166 → 1.4.0-beta.167
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 +1 -1
- 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 +1 -1
- 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",
|
|
@@ -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