@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.
Files changed (30) hide show
  1. package/__mocks__/next-image.tsx +3 -1
  2. package/package.json +3 -4
  3. package/src/api-manager/datasources/d365/d365-product.datasource.ts +1 -1
  4. package/src/api-manager/datasources/d365/utils/product-inventory-utils.ts +1 -3
  5. package/src/api-manager/datasources/e4/cart/e4-cart.remaps.ts +7 -5
  6. package/src/api-manager/datasources/e4/product/e4-product.datasource.ts +1 -4
  7. package/src/api-manager/datasources/e4/product/e4-product.translator.ts +13 -19
  8. package/src/api-manager/datasources/e4/utils/unwrap-e4-variants.ts +10 -13
  9. package/src/auth/auth-options.ts +34 -1
  10. package/src/auth/providers/entra-external-id-provider.ts +50 -0
  11. package/src/auth/providers/index.ts +8 -1
  12. package/src/auth/refresh-token.ts +1 -1
  13. package/src/cms/blocks/interfaces.ts +2 -0
  14. package/src/components-v2/BazaarMenu.stories.tsx +1 -1
  15. package/src/components-v2/BazaarMenu.tsx +4 -3
  16. package/src/components-v2/categories/category-list/category-list.tsx +5 -1
  17. package/src/components-v2/categories/category-menu.tsx +11 -5
  18. package/src/components-v2/product-dimensions/compound/dimensions-group-list-chip.tsx +36 -37
  19. package/src/components-v2/scrollbar/Scrollbar.stories.tsx +2 -2
  20. package/src/components-v2/search-bar/compound/results.tsx +1 -1
  21. package/src/components-v2/wishlist-dialogs/add-to-wishlist/add-to-wishlist-button.tsx +14 -15
  22. package/src/components-v2/wishlist-dialogs/styles.ts +8 -10
  23. package/src/pages-v2/account/addresses/address-item.tsx +2 -1
  24. package/src/pages-v2/checkout/checkout-alt-form/checkout-form.tsx +6 -1
  25. package/src/pages-v2/checkout/checkout-alt-form/steps/address/new-address-form.tsx +9 -8
  26. package/src/pages-v2/product-details/bopis/find-in-store-modal.tsx +1 -1
  27. package/src/pages-v2/product-details/product-intro/compound/thumbnail-with-skeleton.tsx +1 -0
  28. package/src/pages-v2/product-list/product-list-context.tsx +123 -44
  29. package/src/pages-v2/product-list/range-filter.tsx +36 -25
  30. package/src/pages-v2/product-list/selected-facets.tsx +15 -8
@@ -1,7 +1,9 @@
1
1
  // __mocks__/next/image.js
2
2
  const Image = (props) => {
3
+ const { alt = "", ...rest } = props;
4
+
3
5
  // eslint-disable-next-line @next/next/no-img-element
4
- return <img {...props} />;
6
+ return <img alt={alt} {...rest} />;
5
7
  };
6
8
 
7
9
  export default Image;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evenicanpm/storefront-core",
3
- "version": "1.6.0",
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": "^1.6.0"
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
- ...(queryResultSettings ?? {}),
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 translated = merge(product, productMap) as DynProduct & {
34
- ProductTypeValue?: number;
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 as ProductSearchResult[];
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.AttributeName ||
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.AttributeValue ||
223
- typeof rawValue.Count !== "number"
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.AttributeValue,
241
- RightValueBoundString: rawValue.AttributeValue,
237
+ LeftValueBoundString: rawValue.value,
238
+ RightValueBoundString: rawValue.value,
242
239
  UnitText: "",
243
240
  RowNumber: 0,
244
- Count: rawValue.Count,
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.AttributeId?.toLowerCase() ?? "",
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
- productData,
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
- v.Attributes?.map((attr: Maybe<Attributes>) => {
34
- const dimension = attr ? merge(attr, dimensionMap) : null;
27
+ const attrs: UnwrappedAttribute[] = variants.flatMap((v) =>
28
+ (v.Attributes ?? [])
29
+ .map((attr) => {
30
+ if (!attr) return null;
35
31
 
36
- if (!dimension) return null;
32
+ const dimension = merge(attr, dimensionMap);
37
33
 
38
34
  return {
39
- ...dimension,
35
+ ...(dimension as object),
40
36
  ProductIds: [v.ProductId],
41
- } as UnwrappedAttribute;
42
- }).filter(Boolean) as UnwrappedAttribute[],
37
+ };
38
+ })
39
+ .filter((item): item is UnwrappedAttribute => item !== null),
43
40
  );
