@evenicanpm/storefront-core 2.3.1 → 2.4.1

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 (90) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +42 -9
  3. package/src/api-manager/datasources/d365/d365-address.datasource.ts +148 -61
  4. package/src/api-manager/datasources/d365/d365-cart.datasource.ts +23 -1
  5. package/src/api-manager/datasources/d365/d365-order.datasource.ts +6 -1
  6. package/src/api-manager/datasources/d365/d365-user.datasource.ts +109 -37
  7. package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +44 -10
  8. package/src/api-manager/index.ts +2 -1
  9. package/src/api-manager/lib/get-graphql-client.ts +35 -7
  10. package/src/api-manager/schemas/product.schema.ts +1 -1
  11. package/src/api-manager/services/create-query.ts +36 -3
  12. package/src/api-manager/services/get-query-client.ts +10 -0
  13. package/src/auth/better-auth.ts +282 -15
  14. package/src/cms/blocks/block-manager.tsx +1 -1
  15. package/src/cms/blocks/components/product-section-fullwidth/index.tsx +3 -1
  16. package/src/cms/endpoints.ts +7 -2
  17. package/src/components/BazaarMenu.tsx +1 -1
  18. package/src/components/Typography.tsx +4 -2
  19. package/src/components/carousel-cards/carousel-card-1/CarouselCard1.stories.tsx +1 -1
  20. package/src/components/categories/category-list/category-list.tsx +9 -5
  21. package/src/components/categories/category-menu.tsx +1 -1
  22. package/src/components/countries-input.tsx +4 -0
  23. package/src/components/header/__tests__/user.test.tsx +5 -107
  24. package/src/components/header/components/user.tsx +72 -45
  25. package/src/components/header/sticky-header.tsx +0 -1
  26. package/src/components/navbar/mobile-menu/mobile-menu.test.tsx +1 -1
  27. package/src/components/navbar/utils/transform-nav.test.ts +1 -1
  28. package/src/components/navbar/utils/transform-nav.ts +1 -1
  29. package/src/components/product-cards/product-card/product-card.tsx +5 -2
  30. package/src/components/product-cards/product-card/styles/index.ts +1 -5
  31. package/src/components/products-view/compound/products-grid-view.tsx +5 -1
  32. package/src/components/products-view/compound/products-list-view.tsx +5 -1
  33. package/src/global.d.ts +3 -0
  34. package/src/hooks/use-nextauth-session.ts +0 -33
  35. package/src/lib/auth-client.ts +14 -0
  36. package/src/lib/auth.ts +4 -0
  37. package/src/lib/entra-native-auth.ts +138 -0
  38. package/src/lib/native-session.ts +434 -0
  39. package/src/pages/account/addresses/address-form.tsx +20 -2
  40. package/src/pages/account/profile/__tests__/profile-form.test.tsx +173 -0
  41. package/src/pages/account/profile/profile-form.tsx +285 -0
  42. package/src/pages/account/profile/profile-validation.ts +52 -0
  43. package/src/pages/account/wishlist/wishlist-item.tsx +1 -2
  44. package/src/pages/auth/auth-activate-page.tsx +509 -0
  45. package/src/pages/auth/auth-layout.tsx +73 -0
  46. package/src/pages/auth/auth-login-page.tsx +241 -0
  47. package/src/pages/auth/auth-reset-password-page.tsx +487 -0
  48. package/src/pages/auth/compound/auth-form.tsx +182 -0
  49. package/src/pages/auth/compound/auth-pages.tsx +21 -0
  50. package/src/pages/auth/lib/friendly-error.ts +70 -0
  51. package/src/pages/auth/lib/index.ts +2 -0
  52. package/src/pages/auth/lib/types.ts +5 -0
  53. package/src/pages/cart/cart-item.tsx +1 -7
  54. package/src/pages/checkout/checkout-alt-form/checkout-form.tsx +14 -3
  55. package/src/pages/checkout/checkout-alt-form/steps/address/address-card.tsx +5 -3
  56. package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +20 -6
  57. package/src/pages/checkout/checkout-alt-form/steps/address/new-address-form.tsx +1 -1
  58. package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +1 -1
  59. package/src/pages/confirmation/ordered-products.tsx +3 -1
  60. package/src/pages/product-details/bopis/find-in-store-modal.tsx +4 -4
  61. package/src/pages/product-details/bopis/pickup-option-select.tsx +2 -2
  62. package/src/pages/product-details/bopis/search-header.tsx +2 -2
  63. package/src/pages/product-details/product-intro/compound/context.ts +8 -3
  64. package/src/pages/product-details/product-intro/compound/product-info.tsx +25 -19
  65. package/src/pages/product-list/product-list-view.tsx +2 -1
  66. package/src/providers/nav-provider/index.tsx +1 -1
  67. package/src/providers/nav-provider/utils/createLink.ts +1 -1
  68. package/tsconfig.json +20 -15
  69. package/__mocks__/countries.ts +0 -11
  70. package/__mocks__/create-mutation.ts +0 -68
  71. package/__mocks__/create-query.ts +0 -94
  72. package/__mocks__/data/categories.json +0 -795
  73. package/__mocks__/get-product-by-id.ts +0 -6
  74. package/__mocks__/next-auth-react.ts +0 -9
  75. package/__mocks__/next-font.js +0 -4
  76. package/__mocks__/next-headers.js +0 -13
  77. package/__mocks__/next-image.tsx +0 -18
  78. package/__mocks__/next-link.js +0 -19
  79. package/__mocks__/next-navigation.ts +0 -29
  80. package/__mocks__/product-dimensions.ts +0 -635
  81. package/__mocks__/products.ts +0 -533
  82. package/__mocks__/query-client.ts +0 -3
  83. package/__mocks__/wishlists.json +0 -408
  84. package/src/auth/msal.ts +0 -68
  85. package/src/cms/blog.ts +0 -229
  86. package/src/cms/interfaces/navigation.ts +0 -52
  87. package/src/cms/interfaces/product-data.ts +0 -83
  88. package/src/cms/pages.ts +0 -149
  89. package/src/pages/account/profile/profile-button.test.tsx +0 -59
  90. package/src/pages/account/profile/profile-button.tsx +0 -51
