@ikas/code-components-mcp 1.4.0-beta.23 → 1.4.0-beta.230

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/data/framework.json +191 -78
  2. package/data/migration.json +10 -4
  3. package/data/section-templates/account-info-section/children/AccountFavorites/ikas-config-snippet.json +13 -0
  4. package/data/section-templates/account-info-section/children/AccountOrders/index.tsx +1 -1
  5. package/data/section-templates/account-info-section/ikas-config-snippet.json +19 -0
  6. package/data/section-templates/add-to-cart/ikas-config-snippet.json +17 -0
  7. package/data/section-templates/add-to-cart/index.tsx +16 -2
  8. package/data/section-templates/add-to-cart/sub-components/QuantitySelector/index.tsx +11 -3
  9. package/data/section-templates/add-to-cart/utils/cartLimits.ts +24 -0
  10. package/data/section-templates/blog-home-section/ikas-config-snippet.json +2 -0
  11. package/data/section-templates/blog-home-section/index.tsx +2 -1
  12. package/data/section-templates/blog-post-section/ikas-config-snippet.json +2 -0
  13. package/data/section-templates/blog-post-section/index.tsx +3 -2
  14. package/data/section-templates/bundle-products/hooks/useBundleProducts.ts +7 -6
  15. package/data/section-templates/bundle-products/ikas-config-snippet.json +20 -0
  16. package/data/section-templates/cart-section/components/EmptyState/index.tsx +1 -1
  17. package/data/section-templates/cart-section/sub-components/CartItem/index.tsx +66 -24
  18. package/data/section-templates/cart-section/sub-components/CartItem/styles.css +14 -0
  19. package/data/section-templates/cart-section/sub-components/QuantitySelector/index.tsx +11 -3
  20. package/data/section-templates/cart-section/utils/cartLimits.ts +24 -0
  21. package/data/section-templates/cart-section/utils/cartLineLimits.ts +100 -0
  22. package/data/section-templates/category-images-section/children/CategoryImageItem/ikas-config-snippet.json +4 -0
  23. package/data/section-templates/category-images-section/ikas-config-snippet.json +15 -0
  24. package/data/section-templates/category-list-section/children/CardProductName/ikas-config-snippet.json +2 -1
  25. package/data/section-templates/category-list-section/ikas-config-snippet.json +21 -0
  26. package/data/section-templates/category-list-section/index.tsx +2 -1
  27. package/data/section-templates/component-renderer/additional/ProductDetail/index.tsx +5 -1
  28. package/data/section-templates/component-renderer/ikas-config-snippet.json +13 -0
  29. package/data/section-templates/favorites/ikas-config-snippet.json +18 -1
  30. package/data/section-templates/features-section/ikas-config-snippet.json +13 -0
  31. package/data/section-templates/footer-section/ikas-config-snippet.json +15 -0
  32. package/data/section-templates/forgot-password-section/components/ForgotPasswordForm/index.tsx +1 -1
  33. package/data/section-templates/header-section/children/Announcements/ikas-config-snippet.json +7 -0
  34. package/data/section-templates/header-section/children/CookieBar/ikas-config-snippet.json +3 -0
  35. package/data/section-templates/header-section/children/Navbar/components/CartSidebar/index.tsx +2 -1
  36. package/data/section-templates/header-section/children/Navbar/components/SearchModal/index.tsx +2 -1
  37. package/data/section-templates/header-section/children/Navbar/ikas-config-snippet.json +27 -0
  38. package/data/section-templates/header-section/children/Navbar/index.tsx +6 -5
  39. package/data/section-templates/header-section/ikas-config-snippet.json +13 -0
  40. package/data/section-templates/header-section/sub-components/CartItem/index.tsx +66 -24
  41. package/data/section-templates/header-section/sub-components/CartItem/styles.css +14 -0
  42. package/data/section-templates/header-section/sub-components/QuantitySelector/index.tsx +11 -3
  43. package/data/section-templates/header-section/utils/cartLimits.ts +24 -0
  44. package/data/section-templates/header-section/utils/cartLineLimits.ts +100 -0
  45. package/data/section-templates/hero-slider-section/children/HeroSliderItem/ikas-config-snippet.json +18 -0
  46. package/data/section-templates/hero-slider-section/children/HeroSliderItem/index.tsx +12 -7
  47. package/data/section-templates/hero-slider-section/ikas-config-snippet.json +13 -0
  48. package/data/section-templates/image-handling/ikas-config-snippet.json +53 -0
  49. package/data/section-templates/image-handling/index.tsx +5 -1
  50. package/data/section-templates/login-section/ikas-config-snippet.json +2 -0
  51. package/data/section-templates/navigation/components/CartSidebar/index.tsx +2 -1
  52. package/data/section-templates/navigation/components/SearchModal/index.tsx +2 -1
  53. package/data/section-templates/navigation/ikas-config-snippet.json +27 -0
  54. package/data/section-templates/navigation/index.tsx +6 -5
  55. package/data/section-templates/navigation/sub-components/CartItem/index.tsx +66 -24
  56. package/data/section-templates/navigation/sub-components/CartItem/styles.css +14 -0
  57. package/data/section-templates/navigation/sub-components/QuantitySelector/index.tsx +11 -3
  58. package/data/section-templates/navigation/utils/cartLimits.ts +24 -0
  59. package/data/section-templates/navigation/utils/cartLineLimits.ts +100 -0
  60. package/data/section-templates/product-detail-section/children/ProductDetailAddToCart/ikas-config-snippet.json +17 -0
  61. package/data/section-templates/product-detail-section/children/ProductDetailAddToCart/index.tsx +16 -2
  62. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/components/BackInStockForm/index.tsx +92 -0
  63. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/ikas-config-snippet.json +175 -0
  64. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/index.tsx +141 -0
  65. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/styles.css +47 -0
  66. package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/types.ts +25 -0
  67. package/data/section-templates/product-detail-section/children/ProductDetailBundleFurniture/ikas-config-snippet.json +18 -0
  68. package/data/section-templates/product-detail-section/children/ProductDetailBundleProduct/ikas-config-snippet.json +20 -0
  69. package/data/section-templates/product-detail-section/children/ProductDetailDescription/ikas-config-snippet.json +27 -0
  70. package/data/section-templates/product-detail-section/children/ProductDetailFeatures/ikas-config-snippet.json +29 -0
  71. package/data/section-templates/product-detail-section/children/ProductDetailNameFavorite/ikas-config-snippet.json +18 -1
  72. package/data/section-templates/product-detail-section/children/ProductDetailOffer/ikas-config-snippet.json +20 -0
  73. package/data/section-templates/product-detail-section/children/ProductDetailOptionSet/ikas-config-snippet.json +16 -0
  74. package/data/section-templates/product-detail-section/children/ProductDetailPrices/ikas-config-snippet.json +16 -0
  75. package/data/section-templates/product-detail-section/children/ProductDetailProductGroup/ikas-config-snippet.json +16 -0
  76. package/data/section-templates/product-detail-section/children/ProductDetailSku/ikas-config-snippet.json +16 -0
  77. package/data/section-templates/product-detail-section/children/ProductDetailVariant/ikas-config-snippet.json +17 -0
  78. package/data/section-templates/product-detail-section/hooks/useBundleProducts.ts +7 -6
  79. package/data/section-templates/product-detail-section/ikas-config-snippet.json +53 -0
  80. package/data/section-templates/product-detail-section/index.tsx +5 -1
  81. package/data/section-templates/product-detail-section/sub-components/Modal/index.tsx +120 -0
  82. package/data/section-templates/product-detail-section/sub-components/Modal/styles.css +70 -0
  83. package/data/section-templates/product-detail-section/sub-components/QuantitySelector/index.tsx +11 -3
  84. package/data/section-templates/product-detail-section/utils/cartLimits.ts +24 -0
  85. package/data/section-templates/product-pricing/ikas-config-snippet.json +16 -0
  86. package/data/section-templates/product-slider-section/children/CardProductName/ikas-config-snippet.json +2 -1
  87. package/data/section-templates/product-slider-section/ikas-config-snippet.json +17 -0
  88. package/data/section-templates/register-section/ikas-config-snippet.json +2 -0
  89. package/data/section-templates/variant-selection/ikas-config-snippet.json +17 -0
  90. package/data/storefront-api.json +1046 -80
  91. package/data/storefront-types.json +9868 -6037
  92. package/dist/index.js +2578 -257
  93. package/dist/index.js.map +1 -1
  94. package/package.json +1 -1
