@ikas/code-components-mcp 1.4.0-beta.17 → 1.4.0-beta.170

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.
Files changed (94) hide show
  1. package/data/framework.json +108 -17
  2. package/data/migration.json +10 -4
  3. package/data/section-templates/account-info-section/children/AccountFavorites/ikas-config-snippet.json +13 -0
  4. package/data/section-templates/account-info-section/children/AccountOrders/index.tsx +1 -1
  5. package/data/section-templates/account-info-section/ikas-config-snippet.json +19 -0
  6. package/data/section-templates/add-to-cart/ikas-config-snippet.json +17 -0
  7. package/data/section-templates/add-to-cart/index.tsx +16 -2
  8. package/data/section-templates/add-to-cart/sub-components/QuantitySelector/index.tsx +11 -3
  9. package/data/section-templates/add-to-cart/utils/cartLimits.ts +24 -0
  10. package/data/section-templates/blog-home-section/ikas-config-snippet.json +2 -0
  11. package/data/section-templates/blog-home-section/index.tsx +2 -1
  12. package/data/section-templates/blog-post-section/ikas-config-snippet.json +2 -0
  13. package/data/section-templates/blog-post-section/index.tsx +3 -2
  14. package/data/section-templates/bundle-products/hooks/useBundleProducts.ts +7 -6
  15. package/data/section-templates/bundle-products/ikas-config-snippet.json +20 -0
  16. package/data/section-templates/cart-section/components/EmptyState/index.tsx +1 -1
  17. package/data/section-templates/cart-section/sub-components/CartItem/index.tsx +84 -24
  18. package/data/section-templates/cart-section/sub-components/CartItem/styles.css +14 -0
  19. package/data/section-templates/cart-section/sub-components/QuantitySelector/index.tsx +11 -3
  20. package/data/section-templates/cart-section/utils/cartLimits.ts +24 -0
  21. package/data/section-templates/cart-section/utils/cartLineLimits.ts +82 -0
  22. package/data/section-templates/category-images-section/children/CategoryImageItem/ikas-config-snippet.json +4 -0
  23. package/data/section-templates/category-images-section/ikas-config-snippet.json +15 -0
  24. package/data/section-templates/category-list-section/children/CardProductName/ikas-config-snippet.json +2 -1
  25. package/data/section-templates/category-list-section/ikas-config-snippet.json +21 -0
  26. package/data/section-templates/category-list-section/index.tsx +2 -1
  27. package/data/section-templates/component-renderer/additional/ProductDetail/index.tsx +5 -1
  28. package/data/section-templates/component-renderer/ikas-config-snippet.json +13 -0
  29. package/data/section-templates/favorites/ikas-config-snippet.json +18 -1
  30. package/data/section-templates/features-section/ikas-config-snippet.json +13 -0
  31. package/data/section-templates/footer-section/ikas-config-snippet.json +15 -0
  32. package/data/section-templates/forgot-password-section/components/ForgotPasswordForm/index.tsx +1 -1
  33. package/data/section-templates/header-section/children/Announcements/ikas-config-snippet.json +7 -0
  34. package/data/section-templates/header-section/children/CookieBar/ikas-config-snippet.json +3 -0
  35. package/data/section-templates/header-section/children/Navbar/components/CartSidebar/index.tsx +2 -1
  36. package/data/section-templates/header-section/children/Navbar/components/SearchModal/index.tsx +2 -1
  37. package/data/section-templates/header-section/children/Navbar/ikas-config-snippet.json +27 -0
  38. package/data/section-templates/header-section/children/Navbar/index.tsx +6 -5
  39. package/data/section-templates/header-section/ikas-config-snippet.json +13 -0
  40. package/data/section-templates/header-section/sub-components/CartItem/index.tsx +84 -24
  41. package/data/section-templates/header-section/sub-components/CartItem/styles.css +14 -0
  42. package/data/section-templates/header-section/sub-components/QuantitySelector/index.tsx +11 -3
  43. package/data/section-templates/header-section/utils/cartLimits.ts +24 -0
  44. package/data/section-templates/header-section/utils/cartLineLimits.ts +82 -0
  45. package/data/section-templates/hero-slider-section/children/HeroSliderItem/ikas-config-snippet.json +18 -0
  46. package/data/section-templates/hero-slider-section/children/HeroSliderItem/index.tsx +12 -7
  47. package/data/section-templates/hero-slider-section/ikas-config-snippet.json +13 -0
  48. package/data/section-templates/image-handling/ikas-config-snippet.json +53 -0
  49. package/data/section-templates/image-handling/index.tsx +5 -1
  50. package/data/section-templates/login-section/ikas-config-snippet.json +2 -0
  51. package/data/section-templates/navigation/components/CartSidebar/index.tsx +2 -1
  52. package/data/section-templates/navigation/components/SearchModal/index.tsx +2 -1
  53. package/data/section-templates/navigation/ikas-config-snippet.json +27 -0
  54. package/data/section-templates/navigation/index.tsx +6 -5
  55. package/data/section-templates/navigation/sub-components/CartItem/index.tsx +84 -24
  56. package/data/section-templates/navigation/sub-components/CartItem/styles.css +14 -0
  57. package/data/section-templates/navigation/sub-components/QuantitySelector/index.tsx +11 -3
  58. package/data/section-templates/navigation/utils/cartLimits.ts +24 -0
  59. package/data/section-templates/navigation/utils/cartLineLimits.ts +82 -0
  60. package/data/section-templates/product-detail-section/children/ProductDetailAddToCart/ikas-config-snippet.json +17 -0
  61. package/data/section-templates/product-detail-section/children/ProductDetailAddToCart/index.tsx +16 -2
  62. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/components/BackInStockForm/index.tsx +92 -0
  63. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/ikas-config-snippet.json +175 -0
  64. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/index.tsx +141 -0
  65. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/styles.css +47 -0
  66. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/types.ts +25 -0
  67. package/data/section-templates/product-detail-section/children/ProductDetailBundleFurniture/ikas-config-snippet.json +18 -0
  68. package/data/section-templates/product-detail-section/children/ProductDetailBundleProduct/ikas-config-snippet.json +20 -0
  69. package/data/section-templates/product-detail-section/children/ProductDetailDescription/ikas-config-snippet.json +27 -0
  70. package/data/section-templates/product-detail-section/children/ProductDetailFeatures/ikas-config-snippet.json +29 -0
  71. package/data/section-templates/product-detail-section/children/ProductDetailNameFavorite/ikas-config-snippet.json +18 -1
  72. package/data/section-templates/product-detail-section/children/ProductDetailOffer/ikas-config-snippet.json +20 -0
  73. package/data/section-templates/product-detail-section/children/ProductDetailOptionSet/ikas-config-snippet.json +16 -0
  74. package/data/section-templates/product-detail-section/children/ProductDetailPrices/ikas-config-snippet.json +16 -0
  75. package/data/section-templates/product-detail-section/children/ProductDetailProductGroup/ikas-config-snippet.json +16 -0
  76. package/data/section-templates/product-detail-section/children/ProductDetailSku/ikas-config-snippet.json +16 -0
  77. package/data/section-templates/product-detail-section/children/ProductDetailVariant/ikas-config-snippet.json +17 -0
  78. package/data/section-templates/product-detail-section/hooks/useBundleProducts.ts +7 -6
  79. package/data/section-templates/product-detail-section/ikas-config-snippet.json +53 -0
  80. package/data/section-templates/product-detail-section/index.tsx +5 -1
  81. package/data/section-templates/product-detail-section/sub-components/Modal/index.tsx +120 -0
  82. package/data/section-templates/product-detail-section/sub-components/Modal/styles.css +70 -0
  83. package/data/section-templates/product-detail-section/sub-components/QuantitySelector/index.tsx +11 -3
  84. package/data/section-templates/product-detail-section/utils/cartLimits.ts +24 -0
  85. package/data/section-templates/product-pricing/ikas-config-snippet.json +16 -0
  86. package/data/section-templates/product-slider-section/children/CardProductName/ikas-config-snippet.json +2 -1
  87. package/data/section-templates/product-slider-section/ikas-config-snippet.json +17 -0
  88. package/data/section-templates/register-section/ikas-config-snippet.json +2 -0
  89. package/data/section-templates/variant-selection/ikas-config-snippet.json +17 -0
  90. package/data/storefront-api.json +307 -38
  91. package/data/storefront-types.json +9554 -6145
  92. package/dist/index.js +1299 -31
  93. package/dist/index.js.map +1 -1
  94. package/package.json +1 -1