44
41
 
45
42
  const grouped: UnwrappedAttribute[][] = attrs.reduce((acc, item) => {
@@ -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
- _token.accessToken = account.access_token;
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 [authentikProvider, azureProvider, keycloakProvider];
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,
@@ -131,6 +131,8 @@ export interface ImageBlock {
131
131
  id: number;
132
132
  __component: "blocks.image-block";
133
133
  media: MediaData;
134
+ size?: string;
135
+ justify?: string;
134
136
  }
135
137
 
136
138
  export interface RichTextBlock {
@@ -16,7 +16,7 @@ export const Basic: Story = {
16
16
  args: {
17
17
  direction: "left",
18
18
  handler: (onClick) => (
19
- <Button variant="contained" onClick={onClick as any}>
19
+ <Button variant="contained" onClick={onClick}>
20
20
  Open Menu
21
21
  </Button>
22
22
  ),
@@ -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?: (e: (event: React.MouseEvent) => void) => ReactNode;
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 = (event: React.MouseEvent<Element>) =>
29
- setAnchorEl(event.currentTarget as HTMLElement);
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={hasChildren ? megaMenuRenderers[menuType]?.(Children!) : null}
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 { type MouseEvent, useCallback, useEffect, useState } from "react";
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 = (e: MouseEvent<HTMLButtonElement>) => {
36
- e.stopPropagation();
36
+ const onClick = () => {
37
37
  setOpen((open) => !open);
38
38
  };
39
39
 
40
- const handleDocumentClick = useCallback(() => setOpen(false), []);
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
- switch (type) {
41
- case "Color":
42
- return (
43
- <Chip
44
- key={dimension.DimensionValue?.RecordId}
45
- onClick={() => {
46
- handleChangeVariant(dimension);
47
- }}
48
- disabled={disabled}
49
- sx={{
50
- borderRadius: "100%",
51
- mr: 1,
52
- cursor: "pointer",
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
- width: 32,
55
- height: 32,
56
- border: selected ? "2px solid" : "2px solid transparent",
57
- "&:hover": {
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
  };
@@ -22,8 +22,8 @@ export const WithLink = () => (
22
22
  padding: "25px",
23
23
  }}
24
24
  >
25
- {[...Array(50)].map((x, i) => (
26
- <p>{i}</p>
25
+ {Array.from({ length: 50 }).map((_, i) => (
26
+ <p key={`item-${i + 1}`}>{i}</p>
27
27
  ))}
28
28
  </Scrollbar>
29
29
  </div>
@@ -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, btnFullwidth: boolean;
26
+ let style: any;
27
+ let btnFullwidth: boolean;
27
28
  let btnColor: ButtonProps["color"];
28
29
  let btnVariant: ButtonProps["variant"];
29
- switch (props.variant) {
30
- case "minicart":
31
- style = {
32
- px: 4,
33
- };
34
- btnFullwidth = true;
35
- btnVariant = "outlined";
36
- btnColor = "primary";
37
- break;
38
- default:
39
- btnFullwidth = false;
40
- btnVariant = "contained";
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: ComponentType<CardProps> = styled(Card)(
13
- ({ theme }) => ({
14
- paddingBottom: "1.5rem",
15
- [theme.breakpoints.down("md")]: {
16
- boxShadow: "none",
17
- overflowY: "auto",
18
- height: "calc(100vh - 64px)",
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: string | number | boolean, address: Address) => ReactNode;
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?.map((s) => ({
182
+ states: Array.isArray(states)
183
+ ? states.map((s) => ({
184
184
  StateName: s.StateName ?? "",
185
185
  StateId: s.StateId ?? "",
186
186
  }))
187
- : undefined,
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?.map(({ StateName, StateId }) => (
316
- <MenuItem key={StateId} value={StateId}>
317
- {StateName}
318
- </MenuItem>
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 = [...Object.values(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 = buildInitialState(
100
- pathname.split("/"),
101
- topParameter,
102
- pageParameter,
103
- sortParameter,
104
- refinerParameter,
105
- searchParameter,
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
- const newInputParams = { ...inputParams };
117
- const refiners = newInputParams.searchState.refinement;
158
+ isUserInitiated.current = true;
159
+ const refiners = inputParams.searchState.refinement;
118
160
  const priceIndex = ProductRefinerSource.Price;
119
- const idx = refiners.findIndex((r) => areRefinersEqual(r, refiner));
161
+
162
+ let newRefiners: ProductRefinerValue[];
120
163
 
121
164
  if (refiner.RefinerSourceValue === priceIndex) {
122
- const priceRefiner = refiners[idx];
165
+ const priceRefinerIndex = refiners.findIndex(
166
+ (r) => r.RefinerSourceValue === priceIndex,
167
+ );
123
168
 
124
- if (idx > -1) {
169
+ if (priceRefinerIndex > -1) {
170
+ const existingPriceRefiner = refiners[priceRefinerIndex];
125
171
  if (
126
- priceRefiner.LeftValueBoundString ===
172
+ existingPriceRefiner.LeftValueBoundString ===
127
173
  refiner.LeftValueBoundString &&
128
- priceRefiner.RightValueBoundString === refiner.RightValueBoundString
174
+ existingPriceRefiner.RightValueBoundString ===
175
+ refiner.RightValueBoundString
129
176
  ) {
130
- refiners.splice(idx, 1);
177
+ newRefiners = refiners.filter((_, i) => i !== priceRefinerIndex);
131
178
  } else {
132
- priceRefiner.LeftValueBoundString = refiner.LeftValueBoundString;
133
- priceRefiner.RightValueBoundString = refiner.RightValueBoundString;
179
+ newRefiners = refiners.map((r, i) =>
180
+ i === priceRefinerIndex ? refiner : r,
181
+ );
134
182
  }
135
183
  } else {
136
- refiners.push(refiner);
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.splice(idx, 1);
189
+ newRefiners = refiners.filter((_, i) => i !== idx);
143
190
  } else {
144
- refiners.push(refiner);
191
+ newRefiners = [...refiners, refiner];
145
192
  }
146
193
  }
147
194
 
148
- setInputParams(newInputParams);
195
+ setInputParams({
196
+ ...inputParams,
197
+ searchState: {
198
+ ...inputParams.searchState,
199
+ refinement: newRefiners,
200
+ },
201
+ });
149
202
  },
150
- [inputParams, setInputParams],
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, setInputParams],
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, setInputParams],
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, setInputParams],
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, setInputParams],
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, setInputParams]);
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
- setTopParameter(
250
- inputParams.searchState.queryResultSettings.Paging?.Top || 20,
251
- );
252
- setPageParameter(
253
- calculatePage(inputParams.searchState.queryResultSettings),
254
- );
255
- setRefinerParameter(
256
- inputParams.searchState.refinement
257
- .map((r) => convertRefinerToParameter(r))
258
- .join("||"),
259
- );
260
- setSortParameter(sortCode);
261
- setSearchParameter(inputParams.searchState.searchTerm || "");
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
- setMinVal(Number(selectedValues.LeftValueBoundString));
44
- setMaxVal(Number(selectedValues.RightValueBoundString));
45
- setTextMin(Number(selectedValues.LeftValueBoundString));
46
- setTextMax(Number(selectedValues.RightValueBoundString));
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
- // debouncing
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
- <Chip
67
- key={`${refiner.RefinerRecordId}-${refiner.LeftValueBoundString}`}
68
- title={refiner.LeftValueBoundString}
69
- label={`${getRefinerLabel(refiner)}: ${getRefinerValue(refiner)}`}
70
- onDelete={() => handleRefinerChange(refiner)}
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>