@ikas/code-components-mcp 2.6.1 → 2.8.0
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 +68 -8
- package/data/migration.json +9 -3
- package/data/section-templates/add-to-cart/index.tsx +16 -2
- package/data/section-templates/add-to-cart/sub-components/QuantitySelector/index.tsx +11 -3
- package/data/section-templates/add-to-cart/utils/cartLimits.ts +23 -0
- package/data/section-templates/bundle-products/hooks/useBundleProducts.ts +7 -6
- package/data/section-templates/cart-section/sub-components/CartItem/index.tsx +57 -24
- package/data/section-templates/cart-section/sub-components/CartItem/styles.css +14 -0
- package/data/section-templates/cart-section/sub-components/QuantitySelector/index.tsx +11 -3
- package/data/section-templates/header-section/sub-components/CartItem/index.tsx +57 -24
- package/data/section-templates/header-section/sub-components/CartItem/styles.css +14 -0
- package/data/section-templates/header-section/sub-components/QuantitySelector/index.tsx +11 -3
- package/data/section-templates/image-handling/ikas-config-snippet.json +1 -0
- package/data/section-templates/navigation/sub-components/CartItem/index.tsx +57 -24
- package/data/section-templates/navigation/sub-components/CartItem/styles.css +14 -0
- package/data/section-templates/navigation/sub-components/QuantitySelector/index.tsx +11 -3
- package/data/section-templates/product-detail-section/children/ProductDetailAddToCart/index.tsx +16 -2
- package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/components/BackInStockForm/index.tsx +92 -0
- package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/ikas-config-snippet.json +159 -0
- package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/index.tsx +141 -0
- package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/styles.css +47 -0
- package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/types.ts +25 -0
- package/data/section-templates/product-detail-section/hooks/useBundleProducts.ts +7 -6
- package/data/section-templates/product-detail-section/ikas-config-snippet.json +1 -0
- package/data/section-templates/product-detail-section/sub-components/Modal/index.tsx +120 -0
- package/data/section-templates/product-detail-section/sub-components/Modal/styles.css +70 -0
- package/data/section-templates/product-detail-section/sub-components/QuantitySelector/index.tsx +11 -3
- package/data/section-templates/product-detail-section/utils/cartLimits.ts +23 -0
- package/data/storefront-api.json +82 -12
- package/data/storefront-types.json +2747 -22
- package/dist/index.js +582 -179
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/data/migration.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"prop-type-mapping": {
|
|
18
18
|
"title": "Prop Type Mapping: Old → New",
|
|
19
19
|
"description": "Complete mapping of old theme.json prop types to new ikas.config.json prop types",
|
|
20
|
-
"content": "## IMPORTANT: Different Systems, Different Types\n\n**The old prop type system (theme.json) and the new prop type system (ikas.config.json) are completely separate.** Even when a prop type has the same name in both systems (e.g., `IMAGE`, `LINK`, `PRODUCT_LIST`), the underlying TypeScript types come from different packages (`@ikas/storefront` vs `@ikas/bp-storefront`) and may have different properties, methods, and nullability. Always use the MCP tools (`get_type_definition`, `get_model_guide`) to look up the exact new-system types — never assume old-system knowledge applies.\n\n## Direct Mappings (1:1)\n\nThese prop types have similar equivalents, but remember the TypeScript types are from different packages:\n\n| Old Type | New Type | TypeScript (Old) | TypeScript (New) | Notes |\n|----------|----------|-----------------|-----------------|-------|\n| `TEXT` | `TEXT` | `string` | `string` | |\n| `RICH_TEXT` | `RICH_TEXT` | `string` | `string` | |\n| `BOOLEAN` | `BOOLEAN` | `boolean` | `boolean` | |\n| `IMAGE` | `IMAGE` | `IkasImage` | `IkasImage \\| null` | New type is nullable |\n| `IMAGE_LIST` | `IMAGE_LIST` | `IkasImage[]` | `IkasImageList` | Slight type difference |\n| `LINK` | `LINK` | `IkasNavigationLink` | `IkasNavigationLink` | |\n| `LIST_OF_LINK` | `LIST_OF_LINK` | `IkasNavigationLink[]` | `IkasNavigationLinkList` | Slight type difference |\n| `COLOR` | `COLOR` | `string` | `string` | |\n| `VIDEO` | `VIDEO` | `IkasVideo` | `IkasVideo \\| null` | New type is nullable |\n| `PRODUCT_LIST` | `PRODUCT_LIST` | `IkasProductList` | `IkasProductList` | |\n| `CATEGORY` | `CATEGORY` | `IkasCategory` | `IkasCategory \\| null` | |\n| `CATEGORY_LIST` | `CATEGORY_LIST` | `IkasCategoryList` | `IkasCategoryList` | |\n| `BRAND` | `BRAND` | `IkasBrand` | `IkasBrand \\| null` | |\n| `BRAND_LIST` | `BRAND_LIST` | — | `IkasBrandList` | |\n| `BLOG` | `BLOG` | `IkasBlog` | `IkasBlog \\| null` | |\n| `BLOG_LIST` | `BLOG_LIST` | `IkasBlogList` | `IkasBlogList` | |\n| `BLOG_CATEGORY` | `BLOG_CATEGORY` | `IkasBlogCategory` | `IkasBlogCategory \\| null` | |\n| `BLOG_CATEGORY_LIST` | `BLOG_CATEGORY_LIST` | `IkasBlogCategoryList` | `IkasBlogCategoryList` | |\n| `PRODUCT_ATTRIBUTE` | `PRODUCT_ATTRIBUTE` | `IkasAttributeDetail` | `
|
|
20
|
+
"content": "## IMPORTANT: Different Systems, Different Types\n\n**The old prop type system (theme.json) and the new prop type system (ikas.config.json) are completely separate.** Even when a prop type has the same name in both systems (e.g., `IMAGE`, `LINK`, `PRODUCT_LIST`), the underlying TypeScript types come from different packages (`@ikas/storefront` vs `@ikas/bp-storefront`) and may have different properties, methods, and nullability. Always use the MCP tools (`get_type_definition`, `get_model_guide`) to look up the exact new-system types — never assume old-system knowledge applies.\n\n## Direct Mappings (1:1)\n\nThese prop types have similar equivalents, but remember the TypeScript types are from different packages:\n\n| Old Type | New Type | TypeScript (Old) | TypeScript (New) | Notes |\n|----------|----------|-----------------|-----------------|-------|\n| `TEXT` | `TEXT` | `string` | `string` | |\n| `RICH_TEXT` | `RICH_TEXT` | `string` | `string` | |\n| `BOOLEAN` | `BOOLEAN` | `boolean` | `boolean` | |\n| `IMAGE` | `IMAGE` | `IkasImage` | `IkasImage \\| null` | New type is nullable |\n| `IMAGE_LIST` | `IMAGE_LIST` | `IkasImage[]` | `IkasImageList` | Slight type difference |\n| `LINK` | `LINK` | `IkasNavigationLink` | `IkasNavigationLink` | |\n| `LIST_OF_LINK` | `LIST_OF_LINK` | `IkasNavigationLink[]` | `IkasNavigationLinkList` | Slight type difference |\n| `COLOR` | `COLOR` | `string` | `string` | |\n| `VIDEO` | `VIDEO` | `IkasVideo` | `IkasVideo \\| null` | New type is nullable |\n| `PRODUCT_LIST` | `PRODUCT_LIST` | `IkasProductList` | `IkasProductList` | |\n| `CATEGORY` | `CATEGORY` | `IkasCategory` | `IkasCategory \\| null` | |\n| `CATEGORY_LIST` | `CATEGORY_LIST` | `IkasCategoryList` | `IkasCategoryList` | |\n| `BRAND` | `BRAND` | `IkasBrand` | `IkasBrand \\| null` | |\n| `BRAND_LIST` | `BRAND_LIST` | — | `IkasBrandList` | |\n| `BLOG` | `BLOG` | `IkasBlog` | `IkasBlog \\| null` | |\n| `BLOG_LIST` | `BLOG_LIST` | `IkasBlogList` | `IkasBlogList` | |\n| `BLOG_CATEGORY` | `BLOG_CATEGORY` | `IkasBlogCategory` | `IkasBlogCategory \\| null` | |\n| `BLOG_CATEGORY_LIST` | `BLOG_CATEGORY_LIST` | `IkasBlogCategoryList` | `IkasBlogCategoryList` | |\n| `PRODUCT_ATTRIBUTE` | `PRODUCT_ATTRIBUTE` | `IkasAttributeDetail` | `IkasProductAttributeDetail \\| null` | Renamed only — same wrapper shape `{ product, attributePropValue }`; read values with `getAttributeDetailValues()` |\n| `PRODUCT_ATTRIBUTE_LIST` | `PRODUCT_ATTRIBUTE_LIST` | `IkasAttributeList` | `IkasProductAttributeList` | Renamed only — still a single wrapper object (NOT an array); read values with `getAttributeListValues()` |\n| `COMPONENT_LIST` | `COMPONENT_LIST` | `IkasComponentRenderer` | `any` (rendered via `<IkasComponentRenderer>`) | |\n\n## Renamed Types\n\n| Old Type | New Type | Notes |\n|----------|----------|-------|\n| `PRODUCT_DETAIL` | `PRODUCT` | Same concept, just renamed. Old: `IkasProduct`, New: `IkasProduct \\| null` |\n\n## Types Requiring Conversion\n\n### SLIDER → NUMBER\nOld `SLIDER` type used `IkasSlider` with `{ value: number }`. The new system uses `NUMBER` which maps to plain `number`.\n\n**Old theme.json:**\n```json\n{\n \"name\": \"logoWidth\",\n \"type\": \"SLIDER\",\n \"sliderData\": { \"min\": 80, \"max\": 300, \"interval\": 1 }\n}\n```\n\n**New ikas.config.json:**\n```json\n{\n \"name\": \"logoWidth\",\n \"displayName\": \"Logo Width\",\n \"type\": \"NUMBER\",\n \"required\": false,\n \"defaultValue\": 120\n}\n```\n\n**Code change:** Replace `logoWidth?.value` or `logoWidth.value` with just `logoWidth` (it's already a number).\n\n### CUSTOM → Multiple approaches\nSee `get_migration_guide(\"custom-data-conversion\")` for the full guide. Summary:\n\n| Old customData type | New approach |\n|--------------------|--------------|\n| DYNAMIC_LIST (list of objects) | `COMPONENT_LIST` + child component |\n| OBJECT (≤3 simple fields) | Flatten into direct props on parent |\n| OBJECT (>3 fields or complex) | `COMPONENT_LIST` + child component |\n| STATIC_LIST (fixed count items) | `COMPONENT_LIST` (store owner manages count) |\n| ENUM (enumOptions) | `ENUM` with custom enum via `config add-enum` |\n\n### ENUM → ENUM\nOld ENUM type with `enumOptions` maps to new ENUM with `enumTypeId`. Create custom enums first:\n```bash\nnpx ikas-component config add-enum --name \"AspectRatio\" --options '{\"Square\":\"1:1\",\"Landscape\":\"16:9\",\"Portrait\":\"3:4\"}'\n# Use returned enumId in prop definition\n```\n\n## Types Not Available in New System\n\nThese old types do not exist in the new system and need workarounds:\n\n| Old Type | Workaround |\n|----------|------------|\n| `SLIDER` | Use `NUMBER`. Access value directly instead of `.value` |\n| `OBJECT` (customData) | Flatten into parent props or use COMPONENT_LIST |\n| `STATIC_LIST` (customData) | Use COMPONENT_LIST |\n\n## New Types Not in Old System\n\n| New Type | Description |\n|----------|-------------|\n| `NUMBER` | Numeric input (replaces SLIDER) |\n| `NUMBER_RANGE` | Two-number range (IkasNumberRange) |\n| `DATE` | Date picker |\n| `FUNCTION` | Function prop (callback) |\n| `TYPE` | Structured style type (padding, margin, etc.) |\n| `COMPONENT` | Single child component slot |\n| `RAFFLE` / `RAFFLE_LIST` | Raffle references |",
|
|
21
21
|
"tags": [
|
|
22
22
|
"migration",
|
|
23
23
|
"props",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"storefront-import-mapping": {
|
|
89
89
|
"title": "Import Mapping: @ikas/storefront → @ikas/bp-storefront",
|
|
90
90
|
"description": "Maps old @ikas/storefront imports to new @ikas/bp-storefront equivalents",
|
|
91
|
-
"content": "## Old Package → New Package\n\nAll imports change from `@ikas/storefront` to `@ikas/bp-storefront`. **This is NOT a find-and-replace.** Even types with the same name (`IkasImage`, `IkasProduct`, etc.) come from different packages and may have different shapes. Always verify with `get_type_definition` or `get_model_guide` before assuming compatibility.\n\n## Type Rename Table\n\n| Old Import (`@ikas/storefront`) | New Import (`@ikas/bp-storefront`) | Notes |\n|-----------|-----------|-------|\n| `IkasImage`, `IkasVideo`, `IkasNavigationLink`, `IkasProduct`, `IkasProductList`, `IkasCategory`, `IkasCategoryList`, `IkasBrand`, `IkasBlog`, `IkasBlogList`, `IkasBlogCategory`, `IkasBlogCategoryList` | Same name | Different package — shape may differ; verify before assuming. |\n| `IkasSlider` | *(removed)* | Use plain `number`. |\n| `IkasAttributeDetail` | `
|
|
91
|
+
"content": "## Old Package → New Package\n\nAll imports change from `@ikas/storefront` to `@ikas/bp-storefront`. **This is NOT a find-and-replace.** Even types with the same name (`IkasImage`, `IkasProduct`, etc.) come from different packages and may have different shapes. Always verify with `get_type_definition` or `get_model_guide` before assuming compatibility.\n\n## Type Rename Table\n\n| Old Import (`@ikas/storefront`) | New Import (`@ikas/bp-storefront`) | Notes |\n|-----------|-----------|-------|\n| `IkasImage`, `IkasVideo`, `IkasNavigationLink`, `IkasProduct`, `IkasProductList`, `IkasCategory`, `IkasCategoryList`, `IkasBrand`, `IkasBlog`, `IkasBlogList`, `IkasBlogCategory`, `IkasBlogCategoryList` | Same name | Different package — shape may differ; verify before assuming. |\n| `IkasSlider` | *(removed)* | Use plain `number`. |\n| `IkasAttributeDetail` | `IkasProductAttributeDetail` | Renamed only; still the `{ product, attributePropValue }` wrapper. `IkasProductAttributeValue` is a DIFFERENT type — the resolved value returned by `getAttributeDetailValues()`. |\n| `IkasAttributeList` | `IkasProductAttributeList` | Renamed only; still a single `{ product, attributeListPropValue }` wrapper, NOT an array. Values come from `getAttributeListValues()`. |\n| `IkasComponentRenderer` | `IkasComponentRenderer` | Same name, different usage — see below. |\n\n## Function / Pattern Rename Table\n\n| Old | New | Notes |\n|-----|-----|-------|\n| `<Image>` component | `getDefaultSrc()` / `getSrc()` + native `<img>` | See `react-to-preact` for image patterns. |\n| `useStore()` hook | Direct imports (`cartStore`, `customerStore`, ...) | Stores are MobX observables; no wrapper hook. |\n| `<IkasComponentRenderer>{children}</...>` | `<IkasComponentRenderer id=\"...\" components={list as any[]} parentProps={props} />` | New props-based API. |\n\nFor all import/observer/hook/event-handling behavior changes, see `get_migration_guide(\"react-to-preact\")` — this topic is intentionally a quick rename reference.\n",
|
|
92
92
|
"tags": [
|
|
93
93
|
"migration",
|
|
94
94
|
"imports",
|
|
@@ -212,13 +212,19 @@
|
|
|
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` | `
|
|
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` | `IkasProductAttributeDetail \\| null` | Wrapper `{ product, attributePropValue }` — **NOT** the value; `getAttributeDetailValues(prop)` |\n| `PRODUCT_ATTRIBUTE_LIST` | `IkasProductAttributeList` | Wrapper `{ product, attributeListPropValue }` — a single object, **NOT** an array; `getAttributeListValues(prop)` |\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## Three shapes, one prop (PRODUCT_ATTRIBUTE / PRODUCT_ATTRIBUTE_LIST)\n\nSame trap as LINK, but worse: the prop you receive is a **wrapper**, not a value, and accessing the value you expect fails silently — `check`/`build` pass, every field is `undefined`, the component renders nothing. The editor stores `{ attributeId, usePageData }`, your component receives the `{ product, attributePropValue }` wrapper, and the values only come out of the helpers:\n\n```tsx\nimport { getAttributeDetailValues, getAttributeListValues } from \"@ikas/bp-storefront\";\n\n// WRONG — no such field at runtime; compiles clean, renders nothing\nreturn attribute?.value ?? null;\n\n// RIGHT — PRODUCT_ATTRIBUTE\nif (!attribute?.attributePropValue?.attributeId) return null;\nconst html = getAttributeDetailValues(attribute).map((v) => v.value).filter(Boolean).join(\"\");\n\n// RIGHT — PRODUCT_ATTRIBUTE_LIST (a single object, never `.map` it directly)\nconst groups = getAttributeListValues(attributeList); // IkasProductAttributeMap[]: { attribute, values }\n```\n\nFull reference: `get_framework_guide(\"prop-types\")` → \"PRODUCT_ATTRIBUTE and PRODUCT_ATTRIBUTE_LIST 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` → a single wrapper object (NOT an array): `.attributeListPropValue.attributeIds` holds the selection; values come from `getAttributeListValues(prop)` (grouped `{ attribute, values }` maps)\n- `PRODUCT_ATTRIBUTE` → a single wrapper object too: `.attributePropValue.attributeId`; values come from `getAttributeDetailValues(prop)`\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 `IkasProductAttributeDetail` / `IkasProductAttributeList`. **Rename only — the wrapper shape did NOT change.** See `get_framework_guide(\"prop-types\")` → \"PRODUCT_ATTRIBUTE and PRODUCT_ATTRIBUTE_LIST props\".\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",
|
|
219
219
|
"shapes",
|
|
220
220
|
"PRODUCT_LIST",
|
|
221
221
|
"LIST_OF_LINK",
|
|
222
|
+
"PRODUCT_ATTRIBUTE",
|
|
223
|
+
"PRODUCT_ATTRIBUTE_LIST",
|
|
224
|
+
"IkasProductAttributeDetail",
|
|
225
|
+
"IkasProductAttributeList",
|
|
226
|
+
"getAttributeDetailValues",
|
|
227
|
+
"getAttributeListValues",
|
|
222
228
|
"IkasCart",
|
|
223
229
|
"getDefaultSrc",
|
|
224
230
|
"getSrc",
|
|
@@ -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
|
|
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(
|
|
21
|
-
disabled={
|
|
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
|
+
}
|
|
@@ -9,15 +9,16 @@ import {
|
|
|
9
9
|
cartStore,
|
|
10
10
|
waitForCartStoreInit,
|
|
11
11
|
} from "@ikas/bp-storefront";
|
|
12
|
-
import { runInAction } from "mobx";
|
|
13
12
|
import { adjustBundleProductQuantity } from "../utils/bundle";
|
|
14
13
|
|
|
14
|
+
// No mobx action wrapper needed: adjustments go through the public
|
|
15
|
+
// setBundleProductQuantity API (plain mutation, same as the SDK's own usage
|
|
16
|
+
// examples), and keeping the theme free of direct mobx imports lets the
|
|
17
|
+
// scaffold install cleanly under pnpm's strict node_modules layout.
|
|
15
18
|
function adjustBundleQuantities(products: IkasBundleProduct[]) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
});
|
|
19
|
+
for (const bp of products) {
|
|
20
|
+
adjustBundleProductQuantity(bp);
|
|
21
|
+
}
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export function useBundleProducts(product: IkasProduct | null | undefined) {
|
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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(
|
|
21
|
-
disabled={
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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(
|
|
21
|
-
disabled={
|
|
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>",
|