@@ -26,6 +26,13 @@
26
26
  "displayName": "Announcements",
27
27
  "type": "COMPONENT_LIST",
28
28
  "required": false,
29
+ "defaultValue": {
30
+ "components": [
31
+ {
32
+ "codeComponentId": "nb34u3yu-announcement"
33
+ }
34
+ ]
35
+ },
29
36
  "filteredComponentIds": [
30
37
  "<id-of-Announcement>"
31
38
  ]
@@ -42,6 +42,7 @@
42
42
  "displayName": "Background Color",
43
43
  "type": "COLOR",
44
44
  "required": false,
45
+ "defaultValue": "rgba(0, 0, 0, 0.7)",
45
46
  "groupId": "appearance"
46
47
  },
47
48
  {
@@ -49,6 +50,7 @@
49
50
  "displayName": "Text Color",
50
51
  "type": "COLOR",
51
52
  "required": false,
53
+ "defaultValue": "#ffffff",
52
54
  "groupId": "appearance"
53
55
  },
54
56
  {
@@ -56,6 +58,7 @@
56
58
  "displayName": "Button Text Color",
57
59
  "type": "COLOR",
58
60
  "required": false,
61
+ "defaultValue": "#ffffff",
59
62
  "groupId": "appearance"
60
63
  }
