@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.
- package/CHANGELOG.md +17 -0
- package/package.json +42 -9
- package/src/api-manager/datasources/d365/d365-address.datasource.ts +148 -61
- package/src/api-manager/datasources/d365/d365-cart.datasource.ts +23 -1
- package/src/api-manager/datasources/d365/d365-order.datasource.ts +6 -1
- package/src/api-manager/datasources/d365/d365-user.datasource.ts +109 -37
- package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +44 -10
- package/src/api-manager/index.ts +2 -1
- package/src/api-manager/lib/get-graphql-client.ts +35 -7
- package/src/api-manager/schemas/product.schema.ts +1 -1
- package/src/api-manager/services/create-query.ts +36 -3
- package/src/api-manager/services/get-query-client.ts +10 -0
- package/src/auth/better-auth.ts +282 -15
- package/src/cms/blocks/block-manager.tsx +1 -1
- package/src/cms/blocks/components/product-section-fullwidth/index.tsx +3 -1
- package/src/cms/endpoints.ts +7 -2
- package/src/components/BazaarMenu.tsx +1 -1
- package/src/components/Typography.tsx +4 -2
- package/src/components/carousel-cards/carousel-card-1/CarouselCard1.stories.tsx +1 -1
- package/src/components/categories/category-list/category-list.tsx +9 -5
- package/src/components/categories/category-menu.tsx +1 -1
- package/src/components/countries-input.tsx +4 -0
- package/src/components/header/__tests__/user.test.tsx +5 -107
- package/src/components/header/components/user.tsx +72 -45
- package/src/components/header/sticky-header.tsx +0 -1
- package/src/components/navbar/mobile-menu/mobile-menu.test.tsx +1 -1
- package/src/components/navbar/utils/transform-nav.test.ts +1 -1
- package/src/components/navbar/utils/transform-nav.ts +1 -1
- package/src/components/product-cards/product-card/product-card.tsx +5 -2
- package/src/components/product-cards/product-card/styles/index.ts +1 -5
- package/src/components/products-view/compound/products-grid-view.tsx +5 -1
- package/src/components/products-view/compound/products-list-view.tsx +5 -1
- package/src/global.d.ts +3 -0
- package/src/hooks/use-nextauth-session.ts +0 -33
- package/src/lib/auth-client.ts +14 -0
- package/src/lib/auth.ts +4 -0
- package/src/lib/entra-native-auth.ts +138 -0
- package/src/lib/native-session.ts +434 -0
- package/src/pages/account/addresses/address-form.tsx +20 -2
- package/src/pages/account/profile/__tests__/profile-form.test.tsx +173 -0
- package/src/pages/account/profile/profile-form.tsx +285 -0
- package/src/pages/account/profile/profile-validation.ts +52 -0
- package/src/pages/account/wishlist/wishlist-item.tsx +1 -2
- package/src/pages/auth/auth-activate-page.tsx +509 -0
- package/src/pages/auth/auth-layout.tsx +73 -0
- package/src/pages/auth/auth-login-page.tsx +241 -0
- package/src/pages/auth/auth-reset-password-page.tsx +487 -0
- package/src/pages/auth/compound/auth-form.tsx +182 -0
- package/src/pages/auth/compound/auth-pages.tsx +21 -0
- package/src/pages/auth/lib/friendly-error.ts +70 -0
- package/src/pages/auth/lib/index.ts +2 -0
- package/src/pages/auth/lib/types.ts +5 -0
- package/src/pages/cart/cart-item.tsx +1 -7
- package/src/pages/checkout/checkout-alt-form/checkout-form.tsx +14 -3
- package/src/pages/checkout/checkout-alt-form/steps/address/address-card.tsx +5 -3
- package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +20 -6
- package/src/pages/checkout/checkout-alt-form/steps/address/new-address-form.tsx +1 -1
- package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +1 -1
- package/src/pages/confirmation/ordered-products.tsx +3 -1
- package/src/pages/product-details/bopis/find-in-store-modal.tsx +4 -4
- package/src/pages/product-details/bopis/pickup-option-select.tsx +2 -2
- package/src/pages/product-details/bopis/search-header.tsx +2 -2
- package/src/pages/product-details/product-intro/compound/context.ts +8 -3
- package/src/pages/product-details/product-intro/compound/product-info.tsx +25 -19
- package/src/pages/product-list/product-list-view.tsx +2 -1
- package/src/providers/nav-provider/index.tsx +1 -1
- package/src/providers/nav-provider/utils/createLink.ts +1 -1
- package/tsconfig.json +20 -15
- package/__mocks__/countries.ts +0 -11
- package/__mocks__/create-mutation.ts +0 -68
- package/__mocks__/create-query.ts +0 -94
- package/__mocks__/data/categories.json +0 -795
- package/__mocks__/get-product-by-id.ts +0 -6
- package/__mocks__/next-auth-react.ts +0 -9
- package/__mocks__/next-font.js +0 -4
- package/__mocks__/next-headers.js +0 -13
- package/__mocks__/next-image.tsx +0 -18
- package/__mocks__/next-link.js +0 -19
- package/__mocks__/next-navigation.ts +0 -29
- package/__mocks__/product-dimensions.ts +0 -635
- package/__mocks__/products.ts +0 -533
- package/__mocks__/query-client.ts +0 -3
- package/__mocks__/wishlists.json +0 -408
- package/src/auth/msal.ts +0 -68
- package/src/cms/blog.ts +0 -229
- package/src/cms/interfaces/navigation.ts +0 -52
- package/src/cms/interfaces/product-data.ts +0 -83
- package/src/cms/pages.ts +0 -149
- package/src/pages/account/profile/profile-button.test.tsx +0 -59
- 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
|
+
}
|
|
@@ -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
|
-
{
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
<
|
|
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 {
|
|
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):
|
|
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
|
|
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 {
|
|
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):
|
|
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 {
|
|
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):
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
194
|
-
const { shippingInventoryData,
|
|
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
|
-
{
|
|
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) {
|