@evenicanpm/storefront-core 1.6.0 → 1.8.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 (52) hide show
  1. package/__mocks__/next-image.tsx +3 -1
  2. package/package.json +3 -3
  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/T/__tests__/T.test.tsx +91 -0
  17. package/src/components-v2/accordion/__tests__/accordion-header.test.tsx +28 -0
  18. package/src/components-v2/carousel/__tests__/carousel.test.tsx +208 -0
  19. package/src/components-v2/carousel-cards/carousel-card-1/__tests__/carousel-card-1.test.tsx +81 -0
  20. package/src/components-v2/categories/__tests__/category-menu.test.tsx +115 -0
  21. package/src/components-v2/categories/category-list/category-list.tsx +5 -1
  22. package/src/components-v2/categories/category-menu.tsx +11 -5
  23. package/src/components-v2/flex-box/__tests__/flex-box.test.tsx +88 -0
  24. package/src/components-v2/header/__tests__/header.test.tsx +122 -0
  25. package/src/components-v2/icons/__tests__/icons.test.tsx +53 -0
  26. package/src/components-v2/mobile-navigation/__tests__/mobile-navigation-bar.test.tsx +94 -0
  27. package/src/components-v2/nav-link/__tests__/nav-link.test.tsx +75 -0
  28. package/src/components-v2/navbar/__tests__/navbar.test.tsx +96 -0
  29. package/src/components-v2/product-cards/__tests__/product-cards.test.tsx +91 -0
  30. package/src/components-v2/product-dialog/__tests__/product-dialog.test.tsx +129 -0
  31. package/src/components-v2/product-dimensions/__tests__/product-dimensions.test.tsx +130 -0
  32. package/src/components-v2/product-dimensions/compound/dimensions-group-list-chip.tsx +36 -37
  33. package/src/components-v2/product-quantity-buttons/__tests__/product-quantity-buttons.test.tsx +77 -0
  34. package/src/components-v2/product-quantity-variants/__tests__/product-quantity-variants.test.tsx +109 -0
  35. package/src/components-v2/products-view/__tests__/products-view.test.tsx +147 -0
  36. package/src/components-v2/progress/__tests__/progress.test.tsx +62 -0
  37. package/src/components-v2/scrollbar/Scrollbar.stories.tsx +2 -2
  38. package/src/components-v2/scrollbar/__tests__/scrollbar.test.tsx +93 -0
  39. package/src/components-v2/search-bar/__tests__/search-bar.test.tsx +142 -0
  40. package/src/components-v2/search-bar/compound/results.tsx +1 -1
  41. package/src/components-v2/section/__tests__/section.test.tsx +100 -0
  42. package/src/components-v2/side-nav/__tests__/side-nav.test.tsx +156 -0
  43. package/src/components-v2/wishlist-dialogs/add-to-wishlist/add-to-wishlist-button.tsx +14 -15
  44. package/src/components-v2/wishlist-dialogs/styles.ts +8 -10
  45. package/src/pages-v2/account/addresses/address-item.tsx +2 -1
  46. package/src/pages-v2/checkout/checkout-alt-form/checkout-form.tsx +6 -1
  47. package/src/pages-v2/checkout/checkout-alt-form/steps/address/new-address-form.tsx +9 -8
  48. package/src/pages-v2/product-details/bopis/find-in-store-modal.tsx +1 -1
  49. package/src/pages-v2/product-details/product-intro/compound/thumbnail-with-skeleton.tsx +1 -0
  50. package/src/pages-v2/product-list/product-list-context.tsx +123 -44
  51. package/src/pages-v2/product-list/range-filter.tsx +36 -25
  52. package/src/pages-v2/product-list/selected-facets.tsx +15 -8
@@ -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>