61
64
  ],
@@ -19,6 +19,7 @@ import {
19
19
  getCheckoutUrlFromCartStore,
20
20
  IkasOrderLineItem,
21
21
  Router,
22
+ withRoutePrefix,
22
23
  } from "@ikas/bp-storefront";
23
24
  import type { AspectRatio, ObjectFit } from "../../../../global-types";
24
25
  import CartItem from "../../../../sub-components/CartItem";
@@ -164,7 +165,7 @@ const CartSidebar = observer(function CartSidebar({
164
165
  </div>
165
166
  <div className="kombos-cart-sidebar__footer-buttons">
166
167
  <a
167
- href="/cart"
168
+ href={withRoutePrefix("/cart")}
168
169
  className="kombos-cart-sidebar__view-cart-link"
169
170
  onClick={(e: Event) => {
170
171
  e.preventDefault();
@@ -12,6 +12,7 @@ import {
12
12
  createMediaSrcset,
13
13
  getProductOptionSet,
14
14
  IkasComponentRenderer,
15
+ withRoutePrefix,
15
16
  } from "@ikas/bp-storefront";
16
17
  import type { IkasProduct } from "@ikas/bp-storefront";
17
18
  import { cx } from "../../../../utils/cx";
@@ -199,7 +200,7 @@ const SearchModal = observer(function SearchModal({
199
200
  <div className="kombos-search__logo-area">
200
201
  {logo && (
201
202
  <a
202
- href="/"
203
+ href={withRoutePrefix("/")}
203
204
  className="kombos-search__logo"
204
205
  style={{
205
206
  "--search-logo-h-desktop": logoSizeDesktop,
@@ -17,6 +17,10 @@
17
17
  "displayName": "Desktop Logo Size",
18
18
  "type": "TYPE",
19
19
  "required": false,
20
+ "defaultValue": {
21
+ "value": 60,
22
+ "unit": "px"
23
+ },
20
24
  "groupId": "logo-settings",
21
25
  "typeId": "@ikas/bp-storefront-models-HeightStyleType"
22
26
  },
@@ -25,6 +29,10 @@
25
29
  "displayName": "Mobile Logo Size",
26
30
  "type": "TYPE",
27
31
  "required": false,
32
+ "defaultValue": {
33
+ "value": 48,
34
+ "unit": "px"
35
+ },
28
36
  "groupId": "logo-settings",
29
37
  "typeId": "@ikas/bp-storefront-models-HeightStyleType"
30
38
  },
@@ -40,6 +48,7 @@
40
48
  "displayName": "Link Color",
41
49
  "type": "COLOR",
42
50
  "required": false,
51
+ "defaultValue": "#2e2e33",
43
52
  "groupId": "navigation"
44
53
  },
45
54
  {
@@ -54,6 +63,7 @@
54
63
  "displayName": "Link Color",
55
64
  "type": "COLOR",
56
65
  "required": false,
66
+ "defaultValue": "#2e2e33",
57
67
  "groupId": "colored-links"
58
68
  },
59
69
  {
@@ -140,6 +150,7 @@
140
150
  "displayName": "Hide Add to Cart Button",
141
151
  "type": "BOOLEAN",
142
152
  "required": false,
153
+ "defaultValue": false,
143
154
  "groupId": "search-card-settings"
144
155
  },
145
156
  {
@@ -219,6 +230,19 @@
219
230
  "displayName": "Components",
220
231
  "type": "COMPONENT_LIST",
221
232
  "required": false,
233
+ "defaultValue": {
234
+ "components": [
235
+ {
236
+ "codeComponentId": "nb34u3yu-card-product-name"
237
+ },
238
+ {
239
+ "codeComponentId": "nb34u3yu-card-product-variants"
240
+ },
241
+ {
242
+ "codeComponentId": "nb34u3yu-card-product-price"
243
+ }
244
+ ]
245
+ },
222
246
  "privateVarMap": {
223
247
  "product": {
224
248
  "id": "pvm_1772803767210_2",
@@ -236,6 +260,7 @@
236
260
  "displayName": "Image Ratio",
237
261
  "type": "ENUM",
238
262
  "required": false,
263
+ "defaultValue": "Square",
239
264
  "groupId": "search-image",
240
265
  "enumTypeId": "{{ENUM_ID:AspectRatio}}"
241
266
  },
@@ -244,6 +269,7 @@
244
269
  "displayName": "Image Fit",
245
270
  "type": "ENUM",
246
271
  "required": false,
272
+ "defaultValue": "Cover",
247
273
  "groupId": "search-image",
248
274
  "enumTypeId": "{{ENUM_ID:ObjectFit}}"
249
275
  },
@@ -260,6 +286,7 @@
260
286
  "displayName": "Enable Sticky",
261
287
  "type": "BOOLEAN",
262
288
  "required": false,
289
+ "defaultValue": false,
263
290
  "description": "When enabled, the navbar stays fixed at the top of the page and remains visible while scrolling",
264
291
  "groupId": "settings"
265
292
  },
@@ -8,6 +8,7 @@ import {
8
8
  getFormattedHeightSize,
9
9
  Router,
10
10
  createMediaSrcset,
11
+ withRoutePrefix,
11
12
  } from "@ikas/bp-storefront";
12
13
  import { Props } from "./types";
13
14
  import CartSidebar from "./components/CartSidebar";
@@ -104,10 +105,10 @@ export function Navbar(props: Props) {
104
105
  {logo && (
105
106
  <a
106
107
  className="kombos-navbar__logo"
107
- href="/"
108
+ href={withRoutePrefix("/")}
108
109
  onClick={(e) => {
109
110
  e.preventDefault();
110
- Router.navigate("/");
111
+ Router.navigateToPage("INDEX");
111
112
  }}
112
113
  style={{
113
114
  "--logo-h-desktop": logoSizeDesktop,
@@ -145,7 +146,7 @@ export function Navbar(props: Props) {
145
146
  {/* Search */}
146
147
  <a
147
148
  className="kombos-navbar__icon-btn"
148
- href="/search"
149
+ href={withRoutePrefix("/search")}
149
150
  onClick={(e) => {
150
151
  e.preventDefault();
151
152
  e.stopPropagation();
@@ -159,7 +160,7 @@ export function Navbar(props: Props) {
159
160
  {/* Account */}
160
161
  <a
161
162
  className="kombos-navbar__icon-btn"
162
- href={isLoggedIn ? "/account" : "/login"}
163
+ href={withRoutePrefix(isLoggedIn ? "/account" : "/account/login")}
163
164
  onClick={(e) => {
164
165
  e.preventDefault();
165
166
  e.stopPropagation();
@@ -173,7 +174,7 @@ export function Navbar(props: Props) {
173
174
  {/* Cart */}
174
175
  <a
175
176
  className="kombos-navbar__icon-btn kombos-navbar__cart-trigger"
176
- href="/cart"
177
+ href={withRoutePrefix("/cart")}
177
178
  onClick={(e) => {
178
179
  e.preventDefault();
179
180
  e.stopPropagation();
@@ -10,6 +10,19 @@
10
10
  "displayName": "Components",
11
11
  "type": "COMPONENT_LIST",
12
12
  "required": false,
13
+ "defaultValue": {
14
+ "components": [
15
+ {
16
+ "codeComponentId": "nb34u3yu-announcements"
17
+ },
18
+ {
19
+ "codeComponentId": "nb34u3yu-navbar"
20
+ },
21
+ {
22
+ "codeComponentId": "nb34u3yu-cookie-bar"
23
+ }
24
+ ]
25
+ },
13
26
  "filteredComponentIds": [
14
27
  "<id-of-Navbar>",
15
28
  "<id-of-Announcements>",
@@ -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";
@@ -14,9 +15,16 @@ import {
14
15
  getOrderLineItemFormattedOverridenPriceWithQuantity,
15
16
  hasOrderLineItemDiscount,
16
17
  getOrderLineItemOverridenPriceWithQuantity,
18
+ cartStore,
19
+ isOrderLineItemAutoCreated,
17
20
  } from "@ikas/bp-storefront";
18
21
  import { resolveAspectRatio, resolveObjectFit } from "../../utils/media";
19
22
  import { cx } from "../../utils/cx";
23
+ import {
24
+ ensureCartLineLimits,
25
+ getCartLineLimits,
26
+ subscribeCartLineLimits,
27
+ } from "../../utils/cartLineLimits";
20
28
  import type { AspectRatio, ObjectFit } from "../../global-types";
21
29
 
22
30
  interface CartItemProps {
@@ -25,6 +33,7 @@ interface CartItemProps {
25
33
  variant?: "sidebar" | "page";
26
34
  aspectRatio?: AspectRatio;
27
35
  objectFit?: ObjectFit;
36
+ giftLabel?: string;
28
37
  }
29
38
 
30
39
  const CartItem = observer(function CartItem({
@@ -33,9 +42,42 @@ const CartItem = observer(function CartItem({
33
42
  variant = "sidebar",
34
43
  aspectRatio,
35
44
  objectFit,
45
+ giftLabel = "Gift",
36
46
  }: CartItemProps) {
37
47
  const isPage = variant === "page";
38
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
+
66
+ // Lines a campaign added automatically (e.g. a "buy 1 get 1" gift) cannot be
67
+ // edited or removed on their own, so render them read-only with a badge.
68
+ const isAutoCreated =
69
+ !!cartStore.cart && isOrderLineItemAutoCreated(cartStore.cart, item);
70
+ const giftBadge = isAutoCreated ? (
71
+ <span className="kombos-cart-item__gift-badge text-xs-medium">
72
+ {giftLabel}
73
+ </span>
74
+ ) : null;
75
+ const staticQuantity = (
76
+ <span className="kombos-cart-item__qty-static text-sm-medium">
77
+ ×{item.quantity}
78
+ </span>
79
+ );
80
+
39
81
  const mediaStyle: Record<string, string> = {
40
82
  aspectRatio: resolveAspectRatio(aspectRatio),
41
83
  objectFit: resolveObjectFit(objectFit),
@@ -103,6 +145,7 @@ const CartItem = observer(function CartItem({
103
145
  <a href={href} className={nameClass}>
104
146
  {item.variant?.name}
105
147
  </a>
148
+ {giftBadge}
106
149
  {variantValues.length > 0 && (
107
150
  <p className="kombos-cart-item__variant-text text-sm-regular">
108
151
  {variantValues
@@ -113,19 +156,22 @@ const CartItem = observer(function CartItem({
113
156
  </p>
114
157
  )}
115
158
  </div>
116
- <button
117
- className="kombos-cart-item__remove"
118
- onClick={() => onRemove(item)}
119
- aria-label="Remove"
120
- >
121
- <TrashSVG />
122
- </button>
159
+ {!isAutoCreated && (
160
+ <button
161
+ className="kombos-cart-item__remove"
162
+ onClick={() => onRemove(item)}
163
+ aria-label="Remove"
164
+ >
165
+ <TrashSVG />
166
+ </button>
167
+ )}
123
168
  </div>
124
169
  ) : (
125
170
  <>
126
171
  <a href={href} className={nameClass}>
127
172
  {item.variant?.name}
128
173
  </a>
174
+ {giftBadge}
129
175
 
130
176
  {variantValues.length > 0 && (
131
177
  <div className="kombos-cart-item__variants">
@@ -151,11 +197,17 @@ const CartItem = observer(function CartItem({
151
197
 
152
198
  {isPage ? (
153
199
  <div className="kombos-cart-item__bottom">
154
- <QuantitySelector
155
- size="sm"
156
- value={item.quantity}
157
- onChange={(qty) => changeItemQuantity(item, qty)}
158
- />
200
+ {isAutoCreated ? (
201
+ staticQuantity
202
+ ) : (
203
+ <QuantitySelector
204
+ size="sm"
205
+ value={item.quantity}
206
+ min={limits?.min ?? 1}
207
+ max={limits?.max}
208
+ onChange={(qty) => changeItemQuantity(item, qty)}
209
+ />
210
+ )}
159
211
  <div className="kombos-cart-item__prices">
160
212
  {hasDiscount && (
161
213
  <span className="kombos-cart-item__original-price text-sm-regular-strike">
@@ -193,18 +245,26 @@ const CartItem = observer(function CartItem({
193
245
  </div>
194
246
 
195
247
  <div className="kombos-cart-item__actions">
196
- <QuantitySelector
197
- size="sm"
198
- value={item.quantity}
199
- onChange={(qty) => changeItemQuantity(item, qty)}
200
- />
201
- <button
202
- className="kombos-cart-item__remove"
203
- onClick={() => onRemove(item)}
204
- aria-label="Remove"
205
- >
206
- <TrashSVG />
207
- </button>
248
+ {isAutoCreated ? (
249
+ staticQuantity
250
+ ) : (
251
+ <>
252
+ <QuantitySelector
253
+ size="sm"
254
+ value={item.quantity}
255
+ min={limits?.min ?? 1}
256
+ max={limits?.max}
257
+ onChange={(qty) => changeItemQuantity(item, qty)}
258
+ />
259
+ <button
260
+ className="kombos-cart-item__remove"
261
+ onClick={() => onRemove(item)}
262
+ aria-label="Remove"
263
+ >
264
+ <TrashSVG />
265
+ </button>
266
+ </>
267
+ )}
208
268
  </div>
209
269
  </>
210
270
  )}
@@ -168,3 +168,17 @@
168
168
  flex-shrink: 0;
169
169
  }
170
170
 
171
+ /* Auto-created (campaign gift) line: badge + read-only quantity */
172
+ .kombos-cart-item__gift-badge {
173
+ display: inline-flex;
174
+ align-self: flex-start;
175
+ padding: 0.125rem 0.5rem;
176
+ border-radius: 999px;
177
+ background: var(--kombos-gray-100);
178
+ color: var(--kombos-gray-500);
179
+ }
180
+
181
+ .kombos-cart-item__qty-static {
182
+ color: var(--kombos-gray-500);
183
+ }
184
+
@@ -5,20 +5,27 @@ interface Props {
5
5
  value: number;
6
6
  onChange: (value: number) => void;
7
7
  size?: "default" | "sm";
8
+ min?: number;
9
+ max?: number;
8
10
  }
9
11
 
10
12
  export default function QuantitySelector({
11
13
  value,
12
14
  onChange,
13
15
  size = "default",
16
+ min = 1,
17
+ max,
14
18
  }: Props) {
19
+ const canDecrease = value > min;
20
+ const canIncrease = max == null || value < max;
21
+
15
22
  return (
16
23
  <div className={cx("kombos-pd__qty", size === "sm" && "kombos-pd__qty--sm")}>
17
24
  <button
18
25
  type="button"
19
26
  className="kombos-pd__qty-btn"
20
- onClick={() => onChange(Math.max(1, value - 1))}
21
- disabled={value <= 1}
27
+ onClick={() => onChange(Math.max(min, value - 1))}
28
+ disabled={!canDecrease}
22
29
  aria-label="Decrease quantity"
23
30
  >
24
31
  <MinusSVG />
@@ -31,7 +38,8 @@ export default function QuantitySelector({
31
38
  <button
32
39
  type="button"
33
40
  className="kombos-pd__qty-btn"
34
- onClick={() => onChange(value + 1)}
41
+ onClick={() => onChange(max == null ? value + 1 : Math.min(max, value + 1))}
42
+ disabled={!canIncrease}
35
43
  aria-label="Increase quantity"
36
44
  >
37
45
  <PlusSVG />
@@ -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
+ }