@ikas/code-components-mcp 1.4.0-beta.14 → 1.4.0-beta.141

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 (31) hide show
  1. package/data/framework.json +80 -10
  2. package/data/migration.json +2 -2
  3. package/data/section-templates/add-to-cart/index.tsx +16 -2
  4. package/data/section-templates/add-to-cart/sub-components/QuantitySelector/index.tsx +11 -3
  5. package/data/section-templates/add-to-cart/utils/cartLimits.ts +23 -0
  6. package/data/section-templates/cart-section/sub-components/CartItem/index.tsx +57 -24
  7. package/data/section-templates/cart-section/sub-components/CartItem/styles.css +14 -0
  8. package/data/section-templates/cart-section/sub-components/QuantitySelector/index.tsx +11 -3
  9. package/data/section-templates/header-section/sub-components/CartItem/index.tsx +57 -24
  10. package/data/section-templates/header-section/sub-components/CartItem/styles.css +14 -0
  11. package/data/section-templates/header-section/sub-components/QuantitySelector/index.tsx +11 -3
  12. package/data/section-templates/image-handling/ikas-config-snippet.json +1 -0
  13. package/data/section-templates/navigation/sub-components/CartItem/index.tsx +57 -24
  14. package/data/section-templates/navigation/sub-components/CartItem/styles.css +14 -0
  15. package/data/section-templates/navigation/sub-components/QuantitySelector/index.tsx +11 -3
  16. package/data/section-templates/product-detail-section/children/ProductDetailAddToCart/index.tsx +16 -2
  17. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/components/BackInStockForm/index.tsx +92 -0
  18. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/ikas-config-snippet.json +159 -0
  19. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/index.tsx +141 -0
  20. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/styles.css +47 -0
  21. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/types.ts +25 -0
  22. package/data/section-templates/product-detail-section/ikas-config-snippet.json +1 -0
  23. package/data/section-templates/product-detail-section/sub-components/Modal/index.tsx +120 -0
  24. package/data/section-templates/product-detail-section/sub-components/Modal/styles.css +70 -0
  25. package/data/section-templates/product-detail-section/sub-components/QuantitySelector/index.tsx +11 -3
  26. package/data/section-templates/product-detail-section/utils/cartLimits.ts +23 -0
  27. package/data/storefront-api.json +203 -23
  28. package/data/storefront-types.json +9334 -6021
  29. package/dist/index.js +1885 -242
  30. package/dist/index.js.map +1 -1
  31. package/package.json +1 -1
