@evenicanpm/storefront-core 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__mocks__/next-image.tsx +3 -1
- package/package.json +3 -4
- package/src/api-manager/datasources/d365/d365-product.datasource.ts +1 -1
- package/src/api-manager/datasources/d365/utils/product-inventory-utils.ts +1 -3
- package/src/api-manager/datasources/e4/cart/e4-cart.remaps.ts +7 -5
- package/src/api-manager/datasources/e4/product/e4-product.datasource.ts +1 -4
- package/src/api-manager/datasources/e4/product/e4-product.translator.ts +13 -19
- package/src/api-manager/datasources/e4/utils/unwrap-e4-variants.ts +10 -13
- package/src/auth/auth-options.ts +34 -1
- package/src/auth/providers/entra-external-id-provider.ts +50 -0
- package/src/auth/providers/index.ts +8 -1
- package/src/auth/refresh-token.ts +1 -1
- package/src/cms/blocks/interfaces.ts +2 -0
- package/src/components-v2/BazaarMenu.stories.tsx +1 -1
- package/src/components-v2/BazaarMenu.tsx +4 -3
- package/src/components-v2/categories/category-list/category-list.tsx +5 -1
- package/src/components-v2/categories/category-menu.tsx +11 -5
- package/src/components-v2/product-dimensions/compound/dimensions-group-list-chip.tsx +36 -37
- package/src/components-v2/scrollbar/Scrollbar.stories.tsx +2 -2
- package/src/components-v2/search-bar/compound/results.tsx +1 -1
- package/src/components-v2/wishlist-dialogs/add-to-wishlist/add-to-wishlist-button.tsx +14 -15
- package/src/components-v2/wishlist-dialogs/styles.ts +8 -10
- package/src/pages-v2/account/addresses/address-item.tsx +2 -1
- package/src/pages-v2/checkout/checkout-alt-form/checkout-form.tsx +6 -1
- package/src/pages-v2/checkout/checkout-alt-form/steps/address/new-address-form.tsx +9 -8
- package/src/pages-v2/product-details/bopis/find-in-store-modal.tsx +1 -1
- package/src/pages-v2/product-details/product-intro/compound/thumbnail-with-skeleton.tsx +1 -0
- package/src/pages-v2/product-list/product-list-context.tsx +123 -44
- package/src/pages-v2/product-list/range-filter.tsx +36 -25
- package/src/pages-v2/product-list/selected-facets.tsx +15 -8
package/__mocks__/next-image.tsx
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evenicanpm/storefront-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Core module for D365/e4 Headless Storefront",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"author": "Evenica",
|
|
17
17
|
"license": "ISC",
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@evenicanpm/ui": "
|
|
19
|
+
"@evenicanpm/ui": "file:../../../common/@evenicanpm/ui"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@storybook/addon-docs": "^9.1.6",
|
|
@@ -48,6 +48,5 @@
|
|
|
48
48
|
"@storybook/builder-manager": {
|
|
49
49
|
"esbuild": "0.19.11"
|
|
50
50
|
}
|
|
51
|
-
}
|
|
52
|
-
"gitHead": "9b18fe6294cc0e57332ec8703825a609ac7b73b2"
|
|
51
|
+
}
|
|
53
52
|
}
|
|
@@ -150,7 +150,7 @@ class D365ProductDatasource extends D365DatasourceBase implements ProductApi {
|
|
|
150
150
|
|
|
151
151
|
const refinerSearchSettings = { ...searchCriteria };
|
|
152
152
|
const queryResultSettingsWithCount: IQueryResultSettings = {
|
|
153
|
-
...
|
|
153
|
+
...queryResultSettings,
|
|
154
154
|
count: true,
|
|
155
155
|
};
|
|
156
156
|
|
|
@@ -17,9 +17,7 @@ const createInventoryAvailabilitySearchCriteria = (
|
|
|
17
17
|
defaultDeliveryMode: DeliveryMode,
|
|
18
18
|
) => {
|
|
19
19
|
const salesUnitType = 2; // Sales unit type is always 2 for product inventory
|
|
20
|
-
const deliveryMode = defaultDeliveryMode
|
|
21
|
-
? defaultDeliveryMode
|
|
22
|
-
: DeliveryMode.shipping;
|
|
20
|
+
const deliveryMode = defaultDeliveryMode || DeliveryMode.shipping;
|
|
23
21
|
|
|
24
22
|
const inventoryAvailabilitySearchCriteria: InventoryAvailabilitySearchCriteria =
|
|
25
23
|
{
|
|
@@ -30,12 +30,14 @@ export const cartLineMap = {
|
|
|
30
30
|
if (!product) return;
|
|
31
31
|
|
|
32
32
|
// For some reason transform is run multiple times. This prevents merge from running multiple times and overwriting translation.
|
|
33
|
-
const
|
|
34
|
-
|
|
33
|
+
const raw = merge(product, productMap) as unknown as DynProduct;
|
|
34
|
+
|
|
35
|
+
if (!raw?.RecordId) return product;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
...raw,
|
|
39
|
+
ProductTypeValue: 4, // default for now. Type = Standalone
|
|
35
40
|
};
|
|
36
|
-
if (!translated.RecordId) return product;
|
|
37
|
-
translated.ProductTypeValue = 4; // default for now. Type = Standalone
|
|
38
|
-
return translated;
|
|
39
41
|
},
|
|
40
42
|
},
|
|
41
43
|
"[].id": "[].LineId",
|
|
@@ -13,9 +13,6 @@ import type {
|
|
|
13
13
|
SimpleProduct,
|
|
14
14
|
} from "@msdyn365-commerce/retail-proxy";
|
|
15
15
|
|
|
16
|
-
// See TODO below
|
|
17
|
-
//import { generateCategoryFilterString } from "./e4.translator";
|
|
18
|
-
|
|
19
16
|
const product: ProductApi = {
|
|
20
17
|
async getProductById(
|
|
21
18
|
input: productSchema.GetProductByIdInput,
|
|
@@ -92,7 +89,7 @@ const product: ProductApi = {
|
|
|
92
89
|
return null;
|
|
93
90
|
})
|
|
94
91
|
.filter(Boolean) as ProductSearchResult[];
|
|
95
|
-
return products
|
|
92
|
+
return products;
|
|
96
93
|
},
|
|
97
94
|
|
|
98
95
|
async getProductsList(
|
|
@@ -207,7 +207,7 @@ export const refinersTransformValidate = (
|
|
|
207
207
|
if (
|
|
208
208
|
!rawRefiner ||
|
|
209
209
|
typeof rawRefiner !== "object" ||
|
|
210
|
-
!rawRefiner.
|
|
210
|
+
!rawRefiner.name ||
|
|
211
211
|
!Array.isArray(rawRefiner.value)
|
|
212
212
|
) {
|
|
213
213
|
console.warn("Skipping invalid raw refiner object:", rawRefiner);
|
|
@@ -219,8 +219,8 @@ export const refinersTransformValidate = (
|
|
|
219
219
|
if (
|
|
220
220
|
!rawValue ||
|
|
221
221
|
typeof rawValue !== "object" ||
|
|
222
|
-
!rawValue.
|
|
223
|
-
typeof rawValue.
|
|
222
|
+
!rawValue.value ||
|
|
223
|
+
typeof rawValue.count !== "number"
|
|
224
224
|
) {
|
|
225
225
|
console.warn(
|
|
226
226
|
"Skipping invalid raw refiner value object:",
|
|
@@ -229,33 +229,29 @@ export const refinersTransformValidate = (
|
|
|
229
229
|
return null;
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
RECORD_ID_MAPPER.add(
|
|
233
|
-
Number(rawValue?.id),
|
|
234
|
-
rawValue?.AttributeId ?? "",
|
|
235
|
-
);
|
|
232
|
+
RECORD_ID_MAPPER.add(Number(rawValue?.id), rawValue?.id ?? "");
|
|
236
233
|
|
|
237
234
|
return {
|
|
238
235
|
RefinerRecordId: Number(rawValue?.id),
|
|
239
236
|
DataTypeValue: DATA_TYPE_VALUE_STRING,
|
|
240
|
-
LeftValueBoundString: rawValue.
|
|
241
|
-
RightValueBoundString: rawValue.
|
|
237
|
+
LeftValueBoundString: rawValue.value,
|
|
238
|
+
RightValueBoundString: rawValue.value,
|
|
242
239
|
UnitText: "",
|
|
243
240
|
RowNumber: 0,
|
|
244
|
-
Count: rawValue.
|
|
241
|
+
Count: rawValue.count,
|
|
245
242
|
LeftValueBoundLocalizedString: "",
|
|
246
243
|
RightValueBoundLocalizedString: "",
|
|
247
244
|
ExtensionProperties: [],
|
|
248
245
|
RefinerSourceValue: SOURCE_VALUE_ATTRIBUTE,
|
|
249
246
|
SwatchImageUrl: "",
|
|
250
|
-
SwatchColorHexCode:
|
|
251
|
-
rawValue?.AttributeId === "color" ? rawValue.AttributeValue : "",
|
|
247
|
+
SwatchColorHexCode: rawValue?.id === "color" ? rawValue.value : "",
|
|
252
248
|
"@odata.type": ODATA_TYPE_REFINER_VALUE,
|
|
253
249
|
};
|
|
254
250
|
})
|
|
255
251
|
.filter((value): value is TransformedRefinerValue => value !== null);
|
|
256
252
|
|
|
257
253
|
const isDimension = DIMENSION_ATTRIBUTE_IDS.has(
|
|
258
|
-
rawRefiner.
|
|
254
|
+
rawRefiner.id?.toLowerCase() ?? "",
|
|
259
255
|
);
|
|
260
256
|
|
|
261
257
|
return {
|
|
@@ -292,16 +288,14 @@ export const refinersTransformValidate = (
|
|
|
292
288
|
export const productTransformValidate = (
|
|
293
289
|
productData: Product,
|
|
294
290
|
): ProductDetails => {
|
|
295
|
-
const translatedData = merge(
|
|
296
|
-
|
|
297
|
-
productDetailsMap,
|
|
298
|
-
) as ProductDetails;
|
|
291
|
+
const translatedData = merge(productData, productDetailsMap);
|
|
292
|
+
|
|
299
293
|
try {
|
|
300
|
-
productSchema.parse(translatedData);
|
|
294
|
+
return productSchema.parse(translatedData) as ProductDetails;
|
|
301
295
|
} catch (e) {
|
|
302
296
|
console.warn("Product data does not match retail-proxy schema", e);
|
|
297
|
+
return translatedData as ProductDetails; // fallback only if needed
|
|
303
298
|
}
|
|
304
|
-
return translatedData;
|
|
305
299
|
};
|
|
306
300
|
|
|
307
301
|
export const productPricesTransformValidate = (
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Attributes,
|
|
3
|
-
Maybe,
|
|
4
|
-
Variants,
|
|
5
|
-
} from "@evenicanpm/storefront-core/src/api-manager/datasources/e4/graphqlRequestSdk";
|
|
1
|
+
import type { Variants } from "@evenicanpm/storefront-core/src/api-manager/datasources/e4/graphqlRequestSdk";
|
|
6
2
|
import { dimensionMap } from "@evenicanpm/storefront-core/src/api-manager/datasources/e4/product/e4-product.remaps";
|
|
7
3
|
import { merge } from "object-mapper";
|
|
8
4
|
|
|
@@ -28,18 +24,19 @@ export default function unwrapE4Variants(
|
|
|
28
24
|
variants: Variants[],
|
|
29
25
|
): UnwrappedAttribute[][] {
|
|
30
26
|
if (!variants?.length) return [];
|
|
31
|
-
const attrs: UnwrappedAttribute[] = variants.flatMap(
|
|
32
|
-
(v)
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
const attrs: UnwrappedAttribute[] = variants.flatMap((v) =>
|
|
28
|
+
(v.Attributes ?? [])
|
|
29
|
+
.map((attr) => {
|
|
30
|
+
if (!attr) return null;
|
|
35
31
|
|
|
36
|
-
|
|
32
|
+
const dimension = merge(attr, dimensionMap);
|
|
37
33
|
|
|
38
34
|
return {
|
|
39
|
-
...dimension,
|
|
35
|
+
...(dimension as object),
|
|
40
36
|
ProductIds: [v.ProductId],
|
|
41
|
-
}
|
|
42
|
-
})
|
|
37
|
+
};
|
|
38
|
+
})
|
|
39
|
+
.filter((item): item is UnwrappedAttribute => item !== null),
|
|
43
40
|
);
|
|
44
41
|
|
|
45
42
|
const grouped: UnwrappedAttribute[][] = attrs.reduce((acc, item) => {
|
package/src/auth/auth-options.ts
CHANGED
|
@@ -23,6 +23,35 @@ const createAuthOptions = (): NextAuthOptions => {
|
|
|
23
23
|
return {
|
|
24
24
|
providers,
|
|
25
25
|
secret,
|
|
26
|
+
cookies: {
|
|
27
|
+
csrfToken: {
|
|
28
|
+
name: "next-auth.csrf-token",
|
|
29
|
+
options: {
|
|
30
|
+
httpOnly: true,
|
|
31
|
+
sameSite: "none",
|
|
32
|
+
path: "/",
|
|
33
|
+
secure: true,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
pkceCodeVerifier: {
|
|
37
|
+
name: "next-auth.pkce.code_verifier",
|
|
38
|
+
options: {
|
|
39
|
+
httpOnly: true,
|
|
40
|
+
sameSite: "none",
|
|
41
|
+
path: "/",
|
|
42
|
+
secure: true,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
nonce: {
|
|
46
|
+
name: "next-auth.nonce",
|
|
47
|
+
options: {
|
|
48
|
+
httpOnly: true,
|
|
49
|
+
sameSite: "none",
|
|
50
|
+
path: "/",
|
|
51
|
+
secure: true,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
26
55
|
// Order in which callbacks are called = signin > redirect > jwt > session. Thats why session callback has token info.
|
|
27
56
|
callbacks: {
|
|
28
57
|
// By default, NextAuth uses "session" with strategy: "jwt"
|
|
@@ -31,7 +60,11 @@ const createAuthOptions = (): NextAuthOptions => {
|
|
|
31
60
|
// account and profile is available only in first callback after signin
|
|
32
61
|
if (account && profile) {
|
|
33
62
|
_token.provider = account.provider;
|
|
34
|
-
|
|
63
|
+
// use id_token for entra external id and access_token for aadb2c
|
|
64
|
+
_token.accessToken =
|
|
65
|
+
_token.provider === "entra-external-id"
|
|
66
|
+
? account.id_token
|
|
67
|
+
: account.access_token;
|
|
35
68
|
_token.refreshToken = account.refresh_token;
|
|
36
69
|
}
|
|
37
70
|
return _token;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Provider } from "next-auth/providers";
|
|
2
|
+
|
|
3
|
+
const createEntraExternalIdProvider = (): Provider => {
|
|
4
|
+
const clientId = process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_ID || "";
|
|
5
|
+
const customScopes =
|
|
6
|
+
process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CUSTOM_SCOPES || "";
|
|
7
|
+
const tenantName =
|
|
8
|
+
process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_TENANT_NAME || "";
|
|
9
|
+
const clientSecret =
|
|
10
|
+
process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_SECRET || "";
|
|
11
|
+
|
|
12
|
+
// Build auth url and wellKnown from the loginAuthority
|
|
13
|
+
const loginAuthority = `https://${tenantName}.ciamlogin.com/${tenantName}.onmicrosoft.com`;
|
|
14
|
+
const authUrl = `${loginAuthority}/oauth2/v2.0/authorize`;
|
|
15
|
+
const wellKnownUrl = `${loginAuthority}/v2.0/.well-known/openid-configuration`;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
id: "entra-external-id",
|
|
19
|
+
clientId: clientId,
|
|
20
|
+
client: {
|
|
21
|
+
client_id: clientId,
|
|
22
|
+
response_types: ["code id_token"],
|
|
23
|
+
client_secret: clientSecret,
|
|
24
|
+
// token_endpoint_auth_method: 'none',
|
|
25
|
+
},
|
|
26
|
+
type: "oauth",
|
|
27
|
+
idToken: true,
|
|
28
|
+
name: "Microsoft Entra External ID",
|
|
29
|
+
authorization: {
|
|
30
|
+
url: authUrl,
|
|
31
|
+
params: {
|
|
32
|
+
scope: customScopes,
|
|
33
|
+
prompt: "login",
|
|
34
|
+
response_type: "code id_token",
|
|
35
|
+
response_mode: "form_post",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
wellKnown: wellKnownUrl,
|
|
39
|
+
profile(profile) {
|
|
40
|
+
return {
|
|
41
|
+
id: profile.oid,
|
|
42
|
+
name: profile.name,
|
|
43
|
+
email: profile.email,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
checks: ["pkce", "nonce"],
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export default createEntraExternalIdProvider;
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import azureADB2CProvider from "@/auth/providers/aadb2c-provider";
|
|
2
2
|
import authentikProvider from "@/auth/providers/authentik-provider";
|
|
3
3
|
import keycloakProvider from "@/auth/providers/keycloak-provider";
|
|
4
|
+
import createEntraExternalIdProvider from "@/auth/providers/entra-external-id-provider";
|
|
4
5
|
|
|
5
6
|
export const getProviders = () => {
|
|
6
7
|
const azureProvider = azureADB2CProvider();
|
|
8
|
+
const entraExternalIdProvider = createEntraExternalIdProvider();
|
|
7
9
|
// once we stop hard coding others use loader function here
|
|
8
|
-
return [
|
|
10
|
+
return [
|
|
11
|
+
authentikProvider,
|
|
12
|
+
azureProvider,
|
|
13
|
+
keycloakProvider,
|
|
14
|
+
entraExternalIdProvider,
|
|
15
|
+
];
|
|
9
16
|
};
|
|
@@ -53,7 +53,7 @@ const checkAccessTokenExpiry = async (
|
|
|
53
53
|
const matchedProvider = providers.find(
|
|
54
54
|
(p) => p.id.toLowerCase() === token.provider.toLowerCase(),
|
|
55
55
|
);
|
|
56
|
-
if (!matchedProvider) {
|
|
56
|
+
if (!matchedProvider || !("wellKnown" in matchedProvider)) {
|
|
57
57
|
token.error = "NoProviderMatch";
|
|
58
58
|
return {
|
|
59
59
|
token,
|
|
@@ -8,7 +8,7 @@ import { Fragment, type ReactNode, useState } from "react";
|
|
|
8
8
|
interface Props {
|
|
9
9
|
open?: boolean;
|
|
10
10
|
sx?: SxProps<Theme>;
|
|
11
|
-
handler?: (
|
|
11
|
+
handler?: (onClick: React.MouseEventHandler<HTMLElement>) => ReactNode;
|
|
12
12
|
options: (e: () => void) => ReactNode;
|
|
13
13
|
direction?: "left" | "right" | "center";
|
|
14
14
|
}
|
|
@@ -25,8 +25,9 @@ export default function BazaarMenu({
|
|
|
25
25
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
|
26
26
|
|
|
27
27
|
const handleClose = () => setAnchorEl(null);
|
|
28
|
-
const handleClick
|
|
29
|
-
setAnchorEl(event.currentTarget
|
|
28
|
+
const handleClick: React.MouseEventHandler<HTMLElement> = (event) => {
|
|
29
|
+
setAnchorEl(event.currentTarget);
|
|
30
|
+
};
|
|
30
31
|
|
|
31
32
|
return (
|
|
32
33
|
<Fragment>
|
|
@@ -82,7 +82,11 @@ CategoryList.Item = ({ category }: ItemProps) => {
|
|
|
82
82
|
href={`/products/${Url}`}
|
|
83
83
|
title={Name ?? ""}
|
|
84
84
|
caret={hasChildren}
|
|
85
|
-
render={
|
|
85
|
+
render={
|
|
86
|
+
Children && Children.length > 0
|
|
87
|
+
? megaMenuRenderers[menuType]?.(Children)
|
|
88
|
+
: null
|
|
89
|
+
}
|
|
86
90
|
/>
|
|
87
91
|
);
|
|
88
92
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import CategoryList from "@evenicanpm/storefront-core/src/components-v2/categories/category-list/index";
|
|
4
4
|
import styled from "@mui/material/styles/styled";
|
|
5
|
-
import {
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
6
|
|
|
7
7
|
// STYLED COMPONENT
|
|
8
8
|
const Wrapper = styled("div", {
|
|
@@ -31,13 +31,19 @@ type Props = {
|
|
|
31
31
|
// not as a reusable UI element that needs further extensibility.
|
|
32
32
|
export default function CategoryMenu({ render }: Readonly<Props>) {
|
|
33
33
|
const [open, setOpen] = useState(false);
|
|
34
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
34
35
|
|
|
35
|
-
const onClick = (
|
|
36
|
-
e.stopPropagation();
|
|
36
|
+
const onClick = () => {
|
|
37
37
|
setOpen((open) => !open);
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
const handleDocumentClick = useCallback(() =>
|
|
40
|
+
const handleDocumentClick = useCallback((e: globalThis.MouseEvent) => {
|
|
41
|
+
const target = e.target as Node | null;
|
|
42
|
+
if (target && wrapperRef.current?.contains(target)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
setOpen(false);
|
|
46
|
+
}, []);
|
|
41
47
|
|
|
42
48
|
useEffect(() => {
|
|
43
49
|
document.addEventListener("click", handleDocumentClick);
|
|
@@ -45,7 +51,7 @@ export default function CategoryMenu({ render }: Readonly<Props>) {
|
|
|
45
51
|
}, [handleDocumentClick]);
|
|
46
52
|
|
|
47
53
|
return (
|
|
48
|
-
<Wrapper open={open}>
|
|
54
|
+
<Wrapper ref={wrapperRef} open={open}>
|
|
49
55
|
{render(onClick)}
|
|
50
56
|
|
|
51
57
|
<CategoryList open={open} />
|
|
@@ -37,43 +37,42 @@ export const DimensionGroupListChip = ({ dimension }: Props) => {
|
|
|
37
37
|
selectDimensions[dimension.DimensionTypeValue || 0]?.DimensionValue ===
|
|
38
38
|
dimension.DimensionValue;
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
40
|
+
if (type === "Color") {
|
|
41
|
+
return (
|
|
42
|
+
<Chip
|
|
43
|
+
key={dimension.DimensionValue?.RecordId}
|
|
44
|
+
onClick={() => {
|
|
45
|
+
handleChangeVariant(dimension);
|
|
46
|
+
}}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
sx={{
|
|
49
|
+
borderRadius: "100%",
|
|
50
|
+
mr: 1,
|
|
51
|
+
cursor: "pointer",
|
|
52
|
+
backgroundColor: dimension.DimensionValue?.Value,
|
|
53
|
+
width: 32,
|
|
54
|
+
height: 32,
|
|
55
|
+
border: selected ? "2px solid" : "2px solid transparent",
|
|
56
|
+
"&:hover": {
|
|
53
57
|
backgroundColor: dimension.DimensionValue?.Value,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
backgroundColor: dimension.DimensionValue?.Value,
|
|
59
|
-
},
|
|
60
|
-
}}
|
|
61
|
-
/>
|
|
62
|
-
);
|
|
63
|
-
// Standard text dimension
|
|
64
|
-
default:
|
|
65
|
-
return (
|
|
66
|
-
<Chip
|
|
67
|
-
key={dimension.DimensionValue?.RecordId}
|
|
68
|
-
label={dimension.DimensionValue?.Value}
|
|
69
|
-
onClick={() => {
|
|
70
|
-
handleChangeVariant(dimension);
|
|
71
|
-
}}
|
|
72
|
-
disabled={disabled}
|
|
73
|
-
sx={{ borderRadius: "4px", mr: 1, cursor: "pointer", mb: 1 }}
|
|
74
|
-
color={selected ? "primary" : "default"}
|
|
75
|
-
/>
|
|
76
|
-
);
|
|
77
|
-
// Add more chips as needed.
|
|
58
|
+
},
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
78
62
|
}
|
|
63
|
+
|
|
64
|
+
// Standard text dimension
|
|
65
|
+
return (
|
|
66
|
+
<Chip
|
|
67
|
+
key={dimension.DimensionValue?.RecordId}
|
|
68
|
+
label={dimension.DimensionValue?.Value}
|
|
69
|
+
onClick={() => {
|
|
70
|
+
handleChangeVariant(dimension);
|
|
71
|
+
}}
|
|
72
|
+
disabled={disabled}
|
|
73
|
+
sx={{ borderRadius: "4px", mr: 1, cursor: "pointer", mb: 1 }}
|
|
74
|
+
color={selected ? "primary" : "default"}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
// Add more chips as needed.
|
|
79
78
|
};
|
|
@@ -29,7 +29,7 @@ export default function SearchResultsContainer({
|
|
|
29
29
|
category: state.searchCategory,
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
if (!results || results.length === 0) {
|
|
32
|
+
if (!results || !Array.isArray(results) || results.length === 0) {
|
|
33
33
|
return <SearchResultCard>{t("Search.noResults")}</SearchResultCard>;
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -23,23 +23,22 @@ const AddToWishlistButton = (props: Props) => {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
// conditionally render button styles based on variant (pdp or minicart)
|
|
26
|
-
let style: any
|
|
26
|
+
let style: any;
|
|
27
|
+
let btnFullwidth: boolean;
|
|
27
28
|
let btnColor: ButtonProps["color"];
|
|
28
29
|
let btnVariant: ButtonProps["variant"];
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
btnColor = "secondary";
|
|
42
|
-
break;
|
|
30
|
+
|
|
31
|
+
if (props.variant === "minicart") {
|
|
32
|
+
style = {
|
|
33
|
+
px: 4,
|
|
34
|
+
};
|
|
35
|
+
btnFullwidth = true;
|
|
36
|
+
btnVariant = "outlined";
|
|
37
|
+
btnColor = "primary";
|
|
38
|
+
} else {
|
|
39
|
+
btnFullwidth = false;
|
|
40
|
+
btnVariant = "contained";
|
|
41
|
+
btnColor = "secondary";
|
|
43
42
|
}
|
|
44
43
|
|
|
45
44
|
return (
|
|
@@ -9,16 +9,14 @@ type ExtendedNavLinkProps = NavLinkProps & {
|
|
|
9
9
|
isCurrentPath?: boolean;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
export const MainContainer
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}),
|
|
21
|
-
) as typeof Card;
|
|
12
|
+
export const MainContainer = styled(Card)(({ theme }) => ({
|
|
13
|
+
paddingBottom: "1.5rem",
|
|
14
|
+
[theme.breakpoints.down("md")]: {
|
|
15
|
+
boxShadow: "none",
|
|
16
|
+
overflowY: "auto",
|
|
17
|
+
height: "calc(100vh - 64px)",
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
22
20
|
|
|
23
21
|
export const StyledNavLink: ComponentType<ExtendedNavLinkProps> = styled(
|
|
24
22
|
NavLink,
|
|
@@ -83,9 +83,10 @@ const AddressListItem = ({ address, children, sx }: RootProps) => {
|
|
|
83
83
|
};
|
|
84
84
|
|
|
85
85
|
// ----------------- Subcomponents -----------------
|
|
86
|
+
type FieldValue = string | number | boolean;
|
|
86
87
|
interface FieldProps {
|
|
87
88
|
field: keyof Address; // "Name" | "FullAddress" | "Phone" etc.
|
|
88
|
-
render?: (value:
|
|
89
|
+
render?: (value: FieldValue, address: Address) => ReactNode;
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
AddressListItem.Field = ({ field, render }: FieldProps) => {
|
|
@@ -166,10 +166,15 @@ CheckoutForm.Step = <T extends object>({
|
|
|
166
166
|
const t = useTranslations("Checkout");
|
|
167
167
|
if (!ctx) return null;
|
|
168
168
|
const { activeStep, setActiveStep, cart } = ctx;
|
|
169
|
+
|
|
170
|
+
if (stepNumber === undefined) {
|
|
171
|
+
throw new Error("CheckoutForm.Step must be used inside CheckoutForm.Steps");
|
|
172
|
+
}
|
|
173
|
+
|
|
169
174
|
return (
|
|
170
175
|
<CheckoutStep
|
|
171
176
|
title={t(title)}
|
|
172
|
-
stepNumber={stepNumber
|
|
177
|
+
stepNumber={stepNumber}
|
|
173
178
|
activeStep={activeStep}
|
|
174
179
|
setActiveStep={setActiveStep}
|
|
175
180
|
cart={cart}
|
|
@@ -179,12 +179,12 @@ const NewAddressForm = ({
|
|
|
179
179
|
const ctx: NewAddressFormCtx = {
|
|
180
180
|
form,
|
|
181
181
|
countries,
|
|
182
|
-
states: states
|
|
183
|
-
? states
|
|
182
|
+
states: Array.isArray(states)
|
|
183
|
+
? states.map((s) => ({
|
|
184
184
|
StateName: s.StateName ?? "",
|
|
185
185
|
StateId: s.StateId ?? "",
|
|
186
186
|
}))
|
|
187
|
-
:
|
|
187
|
+
: [],
|
|
188
188
|
selectedCountry,
|
|
189
189
|
setSelectedCountry,
|
|
190
190
|
errors,
|
|
@@ -312,11 +312,12 @@ NewAddressForm.StateSelect = () => {
|
|
|
312
312
|
render={({ field }) => (
|
|
313
313
|
<Select labelId="state-label" {...field}>
|
|
314
314
|
<MenuItem value="0">Select a state</MenuItem>
|
|
315
|
-
{states
|
|
316
|
-
|
|
317
|
-
{
|
|
318
|
-
|
|
319
|
-
|
|
315
|
+
{Array.isArray(states) &&
|
|
316
|
+
states?.map(({ StateName, StateId }) => (
|
|
317
|
+
<MenuItem key={StateId} value={StateId}>
|
|
318
|
+
{StateName}
|
|
319
|
+
</MenuItem>
|
|
320
|
+
))}
|
|
320
321
|
</Select>
|
|
321
322
|
)}
|
|
322
323
|
/>
|
|
@@ -155,7 +155,7 @@ const FindInStoreModal = ({
|
|
|
155
155
|
if (!stores) {
|
|
156
156
|
return [];
|
|
157
157
|
}
|
|
158
|
-
const _stores =
|
|
158
|
+
const _stores = Object.values(stores);
|
|
159
159
|
if (hideOutOfStock) {
|
|
160
160
|
return _stores.filter(
|
|
161
161
|
(store) => !isOutOfStock(store.location.OrgUnitNumber || ""),
|
|
@@ -13,6 +13,7 @@ function ThumbnailWithSkeleton({ src }: Readonly<{ src: string }>) {
|
|
|
13
13
|
{!loaded && <Skeleton variant="rounded" width={50} height={50} />}
|
|
14
14
|
<img
|
|
15
15
|
src={src}
|
|
16
|
+
alt="Product thumbnail"
|
|
16
17
|
onLoad={() => setLoaded(true)}
|
|
17
18
|
style={{
|
|
18
19
|
display: loaded ? "block" : "none",
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "@msdyn365-commerce/retail-proxy";
|
|
16
16
|
import { usePathname, useSearchParams } from "next/navigation";
|
|
17
17
|
import { parseAsInteger, parseAsString, useQueryState } from "nuqs";
|
|
18
|
-
import React, { createContext, useCallback, useMemo } from "react";
|
|
18
|
+
import React, { createContext, useCallback, useMemo, useRef } from "react";
|
|
19
19
|
import {
|
|
20
20
|
buildInitialState,
|
|
21
21
|
calculatePage,
|
|
@@ -52,6 +52,7 @@ export const ProductListContext = createContext<
|
|
|
52
52
|
const ProductListProvider = ({ children }: { children: React.ReactNode }) => {
|
|
53
53
|
const pathname = usePathname();
|
|
54
54
|
const searchParams = useSearchParams();
|
|
55
|
+
const isUserInitiated = useRef(false);
|
|
55
56
|
const [topParameter, setTopParameter] = useQueryState(
|
|
56
57
|
PageKey.TOP,
|
|
57
58
|
parseAsInteger
|
|
@@ -96,16 +97,57 @@ const ProductListProvider = ({ children }: { children: React.ReactNode }) => {
|
|
|
96
97
|
.withOptions({ clearOnDefault: true }),
|
|
97
98
|
);
|
|
98
99
|
|
|
99
|
-
const initialState =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
100
|
+
const initialState = React.useMemo(
|
|
101
|
+
() =>
|
|
102
|
+
buildInitialState(
|
|
103
|
+
pathname.split("/"),
|
|
104
|
+
topParameter,
|
|
105
|
+
pageParameter,
|
|
106
|
+
sortParameter,
|
|
107
|
+
refinerParameter,
|
|
108
|
+
searchParameter,
|
|
109
|
+
),
|
|
110
|
+
[
|
|
111
|
+
pathname,
|
|
112
|
+
topParameter,
|
|
113
|
+
pageParameter,
|
|
114
|
+
sortParameter,
|
|
115
|
+
refinerParameter,
|
|
116
|
+
searchParameter,
|
|
117
|
+
],
|
|
106
118
|
);
|
|
107
119
|
const [inputParams, setInputParams] =
|
|
108
120
|
React.useState<SearchByCriteriaInput>(initialState);
|
|
121
|
+
React.useEffect(() => {
|
|
122
|
+
if (isUserInitiated.current) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
setInputParams((prev) => {
|
|
127
|
+
const prevRefiners = prev.searchState.refinement
|
|
128
|
+
.map((r) => convertRefinerToParameter(r))
|
|
129
|
+
.join("||");
|
|
130
|
+
if (prevRefiners === refinerParameter) {
|
|
131
|
+
return prev;
|
|
132
|
+
}
|
|
133
|
+
const newState = buildInitialState(
|
|
134
|
+
pathname.split("/"),
|
|
135
|
+
topParameter,
|
|
136
|
+
pageParameter,
|
|
137
|
+
sortParameter,
|
|
138
|
+
refinerParameter,
|
|
139
|
+
searchParameter,
|
|
140
|
+
);
|
|
141
|
+
return newState;
|
|
142
|
+
});
|
|
143
|
+
}, [
|
|
144
|
+
refinerParameter,
|
|
145
|
+
searchParameter,
|
|
146
|
+
sortParameter,
|
|
147
|
+
topParameter,
|
|
148
|
+
pageParameter,
|
|
149
|
+
pathname,
|
|
150
|
+
]);
|
|
109
151
|
|
|
110
152
|
const { data, isLoading } = getProductList.useData(inputParams);
|
|
111
153
|
const { data: productPrices } = getProductPrices.useSuspenseData({
|
|
@@ -113,45 +155,57 @@ const ProductListProvider = ({ children }: { children: React.ReactNode }) => {
|
|
|
113
155
|
});
|
|
114
156
|
const handleRefinerChange = useCallback(
|
|
115
157
|
(refiner: ProductRefinerValue) => {
|
|
116
|
-
|
|
117
|
-
const refiners =
|
|
158
|
+
isUserInitiated.current = true;
|
|
159
|
+
const refiners = inputParams.searchState.refinement;
|
|
118
160
|
const priceIndex = ProductRefinerSource.Price;
|
|
119
|
-
|
|
161
|
+
|
|
162
|
+
let newRefiners: ProductRefinerValue[];
|
|
120
163
|
|
|
121
164
|
if (refiner.RefinerSourceValue === priceIndex) {
|
|
122
|
-
const
|
|
165
|
+
const priceRefinerIndex = refiners.findIndex(
|
|
166
|
+
(r) => r.RefinerSourceValue === priceIndex,
|
|
167
|
+
);
|
|
123
168
|
|
|
124
|
-
if (
|
|
169
|
+
if (priceRefinerIndex > -1) {
|
|
170
|
+
const existingPriceRefiner = refiners[priceRefinerIndex];
|
|
125
171
|
if (
|
|
126
|
-
|
|
172
|
+
existingPriceRefiner.LeftValueBoundString ===
|
|
127
173
|
refiner.LeftValueBoundString &&
|
|
128
|
-
|
|
174
|
+
existingPriceRefiner.RightValueBoundString ===
|
|
175
|
+
refiner.RightValueBoundString
|
|
129
176
|
) {
|
|
130
|
-
refiners.
|
|
177
|
+
newRefiners = refiners.filter((_, i) => i !== priceRefinerIndex);
|
|
131
178
|
} else {
|
|
132
|
-
|
|
133
|
-
|
|
179
|
+
newRefiners = refiners.map((r, i) =>
|
|
180
|
+
i === priceRefinerIndex ? refiner : r,
|
|
181
|
+
);
|
|
134
182
|
}
|
|
135
183
|
} else {
|
|
136
|
-
refiners
|
|
184
|
+
newRefiners = [...refiners, refiner];
|
|
137
185
|
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (refiner.RefinerSourceValue !== priceIndex) {
|
|
186
|
+
} else {
|
|
187
|
+
const idx = refiners.findIndex((r) => areRefinersEqual(r, refiner));
|
|
141
188
|
if (idx > -1) {
|
|
142
|
-
refiners.
|
|
189
|
+
newRefiners = refiners.filter((_, i) => i !== idx);
|
|
143
190
|
} else {
|
|
144
|
-
refiners
|
|
191
|
+
newRefiners = [...refiners, refiner];
|
|
145
192
|
}
|
|
146
193
|
}
|
|
147
194
|
|
|
148
|
-
setInputParams(
|
|
195
|
+
setInputParams({
|
|
196
|
+
...inputParams,
|
|
197
|
+
searchState: {
|
|
198
|
+
...inputParams.searchState,
|
|
199
|
+
refinement: newRefiners,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
149
202
|
},
|
|
150
|
-
[inputParams
|
|
203
|
+
[inputParams],
|
|
151
204
|
);
|
|
152
205
|
|
|
153
206
|
const handlePageChange = useCallback(
|
|
154
207
|
(page: number) => {
|
|
208
|
+
isUserInitiated.current = true;
|
|
155
209
|
const updatedInputParams = { ...inputParams };
|
|
156
210
|
const calculationSkip = calculateSkip(page, topParameter);
|
|
157
211
|
let paging = updatedInputParams.searchState.queryResultSettings.Paging;
|
|
@@ -159,18 +213,19 @@ const ProductListProvider = ({ children }: { children: React.ReactNode }) => {
|
|
|
159
213
|
paging.Skip = calculationSkip;
|
|
160
214
|
setInputParams(updatedInputParams);
|
|
161
215
|
},
|
|
162
|
-
[inputParams, topParameter
|
|
216
|
+
[inputParams, topParameter],
|
|
163
217
|
);
|
|
164
218
|
|
|
165
219
|
const handlePerPageChange = useCallback(
|
|
166
220
|
(newValue: number) => {
|
|
221
|
+
isUserInitiated.current = true;
|
|
167
222
|
const updatedInputParams = { ...inputParams };
|
|
168
223
|
let paging = updatedInputParams.searchState.queryResultSettings.Paging;
|
|
169
224
|
paging = paging || {};
|
|
170
225
|
paging.Top = newValue;
|
|
171
226
|
setInputParams(updatedInputParams);
|
|
172
227
|
},
|
|
173
|
-
[inputParams
|
|
228
|
+
[inputParams],
|
|
174
229
|
);
|
|
175
230
|
|
|
176
231
|
const handleViewChange = useCallback(
|
|
@@ -182,6 +237,7 @@ const ProductListProvider = ({ children }: { children: React.ReactNode }) => {
|
|
|
182
237
|
|
|
183
238
|
const handleSortChange = useCallback(
|
|
184
239
|
(sort: SortOption) => {
|
|
240
|
+
isUserInitiated.current = true;
|
|
185
241
|
const updatedInputParams = { ...inputParams };
|
|
186
242
|
const sortOption = getSortOptions().find((s) => s.code === sort.code);
|
|
187
243
|
if (!sortOption) {
|
|
@@ -194,23 +250,25 @@ const ProductListProvider = ({ children }: { children: React.ReactNode }) => {
|
|
|
194
250
|
];
|
|
195
251
|
setInputParams(updatedInputParams);
|
|
196
252
|
},
|
|
197
|
-
[inputParams
|
|
253
|
+
[inputParams],
|
|
198
254
|
);
|
|
199
255
|
|
|
200
256
|
const handleSearchChange = useCallback(
|
|
201
257
|
(searchTerm: string) => {
|
|
258
|
+
isUserInitiated.current = true;
|
|
202
259
|
const updatedInputParams = { ...inputParams };
|
|
203
260
|
updatedInputParams.searchState.searchTerm = searchTerm;
|
|
204
261
|
setInputParams(updatedInputParams);
|
|
205
262
|
},
|
|
206
|
-
[inputParams
|
|
263
|
+
[inputParams],
|
|
207
264
|
);
|
|
208
265
|
|
|
209
266
|
const handleRefinersClear = useCallback(() => {
|
|
267
|
+
isUserInitiated.current = true;
|
|
210
268
|
const updatedInputParams = { ...inputParams };
|
|
211
269
|
updatedInputParams.searchState.refinement = [];
|
|
212
270
|
setInputParams(updatedInputParams);
|
|
213
|
-
}, [inputParams
|
|
271
|
+
}, [inputParams]);
|
|
214
272
|
|
|
215
273
|
const paramHandlers = useMemo(
|
|
216
274
|
() => ({
|
|
@@ -234,6 +292,10 @@ const ProductListProvider = ({ children }: { children: React.ReactNode }) => {
|
|
|
234
292
|
);
|
|
235
293
|
|
|
236
294
|
React.useEffect(() => {
|
|
295
|
+
if (!isUserInitiated.current) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
237
299
|
const sortObj =
|
|
238
300
|
inputParams.searchState.queryResultSettings.Sorting?.Columns?.[0];
|
|
239
301
|
let sortCode = "";
|
|
@@ -246,21 +308,38 @@ const ProductListProvider = ({ children }: { children: React.ReactNode }) => {
|
|
|
246
308
|
)?.code || "";
|
|
247
309
|
}
|
|
248
310
|
|
|
249
|
-
|
|
250
|
-
inputParams.searchState.queryResultSettings.Paging?.Top || 20
|
|
251
|
-
);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
311
|
+
const newTop =
|
|
312
|
+
inputParams.searchState.queryResultSettings.Paging?.Top || 20;
|
|
313
|
+
const newPage = calculatePage(inputParams.searchState.queryResultSettings);
|
|
314
|
+
const newRefiners = inputParams.searchState.refinement
|
|
315
|
+
.map((r) => convertRefinerToParameter(r))
|
|
316
|
+
.join("||");
|
|
317
|
+
const newSearch = inputParams.searchState.searchTerm || "";
|
|
318
|
+
|
|
319
|
+
if (topParameter !== newTop) {
|
|
320
|
+
setTopParameter(newTop);
|
|
321
|
+
}
|
|
322
|
+
if (pageParameter !== newPage) {
|
|
323
|
+
setPageParameter(newPage);
|
|
324
|
+
}
|
|
325
|
+
if (refinerParameter !== newRefiners) {
|
|
326
|
+
setRefinerParameter(newRefiners);
|
|
327
|
+
}
|
|
328
|
+
if (sortParameter !== sortCode) {
|
|
329
|
+
setSortParameter(sortCode);
|
|
330
|
+
}
|
|
331
|
+
if (searchParameter !== newSearch) {
|
|
332
|
+
setSearchParameter(newSearch);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
isUserInitiated.current = false;
|
|
262
336
|
}, [
|
|
263
337
|
inputParams,
|
|
338
|
+
topParameter,
|
|
339
|
+
pageParameter,
|
|
340
|
+
refinerParameter,
|
|
341
|
+
sortParameter,
|
|
342
|
+
searchParameter,
|
|
264
343
|
setPageParameter,
|
|
265
344
|
setRefinerParameter,
|
|
266
345
|
setSearchParameter,
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "@msdyn365-commerce/retail-proxy";
|
|
7
7
|
import { Box, Slider, TextField, Typography } from "@mui/material";
|
|
8
8
|
import type React from "react";
|
|
9
|
-
import { useEffect, useState } from "react";
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
10
10
|
import type { RenderedFacetProps } from "./facet-group";
|
|
11
11
|
|
|
12
12
|
type Props = RenderedFacetProps & {
|
|
@@ -17,6 +17,7 @@ export default function RangeFilter({
|
|
|
17
17
|
handleRefinerSelect,
|
|
18
18
|
selectedValues,
|
|
19
19
|
}: Props) {
|
|
20
|
+
const isUserInteraction = useRef(false);
|
|
20
21
|
const getMinValForSlider = () => {
|
|
21
22
|
const item = facet.Values?.[0];
|
|
22
23
|
if (item) {
|
|
@@ -36,58 +37,68 @@ export default function RangeFilter({
|
|
|
36
37
|
const [maxVal, setMaxVal] = useState<number>(getMaxValForSlider());
|
|
37
38
|
const [textMin, setTextMin] = useState<number>(getMinValForSlider());
|
|
38
39
|
const [textMax, setTextMax] = useState<number>(getMaxValForSlider());
|
|
39
|
-
const [selected, setSelected] = useState<boolean>(false);
|
|
40
40
|
|
|
41
|
+
// Sync slider position when selectedValues changes from URL
|
|
41
42
|
useEffect(() => {
|
|
42
43
|
if (selectedValues) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
const newMin = Number(selectedValues.LeftValueBoundString);
|
|
45
|
+
const newMax = Number(selectedValues.RightValueBoundString);
|
|
46
|
+
// Only sync from URL if not from user interaction
|
|
47
|
+
if (
|
|
48
|
+
!isUserInteraction.current &&
|
|
49
|
+
(minVal !== newMin || maxVal !== newMax)
|
|
50
|
+
) {
|
|
51
|
+
setMinVal(newMin);
|
|
52
|
+
setMaxVal(newMax);
|
|
53
|
+
setTextMin(newMin);
|
|
54
|
+
setTextMax(newMax);
|
|
55
|
+
}
|
|
47
56
|
}
|
|
48
|
-
}, [selectedValues]);
|
|
57
|
+
}, [selectedValues, minVal, maxVal]);
|
|
58
|
+
|
|
59
|
+
const pushPriceFilter = useCallback(() => {
|
|
60
|
+
const refiner: ProductRefinerValue = {
|
|
61
|
+
RefinerRecordId: 0,
|
|
62
|
+
RefinerSourceValue: ProductRefinerSource.Price,
|
|
63
|
+
LeftValueBoundString: minVal.toString(),
|
|
64
|
+
RightValueBoundString: maxVal.toString(),
|
|
65
|
+
DataTypeValue: 1,
|
|
66
|
+
};
|
|
67
|
+
handleRefinerSelect(refiner);
|
|
68
|
+
// Reset flag after pushing
|
|
69
|
+
isUserInteraction.current = false;
|
|
70
|
+
}, [minVal, maxVal, handleRefinerSelect]);
|
|
49
71
|
|
|
50
72
|
useEffect(() => {
|
|
51
|
-
|
|
73
|
+
if (!isUserInteraction.current) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
52
76
|
const request = setTimeout(pushPriceFilter, 600);
|
|
53
77
|
return () => clearTimeout(request);
|
|
54
|
-
});
|
|
78
|
+
}, [pushPriceFilter]);
|
|
55
79
|
|
|
56
80
|
const handleSliderChange = (_: Event, newValue: number | number[]) => {
|
|
81
|
+
isUserInteraction.current = true;
|
|
57
82
|
if (Array.isArray(newValue)) {
|
|
58
83
|
setMinVal(Number(newValue[0]));
|
|
59
84
|
setTextMin(Number(newValue[0]));
|
|
60
85
|
setMaxVal(Number(newValue[1]));
|
|
61
86
|
setTextMax(Number(newValue[1]));
|
|
62
|
-
setSelected(true);
|
|
63
87
|
}
|
|
64
88
|
};
|
|
65
89
|
|
|
66
90
|
const handleTextMin = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
|
91
|
+
isUserInteraction.current = true;
|
|
67
92
|
const val = Number(evt.target.value);
|
|
68
93
|
setTextMin(val);
|
|
69
94
|
setMinVal(val);
|
|
70
|
-
setSelected(true);
|
|
71
95
|
};
|
|
72
96
|
|
|
73
97
|
const handleTextMax = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
|
98
|
+
isUserInteraction.current = true;
|
|
74
99
|
const val = Number(evt.target.value);
|
|
75
100
|
setTextMax(val);
|
|
76
101
|
setMaxVal(val);
|
|
77
|
-
setSelected(true);
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const pushPriceFilter = () => {
|
|
81
|
-
if (selected) {
|
|
82
|
-
const refiner: ProductRefinerValue = {
|
|
83
|
-
RefinerRecordId: 0,
|
|
84
|
-
RefinerSourceValue: ProductRefinerSource.Price,
|
|
85
|
-
LeftValueBoundString: minVal.toString(),
|
|
86
|
-
RightValueBoundString: maxVal.toString(),
|
|
87
|
-
DataTypeValue: 1,
|
|
88
|
-
};
|
|
89
|
-
handleRefinerSelect(refiner);
|
|
90
|
-
}
|
|
91
102
|
};
|
|
92
103
|
|
|
93
104
|
return (
|
|
@@ -62,14 +62,21 @@ export default function SelectedFacets() {
|
|
|
62
62
|
|
|
63
63
|
return (
|
|
64
64
|
<Stack direction={"row"} spacing={2}>
|
|
65
|
-
{selectedRefiners.map((refiner) =>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
{selectedRefiners.map((refiner, _) => {
|
|
66
|
+
// For price refiners (RefinerRecordId is always 0), use index + source value for unique key
|
|
67
|
+
const key =
|
|
68
|
+
refiner.RefinerSourceValue === ProductRefinerSource.Price
|
|
69
|
+
? `price-${refiner.LeftValueBoundString}-${refiner.RightValueBoundString}`
|
|
70
|
+
: `${refiner.RefinerRecordId}-${refiner.LeftValueBoundString}`;
|
|
71
|
+
return (
|
|
72
|
+
<Chip
|
|
73
|
+
key={key}
|
|
74
|
+
title={refiner.LeftValueBoundString}
|
|
75
|
+
label={`${getRefinerLabel(refiner)}: ${getRefinerValue(refiner)}`}
|
|
76
|
+
onDelete={() => handleRefinerChange(refiner)}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
73
80
|
{selectedRefiners.length > 0 ? (
|
|
74
81
|
<Button variant={"text"} sx={{ ml: 2 }} onClick={handleRefinersClear}>
|
|
75
82
|
<Paragraph>{t("clearAll")}</Paragraph>
|