@ikas/code-components-mcp 1.4.0-beta.21 → 1.4.0-beta.212
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 +191 -78
- package/data/migration.json +10 -4
- package/data/section-templates/account-info-section/children/AccountFavorites/ikas-config-snippet.json +13 -0
- package/data/section-templates/account-info-section/children/AccountOrders/index.tsx +1 -1
- package/data/section-templates/account-info-section/ikas-config-snippet.json +19 -0
- package/data/section-templates/add-to-cart/ikas-config-snippet.json +17 -0
- 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 +24 -0
- package/data/section-templates/blog-home-section/ikas-config-snippet.json +2 -0
- package/data/section-templates/blog-home-section/index.tsx +2 -1
- package/data/section-templates/blog-post-section/ikas-config-snippet.json +2 -0
- package/data/section-templates/blog-post-section/index.tsx +3 -2
- package/data/section-templates/bundle-products/hooks/useBundleProducts.ts +7 -6
- package/data/section-templates/bundle-products/ikas-config-snippet.json +20 -0
- package/data/section-templates/cart-section/components/EmptyState/index.tsx +1 -1
- package/data/section-templates/cart-section/sub-components/CartItem/index.tsx +66 -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/cart-section/utils/cartLimits.ts +24 -0
- package/data/section-templates/cart-section/utils/cartLineLimits.ts +100 -0
- package/data/section-templates/category-images-section/children/CategoryImageItem/ikas-config-snippet.json +4 -0
- package/data/section-templates/category-images-section/ikas-config-snippet.json +15 -0
- package/data/section-templates/category-list-section/children/CardProductName/ikas-config-snippet.json +2 -1
- package/data/section-templates/category-list-section/ikas-config-snippet.json +21 -0
- package/data/section-templates/category-list-section/index.tsx +2 -1
- package/data/section-templates/component-renderer/additional/ProductDetail/index.tsx +5 -1
- package/data/section-templates/component-renderer/ikas-config-snippet.json +13 -0
- package/data/section-templates/favorites/ikas-config-snippet.json +18 -1
- package/data/section-templates/features-section/ikas-config-snippet.json +13 -0
- package/data/section-templates/footer-section/ikas-config-snippet.json +15 -0
- package/data/section-templates/forgot-password-section/components/ForgotPasswordForm/index.tsx +1 -1
- package/data/section-templates/header-section/children/Announcements/ikas-config-snippet.json +7 -0
- package/data/section-templates/header-section/children/CookieBar/ikas-config-snippet.json +3 -0
- package/data/section-templates/header-section/children/Navbar/components/CartSidebar/index.tsx +2 -1
- package/data/section-templates/header-section/children/Navbar/components/SearchModal/index.tsx +2 -1
- package/data/section-templates/header-section/children/Navbar/ikas-config-snippet.json +27 -0
- package/data/section-templates/header-section/children/Navbar/index.tsx +6 -5
- package/data/section-templates/header-section/ikas-config-snippet.json +13 -0
- package/data/section-templates/header-section/sub-components/CartItem/index.tsx +66 -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/header-section/utils/cartLimits.ts +24 -0
- package/data/section-templates/header-section/utils/cartLineLimits.ts +100 -0
- package/data/section-templates/hero-slider-section/children/HeroSliderItem/ikas-config-snippet.json +18 -0
- package/data/section-templates/hero-slider-section/children/HeroSliderItem/index.tsx +12 -7
- package/data/section-templates/hero-slider-section/ikas-config-snippet.json +13 -0
- package/data/section-templates/image-handling/ikas-config-snippet.json +53 -0
- package/data/section-templates/image-handling/index.tsx +5 -1
- package/data/section-templates/login-section/ikas-config-snippet.json +2 -0
- package/data/section-templates/navigation/components/CartSidebar/index.tsx +2 -1
- package/data/section-templates/navigation/components/SearchModal/index.tsx +2 -1
- package/data/section-templates/navigation/ikas-config-snippet.json +27 -0
- package/data/section-templates/navigation/index.tsx +6 -5
- package/data/section-templates/navigation/sub-components/CartItem/index.tsx +66 -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/navigation/utils/cartLimits.ts +24 -0
- package/data/section-templates/navigation/utils/cartLineLimits.ts +100 -0
- package/data/section-templates/product-detail-section/children/ProductDetailAddToCart/ikas-config-snippet.json +17 -0
- 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 +175 -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/children/ProductDetailBundleFurniture/ikas-config-snippet.json +18 -0
- package/data/section-templates/product-detail-section/children/ProductDetailBundleProduct/ikas-config-snippet.json +20 -0
- package/data/section-templates/product-detail-section/children/ProductDetailDescription/ikas-config-snippet.json +27 -0
- package/data/section-templates/product-detail-section/children/ProductDetailFeatures/ikas-config-snippet.json +29 -0
- package/data/section-templates/product-detail-section/children/ProductDetailNameFavorite/ikas-config-snippet.json +18 -1
- package/data/section-templates/product-detail-section/children/ProductDetailOffer/ikas-config-snippet.json +20 -0
- package/data/section-templates/product-detail-section/children/ProductDetailOptionSet/ikas-config-snippet.json +16 -0
- package/data/section-templates/product-detail-section/children/ProductDetailPrices/ikas-config-snippet.json +16 -0
- package/data/section-templates/product-detail-section/children/ProductDetailProductGroup/ikas-config-snippet.json +16 -0
- package/data/section-templates/product-detail-section/children/ProductDetailSku/ikas-config-snippet.json +16 -0
- package/data/section-templates/product-detail-section/children/ProductDetailVariant/ikas-config-snippet.json +17 -0
- package/data/section-templates/product-detail-section/hooks/useBundleProducts.ts +7 -6
- package/data/section-templates/product-detail-section/ikas-config-snippet.json +53 -0
- package/data/section-templates/product-detail-section/index.tsx +5 -1
- 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 +24 -0
- package/data/section-templates/product-pricing/ikas-config-snippet.json +16 -0
- package/data/section-templates/product-slider-section/children/CardProductName/ikas-config-snippet.json +2 -1
- package/data/section-templates/product-slider-section/ikas-config-snippet.json +17 -0
- package/data/section-templates/register-section/ikas-config-snippet.json +2 -0
- package/data/section-templates/variant-selection/ikas-config-snippet.json +17 -0
- package/data/storefront-api.json +682 -39
- package/data/storefront-types.json +9694 -6047
- package/dist/index.js +1614 -42
- package/dist/index.js.map +1 -1
- 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(
|
|
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,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
|
{
|
package/data/section-templates/product-detail-section/children/ProductDetailAddToCart/index.tsx
CHANGED
|
@@ -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"
|
|
@@ -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
|
+
}
|
package/data/section-templates/product-detail-section/children/ProductDetailBackInStock/index.tsx
ADDED
|
@@ -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;
|