@@ -5,20 +5,27 @@ interface Props {
5
5
  value: number;
6
6
  onChange: (value: number) => void;
7
7
  size?: "default" | "sm";
8
+ min?: number;
9
+ max?: number;
8
10
  }
9
11
 
10
12
  export default function QuantitySelector({
11
13
  value,
12
14
  onChange,
13
15
  size = "default",
16
+ min = 1,
17
+ max,
14
18
  }: Props) {
19
+ const canDecrease = value > min;
20
+ const canIncrease = max == null || value < max;
21
+
15
22
  return (
16
23
  <div className={cx("kombos-pd__qty", size === "sm" && "kombos-pd__qty--sm")}>
17
24
  <button
18
25
  type="button"
19
26
  className="kombos-pd__qty-btn"
20
- onClick={() => onChange(Math.max(1, value - 1))}
21
- disabled={value <= 1}
27
+ onClick={() => onChange(Math.max(min, value - 1))}
28
+ disabled={!canDecrease}
22
29
  aria-label="Decrease quantity"
23
30
  >
24
31
  <MinusSVG />
@@ -31,7 +38,8 @@ export default function QuantitySelector({
31
38
  <button
32
39
  type="button"
33
40
  className="kombos-pd__qty-btn"
34
- onClick={() => onChange(value + 1)}
41
+ onClick={() => onChange(max == null ? value + 1 : Math.min(max, value + 1))}
42
+ disabled={!canIncrease}
35
43
  aria-label="Increase quantity"
36
44
  >
37
45
  <PlusSVG />
@@ -0,0 +1,24 @@
1
+ import { IkasProduct, IkasStorefrontConfig } from "@ikas/bp-storefront";
2
+
3
+ export interface CartQuantityLimits {
4
+ min: number;
5
+ max?: number;
6
+ }
7
+
8
+ /**
9
+ * Resolve the per-cart quantity limits for a product from its active sales
10
+ * channel. Falls back to a minimum of 1 and no maximum when the channel or its
11
+ * limits are not set. `addItemToCart` clamps to the same limits internally only
12
+ * when it creates a NEW cart line — incrementing an existing line goes through
13
+ * `changeItemQuantity`, which never clamps — so enforcing these in the stepper
14
+ * is the only guard that covers both paths.
15
+ */
16
+ export function getProductCartLimits(product: IkasProduct): CartQuantityLimits {
17
+ const channel = product.salesChannels?.find(
18
+ (sc) => sc.id === IkasStorefrontConfig.salesChannelId,
19
+ );
20
+ return {
21
+ min: channel?.minQuantityPerCart ?? 1,
22
+ max: channel?.maxQuantityPerCart ?? undefined,
23
+ };
24
+ }
@@ -0,0 +1,100 @@
1
+ import { useEffect, useState } from "preact/hooks";
2
+ import { baseStore, bs_searchProductsById } from "@ikas/bp-storefront";
3
+ import { CartQuantityLimits, getProductCartLimits } from "./cartLimits";
4
+
5
+ /**
6
+ * Per-cart quantity limits for cart lines.
7
+ *
8
+ * A cart line's `IkasOrderLineVariant` carries no `salesChannels`, so the
9
+ * min/max per-cart limits can only be read from the full `IkasProduct`. This
10
+ * store batch-fetches the line products with `bs_searchProductsById` (lines
11
+ * register their productId one by one; the ids are flushed as ONE request on
12
+ * the next tick) and maps the resolved limits by product id for the cart
13
+ * steppers to read. Each product is fetched at most once; results stay cached
14
+ * for the session.
15
+ */
16
+
17
+ const limitsByProductId = new Map<string, CartQuantityLimits>();
18
+ const requestedProductIds = new Set<string>();
19
+ const listeners = new Set<() => void>();
20
+
21
+ let pendingProductIds: string[] = [];
22
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
23
+
24
+ function emit() {
25
+ for (const listener of listeners) listener();
26
+ }
27
+
28
+ async function flush() {
29
+ flushTimer = null;
30
+ const batch = pendingProductIds;
31
+ pendingProductIds = [];
32
+
33
+ // bs_searchProductsById never throws — API/network failures resolve with
34
+ // isSuccess: false — so the retry unmark must live on the result check.
35
+ const result = await bs_searchProductsById(baseStore, { productIds: batch }).catch(
36
+ () => null,
37
+ );
38
+ if (!result?.isSuccess || !result.products) {
39
+ // Limits stay unenforced for now; unmark the batch so a later
40
+ // ensureCartLineLimits call (e.g. the next cart open) can retry.
41
+ for (const id of batch) requestedProductIds.delete(id);
42
+ return;
43
+ }
44
+
45
+ for (const product of result.products) {
46
+ limitsByProductId.set(product.id, getProductCartLimits(product));
47
+ }
48
+ emit();
49
+ }
50
+
51
+ /**
52
+ * Register a cart line's productId for limit resolution. Ids already requested
53
+ * are skipped; new ids are collected and fetched in a single batched request
54
+ * on the next tick, so per-line calls do not fan out into per-line requests.
55
+ */
56
+ export function ensureCartLineLimits(productId: string) {
57
+ if (requestedProductIds.has(productId)) return;
58
+ requestedProductIds.add(productId);
59
+ pendingProductIds.push(productId);
60
+ if (!flushTimer) flushTimer = setTimeout(flush, 0);
61
+ }
62
+
63
+ /** Resolved limits for a product — undefined until the batch fetch lands. */
64
+ export function getCartLineLimits(
65
+ productId: string | null | undefined,
66
+ ): CartQuantityLimits | undefined {
67
+ return productId ? limitsByProductId.get(productId) : undefined;
68
+ }
69
+
70
+ /** Subscribe to limit resolutions; returns the unsubscribe function. */
71
+ export function subscribeCartLineLimits(listener: () => void) {
72
+ listeners.add(listener);
73
+ return () => {
74
+ listeners.delete(listener);
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Hook: resolved limits for one cart line's product — undefined until the
80
+ * batched fetch lands. Encapsulates the subscription protocol so components
81
+ * cannot get the ordering wrong.
82
+ */
83
+ export function useCartLineLimits(
84
+ productId: string | null | undefined,
85
+ ): CartQuantityLimits | undefined {
86
+ const [, setVersion] = useState(0);
87
+ const limits = getCartLineLimits(productId);
88
+ useEffect(() => {
89
+ if (!productId) return;
90
+ // Subscribe BEFORE registering the id: a batch already in flight can
91
+ // resolve between render and this effect, and its emit must not be missed.
92
+ const unsubscribe = subscribeCartLineLimits(() => setVersion((v) => v + 1));
93
+ ensureCartLineLimits(productId);
94
+ // Re-check once for a result that landed before the subscription armed —
95
+ // skipped when the arming render already saw it (no redundant re-render).
96
+ if (!limits && getCartLineLimits(productId)) setVersion((v) => v + 1);
97
+ return unsubscribe;
98
+ }, [productId]);
99
+ return limits;
100
+ }
@@ -40,6 +40,10 @@
40
40
  "displayName": "Top Spacing",
41
41
  "type": "TYPE",
42
42
  "required": false,
43
+ "defaultValue": {
44
+ "value": 0,
45
+ "unit": "px"
46
+ },
43
47
  "groupId": "mobile",
44
48
  "typeId": "@ikas/bp-storefront-models-MarginTopStyleType"
45
49
  },
@@ -48,6 +52,10 @@
48
52
  "displayName": "Bottom Spacing",
49
53
  "type": "TYPE",
50
54
  "required": false,
55
+ "defaultValue": {
56
+ "value": 0,
57
+ "unit": "px"
58
+ },
51
59
  "groupId": "mobile",
52
60
  "typeId": "@ikas/bp-storefront-models-MarginBottomStyleType"
53
61
  },
@@ -56,6 +64,10 @@
56
64
  "displayName": "Top Spacing",
57
65
  "type": "TYPE",
58
66
  "required": false,
67
+ "defaultValue": {
68
+ "value": 0,
69
+ "unit": "px"
70
+ },
59
71
  "groupId": "desktop",
60
72
  "typeId": "@ikas/bp-storefront-models-MarginTopStyleType"
61
73
  },
@@ -64,6 +76,10 @@
64
76
  "displayName": "Bottom Spacing",
65
77
  "type": "TYPE",
66
78
  "required": false,
79
+ "defaultValue": {
80
+ "value": 0,
81
+ "unit": "px"
82
+ },
67
83
  "groupId": "desktop",
68
84
  "typeId": "@ikas/bp-storefront-models-MarginBottomStyleType"
69
85
  },
@@ -72,6 +88,7 @@
72
88
  "displayName": "Hide Quantity Selector",
73
89
  "type": "BOOLEAN",
74
90
  "required": false,
91
+ "defaultValue": false,
75
92
  "groupId": "settings"
76
93
  },
77
94
  {
@@ -10,13 +10,14 @@ import {
10
10
  IkasBundleSettings,
11
11
  IkasStorefrontConfig,
12
12
  } from "@ikas/bp-storefront";
13
- import { useState } from "preact/hooks";
13
+ import { useEffect, useState } from "preact/hooks";
14
14
  import { Props } from "./types";
15
15
  import Button from "../../sub-components/Button";
16
16
  import QuantitySelector from "../../sub-components/QuantitySelector";
17
17
  import PayWithIkas from "./components/PayWithIkas";
18
18
  import { isBundleOutOfStock } from "../../utils/bundle";
19
19
  import { validateOptionSet } from "../../utils/optionSet";
20
+ import { getProductCartLimits } from "../../utils/cartLimits";
20
21
  import { showToast } from "../../utils/toast";
21
22
 
22
23
  export function ProductDetailAddToCart({
@@ -37,6 +38,12 @@ export function ProductDetailAddToCart({
37
38
  const [quantity, setQuantity] = useState(1);
38
39
  const [isAddingToCart, setIsAddingToCart] = useState(false);
39
40
 
41
+ // Seed the quantity to the product's minimum-per-cart when the product changes.
42
+ useEffect(() => {
43
+ if (!product) return;
44
+ setQuantity(getProductCartLimits(product).min);
45
+ }, [product?.id]);
46
+
40
47
  if (!product) return null;
41
48
 
42
49
  const editLineID =
@@ -59,6 +66,8 @@ export function ProductDetailAddToCart({
59
66
 
60
67
  const isDisabled = isOutOfStock || isAddingToCart;
61
68
 
69
+ const { min: minQuantity, max: maxQuantity } = getProductCartLimits(product);
70
+
62
71
  const payWithIkasUrl = IkasStorefrontConfig.getPayWithIkasUrl();
63
72
  const routing = IkasStorefrontConfig.getCurrentRouting();
64
73
  const showPayWithIkas =
@@ -119,7 +128,12 @@ export function ProductDetailAddToCart({
119
128
  >
120
129
  <div className="kombos-pd-atc__actions">
121
130
  {!hideQuantityInput && !isOutOfStock && (
122
- <QuantitySelector value={quantity} onChange={setQuantity} />
131
+ <QuantitySelector
132
+ value={quantity}
133
+ onChange={setQuantity}
134
+ min={minQuantity}
135
+ max={maxQuantity}
136
+ />
123
137
  )}
124
138
  <Button
125
139
  variant="primary"
@@ -0,0 +1,92 @@
1
+ import {
2
+ IkasProductVariant,
3
+ getProductVariantBackInStockForm,
4
+ initBackInStockNotificationForm,
5
+ setBackInStockNotificationFormEmail,
6
+ submitBackInStockNotificationForm,
7
+ } from "@ikas/bp-storefront";
8
+ import { observer } from "@ikas/component-utils";
9
+ import { useEffect } from "preact/hooks";
10
+ import Button from "../../../../sub-components/Button";
11
+ import Input from "../../../../sub-components/Input";
12
+ import FormItem from "../../../../sub-components/FormItem";
13
+
14
+ interface Props {
15
+ variant: IkasProductVariant;
16
+ description: string;
17
+ emailLabel: string;
18
+ submitButtonText: string;
19
+ submittingText: string;
20
+ errorMessage: string;
21
+ onSaved: () => void;
22
+ }
23
+
24
+ // observer() because it reads the MobX-backed notification form and must
25
+ // re-render as the customer types and as the submit status flags change.
26
+ const BackInStockForm = observer(function BackInStockForm({
27
+ variant,
28
+ description,
29
+ emailLabel,
30
+ submitButtonText,
31
+ submittingText,
32
+ errorMessage,
33
+ onSaved,
34
+ }: Props) {
35
+ const form = getProductVariantBackInStockForm(variant);
36
+
37
+ // Initialize once per variant (resets fields + validation flags).
38
+ useEffect(() => {
39
+ initBackInStockNotificationForm(form, variant);
40
+ }, [form, variant]);
41
+
42
+ const emailHasError = !!form.email?.hasError;
43
+
44
+ const handleSubmit = async (e: Event) => {
45
+ e.preventDefault();
46
+ if (await submitBackInStockNotificationForm(form)) onSaved();
47
+ };
48
+
49
+ const handleEmailInput = (e: Event) =>
50
+ setBackInStockNotificationFormEmail(
51
+ form,
52
+ (e.target as HTMLInputElement).value,
53
+ );
54
+
55
+ return (
56
+ <form className="kombos-pd-bis__form" onSubmit={handleSubmit}>
57
+ {description && <p className="kombos-pd-bis__desc">{description}</p>}
58
+ <FormItem
59
+ label={emailLabel}
60
+ htmlFor="back-in-stock-email"
61
+ status={emailHasError ? "error" : "default"}
62
+ helper={emailHasError ? form.email?.message : undefined}
63
+ >
64
+ <Input
65
+ id="back-in-stock-email"
66
+ type="email"
67
+ value={form.email?.value ?? ""}
68
+ placeholder={form.email?.placeholder}
69
+ status={emailHasError ? "error" : "default"}
70
+ onInput={handleEmailInput}
71
+ />
72
+ </FormItem>
73
+ {form.isFailure && (
74
+ <p className="kombos-pd-bis__error">
75
+ {form.responseMessage || errorMessage}
76
+ </p>
77
+ )}
78
+ <Button
79
+ variant="primary"
80
+ size="s"
81
+ type="submit"
82
+ className="kombos-pd-bis__submit"
83
+ loading={form.isSubmitting}
84
+ disabled={form.isSubmitting}
85
+ >
86
+ {form.isSubmitting ? submittingText : submitButtonText}
87
+ </Button>
88
+ </form>
89
+ );
90
+ });
91
+
92
+ export default BackInStockForm;
@@ -0,0 +1,175 @@
1
+ {
2
+ "id": "{{PROJECT_ID}}-product-detail-back-in-stock",
3
+ "name": "ProductDetailBackInStock",
4
+ "type": "component",
5
+ "entry": "./src/components/ProductDetailBackInStock/index.tsx",
6
+ "styles": "./src/components/ProductDetailBackInStock/styles.css",
7
+ "props": [
8
+ {
9
+ "name": "product",
10
+ "displayName": "Product",
11
+ "type": "PRODUCT",
12
+ "required": false
13
+ },
14
+ {
15
+ "name": "notifyButtonText",
16
+ "displayName": "Notify Button Text",
17
+ "type": "TEXT",
18
+ "required": false,
19
+ "defaultValue": "Notify Me When Available",
20
+ "groupId": "texts"
21
+ },
22
+ {
23
+ "name": "reminderSavedText",
24
+ "displayName": "Reminder Saved Text",
25
+ "type": "TEXT",
26
+ "required": false,
27
+ "defaultValue": "We'll notify you",
28
+ "groupId": "texts"
29
+ },
30
+ {
31
+ "name": "alreadySavedMessage",
32
+ "displayName": "Already Saved Message",
33
+ "type": "TEXT",
34
+ "required": false,
35
+ "defaultValue": "You're already on the list — we'll email you when it's back.",
36
+ "groupId": "texts"
37
+ },
38
+ {
39
+ "name": "savedMessage",
40
+ "displayName": "Saved Success Message",
41
+ "type": "TEXT",
42
+ "required": false,
43
+ "defaultValue": "Great! We'll email you when this product is back in stock.",
44
+ "groupId": "texts"
45
+ },
46
+ {
47
+ "name": "loginRequiredMessage",
48
+ "displayName": "Login Required Message",
49
+ "type": "TEXT",
50
+ "required": false,
51
+ "defaultValue": "Please sign in so we can notify you.",
52
+ "groupId": "texts"
53
+ },
54
+ {
55
+ "name": "errorMessage",
56
+ "displayName": "Error Message",
57
+ "type": "TEXT",
58
+ "required": false,
59
+ "defaultValue": "Something went wrong. Please try again.",
60
+ "groupId": "texts"
61
+ },
62
+ {
63
+ "name": "modalTitle",
64
+ "displayName": "Email Modal Title",
65
+ "type": "TEXT",
66
+ "required": false,
67
+ "defaultValue": "Notify me when available",
68
+ "groupId": "texts"
69
+ },
70
+ {
71
+ "name": "modalDescription",
72
+ "displayName": "Email Modal Description",
73
+ "type": "TEXT",
74
+ "required": false,
75
+ "defaultValue": "Enter your email and we'll let you know the moment this product is back in stock.",
76
+ "groupId": "texts"
77
+ },
78
+ {
79
+ "name": "emailLabel",
80
+ "displayName": "Email Field Label",
81
+ "type": "TEXT",
82
+ "required": false,
83
+ "defaultValue": "Email",
84
+ "groupId": "texts"
85
+ },
86
+ {
87
+ "name": "submitButtonText",
88
+ "displayName": "Submit Button Text",
89
+ "type": "TEXT",
90
+ "required": false,
91
+ "defaultValue": "Notify Me",
92
+ "groupId": "texts"
93
+ },
94
+ {
95
+ "name": "submittingText",
96
+ "displayName": "Submitting Text",
97
+ "type": "TEXT",
98
+ "required": false,
99
+ "defaultValue": "Saving...",
100
+ "groupId": "texts"
101
+ },
102
+ {
103
+ "name": "mobileMarginTop",
104
+ "displayName": "Top Spacing",
105
+ "type": "TYPE",
106
+ "required": false,
107
+ "defaultValue": {
108
+ "value": 0,
109
+ "unit": "px"
110
+ },
111
+ "groupId": "mobile",
112
+ "typeId": "@ikas/bp-storefront-models-MarginTopStyleType"
113
+ },
114
+ {
115
+ "name": "mobileMarginBottom",
116
+ "displayName": "Bottom Spacing",
117
+ "type": "TYPE",
118
+ "required": false,
119
+ "defaultValue": {
120
+ "value": 0,
121
+ "unit": "px"
122
+ },
123
+ "groupId": "mobile",
124
+ "typeId": "@ikas/bp-storefront-models-MarginBottomStyleType"
125
+ },
126
+ {
127
+ "name": "desktopMarginTop",
128
+ "displayName": "Top Spacing",
129
+ "type": "TYPE",
130
+ "required": false,
131
+ "defaultValue": {
132
+ "value": 0,
133
+ "unit": "px"
134
+ },
135
+ "groupId": "desktop",
136
+ "typeId": "@ikas/bp-storefront-models-MarginTopStyleType"
137
+ },
138
+ {
139
+ "name": "desktopMarginBottom",
140
+ "displayName": "Bottom Spacing",
141
+ "type": "TYPE",
142
+ "required": false,
143
+ "defaultValue": {
144
+ "value": 0,
145
+ "unit": "px"
146
+ },
147
+ "groupId": "desktop",
148
+ "typeId": "@ikas/bp-storefront-models-MarginBottomStyleType"
149
+ }
150
+ ],
151
+ "propGroups": [
152
+ {
153
+ "id": "appearance",
154
+ "name": "Appearance Settings",
155
+ "children": [
156
+ {
157
+ "id": "mobile",
158
+ "name": "Mobile View",
159
+ "description": "Margin settings for mobile devices"
160
+ },
161
+ {
162
+ "id": "desktop",
163
+ "name": "Desktop View",
164
+ "description": "Margin settings for desktop devices"
165
+ }
166
+ ],
167
+ "description": "Component mobile and desktop view settings"
168
+ },
169
+ {
170
+ "id": "texts",
171
+ "name": "Texts",
172
+ "description": "Button, status and modal texts"
173
+ }
174
+ ]
175
+ }
@@ -0,0 +1,141 @@
1
+ import {
2
+ getSelectedProductVariant,
3
+ hasProductStock,
4
+ hasProductVariantStock,
5
+ getProductVariantIsBackInStockEnabled,
6
+ getProductVariantIsBackInStockCustomerLoginRequired,
7
+ saveProductVariantBackInStockReminder,
8
+ getFormattedMarginTopSize,
9
+ getFormattedMarginBottomSize,
10
+ customerStore,
11
+ Router,
12
+ } from "@ikas/bp-storefront";
13
+ import { useEffect, useState } from "preact/hooks";
14
+ import { Props } from "./types";
15
+ import Button from "../../sub-components/Button";
16
+ import Modal from "../../sub-components/Modal";
17
+ import { showToast } from "../../utils/toast";
18
+ import BackInStockForm from "./components/BackInStockForm";
19
+
20
+ export function ProductDetailBackInStock({
21
+ product,
22
+ notifyButtonText = "Notify Me When Available",
23
+ reminderSavedText = "We'll notify you",
24
+ alreadySavedMessage = "You're already on the list — we'll email you when it's back.",
25
+ savedMessage = "Great! We'll email you when this product is back in stock.",
26
+ loginRequiredMessage = "Please sign in so we can notify you.",
27
+ errorMessage = "Something went wrong. Please try again.",
28
+ modalTitle = "Notify me when available",
29
+ modalDescription = "Enter your email and we'll let you know the moment this product is back in stock.",
30
+ emailLabel = "Email",
31
+ submitButtonText = "Notify Me",
32
+ submittingText = "Saving...",
33
+ mobileMarginTop,
34
+ mobileMarginBottom,
35
+ desktopMarginTop,
36
+ desktopMarginBottom,
37
+ }: Props) {
38
+ const [isEmailModalOpen, setIsEmailModalOpen] = useState(false);
39
+ const [reminderSaved, setReminderSaved] = useState(false);
40
+
41
+ const selectedVariant = product
42
+ ? getSelectedProductVariant(product)
43
+ : undefined;
44
+
45
+ // Reset local UI state when the selected variant changes, seeding "saved"
46
+ // from the variant's own persisted reminder state.
47
+ useEffect(() => {
48
+ setReminderSaved(!!selectedVariant?.isBackInStockReminderSaved);
49
+ setIsEmailModalOpen(false);
50
+ }, [selectedVariant?.id]);
51
+
52
+ if (!product || !selectedVariant) return null;
53
+
54
+ // This component owns the notify-me flow only. Stay out of the way unless the
55
+ // variant is out of stock AND back-in-stock reminders are enabled for it.
56
+ const isOutOfStock =
57
+ !hasProductStock(product) || !hasProductVariantStock(selectedVariant);
58
+ if (!isOutOfStock || !getProductVariantIsBackInStockEnabled(selectedVariant)) {
59
+ return null;
60
+ }
61
+
62
+ // Login-required branch: register a logged-in customer immediately, otherwise
63
+ // send an anonymous visitor to login first.
64
+ const registerWithCustomerEmail = async () => {
65
+ const email = customerStore.customer?.email;
66
+ if (!email) {
67
+ showToast(loginRequiredMessage, "error");
68
+ Router.navigateToPage("LOGIN");
69
+ return;
70
+ }
71
+ const ok = await saveProductVariantBackInStockReminder(selectedVariant, email);
72
+ setReminderSaved(ok);
73
+ showToast(ok ? savedMessage : errorMessage, ok ? "success" : "error");
74
+ };
75
+
76
+ const handleNotifyClick = () => {
77
+ if (reminderSaved) {
78
+ showToast(alreadySavedMessage, "success");
79
+ return;
80
+ }
81
+ if (getProductVariantIsBackInStockCustomerLoginRequired(selectedVariant)) {
82
+ void registerWithCustomerEmail();
83
+ return;
84
+ }
85
+ // No login required: collect the email through the form.
86
+ setIsEmailModalOpen(true);
87
+ };
88
+
89
+ const handleFormSaved = () => {
90
+ setReminderSaved(true);
91
+ setIsEmailModalOpen(false);
92
+ showToast(savedMessage, "success");
93
+ };
94
+
95
+ return (
96
+ <div
97
+ className="kombos-pd-bis"
98
+ style={{
99
+ "--mobile-mt": getFormattedMarginTopSize(mobileMarginTop),
100
+ "--mobile-mb": getFormattedMarginBottomSize(mobileMarginBottom),
101
+ "--desktop-mt": getFormattedMarginTopSize(desktopMarginTop),
102
+ "--desktop-mb": getFormattedMarginBottomSize(desktopMarginBottom),
103
+ }}
104
+ >
105
+ {reminderSaved ? (
106
+ <div className="kombos-pd-bis__saved" aria-live="polite">
107
+ {reminderSavedText}
108
+ </div>
109
+ ) : (
110
+ <Button
111
+ variant="secondary"
112
+ size="s"
113
+ className="kombos-pd-bis__btn"
114
+ onClick={handleNotifyClick}
115
+ >
116
+ {notifyButtonText}
117
+ </Button>
118
+ )}
119
+
120
+ {isEmailModalOpen && (
121
+ <Modal
122
+ title={modalTitle}
123
+ onClose={() => setIsEmailModalOpen(false)}
124
+ footer={null}
125
+ >
126
+ <BackInStockForm
127
+ variant={selectedVariant}
128
+ description={modalDescription}
129
+ emailLabel={emailLabel}
130
+ submitButtonText={submitButtonText}
131
+ submittingText={submittingText}
132
+ errorMessage={errorMessage}
133
+ onSaved={handleFormSaved}
134
+ />
135
+ </Modal>
136
+ )}
137
+ </div>
138
+ );
139
+ }
140
+
141
+ export default ProductDetailBackInStock;