@@ -0,0 +1,182 @@
1
+ "use client";
2
+
3
+ import {
4
+ Box,
5
+ type BoxProps,
6
+ Button,
7
+ CircularProgress,
8
+ TextField,
9
+ } from "@mui/material";
10
+ import type { ChangeEvent, FC } from "react";
11
+
12
+ interface AuthFormSubmitButtonProps {
13
+ label: string;
14
+ loading: boolean;
15
+ disabled?: boolean;
16
+ color?: "primary" | "secondary";
17
+ }
18
+
19
+ interface AuthFormPasswordFieldsProps {
20
+ password: string;
21
+ confirmPassword: string;
22
+ onPasswordChange: (value: string) => void;
23
+ onConfirmChange: (value: string) => void;
24
+ disabled?: boolean;
25
+ autoFocus?: boolean;
26
+ label?: string;
27
+ confirmLabel?: string;
28
+ mismatchHelperText?: string;
29
+ }
30
+
31
+ interface AuthFormOtpFieldProps {
32
+ value: string;
33
+ onChange: (value: string) => void;
34
+ codeLength: number;
35
+ disabled?: boolean;
36
+ autoFocus?: boolean;
37
+ label?: string;
38
+ }
39
+
40
+ interface AuthFormErrorProps {
41
+ message: string | null;
42
+ }
43
+
44
+ const AuthFormError = ({ message }: AuthFormErrorProps) => {
45
+ if (!message) return null;
46
+
47
+ return (
48
+ <Box
49
+ role="alert"
50
+ sx={{
51
+ mb: 2,
52
+ color: "error.main",
53
+ fontSize: "0.875rem",
54
+ }}
55
+ >
56
+ {message}
57
+ </Box>
58
+ );
59
+ };
60
+
61
+ const AuthFormSubmitButton = ({
62
+ label,
63
+ loading,
64
+ disabled = false,
65
+ color = "primary",
66
+ }: AuthFormSubmitButtonProps) => {
67
+ return (
68
+ <Button
69
+ type="submit"
70
+ variant="contained"
71
+ color={color}
72
+ fullWidth
73
+ size="large"
74
+ disabled={loading || disabled}
75
+ sx={{ borderRadius: 2, py: 1.4 }}
76
+ >
77
+ {loading ? <CircularProgress size={20} color="inherit" /> : label}
78
+ </Button>
79
+ );
80
+ };
81
+
82
+ const AuthFormPasswordFields = ({
83
+ password,
84
+ confirmPassword,
85
+ onPasswordChange,
86
+ onConfirmChange,
87
+ disabled = false,
88
+ autoFocus = false,
89
+ label = "Password",
90
+ confirmLabel = "Confirm password",
91
+ mismatchHelperText = "Passwords do not match",
92
+ }: AuthFormPasswordFieldsProps) => {
93
+ const mismatch = confirmPassword.length > 0 && password !== confirmPassword;
94
+
95
+ return (
96
+ <>
97
+ <TextField
98
+ label={label}
99
+ type="password"
100
+ value={password}
101
+ onChange={(
102
+ event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
103
+ ) => onPasswordChange(event.target.value)}
104
+ fullWidth
105
+ required
106
+ autoComplete="new-password"
107
+ autoFocus={autoFocus}
108
+ sx={{ mb: 2 }}
109
+ disabled={disabled}
110
+ />
111
+ <TextField
112
+ label={confirmLabel}
113
+ type="password"
114
+ value={confirmPassword}
115
+ onChange={(
116
+ event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
117
+ ) => onConfirmChange(event.target.value)}
118
+ fullWidth
119
+ required
120
+ autoComplete="new-password"
121
+ error={mismatch}
122
+ helperText={mismatch ? mismatchHelperText : undefined}
123
+ sx={{ mb: 3 }}
124
+ disabled={disabled}
125
+ />
126
+ </>
127
+ );
128
+ };
129
+
130
+ const AuthFormOtpField = ({
131
+ value,
132
+ onChange,
133
+ codeLength,
134
+ disabled = false,
135
+ autoFocus = true,
136
+ label = "Verification code",
137
+ }: AuthFormOtpFieldProps) => {
138
+ return (
139
+ <TextField
140
+ label={label}
141
+ type="text"
142
+ value={value}
143
+ onChange={(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
144
+ onChange(event.target.value.replace(/\D/g, "").slice(0, codeLength))
145
+ }
146
+ fullWidth
147
+ required
148
+ autoFocus={autoFocus}
149
+ autoComplete="one-time-code"
150
+ inputProps={{
151
+ maxLength: codeLength,
152
+ style: {
153
+ textAlign: "center",
154
+ letterSpacing: "0.4em",
155
+ fontSize: "1.3rem",
156
+ },
157
+ }}
158
+ sx={{ mb: 3 }}
159
+ disabled={disabled}
160
+ />
161
+ );
162
+ };
163
+
164
+ type AuthFormProps = BoxProps<"form">;
165
+
166
+ type AuthFormComponent = FC<AuthFormProps> & {
167
+ Error: typeof AuthFormError;
168
+ SubmitButton: typeof AuthFormSubmitButton;
169
+ PasswordFields: typeof AuthFormPasswordFields;
170
+ OtpField: typeof AuthFormOtpField;
171
+ };
172
+
173
+ const AuthForm = ((props: AuthFormProps) => (
174
+ <Box component="form" noValidate {...props} />
175
+ )) as AuthFormComponent;
176
+
177
+ AuthForm.Error = AuthFormError;
178
+ AuthForm.SubmitButton = AuthFormSubmitButton;
179
+ AuthForm.PasswordFields = AuthFormPasswordFields;
180
+ AuthForm.OtpField = AuthFormOtpField;
181
+
182
+ export default AuthForm;
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ import AuthActivatePage from "@evenicanpm/storefront-core/src/pages/auth/auth-activate-page";
4
+ import AuthLoginPage from "@evenicanpm/storefront-core/src/pages/auth/auth-login-page";
5
+ import AuthResetPasswordPage from "@evenicanpm/storefront-core/src/pages/auth/auth-reset-password-page";
6
+
7
+ type AuthPagesComponent = ((props: { children?: unknown }) => unknown) & {
8
+ Login: typeof AuthLoginPage;
9
+ Activate: typeof AuthActivatePage;
10
+ ResetPassword: typeof AuthResetPasswordPage;
11
+ };
12
+
13
+ const AuthPages = (({ children }: { children?: unknown }) => (
14
+ <>{children}</>
15
+ )) as AuthPagesComponent;
16
+
17
+ AuthPages.Login = AuthLoginPage;
18
+ AuthPages.Activate = AuthActivatePage;
19
+ AuthPages.ResetPassword = AuthResetPasswordPage;
20
+
21
+ export default AuthPages;
@@ -0,0 +1,70 @@
1
+ type Translate = (key: string) => string;
2
+ const genericMessage = "Something went wrong. Please try again.";
3
+
4
+ const defaultMessages: Record<string, string> = {
5
+ "backend.passwordTooWeak": "Password is too weak.",
6
+ "backend.passwordTooShort": "Password is too short.",
7
+ "backend.passwordTooLong": "Password is too long.",
8
+ "backend.passwordRecentlyUsed": "Password was recently used.",
9
+ "backend.passwordBanned": "Password is not allowed.",
10
+ "backend.passwordInvalid": "Password is invalid.",
11
+ "backend.invalidVerificationCode": "Verification code is invalid.",
12
+ "backend.userAlreadyExists": "An account already exists.",
13
+ "backend.userNotFound": "No account found for this email.",
14
+ "backend.redirectRequired":
15
+ "This tenant requires browser-based authentication.",
16
+ "backend.resetFailed": "Password reset failed.",
17
+ "backend.timeout": "Request timed out. Please try again.",
18
+ generic: genericMessage,
19
+ };
20
+
21
+ const translateOrDefault = (t: Translate | undefined, key: string): string => {
22
+ if (t) return t(key);
23
+
24
+ return defaultMessages[key] ?? genericMessage;
25
+ };
26
+
27
+ export function friendlyError(
28
+ error: string,
29
+ suberror?: string,
30
+ t?: Translate,
31
+ ): string {
32
+ if (suberror === "password_too_weak") {
33
+ return translateOrDefault(t, "backend.passwordTooWeak");
34
+ }
35
+ if (suberror === "password_too_short") {
36
+ return translateOrDefault(t, "backend.passwordTooShort");
37
+ }
38
+ if (suberror === "password_too_long") {
39
+ return translateOrDefault(t, "backend.passwordTooLong");
40
+ }
41
+ if (suberror === "password_recently_used") {
42
+ return translateOrDefault(t, "backend.passwordRecentlyUsed");
43
+ }
44
+ if (suberror === "password_banned") {
45
+ return translateOrDefault(t, "backend.passwordBanned");
46
+ }
47
+ if (suberror === "password_is_invalid") {
48
+ return translateOrDefault(t, "backend.passwordInvalid");
49
+ }
50
+ if (suberror === "invalid_oob_value") {
51
+ return translateOrDefault(t, "backend.invalidVerificationCode");
52
+ }
53
+ if (error === "user_already_exists") {
54
+ return translateOrDefault(t, "backend.userAlreadyExists");
55
+ }
56
+ if (error === "user_not_found") {
57
+ return translateOrDefault(t, "backend.userNotFound");
58
+ }
59
+ if (error === "redirect_required") {
60
+ return translateOrDefault(t, "backend.redirectRequired");
61
+ }
62
+ if (error === "reset_failed") {
63
+ return translateOrDefault(t, "backend.resetFailed");
64
+ }
65
+ if (error === "timeout") {
66
+ return translateOrDefault(t, "backend.timeout");
67
+ }
68
+
69
+ return translateOrDefault(t, "generic");
70
+ }
@@ -0,0 +1,2 @@
1
+ export { friendlyError } from "@evenicanpm/storefront-core/src/pages/auth/lib/friendly-error";
2
+ export type { OtpMeta } from "@evenicanpm/storefront-core/src/pages/auth/lib/types";
@@ -0,0 +1,5 @@
1
+ export interface OtpMeta {
2
+ continuation_token: string;
3
+ challenge_target_label: string;
4
+ code_length: number;
5
+ }
@@ -416,13 +416,6 @@ CartItemRoot.Comment = () => {
416
416
  </FlexBox>
417
417
  </>
418
418
  );
419
- } else if (isLoading) {
420
- content = (
421
- <CircularProgress
422
- size={30}
423
- sx={{ marginTop: "auto", marginLeft: "auto" }}
424
- />
425
- );
426
419
  } else {
427
420
  content = (
428
421
  <>
@@ -448,6 +441,7 @@ CartItemRoot.Comment = () => {
448
441
  <Button
449
442
  color="primary"
450
443
  variant="outlined"
444
+ disabled={isLoading}
451
445
  sx={{ width: 150, mt: "auto", ml: "auto" }}
452
446
  onClick={() => setIsWritingComment(true)}
453
447
  >
@@ -7,7 +7,7 @@ import { PageSlug } from "@evenicanpm/storefront-core/src/lib/page-slugs";
7
7
  import CheckoutStep from "@evenicanpm/storefront-core/src/pages/checkout/checkout-alt-form/checkout-step";
8
8
  import { useNotification } from "@evenicanpm/storefront-core/src/providers/notifications/use-notification";
9
9
  import type { CardPaymentAcceptResult } from "@msdyn365-commerce/retail-proxy";
10
- import { Box, Button } from "@mui/material";
10
+ import { Box, Button, CircularProgress } from "@mui/material";
11
11
  import Grid from "@mui/material/Grid2";
12
12
  import { useQueryClient } from "@tanstack/react-query";
13
13
  import { useRouter } from "next/navigation";
@@ -18,6 +18,7 @@ import React, {
18
18
  type ReactNode,
19
19
  useContext,
20
20
  useEffect,
21
+ useRef,
21
22
  useState,
22
23
  } from "react";
23
24
 
@@ -62,12 +63,15 @@ const CheckoutForm = ({ cart, children }: CheckoutFormProps) => {
62
63
  const { data: channelConfigurationData } = getChannelConfiguration.useData();
63
64
  const [isSubmitting, setIsSubmitting] = useState(false);
64
65
  const [totalSteps, setTotalSteps] = useState(0);
66
+ const isSubmittingRef = useRef(false);
65
67
 
66
68
  const cleanupCart = async (transactionId: string) => {
67
69
  router.push(`${PageSlug.Confirmation}?transactionId=${transactionId}`);
68
70
  };
69
71
 
70
72
  const placeOrder = async () => {
73
+ if (isSubmittingRef.current) return;
74
+ isSubmittingRef.current = true;
71
75
  setIsSubmitting(true);
72
76
  const cache = queryClient.getQueryCache();
73
77
 
@@ -98,6 +102,8 @@ const CheckoutForm = ({ cart, children }: CheckoutFormProps) => {
98
102
  .data as CardPaymentAcceptResult;
99
103
 
100
104
  if (!paymentAcceptResult?.TokenizedPaymentCard) {
105
+ isSubmittingRef.current = false;
106
+ setIsSubmitting(false);
101
107
  return;
102
108
  }
103
109
 
@@ -121,8 +127,9 @@ const CheckoutForm = ({ cart, children }: CheckoutFormProps) => {
121
127
  message: t("placeOrderErrorMessage"),
122
128
  severity: "error",
123
129
  });
130
+ isSubmittingRef.current = false;
131
+ setIsSubmitting(false);
124
132
  }
125
- setIsSubmitting(false);
126
133
  };
127
134
 
128
135
  return (
@@ -227,7 +234,11 @@ CheckoutForm.Footer = () => {
227
234
  color="primary"
228
235
  onClick={placeOrder}
229
236
  >
230
- {t("placeOrderButton")}
237
+ {isSubmitting ? (
238
+ <CircularProgress size={24} color="inherit" />
239
+ ) : (
240
+ t("placeOrderButton")
241
+ )}
231
242
  </Button>
232
243
  </Grid>
233
244
  );
@@ -115,13 +115,15 @@ AddressCard.Body = () => {
115
115
  if (!ctx) return null;
116
116
 
117
117
  const { address } = ctx;
118
+ // Join only non-empty values so a dangling comma is never rendered.
119
+ const stateRegion = [address.State, address.ThreeLetterISORegionName]
120
+ .filter((value) => value?.trim())
121
+ .join(", ");
118
122
  return (
119
123
  <>
120
124
  <Paragraph color="grey.700">{address.Street}</Paragraph>
121
125
  <Paragraph color="grey.700">{address.City}</Paragraph>
122
- <Paragraph color="grey.700">
123
- {address.State}, {address.ThreeLetterISORegionName}
124
- </Paragraph>
126
+ {stateRegion && <Paragraph color="grey.700">{stateRegion}</Paragraph>}
125
127
  <Paragraph color="grey.700">{address.ZipCode}</Paragraph>
126
128
  <Paragraph color="grey.700">{address.Phone}</Paragraph>
127
129
  </>
@@ -105,7 +105,8 @@ const DeliveryAddress: DeliveryAddressCompound = ({
105
105
 
106
106
  // user + guest logic
107
107
  const { data: user, isLoading: userLoading } = getUser.useData();
108
- const isGuest = !userLoading && !user?.RecordId;
108
+ const isAuthenticatedUser = !!user;
109
+ const isGuest = !userLoading && !isAuthenticatedUser;
109
110
 
110
111
  // dropdown visibility
111
112
  const showDropDown = useDropDown && !isGuest;
@@ -165,9 +166,22 @@ const DeliveryAddress: DeliveryAddressCompound = ({
165
166
 
166
167
  const handleAuthFlow = async (address: Address) => {
167
168
  const isNew = !address.Id;
168
- const saved = isNew
169
- ? await createAddress(address)
170
- : await updateAddress(address);
169
+ let saved: Address;
170
+ if (isNew) {
171
+ try {
172
+ saved = await createAddress(address);
173
+ } catch (error) {
174
+ const message = error instanceof Error ? error.message : String(error);
175
+ // Newly created async customers may not be ready for address persistence yet.
176
+ if (message.includes("CreatingAsyncAddressesNotSupported")) {
177
+ saved = address;
178
+ } else {
179
+ throw error;
180
+ }
181
+ }
182
+ } else {
183
+ saved = await updateAddress(address);
184
+ }
171
185
 
172
186
  // update local list
173
187
  if (isNew) {
@@ -184,12 +198,12 @@ const DeliveryAddress: DeliveryAddressCompound = ({
184
198
  };
185
199
 
186
200
  const handleSaveAddress = async (address: Address) => {
187
- if (isGuest) return handleGuestFlow(address);
201
+ if (!isAuthenticatedUser || userLoading) return handleGuestFlow(address);
188
202
  return handleAuthFlow(address);
189
203
  };
190
204
 
191
205
  const handleDelete = async (address: Address) => {
192
- if (!isGuest && address.Id) {
206
+ if (isAuthenticatedUser && address.Id) {
193
207
  await deleteAddress(address.Id);
194
208
  }
195
209
  setAddressList((prev) => prev.filter((a) => a.Id !== address.Id));
@@ -14,9 +14,9 @@ import DialogContent from "@mui/material/DialogContent";
14
14
  import Grid from "@mui/material/Grid2";
15
15
  import TextField from "@mui/material/TextField";
16
16
  import { merge } from "lodash";
17
+ import { useTranslations } from "next-intl";
17
18
  import React from "react";
18
19
  import { Controller, type UseFormReturn, useForm } from "react-hook-form";
19
- import { useTranslations } from "use-intl";
20
20
  import z from "zod";
21
21
 
22
22
  type AddressFormInput = {
@@ -39,7 +39,7 @@ const CustomerInformation = (
39
39
  } = props;
40
40
 
41
41
  const { data: user, isLoading: userLoading } = getUser.useData();
42
- const isGuest = !userLoading && !user?.RecordId;
42
+ const isGuest = !userLoading && !user;
43
43
 
44
44
  const resolveEmail = () => {
45
45
  if (isGuest) return cartEmail;
@@ -117,7 +117,9 @@ OrderedProducts.Comment = ({ line }: LineProps) => {
117
117
  if (!line.Comment) return null;
118
118
  return (
119
119
  <Paragraph mt={1}>
120
- <H6 sx={{ display: "inline" }}>{t("commentLabel")}: </H6>
120
+ <Span sx={{ display: "inline", fontSize: 14, fontWeight: 600 }}>
121
+ {t("commentLabel")}:
122
+ </Span>
121
123
  {line.Comment}
122
124
  </Paragraph>
123
125
  );
@@ -56,7 +56,7 @@ const initialSearchArea: SearchArea = {
56
56
  * @param {Function} props.handleAddToCart - Function to handle adding the product to cart
57
57
  * @param {number} props.productId - ID of the product being searched
58
58
  *
59
- * @returns {JSX.Element} - Rendered modal component that displays available stores with inventory information
59
+ * @returns {React.ReactElement} - Rendered modal component that displays available stores with inventory information
60
60
  *
61
61
  * @example
62
62
  * <FindInStoreModal
@@ -71,7 +71,7 @@ const FindInStoreModal = ({
71
71
  open,
72
72
  handleAddToCart,
73
73
  productId,
74
- }: Props): JSX.Element => {
74
+ }: Props): React.ReactElement => {
75
75
  const t = useTranslations("productDetail");
76
76
  const [hideOutOfStock, setHideOutOfStock] = React.useState(false);
77
77
  const { data: channelConfig } = getChannelConfiguration.useSuspenseData();
@@ -154,7 +154,7 @@ const FindInStoreModal = ({
154
154
  const _stores = Object.values(stores);
155
155
  if (hideOutOfStock) {
156
156
  return _stores.filter(
157
- (store) => !isOutOfStock(store.location.OrgUnitNumber || ""),
157
+ (store) => !isOutOfStock(store?.location?.OrgUnitNumber || ""),
158
158
  );
159
159
  }
160
160
  return _stores;
@@ -205,7 +205,7 @@ const FindInStoreModal = ({
205
205
  address={location.Address || ""}
206
206
  actionArea={renderActionArea(
207
207
  location.OrgUnitNumber || "",
208
- pickupOptions || [],
208
+ (pickupOptions || []) as DeliveryOption[],
209
209
  inventoryRecord?.TotalAvailableInventoryLevelLabel || "",
210
210
  )}
211
211
  openFrom={location.OpenFrom || 0}
@@ -31,7 +31,7 @@ type Props = {
31
31
  * @param {boolean} props.isOutOfStock - Flag indicating if the product is out of stock at this location
32
32
  * @param {string} props.inventoryLabel - Label to display when product is out of stock
33
33
  *
34
- * @returns {JSX.Element} The rendered pickup option selection component
34
+ * @returns {React.ReactElement} The rendered pickup option selection component
35
35
  *
36
36
  * @example
37
37
  * <PickupOptionSelect
@@ -48,7 +48,7 @@ const PickupOptionSelect = ({
48
48
  onPickupClick,
49
49
  isOutOfStock,
50
50
  inventoryLabel,
51
- }: Props): JSX.Element => {
51
+ }: Props): React.ReactElement => {
52
52
  const [selectedOption, setSelectedOption] =
53
53
  React.useState<DeliveryOption | null>(pickupOptions[0] || null);
54
54
  const handlePickupOptionChange = (event: SelectChangeEvent) => {
@@ -19,14 +19,14 @@ type Props = {
19
19
  * @param {Object} props - Component props
20
20
  * @param {Function} props.handleSearch - Callback function that handles the search action with the search term
21
21
  *
22
- * @returns {JSX.Element} A form with a search input field that triggers the search on submit or Enter key
22
+ * @returns {React.ReactElement} A form with a search input field that triggers the search on submit or Enter key
23
23
  *
24
24
  * @example
25
25
  * ```tsx
26
26
  * <SearchHeader handleSearch={(term) => console.log(`Searching for ${term}`)} />
27
27
  * ```
28
28
  */
29
- const SearchHeader = ({ handleSearch }: Props): JSX.Element => {
29
+ const SearchHeader = ({ handleSearch }: Props): React.ReactElement => {
30
30
  const [searchTerm, setSearchTerm] = React.useState<string>("");
31
31
  const t = useTranslations("productDetail.Bopis");
32
32
  const renderSearchAdornment = () => (
@@ -1,4 +1,7 @@
1
- import type { ProductDetails } from "@evenicanpm/storefront-core/src/api-manager/schemas/product.schema";
1
+ import type {
2
+ ProductDetails,
3
+ ProductPrice,
4
+ } from "@evenicanpm/storefront-core/src/api-manager/schemas/product.schema";
2
5
  import type { SelectedDimensions } from "@evenicanpm/storefront-core/src/hooks/use-variants";
3
6
  import type {
4
7
  ProductDimensionValueInventoryAvailability,
@@ -44,8 +47,10 @@ export interface ProductImagesContext {
44
47
  export interface ProductInfoContext {
45
48
  /** Does the product have variants? If not we do not verify dimensions before adding to cart etc. **/
46
49
  hasVariants: boolean;
47
- /** **/
48
- inventoryPending: boolean;
50
+ /** Combined loading state for price and inventory */
51
+ isProductDetailsPending: boolean;
52
+ /** */
53
+ productPrices: ProductPrice[] | undefined;
49
54
  /** */
50
55
  shippingInventoryData: ProductWarehouseInventoryInformation | undefined;
51
56
  /** */
@@ -48,11 +48,17 @@ export interface Props {
48
48
  */
49
49
  const ProductInfo = ({ children }: Props) => {
50
50
  const [showDimensionError, setShowDimensionError] = useState(false);
51
- const { masterProduct, selectedVariant } = useProductIntro();
51
+ const {
52
+ masterProduct,
53
+ selectedVariant,
54
+ variantPending,
55
+ allDimensionsSelected,
56
+ } = useProductIntro();
52
57
 
53
58
  const hasVariants = masterProduct?.Dimensions?.length !== 0;
54
59
 
55
60
  const id = hasVariants ? selectedVariant?.RecordId : masterProduct?.RecordId;
61
+ const productId = selectedVariant?.RecordId || masterProduct?.RecordId;
56
62
 
57
63
  const shippingInventorySearchCriteria =
58
64
  !hasVariants || selectedVariant
@@ -65,11 +71,23 @@ const ProductInfo = ({ children }: Props) => {
65
71
  enabled: !!shippingInventorySearchCriteria,
66
72
  });
67
73
 
74
+ const { data: productPrices, isPending: pricesPending } =
75
+ getProductPrices.useData({
76
+ productIds: [productId],
77
+ });
78
+
79
+ const isProductDetailsPending =
80
+ pricesPending ||
81
+ !productPrices ||
82
+ variantPending ||
83
+ (allDimensionsSelected && inventoryPending);
84
+
68
85
  return (
69
86
  <ProductInfoContext.Provider
70
87
  value={{
71
88
  hasVariants,
72
- inventoryPending,
89
+ isProductDetailsPending,
90
+ productPrices,
73
91
  shippingInventoryData,
74
92
  showDimensionError,
75
93
  setShowDimensionError,
@@ -153,23 +171,13 @@ ProductInfo.Dimensions = () => {
153
171
  };
154
172
 
155
173
  ProductInfo.Price = () => {
156
- const IntroCtx = useProductIntro();
157
- if (!IntroCtx)
158
- throw new Error(
159
- "ProductInfo.Inventory must be wrapped in ProductInfoContext and a ProductIntroContext",
160
- );
161
- const { masterProduct, selectedVariant } = IntroCtx;
162
174
  const { formatCurrency } = useCurrencyFormatter();
163
-
164
- const { data: productPrices, isPending: pricesPending } =
165
- getProductPrices.useData({
166
- productIds: [selectedVariant?.RecordId || masterProduct?.RecordId],
167
- });
175
+ const { isProductDetailsPending, productPrices } = useProductInfo();
168
176
 
169
177
  return (
170
178
  <Box pt={1}>
171
179
  <H2 color="primary.main" mb={0.5} lineHeight="1">
172
- {pricesPending || !productPrices ? (
180
+ {isProductDetailsPending ? (
173
181
  <CircularProgress size={20} />
174
182
  ) : (
175
183
  formatCurrency(productPrices?.[0]?.CustomerContextualPrice || 0)
@@ -190,8 +198,8 @@ ProductInfo.Inventory = () => {
190
198
  throw new Error(
191
199
  "ProductInfo.Inventory must be wrapped in ProductInfoContext and a ProductIntroContext",
192
200
  );
193
- const { variantPending, allDimensionsSelected } = IntroCtx;
194
- const { shippingInventoryData, inventoryPending } = InfoCtx;
201
+ const { allDimensionsSelected } = IntroCtx;
202
+ const { shippingInventoryData, isProductDetailsPending } = InfoCtx;
195
203
  if (!allDimensionsSelected)
196
204
  return (
197
205
  <FlexBox alignItems="center" gap={1}>
@@ -209,9 +217,7 @@ ProductInfo.Inventory = () => {
209
217
  <FlexBox alignItems="center" gap={1}>
210
218
  {/* INVENTORY */}
211
219
  <Box sx={{ my: 3 }}>
212
- {inventoryPending || variantPending ? (
213
- <CircularProgress size={20} />
214
- ) : (
220
+ {!isProductDetailsPending && (
215
221
  <Typography variant="body2" color="text.secondary" fontWeight={600}>
216
222
  {getInventoryLabel(shippingInventoryData)}
217
223
  </Typography>
@@ -15,6 +15,7 @@ import useProductList from "@evenicanpm/storefront-core/src/pages/product-list/u
15
15
  import generateBreadcrumbs from "@evenicanpm/storefront-core/src/pages/product-list/utils/generate-breadcrumbs";
16
16
  import { searchCategory } from "@evenicanpm/storefront-core/src/pages/product-list/utils/search-for-category";
17
17
  import { usePreviousRefiners } from "@evenicanpm/storefront-core/src/pages/product-list/utils/use-previous-refiners";
18
+ import type { ProductSearchResult } from "@msdyn365-commerce/retail-proxy";
18
19
  import { Box, CircularProgress, Paper, Typography } from "@mui/material";
19
20
  import Grid from "@mui/material/Grid2";
20
21
  import { useLocale, useTranslations } from "next-intl";
@@ -149,7 +150,7 @@ ProductListView.Pagination = function Pager() {
149
150
  ProductListView.Results = function Results() {
150
151
  const t = useTranslations("ProductList");
151
152
  const { productData, isLoading, view, productPrices } = useProductList();
152
- const products = productData?.products ?? [];
153
+ const products = (productData?.products ?? []) as ProductSearchResult[];
153
154
  const paginationNode = <ProductListView.Pagination />;
154
155
 
155
156
  if (isLoading) {