@ikas/code-components-mcp 1.4.0-beta.140 → 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.
- package/data/framework.json +32 -2
- 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/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/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 +1 -1
- package/data/storefront-types.json +1 -1
- package/package.json +1 -1
package/data/framework.json
CHANGED
|
@@ -251,7 +251,7 @@
|
|
|
251
251
|
"product-detail-patterns": {
|
|
252
252
|
"title": "Product Detail Patterns (Serel Reference)",
|
|
253
253
|
"description": "Serel ProductDetail with 12 child components in two COMPONENT_LIST slots, ProductGallery local component, Breadcrumb navigation, and component slot architecture",
|
|
254
|
-
"content": "## Product Detail Patterns (Serel Reference)\n\nThe serel ProductDetail section is the most complex section in the theme, with **12 child components** distributed across **two COMPONENT_LIST slots** (`components` and `bottomComponents`), plus a local `ProductGallery` component and Breadcrumb navigation.\n\n### Two-Slot Architecture\n\nThe section declares two separate COMPONENT_LIST props:\n\n```json\n{\n \"props\": [\n {\n \"name\": \"components\",\n \"type\": \"COMPONENT_LIST\",\n \"filteredComponentIds\": [\n \"nb34u3yu-product-detail-name-favorite\",\n \"nb34u3yu-product-detail-sku\",\n \"nb34u3yu-product-detail-prices\",\n \"nb34u3yu-product-detail-product-group\",\n \"nb34u3yu-product-detail-variant\",\n \"nb34u3yu-product-detail-add-to-cart\",\n \"nb34u3yu-product-detail-features\",\n \"nb34u3yu-product-detail-description\",\n \"nb34u3yu-product-detail-bundle-product\",\n \"nb34u3yu-product-detail-option-set\",\n \"nb34u3yu-product-detail-offer\",\n \"nb34u3yu-product-detail-bundle-furniture\"\n ]\n },\n {\n \"name\": \"bottomComponents\",\n \"type\": \"COMPONENT_LIST\",\n \"filteredComponentIds\": [\"nb34u3yu-product-detail-description\"]\n }\n ]\n}\n```\n\n### Section Layout\n\n```tsx\nimport { IkasComponentRenderer } from \"@ikas/bp-storefront\";\nimport Breadcrumb from \"../../sub-components/Breadcrumb\";\nimport ProductGallery from \"./ProductGallery\"; // local component\nimport { Props } from \"./types\";\n\nexport function ProductDetail({ product, components, bottomComponents, backgroundColor, ...props }: Props) {\n if (!product) return null;\n return (\n <section className=\"product-detail\" style={backgroundColor ? { backgroundColor } : undefined}>\n <Breadcrumb product={product} />\n <div className=\"product-detail-inner\">\n <div className=\"product-detail-gallery\">\n <ProductGallery product={product} />\n </div>\n <div className=\"product-detail-info\">\n <IkasComponentRenderer id=\"product-detail-components\" components={components} parentProps={props} />\n </div>\n </div>\n <div className=\"product-detail-bottom\">\n <IkasComponentRenderer id=\"product-detail-bottom-components\" components={bottomComponents} parentProps={props} />\n </div>\n </section>\n );\n}\nexport default ProductDetail;\n```\n\n### 12 Child Components\n\n| Child Component | Purpose |\n|-----------------|--------|\n| ProductDetailNameFavorite | Product name + favorites toggle |\n| ProductDetailSku | SKU display |\n| ProductDetailPrices | Price display with discounts |\n| ProductDetailProductGroup | Product group/collection links |\n| ProductDetailVariant | Variant selector (size, color) |\n| ProductDetailAddToCart | Quantity selector + add-to-cart button |\n| ProductDetailFeatures | Feature list (uses FeatureItem children) |\n| ProductDetailDescription | Collapsible description (uses CollapsibleContent children) |\n| ProductDetailBundleProduct | Bundle product display |\n| ProductDetailBundleFurniture | Bundle furniture configuration |\n| ProductDetailOptionSet | Custom option sets |\n| ProductDetailOffer | Special offer display |\n\n### ProductGallery (Local Component)\n\nProductGallery is defined locally within the ProductDetail folder (not a registered component or sub-component). It handles image display with thumbnail navigation, zoom, and responsive srcset:\n\n```tsx\nconst images = (variant?.images ?? [])\n .map((pi) => pi.image)\n .filter((img): img is IkasImage => img != null);\n```\n\n### Breadcrumb Navigation\n\nThe Breadcrumb sub-component (from `src/sub-components/Breadcrumb/`) renders category path navigation:\n\n```tsx\nimport { getProductCategoryPath, getIkasCategoryHref } from \"@ikas/bp-storefront\";\nconst categoryPath = getProductCategoryPath(product);\n```\n\n### Key Pattern: Multiple COMPONENT_LIST Slots\n\nUsing two slots (`components` and `bottomComponents`) allows the store owner to arrange child components differently:\n- `components` — displayed next to the gallery in a side panel\n- `bottomComponents` — displayed full-width below the gallery\n\nThis pattern can be applied to any section that needs multiple layout regions.",
|
|
254
|
+
"content": "## Product Detail Patterns (Serel Reference)\n\nThe serel ProductDetail section is the most complex section in the theme, with **12 child components** distributed across **two COMPONENT_LIST slots** (`components` and `bottomComponents`), plus a local `ProductGallery` component and Breadcrumb navigation.\n\n### Two-Slot Architecture\n\nThe section declares two separate COMPONENT_LIST props:\n\n```json\n{\n \"props\": [\n {\n \"name\": \"components\",\n \"type\": \"COMPONENT_LIST\",\n \"filteredComponentIds\": [\n \"nb34u3yu-product-detail-name-favorite\",\n \"nb34u3yu-product-detail-sku\",\n \"nb34u3yu-product-detail-prices\",\n \"nb34u3yu-product-detail-product-group\",\n \"nb34u3yu-product-detail-variant\",\n \"nb34u3yu-product-detail-add-to-cart\",\n \"nb34u3yu-product-detail-features\",\n \"nb34u3yu-product-detail-description\",\n \"nb34u3yu-product-detail-bundle-product\",\n \"nb34u3yu-product-detail-option-set\",\n \"nb34u3yu-product-detail-offer\",\n \"nb34u3yu-product-detail-bundle-furniture\"\n ]\n },\n {\n \"name\": \"bottomComponents\",\n \"type\": \"COMPONENT_LIST\",\n \"filteredComponentIds\": [\"nb34u3yu-product-detail-description\"]\n }\n ]\n}\n```\n\n### Section Layout\n\n```tsx\nimport { IkasComponentRenderer } from \"@ikas/bp-storefront\";\nimport Breadcrumb from \"../../sub-components/Breadcrumb\";\nimport ProductGallery from \"./ProductGallery\"; // local component\nimport { Props } from \"./types\";\n\nexport function ProductDetail({ product, components, bottomComponents, backgroundColor, ...props }: Props) {\n if (!product) return null;\n return (\n <section className=\"product-detail\" style={backgroundColor ? { backgroundColor } : undefined}>\n <Breadcrumb product={product} />\n <div className=\"product-detail-inner\">\n <div className=\"product-detail-gallery\">\n <ProductGallery product={product} />\n </div>\n <div className=\"product-detail-info\">\n <IkasComponentRenderer id=\"product-detail-components\" components={components} parentProps={props} />\n </div>\n </div>\n <div className=\"product-detail-bottom\">\n <IkasComponentRenderer id=\"product-detail-bottom-components\" components={bottomComponents} parentProps={props} />\n </div>\n </section>\n );\n}\nexport default ProductDetail;\n```\n\n### 12 Child Components\n\n| Child Component | Purpose |\n|-----------------|--------|\n| ProductDetailNameFavorite | Product name + favorites toggle |\n| ProductDetailSku | SKU display |\n| ProductDetailPrices | Price display with discounts |\n| ProductDetailProductGroup | Product group/collection links |\n| ProductDetailVariant | Variant selector (size, color) |\n| ProductDetailAddToCart | Quantity selector + add-to-cart button |\n| ProductDetailFeatures | Feature list (uses FeatureItem children) |\n| ProductDetailDescription | Collapsible description (uses CollapsibleContent children) |\n| ProductDetailBundleProduct | Bundle product display |\n| ProductDetailBundleFurniture | Bundle furniture configuration |\n| ProductDetailOptionSet | Custom option sets |\n| ProductDetailOffer | Special offer display |\n\n### ProductGallery (Local Component)\n\nProductGallery is defined locally within the ProductDetail folder (not a registered component or sub-component). It handles image display with thumbnail navigation, zoom, and responsive srcset:\n\n```tsx\nconst images = (variant?.images ?? [])\n .map((pi) => pi.image)\n .filter((img): img is IkasImage => img != null);\n```\n\n### Breadcrumb Navigation\n\nThe Breadcrumb sub-component (from `src/sub-components/Breadcrumb/`) renders category path navigation:\n\n```tsx\nimport { getProductCategoryPath, getIkasCategoryHref } from \"@ikas/bp-storefront\";\nconst categoryPath = getProductCategoryPath(product);\n```\n\n### Stock Locations (informational)\n\n`getProductAvailableStockLocations(product)` and the `IkasStockLocation*` types expose per-location availability. These are **not** part of the standard product-detail purchase flow: the reference theme does not use them on the PDP, and fulfillment (which location ships or serves an order) is resolved at checkout. Build a \"find in store\" or pickup surface from them only if your store explicitly needs one; otherwise you can ignore them on the PDP.\n\n**Return-shape gotcha:** despite its TypeScript signature (`Promise<IkasProductStockLocations[]>`), at runtime `getProductAvailableStockLocations` resolves to the raw GraphQL response **object** `{ stockLocations: IkasProductStockLocations[] }` — NOT a bare array. Calling `.filter`/`.map` on the result directly throws `(...).filter is not a function`. Always unwrap first:\n\n```tsx\nconst result = await getProductAvailableStockLocations(product);\nconst rows = Array.isArray(result)\n ? result\n : ((result as { stockLocations?: IkasProductStockLocations[] })?.stockLocations ?? []);\n```\n\n### Key Pattern: Multiple COMPONENT_LIST Slots\n\nUsing two slots (`components` and `bottomComponents`) allows the store owner to arrange child components differently:\n- `components` — displayed next to the gallery in a side panel\n- `bottomComponents` — displayed full-width below the gallery\n\nThis pattern can be applied to any section that needs multiple layout regions.",
|
|
255
255
|
"tags": [
|
|
256
256
|
"product",
|
|
257
257
|
"detail",
|
|
@@ -266,6 +266,36 @@
|
|
|
266
266
|
"serel"
|
|
267
267
|
]
|
|
268
268
|
},
|
|
269
|
+
"back-in-stock-patterns": {
|
|
270
|
+
"title": "Back-in-Stock Notification Patterns",
|
|
271
|
+
"description": "Notify-me-when-available flow for out-of-stock variants: the login-required vs email-form branches, the notification form lifecycle, and the persistent reminder-saved state",
|
|
272
|
+
"content": "## Back-in-Stock Notification Patterns\n\nWhen a variant is out of stock, the add-to-cart CTA is a dead end unless you offer a \"notify me when it's back\" flow. ikas ships the full API for this, wired up in the `ProductDetailBackInStock` child of the product-detail section. Fetch it with:\n\n```\nget_section_child({ section: \"product-detail-section\", name: \"ProductDetailBackInStock\", kind: \"children\" })\n```\n\n### When to show it\n\nRender the notify-me UI only when the variant is out of stock AND back-in-stock is enabled — otherwise render nothing and let `ProductDetailAddToCart` own the CTA:\n\n```tsx\nconst selectedVariant = getSelectedProductVariant(product);\nconst isOutOfStock = !hasProductStock(product) || !hasProductVariantStock(selectedVariant);\nif (!isOutOfStock) return null;\nif (!getProductVariantIsBackInStockEnabled(selectedVariant)) return null;\n```\n\n### Two branches\n\n`getProductVariantIsBackInStockCustomerLoginRequired(variant)` selects the flow:\n\n- **Login required** — a logged-in customer is registered immediately with their own email; an anonymous visitor is sent to login first.\n- **No login required** — collect an email through the notification form.\n\n```tsx\nconst handleNotify = async () => {\n if (getProductVariantIsBackInStockCustomerLoginRequired(selectedVariant)) {\n const email = customerStore.customer?.email;\n if (email) {\n await saveProductVariantBackInStockReminder(selectedVariant, email);\n } else {\n Router.navigateToPage(\"LOGIN\");\n }\n return;\n }\n setEmailModalOpen(true); // anonymous email form\n};\n```\n\n### The email form (form lifecycle)\n\nThe no-login branch uses the standard ikas form model — get the form, init once, drive the input, submit:\n\n```tsx\nconst form = getProductVariantBackInStockForm(variant);\nuseEffect(() => { initBackInStockNotificationForm(form, variant); }, [form, variant]);\n\n<Input\n value={form.email?.value ?? \"\"}\n onInput={(e) => setBackInStockNotificationFormEmail(form, (e.target as HTMLInputElement).value)}\n/>\n\n// returns boolean; sets form.isSubmitting / isSuccess / isFailure / responseMessage\nconst ok = await submitBackInStockNotificationForm(form);\n```\n\nWrap the form component in `observer()` so it re-renders as the customer types and as the status flags flip — it reads the MobX-backed form.\n\n### Persistent \"saved\" state\n\n`variant.isBackInStockReminderSaved` is `true` once a reminder exists for that variant. Seed your CTA from it, and reset when the selected variant changes:\n\n```tsx\nconst [saved, setSaved] = useState(false);\nuseEffect(() => { setSaved(!!selectedVariant?.isBackInStockReminderSaved); }, [selectedVariant?.id]);\n```\n\nShow a non-interactive \"we'll notify you\" label instead of the button when `saved` is true.\n\n### Function reference\n\n| Function | Purpose |\n|----------|---------|\n| `getProductVariantIsBackInStockEnabled(variant)` | Gate: is back-in-stock enabled for this variant |\n| `getProductVariantIsBackInStockCustomerLoginRequired(variant)` | Branch: login-required vs email-form |\n| `saveProductVariantBackInStockReminder(variant, email)` | Register a reminder directly (login branch) |\n| `getProductVariantBackInStockForm(variant)` | Get-or-init the notification form model |\n| `initBackInStockNotificationForm(form, variant)` | Reset form fields + validation flags |\n| `setBackInStockNotificationFormEmail(form, value)` | Update the email field (re-validates after first submit) |\n| `submitBackInStockNotificationForm(form)` | Validate + save; returns boolean, sets status flags |\n| `variant.isBackInStockReminderSaved` | Persisted flag: a reminder already exists |",
|
|
273
|
+
"tags": [
|
|
274
|
+
"back-in-stock",
|
|
275
|
+
"notify-me",
|
|
276
|
+
"out-of-stock",
|
|
277
|
+
"variant",
|
|
278
|
+
"form",
|
|
279
|
+
"reminder",
|
|
280
|
+
"product-detail",
|
|
281
|
+
"conversion"
|
|
282
|
+
]
|
|
283
|
+
},
|
|
284
|
+
"product-options": {
|
|
285
|
+
"title": "Product Options (Customization) Patterns",
|
|
286
|
+
"description": "Personalization option sets: async load, type-aware rendering for all option types, conditional child options, file upload, and submit-time validation with reset",
|
|
287
|
+
"content": "## Product Options (Customization) Patterns\n\nProduct options let customers personalize a product before adding to cart — text, checkbox, choice, color/date picker, and file uploads, including parent/child (conditional) options and per-option prices. The reference theme implements the whole matrix in the `ProductDetailOptionSet` child of the product-detail section. Fetch it with:\n\n```\nget_section_child({ section: \"product-detail-section\", name: \"ProductDetailOptionSet\", kind: \"children\" })\n```\n\n### Load, then render displayed options\n\nThe option set is fetched async — render nothing until it resolves, then render only the currently displayed options (child options appear or hide based on the parent selection):\n\n```tsx\nuseEffect(() => { if (product) getProductOptionSet(product).then(() => setLoaded(true)); }, [product]);\n\nconst optionSet = product.productOptionSet;\nconst displayed = getDisplayedOptions(optionSet) as IkasProductOption[];\n```\n\n### Type-aware rendering\n\nEach option has a type — do not render everything as a text input. Switch on the SDK type guards and render a dedicated control per type:\n\n| Guard | Control |\n|-------|---------|\n| `isTextOption` / `isTextAreaOption` | text input / textarea |\n| `isCheckboxOption` | checkbox |\n| `isChoiceOption` | choice (box / select / swatch by display style) |\n| `isColorPickerOption` | color picker |\n| `isDatePickerOption` | date picker |\n| `isFileOption` | file upload |\n\nChild options render recursively from `getDisplayedChildOptions(option)`.\n\n### File upload\n\nFile options carry extension and min/max-count constraints. Upload through the SDK and surface its errors (file size, file type, max files):\n\n```tsx\nawait productOptionFileUpload(option, files);\n```\n\n### Validation + reset\n\nValidate on submit (used by add-to-cart), and reset after a successful add:\n\n```tsx\n// in add-to-cart, before adding:\nif (!validateOptionSet(product.productOptionSet, errorText)) return; // dispatches \"ikas:show-option-errors\"\n\n// after a successful add:\ninitProductOptionSetValues(product.productOptionSet);\nwindow.dispatchEvent(new CustomEvent(\"ikas:reset-option-state\"));\n```\n\nThe option-set component listens for `ikas:show-option-errors` / `ikas:reset-option-state` to toggle its error display; validation itself is `hasValidProductOptionSetValues(...)` under the hood.\n\n### Key rules\n\n1. Gate render on the async `getProductOptionSet(product)` resolving.\n2. Render from `getDisplayedOptions(...)`, never a hardcoded list — child visibility is dynamic.\n3. Switch on the type guards; each type has its own control.\n4. Validate with `validateOptionSet` at add-to-cart; reset with `initProductOptionSetValues` after a successful add.",
|
|
288
|
+
"tags": [
|
|
289
|
+
"product-options",
|
|
290
|
+
"customization",
|
|
291
|
+
"personalization",
|
|
292
|
+
"option-set",
|
|
293
|
+
"file-upload",
|
|
294
|
+
"child-options",
|
|
295
|
+
"validation",
|
|
296
|
+
"product-detail"
|
|
297
|
+
]
|
|
298
|
+
},
|
|
269
299
|
"product-list-patterns": {
|
|
270
300
|
"title": "Product List & Filtering Patterns",
|
|
271
301
|
"description": "Category pages, product filtering with display-type-aware rendering (swatch, number range, checkbox), sorting, pagination, and search patterns",
|
|
@@ -285,7 +315,7 @@
|
|
|
285
315
|
"cart-patterns": {
|
|
286
316
|
"title": "Cart Management Patterns (Serel Reference)",
|
|
287
317
|
"description": "Serel CartPage section with CartItem sub-component, OrderSummary/CouponCode/EmptyState local components, cartStore patterns, and checkout flow",
|
|
288
|
-
"content": "## Cart Management Patterns (Serel Reference)\n\nThe serel CartPage is a section that uses local internal components (OrderSummary, CouponCode, EmptyState) and the shared `CartItem` sub-component from `src/sub-components/CartItem/`. Unlike most sections, CartPage does NOT use IkasComponentRenderer — it directly composes its UI from sub-components.\n\n### CartPage Section Structure\n\n```tsx\nimport { cartStore } from \"@ikas/bp-storefront\";\nimport CartItem from \"../../sub-components/CartItem\";\nimport { Props } from \"./types\";\n\nexport function CartPage({ backgroundColor, emptyCartText, ...props }: Props) {\n const cart = cartStore.cart;\n const lineItems = cart?.orderLineItems ?? [];\n const isEmpty = lineItems.length === 0;\n\n if (isEmpty) return <EmptyState text={emptyCartText} />;\n\n return (\n <section className=\"cart-page\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"cart-page-inner\">\n <div className=\"cart-items\">\n {lineItems.map((item) => (\n <CartItem key={item.id} item={item} />\n ))}\n </div>\n <div className=\"cart-sidebar\">\n <CouponCode cart={cart} />\n <OrderSummary cart={cart} />\n </div>\n </div>\n </section>\n );\n}\nexport default CartPage;\n```\n\n### CartItem Sub-Component\n\nThe `CartItem` sub-component (in `src/sub-components/CartItem/`) displays a single line item with image, variant name, quantity controls, price, and remove button:\n\n```tsx\nimport {\n getOrderLineItemFormattedFinalPrice,\n getOrderLineItemFormattedUnitPrice,\n getIkasOrderLineVariantMainImage,\n getDefaultSrc,\n changeItemQuantity,\n removeItem,\n IkasOrderLineItem,\n} from \"@ikas/bp-storefront\";\nimport QuantitySelector from \"../QuantitySelector\";\n\ninterface Props { item: IkasOrderLineItem; }\n\nexport default function CartItem({ item }: Props) {\n const image = item.variant ? getIkasOrderLineVariantMainImage(item.variant) : null;\n\n return (\n <div className=\"cart-item\">\n {image && <img src={getDefaultSrc(image)} alt={item.variant?.name} />}\n <div className=\"cart-item-info\">\n <span>{item.variant?.name}</span>\n <span>{getOrderLineItemFormattedUnitPrice(item)}</span>\n </div>\n <QuantitySelector\n quantity={item.quantity}\n onIncrement={() => changeItemQuantity(item, item.quantity + 1)}\n onDecrement={() => changeItemQuantity(item, Math.max(1, item.quantity - 1))}\n />\n <span>{getOrderLineItemFormattedFinalPrice(item)}</span>\n <button onClick={() => removeItem(item)}>Remove</button>\n </div>\n );\n}\n```\n\n### OrderSummary Local Component\n\nDisplays cart totals using `getIkasOrderFormattedTotalFinalPrice(cart)` and `getIkasOrderFormattedTotalPrice(cart)`, plus a checkout button:\n\n```tsx\nimport { Router, getIkasOrderFormattedTotalFinalPrice } from \"@ikas/bp-storefront\";\n\nfunction OrderSummary({ cart }) {\n return (\n <div className=\"order-summary\">\n <span>Total: {getIkasOrderFormattedTotalFinalPrice(cart)}</span>\n <button onClick={() => Router.navigateToPage(\"CHECKOUT\")}>Checkout</button>\n </div>\n );\n}\n```\n\n### CouponCode Local Component\n\nCoupon code uses the standard form model. Get the form from `customerStore` via `getCouponCodeForm`, init it once, drive the input with `setCouponCodeFormCouponCode`, and submit/remove with the form helpers. Do NOT call `saveCart`/`apiSaveCart` or any deep `@ikas/bp-storefront/dist/...` import — the coupon has dedicated public exports:\n\n```tsx\nimport { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n getCouponCodeForm,\n initCouponCodeForm,\n setCouponCodeFormCouponCode,\n submitCouponCodeForm,\n removeCouponCodeForm,\n} from \"@ikas/bp-storefront\";\nimport { observer } from \"@ikas/component-utils\";\n\n// observer() because this sub-component reads the MobX coupon form\nconst CouponCode = observer(function CouponCode({ appliedCoupon }) {\n const couponForm = getCouponCodeForm(customerStore);\n\n useEffect(() => {\n initCouponCodeForm(couponForm);\n }, [couponForm]);\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n await submitCouponCodeForm(couponForm);\n };\n\n if (appliedCoupon) {\n return (\n <div className=\"coupon\">\n <span>{appliedCoupon}</span>\n <button type=\"button\" onClick={() => removeCouponCodeForm(couponForm)}>Remove</button>\n </div>\n );\n }\n\n return (\n <form className=\"coupon\" onSubmit={handleSubmit}>\n <input\n placeholder={couponForm.couponCode?.placeholder ?? \"Coupon code\"}\n value={couponForm.couponCode?.value ?? \"\"}\n onInput={(e) =>\n setCouponCodeFormCouponCode(couponForm, (e.target as HTMLInputElement).value)\n }\n />\n <button type=\"submit\" disabled={couponForm.isSubmitting}>\n {couponForm.isSubmitting ? \"Applying...\" : \"Apply\"}\n </button>\n {couponForm.isFailure && couponForm.responseMessage && (\n <p className=\"coupon-error\">{couponForm.responseMessage}</p>\n )}\n </form>\n );\n});\n```\n\n### cartStore Usage Patterns\n\nThe `cartStore` is a MobX store available globally. Root components are automatically reactive via autorun, so reads from `cartStore.cart` trigger re-renders:\n\n```tsx\nimport { cartStore } from \"@ikas/bp-storefront\";\n\n// Always null-safe:\nconst cart = cartStore.cart;\nconst lineItems = cart?.orderLineItems ?? [];\nconst itemCount = lineItems.length;\nconst isEmpty = itemCount === 0;\n```\n\n### Key Pattern: Section Without IkasComponentRenderer\n\nCartPage demonstrates that not all sections need COMPONENT_LIST slots. Simple sections can directly compose sub-components and local components when the layout is fixed and does not need editor-level child component placement.",
|
|
318
|
+
"content": "## Cart Management Patterns (Serel Reference)\n\nThe serel CartPage is a section that uses local internal components (OrderSummary, CouponCode, EmptyState) and the shared `CartItem` sub-component from `src/sub-components/CartItem/`. Unlike most sections, CartPage does NOT use IkasComponentRenderer — it directly composes its UI from sub-components.\n\n### CartPage Section Structure\n\n```tsx\nimport { cartStore } from \"@ikas/bp-storefront\";\nimport CartItem from \"../../sub-components/CartItem\";\nimport { Props } from \"./types\";\n\nexport function CartPage({ backgroundColor, emptyCartText, ...props }: Props) {\n const cart = cartStore.cart;\n const lineItems = cart?.orderLineItems ?? [];\n const isEmpty = lineItems.length === 0;\n\n if (isEmpty) return <EmptyState text={emptyCartText} />;\n\n return (\n <section className=\"cart-page\" style={backgroundColor ? { backgroundColor } : undefined}>\n <div className=\"cart-page-inner\">\n <div className=\"cart-items\">\n {lineItems.map((item) => (\n <CartItem key={item.id} item={item} />\n ))}\n </div>\n <div className=\"cart-sidebar\">\n <CouponCode cart={cart} />\n <OrderSummary cart={cart} />\n </div>\n </div>\n </section>\n );\n}\nexport default CartPage;\n```\n\n### CartItem Sub-Component\n\nThe `CartItem` sub-component (in `src/sub-components/CartItem/`) displays a single line item with image, variant name, quantity controls, price, and remove button:\n\n```tsx\nimport {\n getOrderLineItemFormattedFinalPrice,\n getOrderLineItemFormattedUnitPrice,\n getIkasOrderLineVariantMainImage,\n getDefaultSrc,\n changeItemQuantity,\n removeItem,\n IkasOrderLineItem,\n} from \"@ikas/bp-storefront\";\nimport QuantitySelector from \"../QuantitySelector\";\n\ninterface Props { item: IkasOrderLineItem; }\n\nexport default function CartItem({ item }: Props) {\n const image = item.variant ? getIkasOrderLineVariantMainImage(item.variant) : null;\n\n return (\n <div className=\"cart-item\">\n {image && <img src={getDefaultSrc(image)} alt={item.variant?.name} />}\n <div className=\"cart-item-info\">\n <span>{item.variant?.name}</span>\n <span>{getOrderLineItemFormattedUnitPrice(item)}</span>\n </div>\n <QuantitySelector\n quantity={item.quantity}\n onIncrement={() => changeItemQuantity(item, item.quantity + 1)}\n onDecrement={() => changeItemQuantity(item, Math.max(1, item.quantity - 1))}\n />\n <span>{getOrderLineItemFormattedFinalPrice(item)}</span>\n <button onClick={() => removeItem(item)}>Remove</button>\n </div>\n );\n}\n```\n\n### OrderSummary Local Component\n\nDisplays cart totals using `getIkasOrderFormattedTotalFinalPrice(cart)` and `getIkasOrderFormattedTotalPrice(cart)`, plus a checkout button:\n\n```tsx\nimport { Router, getIkasOrderFormattedTotalFinalPrice } from \"@ikas/bp-storefront\";\n\nfunction OrderSummary({ cart }) {\n return (\n <div className=\"order-summary\">\n <span>Total: {getIkasOrderFormattedTotalFinalPrice(cart)}</span>\n <button onClick={() => Router.navigateToPage(\"CHECKOUT\")}>Checkout</button>\n </div>\n );\n}\n```\n\n### CouponCode Local Component\n\nCoupon code uses the standard form model. Get the form from `customerStore` via `getCouponCodeForm`, init it once, drive the input with `setCouponCodeFormCouponCode`, and submit/remove with the form helpers. Do NOT call `saveCart`/`apiSaveCart` or any deep `@ikas/bp-storefront/dist/...` import — the coupon has dedicated public exports:\n\n```tsx\nimport { useEffect } from \"preact/hooks\";\nimport {\n customerStore,\n getCouponCodeForm,\n initCouponCodeForm,\n setCouponCodeFormCouponCode,\n submitCouponCodeForm,\n removeCouponCodeForm,\n} from \"@ikas/bp-storefront\";\nimport { observer } from \"@ikas/component-utils\";\n\n// observer() because this sub-component reads the MobX coupon form\nconst CouponCode = observer(function CouponCode({ appliedCoupon }) {\n const couponForm = getCouponCodeForm(customerStore);\n\n useEffect(() => {\n initCouponCodeForm(couponForm);\n }, [couponForm]);\n\n const handleSubmit = async (e: Event) => {\n e.preventDefault();\n await submitCouponCodeForm(couponForm);\n };\n\n if (appliedCoupon) {\n return (\n <div className=\"coupon\">\n <span>{appliedCoupon}</span>\n <button type=\"button\" onClick={() => removeCouponCodeForm(couponForm)}>Remove</button>\n </div>\n );\n }\n\n return (\n <form className=\"coupon\" onSubmit={handleSubmit}>\n <input\n placeholder={couponForm.couponCode?.placeholder ?? \"Coupon code\"}\n value={couponForm.couponCode?.value ?? \"\"}\n onInput={(e) =>\n setCouponCodeFormCouponCode(couponForm, (e.target as HTMLInputElement).value)\n }\n />\n <button type=\"submit\" disabled={couponForm.isSubmitting}>\n {couponForm.isSubmitting ? \"Applying...\" : \"Apply\"}\n </button>\n {couponForm.isFailure && couponForm.responseMessage && (\n <p className=\"coupon-error\">{couponForm.responseMessage}</p>\n )}\n </form>\n );\n});\n```\n\n### cartStore Usage Patterns\n\nThe `cartStore` is a MobX store available globally. Root components are automatically reactive via autorun, so reads from `cartStore.cart` trigger re-renders:\n\n```tsx\nimport { cartStore } from \"@ikas/bp-storefront\";\n\n// Always null-safe:\nconst cart = cartStore.cart;\nconst lineItems = cart?.orderLineItems ?? [];\nconst itemCount = lineItems.length;\nconst isEmpty = itemCount === 0;\n```\n\n### Quantity Limits (min/max per cart)\n\nA product's sales channel can cap how many units go into one cart. The limits live on the product's `salesChannels`, matched to the active channel:\n\n```tsx\nimport { IkasStorefrontConfig } from \"@ikas/bp-storefront\";\n\nconst channel = product.salesChannels?.find((sc) => sc.id === IkasStorefrontConfig.salesChannelId);\nconst min = channel?.minQuantityPerCart ?? 1;\nconst max = channel?.maxQuantityPerCart; // null/undefined = no cap\n```\n\nApply these to the quantity stepper: seed the initial quantity to `min`, and cap increments at `max`. `addItemToCart(variant, product, qty)` also clamps to the same limits internally — but silently, so enforce them in the UI too or the customer sees the number snap with no explanation.\n\n> `IkasStorefrontConfig.salesChannelId` is a property. The older `getSalesChannelId()` method does not exist in this SDK.\n\n### Cart Operation validationError (complete list)\n\n`addItemToCart` / `changeItemQuantity` return `{ success, validationError? }`. The full set of `validationError` values is exactly three:\n\n| Value | Meaning |\n|-------|---------|\n| `INSUFFICIENT_STOCK` | Not enough stock for the requested quantity |\n| `INVALID_PRODUCT_OPTION_VALUES` | Required product options not fully or validly selected |\n| `EMPTY_CART` | The operation needs a non-empty cart |\n\nThere is **no** `MAX_QUANTITY_PER_CART_*` / `MIN_QUANTITY_PER_CART_*` error — quantity limits are clamped silently (see above), never surfaced as a validation error. Prevent limit violations in the stepper rather than branching on error codes that do not exist.\n\n### Campaign Offer Lines in the Cart\n\nCampaign discounts surface as order-level adjustment rows, not as edits on the line items:\n\n```tsx\n{cart.orderAdjustments?.map((adj) => (\n <div key={adj.name} className=\"cart-adjustment\">\n <span>{adj.name}</span>\n <span>{formatCurrency(adj.amount, adj.currencyCode, adj.currencySymbol)}</span>\n </div>\n))}\n```\n\nWhen a campaign auto-adds a bundled or gift line (a \"buy 1 get N\" offer), `isOrderLineItemAutoCreated(cart, item)` returns `true` for that line. Use it to badge the line and hide its quantity/remove controls — the customer cannot edit an auto-created line directly:\n\n```tsx\nimport { isOrderLineItemAutoCreated } from \"@ikas/bp-storefront\";\n\n{lineItems.map((item) => (\n <CartItem key={item.id} item={item} readOnly={isOrderLineItemAutoCreated(cart, item)} />\n))}\n```\n\n### Key Pattern: Section Without IkasComponentRenderer\n\nCartPage demonstrates that not all sections need COMPONENT_LIST slots. Simple sections can directly compose sub-components and local components when the layout is fixed and does not need editor-level child component placement.",
|
|
289
319
|
"tags": [
|
|
290
320
|
"cart",
|
|
291
321
|
"checkout",
|
|
@@ -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
|
+
}
|
|
@@ -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>",
|