@@ -212,7 +212,7 @@
212
212
  "prop-runtime-shapes": {
213
213
  "title": "Runtime Shapes of Prop Types",
214
214
  "description": "Exact TypeScript shapes your component receives at runtime for each prop type. Covers common confusions (.data vs .links, .variant vs .product).",
215
- "content": "When you declare a prop in `ikas.config.json`, the component receives a specific runtime shape. This table documents what you actually get — which is often different from the editor UI suggests and from naive expectations.\n\n## Reference Table\n\n| PropType | Runtime Shape | Key Access Pattern |\n|----------|---------------|--------------------|\n| `TEXT` | `string` | `props.myText` |\n| `RICH_TEXT` | `string` (HTML) | `<div dangerouslySetInnerHTML={{ __html: props.html }} />` |\n| `NUMBER` | `number` | `props.width` (direct number, NOT `.value`) |\n| `BOOLEAN` | `boolean` | `props.enabled` |\n| `COLOR` | `string` (CSS color) | `style={{ color: props.textColor }}` |\n| `IMAGE` | `IkasImage \\| null` | `getDefaultSrc(props.image)` or `getSrc(props.image, { width: 400 })` |\n| `IMAGE_LIST` | `IkasImageList` | `props.images.map(img => getDefaultSrc(img))` |\n| `VIDEO` | `IkasVideo \\| null` | Check `.url`, `.type` (EMBED vs FILE) |\n| `LINK` | `IkasNavigationLink` | `props.link.href`, `props.link.label`, `props.link.subLinks`, `props.link.openInNewTab` |\n| `LIST_OF_LINK` | `IkasNavigationLinkList` | **`props.links.links`** — access via `.links` property (NOT `.data`) |\n| `PRODUCT` | `IkasProduct \\| null` | `props.product.name`, `.prices`, etc. |\n| `PRODUCT_LIST` | `IkasProductList` | **`props.productList.data`** — array of products via `.data` property |\n| `CATEGORY` | `IkasCategory \\| null` | `props.category.name`, `.href` |\n| `CATEGORY_LIST` | `IkasCategoryList` | Check shape with `get_type_definition(\"IkasCategoryList\")` |\n| `BRAND` / `BRAND_LIST` | `IkasBrand \\| null` / `IkasBrandList` | Use `get_model_guide(\"IkasBrand\")` |\n| `BLOG` / `BLOG_LIST` | `IkasBlog \\| null` / `IkasBlogList` | Blog list uses `.data` |\n| `PRODUCT_ATTRIBUTE` | `IkasProductAttributeValue \\| null` | Direct single value |\n| `PRODUCT_ATTRIBUTE_LIST` | `IkasProductAttributeValue[]` | Plain array (NOT wrapped) |\n| `ENUM` | `string` (the enum value) | Compare directly: `props.type === \"email\"` |\n| `COMPONENT` | `any` | Pass to `<IkasComponentRenderer components={[comp] as any[]} />` |\n| `COMPONENT_LIST` | `any[]` | Pass to `<IkasComponentRenderer components={list as any[]} />` |\n\n## Critical: Inconsistent Access Patterns\n\nThe most common mistakes come from **list-type props having inconsistent access patterns**:\n\n- `PRODUCT_LIST` → `.data` (array of products)\n- `BLOG_LIST` → `.data` (array of blogs)\n- `LIST_OF_LINK` → `.links` (NOT `.data`)\n- `PRODUCT_ATTRIBUTE_LIST` → plain array (no wrapper)\n- `IMAGE_LIST` → plain array via `.data` (check with `get_type_definition`)\n\n**When in doubt, call `get_model_guide(\"IkasProductList\")` etc. for the exact shape.**\n\n## Image Sizing: getDefaultSrc vs getSrc\n\n- `getDefaultSrc(image)` — returns the default-sized URL. Use for small images, thumbnails, or when size doesn't matter.\n- `getSrc(image, { width: 400 })` — returns a URL at a specific width. Use for performance-critical images where you know the rendered size.\n- `createMediaSrcset(image)` — generates a responsive `srcset` string for `<img srcset={...}>`.\n\n**Rule of thumb:** Use `getSrc` with a concrete width for large images; use `getDefaultSrc` for icons/small images.\n\n## Store Data Shapes (cartStore, customerStore, etc.)\n\nStore data is NOT a prop type — you import stores directly from `@ikas/bp-storefront`. Their runtime shapes are:\n\n- `cartStore.cart` → `IkasCart`: `.orderLineItems` is the array; each item has **`.variant`** (not `.product`), `.quantity`, `.totalPrice`\n- `customerStore.customer` → `IkasCustomer | null`: null when logged out, has `.firstName`, `.lastName`, `.email`, `.id`\n- `customerStore.isAuthenticated` → boolean\n\n**For full store shapes, always use `get_model_guide(\"IkasCart\")`, `get_model_guide(\"IkasCustomer\")`, etc. — do not guess.**\n\n## Old → New Runtime Shape Changes\n\nWhen migrating, remember these runtime changes:\n\n- Old `IkasSlider` (with `.value`) → New `number` (plain). Replace `width?.value` with just `width`.\n- Old `<Image>` component (renders itself) → New `getDefaultSrc(image)` + native `<img>`.\n- Old `useStore()` hook → New direct import: `import { cartStore } from '@ikas/bp-storefront'`.\n- Old `IkasAttributeDetail` / `IkasAttributeList` → New `IkasProductAttributeValue` / array.\n\n\n\n## Order Line Item Shapes (cart, order pages)\n\nThese come up when iterating over `IkasCart.orderLineItems` or `IkasOrder.orderLineItems`. They trip up LLMs because the field names don't match scalar intuition:\n\n- **`IkasOrderLineItemOption.values`** — this is an **array** of `IkasOrderLineItemOptionValue`, NOT a scalar `.value`. Iterate it with `.map`:\n\n ```tsx\n // WRONG — there is no .value on IkasOrderLineItemOption\n <span>{option.value}</span>\n\n // RIGHT — .values is an array, each item has its own .name/.value/.formattedPrice\n {option.values.map((v) => (\n <span key={v.name}>{v.name}: {v.value}</span>\n ))}\n ```\n\n- **`IkasOrderAdjustment` has no `.formattedAmount` property.** Call the helper instead: `getOrderAdjustmentFormattedAmount(adjustment)` from `@ikas/bp-storefront`. The helper returns the formatted currency string and handles the sign (negated for decrements). See `get_function_doc(\"getOrderAdjustmentFormattedAmount\")`.\n\n## See Also\n- `get_model_guide(\"<TypeName>\")` — one-stop shop for type definition + utility functions + examples\n- `get_type_definition(\"<TypeName>\")` — raw type definition\n- `get_framework_guide(\"common-pitfalls\")` — runtime shape mistakes (point #9 covers `.data`)\n- `get_framework_guide(\"imports\")` — where each type/function comes from",
215
+ "content": "When you declare a prop in `ikas.config.json`, the component receives a specific runtime shape. This table documents what you actually get — which is often different from the editor UI suggests and from naive expectations.\n\n## Reference Table\n\n| PropType | Runtime Shape | Key Access Pattern |\n|----------|---------------|--------------------|\n| `TEXT` | `string` | `props.myText` |\n| `RICH_TEXT` | `string` (HTML) | `<div dangerouslySetInnerHTML={{ __html: props.html }} />` |\n| `NUMBER` | `number` | `props.width` (direct number, NOT `.value`) |\n| `BOOLEAN` | `boolean` | `props.enabled` |\n| `COLOR` | `string` (CSS color) | `style={{ color: props.textColor }}` |\n| `IMAGE` | `IkasImage \\| null` | `getDefaultSrc(props.image)` or `getSrc(props.image, { width: 400 })` |\n| `IMAGE_LIST` | `IkasImageList` | `props.images.map(img => getDefaultSrc(img))` |\n| `VIDEO` | `IkasVideo \\| null` | Check `.url`, `.type` (EMBED vs FILE) |\n| `LINK` | `IkasNavigationLink` | `props.link.href`, `props.link.label`, `props.link.subLinks`, `props.link.openInNewTab` |\n| `LIST_OF_LINK` | `IkasNavigationLinkList` | **`props.links.links`** — access via `.links` property (NOT `.data`) |\n| `PRODUCT` | `IkasProduct \\| null` | `props.product.name`, `.prices`, etc. |\n| `PRODUCT_LIST` | `IkasProductList` | **`props.productList.data`** — array of products via `.data` property |\n| `CATEGORY` | `IkasCategory \\| null` | `props.category.name`, `.href` |\n| `CATEGORY_LIST` | `IkasCategoryList` | Check shape with `get_type_definition(\"IkasCategoryList\")` |\n| `BRAND` / `BRAND_LIST` | `IkasBrand \\| null` / `IkasBrandList` | Use `get_model_guide(\"IkasBrand\")` |\n| `BLOG` / `BLOG_LIST` | `IkasBlog \\| null` / `IkasBlogList` | Blog list uses `.data` |\n| `PRODUCT_ATTRIBUTE` | `IkasProductAttributeValue \\| null` | Direct single value |\n| `PRODUCT_ATTRIBUTE_LIST` | `IkasProductAttributeValue[]` | Plain array (NOT wrapped) |\n| `ENUM` | `string` (the enum value) | Compare directly: `props.type === \"email\"` |\n| `COMPONENT` | `any` | Pass to `<IkasComponentRenderer components={[comp] as any[]} />` |\n| `COMPONENT_LIST` | `any[]` | Pass to `<IkasComponentRenderer components={list as any[]} />` |\n\n## Authored `defaultValue` ≠ runtime read shape (LINK / LIST_OF_LINK)\n\nThe shapes above are what your component RECEIVES (read access: `props.link.href`). They are NOT what you write as a prop `defaultValue` in `ikas.config.json`. The authored default is a typed object:\n- `LINK` → `{ \"linkType\": \"EXTERNAL\", \"label\": \"Shop\", \"externalLink\": \"https://…\", \"subLinks\": [] }` (or `\"linkType\": \"PAGE\"` with `\"pageType\"`).\n- `LIST_OF_LINK` → `{ \"links\": [ <link>, … ] }`.\n\nDo NOT author a default as `{ href, label }` (legacy shape) or as a JSON string — the CLI rejects both. Full reference: `get_framework_guide(\"prop-types\")` → \"LINK and LIST_OF_LINK props\".\n\n## Critical: Inconsistent Access Patterns\n\nThe most common mistakes come from **list-type props having inconsistent access patterns**:\n\n- `PRODUCT_LIST` → `.data` (array of products)\n- `BLOG_LIST` → `.data` (array of blogs)\n- `LIST_OF_LINK` → `.links` (NOT `.data`)\n- `PRODUCT_ATTRIBUTE_LIST` → plain array (no wrapper)\n- `IMAGE_LIST` → plain array via `.data` (check with `get_type_definition`)\n\n**When in doubt, call `get_model_guide(\"IkasProductList\")` etc. for the exact shape.**\n\n## Image Sizing: getDefaultSrc vs getSrc\n\n- `getDefaultSrc(image)` — returns the default-sized URL. Use for small images, thumbnails, or when size doesn't matter.\n- `getSrc(image, { width: 400 })` — returns a URL at a specific width. Use for performance-critical images where you know the rendered size.\n- `createMediaSrcset(image)` — generates a responsive `srcset` string for `<img srcset={...}>`.\n\n**Rule of thumb:** Use `getSrc` with a concrete width for large images; use `getDefaultSrc` for icons/small images.\n\n## Store Data Shapes (cartStore, customerStore, etc.)\n\nStore data is NOT a prop type — you import stores directly from `@ikas/bp-storefront`. Their runtime shapes are:\n\n- `cartStore.cart` → `IkasCart`: `.orderLineItems` is the array; each item has **`.variant`** (not `.product`), `.quantity`, `.totalPrice`\n- `customerStore.customer` → `IkasCustomer | null`: null when logged out, has `.firstName`, `.lastName`, `.email`, `.id`\n- `customerStore.isAuthenticated` → boolean\n\n**For full store shapes, always use `get_model_guide(\"IkasCart\")`, `get_model_guide(\"IkasCustomer\")`, etc. — do not guess.**\n\n## Old → New Runtime Shape Changes\n\nWhen migrating, remember these runtime changes:\n\n- Old `IkasSlider` (with `.value`) → New `number` (plain). Replace `width?.value` with just `width`.\n- Old `<Image>` component (renders itself) → New `getDefaultSrc(image)` + native `<img>`.\n- Old `useStore()` hook → New direct import: `import { cartStore } from '@ikas/bp-storefront'`.\n- Old `IkasAttributeDetail` / `IkasAttributeList` → New `IkasProductAttributeValue` / array.\n\n\n\n## Order Line Item Shapes (cart, order pages)\n\nThese come up when iterating over `IkasCart.orderLineItems` or `IkasOrder.orderLineItems`. They trip up LLMs because the field names don't match scalar intuition:\n\n- **`IkasOrderLineItemOption.values`** — this is an **array** of `IkasOrderLineItemOptionValue`, NOT a scalar `.value`. Iterate it with `.map`:\n\n ```tsx\n // WRONG — there is no .value on IkasOrderLineItemOption\n <span>{option.value}</span>\n\n // RIGHT — .values is an array, each item has its own .name/.value/.formattedPrice\n {option.values.map((v) => (\n <span key={v.name}>{v.name}: {v.value}</span>\n ))}\n ```\n\n- **`IkasOrderAdjustment` has no `.formattedAmount` property.** Call the helper instead: `getOrderAdjustmentFormattedAmount(adjustment)` from `@ikas/bp-storefront`. The helper returns the formatted currency string and handles the sign (negated for decrements). See `get_function_doc(\"getOrderAdjustmentFormattedAmount\")`.\n\n## See Also\n- `get_model_guide(\"<TypeName>\")` — one-stop shop for type definition + utility functions + examples\n- `get_type_definition(\"<TypeName>\")` — raw type definition\n- `get_framework_guide(\"common-pitfalls\")` — runtime shape mistakes (point #9 covers `.data`)\n- `get_framework_guide(\"imports\")` — where each type/function comes from",
216
216
  "tags": [
217
217
  "migration",
218
218
  "runtime",
@@ -230,7 +230,7 @@
230
230
  "link-prop-decision-guide": {
231
231
  "title": "LINK Prop Decision Guide",
232
232
  "description": "When to use LINK, LIST_OF_LINK, LINK with subLinks, or COMPONENT_LIST for link-related data. Addresses common confusion between similar prop types.",
233
- "content": "Several prop types can represent \"links\" in the new system. Choosing the right one depends on the shape of the data and what the store owner should control.\n\n## Decision Tree\n\n**Is it a single link?**\n→ Use `LINK`. Runtime: `IkasNavigationLink { href, label, subLinks, openInNewTab, ... }`\n\n**Is it a flat list of sibling links (no extra data per link)?**\n→ Use `LIST_OF_LINK`. Runtime: `IkasNavigationLinkList` with `.links: IkasNavigationLink[]`\n\n**Is it a hierarchical menu (links with sub-links, no extra data)?**\n→ Use `LIST_OF_LINK`. Each `IkasNavigationLink` already has `.subLinks: IkasNavigationLink[]`. The editor handles sub-link nesting natively.\n\n**Is it a list of links WITH extra per-link data (image, color, badge, mega-menu content)?**\n→ Use `COMPONENT_LIST` with a child component that has one `LINK` prop + the extra fields.\n\n**Is it a list of products/categories/blogs (not generic links)?**\n→ Use the specific prop type (`PRODUCT_LIST`, `CATEGORY_LIST`, `BLOG_LIST`), NOT `LIST_OF_LINK`.\n\n## Examples\n\n### Footer bottom bar: \"Privacy | Terms | Contact\"\nSimple flat list → `LIST_OF_LINK`:\n```json\n{ \"name\": \"bottomLinks\", \"type\": \"LIST_OF_LINK\" }\n```\nAccess: `props.bottomLinks.links.map(link => <a href={link.href}>{link.label}</a>)`\n\n### Main nav menu with dropdowns: \"Women (→ Tops, Bottoms, Shoes)\"\nHierarchical but just labels + links → `LIST_OF_LINK` (sub-links built-in):\n```json\n{ \"name\": \"menu\", \"type\": \"LIST_OF_LINK\" }\n```\nAccess: `props.menu.links.map(link => (<div>{link.label}{link.subLinks?.map(...)}</div>))`\n\n### Mega menu with images per column\nLinks PLUS image + custom layout per menu item → `COMPONENT_LIST`:\n```json\n// Parent:\n{ \"name\": \"menuItems\", \"type\": \"COMPONENT_LIST\", \"filteredComponentIds\": [\"my-theme-mega-menu-item\"] }\n// Child MegaMenuItem:\n{ \"props\": [\n { \"name\": \"mainLink\", \"type\": \"LINK\" },\n { \"name\": \"columnImage\", \"type\": \"IMAGE\" },\n { \"name\": \"subLinks\", \"type\": \"LIST_OF_LINK\" }\n]}\n```\n\n### Social icons: \"Instagram | Facebook | Twitter\" each with its own icon image\nList of links with an image each → `COMPONENT_LIST`:\n```json\n// Parent:\n{ \"name\": \"socials\", \"type\": \"COMPONENT_LIST\", \"filteredComponentIds\": [\"my-theme-social-link\"] }\n// Child SocialLink:\n{ \"props\": [\n { \"name\": \"link\", \"type\": \"LINK\" },\n { \"name\": \"icon\", \"type\": \"IMAGE\" }\n]}\n```\n\n## Old CUSTOM → Which Link Prop?\n\nWhen migrating an old CUSTOM/DYNAMIC_LIST:\n\n| Old customData shape | New prop |\n|----------------------|----------|\n| `OBJECT { link: LINK }` | `LINK` (flatten) |\n| `DYNAMIC_LIST of OBJECT { link: LINK }` | `LIST_OF_LINK` (if just label+href) or `COMPONENT_LIST` (if more fields) |\n| `DYNAMIC_LIST of OBJECT { link, image }` | `COMPONENT_LIST` + child component |\n| `DYNAMIC_LIST of OBJECT { mainlink, sublinks: LIST_OF_LINK }` | `LIST_OF_LINK` (subLinks are native) |\n| `DYNAMIC_LIST of OBJECT { mainlink, sublinks: DYNAMIC_LIST of OBJECT { links, images, cols } }` | `COMPONENT_LIST` + `MenuItem` child + nested `COMPONENT_LIST` for columns |\n\n## See Also\n- `get_migration_guide(\"component-composition-decision-guide\")` — when COMPONENT_LIST is overkill vs. repeated scalars vs. domain LIST types\n- `get_migration_guide(\"prop-runtime-shapes\")` — exact shapes for each link type\n- `get_migration_guide(\"component-renderer-limitations\")` — COMPONENT_LIST constraints\n- `get_framework_guide(\"navigation-patterns\")` — Router.navigate usage, link handling",
233
+ "content": "Several prop types can represent \"links\" in the new system. Choosing the right one depends on the shape of the data and what the store owner should control.\n\n## Decision Tree\n\n**Is it a single link?**\n→ Use `LINK`. Runtime: `IkasNavigationLink { href, label, subLinks, openInNewTab, ... }`\n\n**Is it a flat list of sibling links (no extra data per link)?**\n→ Use `LIST_OF_LINK`. Runtime: `IkasNavigationLinkList` with `.links: IkasNavigationLink[]`\n\n**Is it a hierarchical menu (links with sub-links, no extra data)?**\n→ Use `LIST_OF_LINK`. Each `IkasNavigationLink` already has `.subLinks: IkasNavigationLink[]`. The editor handles sub-link nesting natively.\n\n**Is it a list of links WITH extra per-link data (image, color, badge, mega-menu content)?**\n→ Use `COMPONENT_LIST` with a child component that has one `LINK` prop + the extra fields.\n\n**Is it a list of products/categories/blogs (not generic links)?**\n→ Use the specific prop type (`PRODUCT_LIST`, `CATEGORY_LIST`, `BLOG_LIST`), NOT `LIST_OF_LINK`.\n\n## Examples\n\n### Footer bottom bar: \"Privacy | Terms | Contact\"\nSimple flat list → `LIST_OF_LINK`:\n```json\n{ \"name\": \"bottomLinks\", \"type\": \"LIST_OF_LINK\" }\n```\nAccess: `props.bottomLinks.links.map(link => <a href={link.href}>{link.label}</a>)`\n\n### Main nav menu with dropdowns: \"Women (→ Tops, Bottoms, Shoes)\"\nHierarchical but just labels + links → `LIST_OF_LINK` (sub-links built-in):\n```json\n{ \"name\": \"menu\", \"type\": \"LIST_OF_LINK\" }\n```\nAccess: `props.menu.links.map(link => (<div>{link.label}{link.subLinks?.map(...)}</div>))`\n\n### Mega menu with images per column\nLinks PLUS image + custom layout per menu item → `COMPONENT_LIST`:\n```json\n// Parent:\n{ \"name\": \"menuItems\", \"type\": \"COMPONENT_LIST\", \"filteredComponentIds\": [\"my-theme-mega-menu-item\"] }\n// Child MegaMenuItem:\n{ \"props\": [\n { \"name\": \"mainLink\", \"type\": \"LINK\" },\n { \"name\": \"columnImage\", \"type\": \"IMAGE\" },\n { \"name\": \"subLinks\", \"type\": \"LIST_OF_LINK\" }\n]}\n```\n\n### Social icons: \"Instagram | Facebook | Twitter\" each with its own icon image\nList of links with an image each → `COMPONENT_LIST`:\n```json\n// Parent:\n{ \"name\": \"socials\", \"type\": \"COMPONENT_LIST\", \"filteredComponentIds\": [\"my-theme-social-link\"] }\n// Child SocialLink:\n{ \"props\": [\n { \"name\": \"link\", \"type\": \"LINK\" },\n { \"name\": \"icon\", \"type\": \"IMAGE\" }\n]}\n```\n\n## Old CUSTOM → Which Link Prop?\n\nWhen migrating an old CUSTOM/DYNAMIC_LIST:\n\n| Old customData shape | New prop |\n|----------------------|----------|\n| `OBJECT { link: LINK }` | `LINK` (flatten) |\n| `DYNAMIC_LIST of OBJECT { link: LINK }` | `LIST_OF_LINK` (if just label+href) or `COMPONENT_LIST` (if more fields) |\n| `DYNAMIC_LIST of OBJECT { link, image }` | `COMPONENT_LIST` + child component |\n| `DYNAMIC_LIST of OBJECT { mainlink, sublinks: LIST_OF_LINK }` | `LIST_OF_LINK` (subLinks are native) |\n| `DYNAMIC_LIST of OBJECT { mainlink, sublinks: DYNAMIC_LIST of OBJECT { links, images, cols } }` | `COMPONENT_LIST` + `MenuItem` child + nested `COMPONENT_LIST` for columns |\n\n## Authoring the default value (config shape)\n\nWhichever type you pick, the `defaultValue` in `ikas.config.json` is the typed object — NOT the runtime `.href` read shape, and NOT a JSON string:\n- `LINK` → `{ \"linkType\": \"EXTERNAL\", \"label\": \"Shop\", \"externalLink\": \"https://…\", \"subLinks\": [] }` (or `\"linkType\": \"PAGE\"` + `\"pageType\"`; `\"FILE\"` + `\"fileUrl\"`).\n- `LIST_OF_LINK` → `{ \"links\": [ { \"linkType\": \"PAGE\", \"label\": \"Home\", \"pageType\": \"INDEX\", \"subLinks\": [] } ] }`.\n\nThe legacy `{ label, href }` shape is rejected by the CLI. Full reference: `get_framework_guide(\"prop-types\")` → \"LINK and LIST_OF_LINK props\".\n\n## See Also\n- `get_migration_guide(\"component-composition-decision-guide\")` — when COMPONENT_LIST is overkill vs. repeated scalars vs. domain LIST types\n- `get_migration_guide(\"prop-runtime-shapes\")` — exact shapes for each link type\n- `get_migration_guide(\"component-renderer-limitations\")` — COMPONENT_LIST constraints\n- `get_framework_guide(\"navigation-patterns\")` — Router.navigate usage, link handling",
234
234
  "tags": [
235
235
  "migration",
236
236
  "LINK",
@@ -10,13 +10,14 @@ import {
10
10
  IkasBundleSettings,
11
11
  IkasStorefrontConfig,
12
12
  } from "@ikas/bp-storefront";
13
- import { useState } from "preact/hooks";
13
+ import { useEffect, useState } from "preact/hooks";
14
14
  import { Props } from "./types";
15
15
  import Button from "../../sub-components/Button";
16
16
  import QuantitySelector from "../../sub-components/QuantitySelector";
17
17
  import PayWithIkas from "./components/PayWithIkas";
18
18
  import { isBundleOutOfStock } from "../../utils/bundle";
19
19
  import { validateOptionSet } from "../../utils/optionSet";
20
+ import { getProductCartLimits } from "../../utils/cartLimits";
20
21
  import { showToast } from "../../utils/toast";
21
22
 
22
23
  export function ProductDetailAddToCart({
@@ -37,6 +38,12 @@ export function ProductDetailAddToCart({
37
38
  const [quantity, setQuantity] = useState(1);
38
39
  const [isAddingToCart, setIsAddingToCart] = useState(false);
39
40
 
41
+ // Seed the quantity to the product's minimum-per-cart when the product changes.
42
+ useEffect(() => {
43
+ if (!product) return;
44
+ setQuantity(getProductCartLimits(product).min);
45
+ }, [product?.id]);
46
+
40
47
  if (!product) return null;
41
48
 
42
49
  const editLineID =
@@ -59,6 +66,8 @@ export function ProductDetailAddToCart({
59
66
 
60
67
  const isDisabled = isOutOfStock || isAddingToCart;
61
68
 
69
+ const { min: minQuantity, max: maxQuantity } = getProductCartLimits(product);
70
+
62
71
  const payWithIkasUrl = IkasStorefrontConfig.getPayWithIkasUrl();
63
72
  const routing = IkasStorefrontConfig.getCurrentRouting();
64
73
  const showPayWithIkas =
@@ -119,7 +128,12 @@ export function ProductDetailAddToCart({
119
128
  >
120
129
  <div className="kombos-pd-atc__actions">
121
130
  {!hideQuantityInput && !isOutOfStock && (
122
- <QuantitySelector value={quantity} onChange={setQuantity} />
131
+ <QuantitySelector
132
+ value={quantity}
133
+ onChange={setQuantity}
134
+ min={minQuantity}
135
+ max={maxQuantity}
136
+ />
123
137
  )}
124
138
  <Button
125
139
  variant="primary"
@@ -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,23 @@
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, so
12
+ * applying these in the stepper keeps the UI honest instead of letting the
13
+ * quantity snap silently.
14
+ */
15
+ export function getProductCartLimits(product: IkasProduct): CartQuantityLimits {
16
+ const channel = product.salesChannels?.find(
17
+ (sc) => sc.id === IkasStorefrontConfig.salesChannelId,
18
+ );
19
+ return {
20
+ min: channel?.minQuantityPerCart ?? 1,
21
+ max: channel?.maxQuantityPerCart ?? undefined,
22
+ };
23
+ }
@@ -14,6 +14,8 @@ import {
14
14
  getOrderLineItemFormattedOverridenPriceWithQuantity,
15
15
  hasOrderLineItemDiscount,
16
16
  getOrderLineItemOverridenPriceWithQuantity,
17
+ cartStore,
18
+ isOrderLineItemAutoCreated,
17
19
  } from "@ikas/bp-storefront";
18
20
  import { resolveAspectRatio, resolveObjectFit } from "../../utils/media";
19
21
  import { cx } from "../../utils/cx";
@@ -25,6 +27,7 @@ interface CartItemProps {
25
27
  variant?: "sidebar" | "page";
26
28
  aspectRatio?: AspectRatio;
27
29
  objectFit?: ObjectFit;
30
+ giftLabel?: string;
28
31
  }
29
32
 
30
33
  const CartItem = observer(function CartItem({
@@ -33,9 +36,25 @@ const CartItem = observer(function CartItem({
33
36
  variant = "sidebar",
34
37
  aspectRatio,
35
38
  objectFit,
39
+ giftLabel = "Gift",
36
40
  }: CartItemProps) {
37
41
  const isPage = variant === "page";
38
42
 
43
+ // Lines a campaign added automatically (e.g. a "buy 1 get 1" gift) cannot be
44
+ // edited or removed on their own, so render them read-only with a badge.
45
+ const isAutoCreated =
46
+ !!cartStore.cart && isOrderLineItemAutoCreated(cartStore.cart, item);
47
+ const giftBadge = isAutoCreated ? (
48
+ <span className="kombos-cart-item__gift-badge text-xs-medium">
49
+ {giftLabel}
50
+ </span>
51
+ ) : null;
52
+ const staticQuantity = (
53
+ <span className="kombos-cart-item__qty-static text-sm-medium">
54
+ ×{item.quantity}
55
+ </span>
56
+ );
57
+
39
58
  const mediaStyle: Record<string, string> = {
40
59
  aspectRatio: resolveAspectRatio(aspectRatio),
41
60
  objectFit: resolveObjectFit(objectFit),
@@ -103,6 +122,7 @@ const CartItem = observer(function CartItem({
103
122
  <a href={href} className={nameClass}>
104
123
  {item.variant?.name}
105
124
  </a>
125
+ {giftBadge}
106
126
  {variantValues.length > 0 && (
107
127
  <p className="kombos-cart-item__variant-text text-sm-regular">
108
128
  {variantValues
@@ -113,19 +133,22 @@ const CartItem = observer(function CartItem({
113
133
  </p>
114
134
  )}
115
135
  </div>
116
- <button
117
- className="kombos-cart-item__remove"
118
- onClick={() => onRemove(item)}
119
- aria-label="Remove"
120
- >
121
- <TrashSVG />
122
- </button>
136
+ {!isAutoCreated && (
137
+ <button
138
+ className="kombos-cart-item__remove"
139
+ onClick={() => onRemove(item)}
140
+ aria-label="Remove"
141
+ >
142
+ <TrashSVG />
143
+ </button>
144
+ )}
123
145
  </div>
124
146
  ) : (
125
147
  <>
126
148
  <a href={href} className={nameClass}>
127
149
  {item.variant?.name}
128
150
  </a>
151
+ {giftBadge}
129
152
 
130
153
  {variantValues.length > 0 && (
131
154
  <div className="kombos-cart-item__variants">
@@ -151,11 +174,15 @@ const CartItem = observer(function CartItem({
151
174
 
152
175
  {isPage ? (
153
176
  <div className="kombos-cart-item__bottom">
154
- <QuantitySelector
155
- size="sm"
156
- value={item.quantity}
157
- onChange={(qty) => changeItemQuantity(item, qty)}
158
- />
177
+ {isAutoCreated ? (
178
+ staticQuantity
179
+ ) : (
180
+ <QuantitySelector
181
+ size="sm"
182
+ value={item.quantity}
183
+ onChange={(qty) => changeItemQuantity(item, qty)}
184
+ />
185
+ )}
159
186
  <div className="kombos-cart-item__prices">
160
187
  {hasDiscount && (
161
188
  <span className="kombos-cart-item__original-price text-sm-regular-strike">
@@ -193,18 +220,24 @@ const CartItem = observer(function CartItem({
193
220
  </div>
194
221
 
195
222
  <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>
223
+ {isAutoCreated ? (
224
+ staticQuantity
225
+ ) : (
226
+ <>
227
+ <QuantitySelector
228
+ size="sm"
229
+ value={item.quantity}
230
+ onChange={(qty) => changeItemQuantity(item, qty)}
231
+ />
232
+ <button
233
+ className="kombos-cart-item__remove"
234
+ onClick={() => onRemove(item)}
235
+ aria-label="Remove"
236
+ >
237
+ <TrashSVG />
238
+ </button>
239
+ </>
240
+ )}
208
241
  </div>
209
242
  </>
210
243
  )}
@@ -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 />
@@ -14,6 +14,8 @@ import {
14
14
  getOrderLineItemFormattedOverridenPriceWithQuantity,
15
15
  hasOrderLineItemDiscount,
16
16
  getOrderLineItemOverridenPriceWithQuantity,
17
+ cartStore,
18
+ isOrderLineItemAutoCreated,
17
19
  } from "@ikas/bp-storefront";
18
20
  import { resolveAspectRatio, resolveObjectFit } from "../../utils/media";
19
21
  import { cx } from "../../utils/cx";
@@ -25,6 +27,7 @@ interface CartItemProps {
25
27
  variant?: "sidebar" | "page";
26
28
  aspectRatio?: AspectRatio;
27
29
  objectFit?: ObjectFit;
30
+ giftLabel?: string;
28
31
  }
29
32
 
30
33
  const CartItem = observer(function CartItem({
@@ -33,9 +36,25 @@ const CartItem = observer(function CartItem({
33
36
  variant = "sidebar",
34
37
  aspectRatio,
35
38
  objectFit,
39
+ giftLabel = "Gift",
36
40
  }: CartItemProps) {
37
41
  const isPage = variant === "page";
38
42
 
43
+ // Lines a campaign added automatically (e.g. a "buy 1 get 1" gift) cannot be
44
+ // edited or removed on their own, so render them read-only with a badge.
45
+ const isAutoCreated =
46
+ !!cartStore.cart && isOrderLineItemAutoCreated(cartStore.cart, item);
47
+ const giftBadge = isAutoCreated ? (
48
+ <span className="kombos-cart-item__gift-badge text-xs-medium">
49
+ {giftLabel}
50
+ </span>
51
+ ) : null;
52
+ const staticQuantity = (
53
+ <span className="kombos-cart-item__qty-static text-sm-medium">
54
+ ×{item.quantity}
55
+ </span>
56
+ );
57
+
39
58
  const mediaStyle: Record<string, string> = {
40
59
  aspectRatio: resolveAspectRatio(aspectRatio),
41
60
  objectFit: resolveObjectFit(objectFit),
@@ -103,6 +122,7 @@ const CartItem = observer(function CartItem({
103
122
  <a href={href} className={nameClass}>
104
123
  {item.variant?.name}
105
124
  </a>
125
+ {giftBadge}
106
126
  {variantValues.length > 0 && (
107
127
  <p className="kombos-cart-item__variant-text text-sm-regular">
108
128
  {variantValues
@@ -113,19 +133,22 @@ const CartItem = observer(function CartItem({
113
133
  </p>
114
134
  )}
115
135
  </div>
116
- <button
117
- className="kombos-cart-item__remove"
118
- onClick={() => onRemove(item)}
119
- aria-label="Remove"
120
- >
121
- <TrashSVG />
122
- </button>
136
+ {!isAutoCreated && (
137
+ <button
138
+ className="kombos-cart-item__remove"
139
+ onClick={() => onRemove(item)}
140
+ aria-label="Remove"
141
+ >
142
+ <TrashSVG />
143
+ </button>
144
+ )}
123
145
  </div>
124
146
  ) : (
125
147
  <>
126
148
  <a href={href} className={nameClass}>
127
149
  {item.variant?.name}
128
150
  </a>
151
+ {giftBadge}
129
152
 
130
153
  {variantValues.length > 0 && (
131
154
  <div className="kombos-cart-item__variants">
@@ -151,11 +174,15 @@ const CartItem = observer(function CartItem({
151
174
 
152
175
  {isPage ? (
153
176
  <div className="kombos-cart-item__bottom">
154
- <QuantitySelector
155
- size="sm"
156
- value={item.quantity}
157
- onChange={(qty) => changeItemQuantity(item, qty)}
158
- />
177
+ {isAutoCreated ? (
178
+ staticQuantity
179
+ ) : (
180
+ <QuantitySelector
181
+ size="sm"
182
+ value={item.quantity}
183
+ onChange={(qty) => changeItemQuantity(item, qty)}
184
+ />
185
+ )}
159
186
  <div className="kombos-cart-item__prices">
160
187
  {hasDiscount && (
161
188
  <span className="kombos-cart-item__original-price text-sm-regular-strike">
@@ -193,18 +220,24 @@ const CartItem = observer(function CartItem({
193
220
  </div>
194
221
 
195
222
  <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>
223
+ {isAutoCreated ? (
224
+ staticQuantity
225
+ ) : (
226
+ <>
227
+ <QuantitySelector
228
+ size="sm"
229
+ value={item.quantity}
230
+ onChange={(qty) => changeItemQuantity(item, qty)}
231
+ />
232
+ <button
233
+ className="kombos-cart-item__remove"
234
+ onClick={() => onRemove(item)}
235
+ aria-label="Remove"
236
+ >
237
+ <TrashSVG />
238
+ </button>
239
+ </>
240
+ )}
208
241
  </div>
209
242
  </>
210
243
  )}
@@ -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 />
@@ -39,6 +39,7 @@
39
39
  "<id-of-ProductDetailProductGroup>",
40
40
  "<id-of-ProductDetailVariant>",
41
41
  "<id-of-ProductDetailAddToCart>",
42
+ "<id-of-ProductDetailBackInStock>",
42
43
  "<id-of-ProductDetailFeatures>",
43
44
  "<id-of-ProductDetailDescription>",
44
45
  "<id-of-ProductDetailBundleProduct>",