@salesforce/retail-react-app 7.0.0-preview.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -8
- package/app/components/dynamic-image/index.jsx +91 -16
- package/app/components/dynamic-image/index.test.js +214 -30
- package/app/components/image/index.jsx +5 -13
- package/app/components/image/index.test.js +6 -3
- package/app/components/island/README.md +15 -10
- package/app/components/island/index.jsx +12 -5
- package/app/components/island/index.test.js +35 -0
- package/app/components/passwordless-login/index.jsx +4 -5
- package/app/components/passwordless-login/index.test.js +2 -4
- package/app/components/product-tile/index.jsx +1 -1
- package/app/components/product-view-modal/bundle.jsx +12 -2
- package/app/components/social-login/index.jsx +1 -0
- package/app/components/standard-login/index.jsx +4 -1
- package/app/constants.js +3 -0
- package/app/hooks/use-auth-modal.js +68 -67
- package/app/hooks/use-auth-modal.test.js +93 -23
- package/app/hooks/use-datacloud.js +169 -192
- package/app/hooks/use-datacloud.test.js +273 -17
- package/app/pages/cart/index.jsx +2 -1
- package/app/pages/cart/partials/cart-secondary-button-group.jsx +8 -10
- package/app/pages/cart/partials/cart-secondary-button-group.test.js +2 -3
- package/app/pages/checkout/partials/contact-info.jsx +9 -8
- package/app/pages/checkout/partials/contact-info.test.js +41 -4
- package/app/pages/checkout/partials/login-state.jsx +3 -3
- package/app/pages/home/index.test.js +2 -1
- package/app/pages/login/index.jsx +37 -37
- package/app/pages/login/index.test.js +42 -0
- package/app/pages/product-detail/index.jsx +64 -73
- package/app/pages/product-list/index.jsx +19 -9
- package/app/pages/product-list/index.test.js +153 -19
- package/app/utils/image.js +29 -0
- package/app/utils/image.test.js +141 -1
- package/app/utils/responsive-image.js +197 -115
- package/app/utils/responsive-image.test.js +483 -133
- package/config/default.js +2 -2
- package/config/mocks/default.js +2 -2
- package/package.json +7 -7
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Inspired by [Astro’s islands architecture](https://docs.astro.build/en/concepts/islands/), this component is intended to give developers explicit and fine-granular control over the hydration behavior of their experiences.
|
|
4
4
|
|
|
5
|
-
> **ℹ️ Important:** The aspect of **inspiration** is crucial to emphasize. This component merely attempts to **mimic** some of the islands architecture’s behaviors. It needs to be understood as a single tool in a larger toolbox, which is intended to eventually help address very specific performance problems in connection with server-rendered components and their subsequent hydration on the client. So this is neither a silver bullet nor a construct that is guaranteed to work for every use case.
|
|
5
|
+
> **ℹ️ Important:** The aspect of **inspiration** is crucial to emphasize. This component merely attempts to **mimic** some of the islands architecture’s behaviors. It needs to be understood as a single tool in a larger toolbox, which is intended to eventually help address very specific performance problems in connection with server-rendered components and their subsequent hydration on the client. So this is neither a silver bullet nor a construct that is guaranteed to work for every use case. Use it wisely and carefully.
|
|
6
6
|
|
|
7
7
|
# 🧟️ Hydration’s Curse
|
|
8
8
|
|
|
@@ -26,17 +26,17 @@ Ultimately, the goal is to reduce the load on the browser’s main thread, which
|
|
|
26
26
|
|
|
27
27
|
## React’s Selective Hydration ≠ Partial Hydration
|
|
28
28
|
|
|
29
|
-
React 18, with [Suspense](https://react.dev/reference/react/Suspense), laid the foundation to officially support concepts such as [selective hydration](https://github.com/reactwg/react-18/discussions/37). However, while the Islands Architecture concept treats partial and selective hydration as interchangeable terms, React’s understanding of selective hydration differs in key aspects.
|
|
29
|
+
React 18, with [`Suspense`](https://react.dev/reference/react/Suspense), laid the foundation to officially support concepts such as [selective hydration](https://github.com/reactwg/react-18/discussions/37). However, while the Islands Architecture concept treats partial and selective hydration as interchangeable terms, React’s understanding of selective hydration differs in key aspects.
|
|
30
30
|
|
|
31
|
-
Suspense _delays the **rendering** of certain components altogether_ and instead enables the display of specified fallback content while the actual content is loading.
|
|
31
|
+
`Suspense` _delays the **rendering** of certain components altogether_ and instead enables the display of specified fallback content while the actual content is loading.
|
|
32
32
|
|
|
33
|
-
When using Suspense
|
|
33
|
+
When using `Suspense`, certain contents are therefore not rendered on the server at all, but instead asynchronously on the client at a later time only — with all the associated implications for accessibility and SEO. Depending on the runtime used, the boundaries can be fluid as to when the asynchronous loading of the actual/final content begins — whether already on the server or only on the client — but the outcome remains the same: **`Suspense` enables asynchronous rendering, not asynchronous hydration.**
|
|
34
34
|
|
|
35
|
-
In addition to Suspense
|
|
35
|
+
In addition to `Suspense`, [React Server Components](https://react.dev/reference/rsc/server-components) (RSC) are now another powerful official concept in React for addressing the all-or-nothing problem with hydration. Server Components are components that are rendered exclusively on the server and therefore don’t receive any downstream client-side hydration. In fact, when using RSCs, only the JavaScript that is absolutely necessary to restore application state and interactivity is sent to the client. If used sensibly, RSCs in combination with client components - defined via the `use client` directive - enable a very granular control over the sections of an application requiring hydration. However, it’s then still not possible to actively influence the timing of when the hydration of specific sections should occur.
|
|
36
36
|
|
|
37
37
|
## `<Island/>` Component
|
|
38
38
|
|
|
39
|
-
Unlike Suspense
|
|
39
|
+
Unlike `Suspense`, the `<Island/>` component explicitly **encourages the synchronous rendering of HTML on the server** as much as possible. And instead of delaying the rendering of certain contents, it allows the **explicit delay of the hydration process itself**.
|
|
40
40
|
|
|
41
41
|
This delay can be achieved using various `hydrateOn` strategies:
|
|
42
42
|
|
|
@@ -45,7 +45,11 @@ This delay can be achieved using various `hydrateOn` strategies:
|
|
|
45
45
|
3. `hydrateOn={'visible'}`: Useful for lower-priority UI elements that don’t need to be immediately interactive. Hydration occurs once the component has [entered the user’s viewport](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver). As a result, such an element doesn’t get hydrated at all if the user never sees it.
|
|
46
46
|
4. `hydrateOn={'off'}`: Useful for non-interactive UI elements or lower-priority UI elements to completely suppress the hydration for. On the surface, this behavior overlaps in certain ways with Server Components. However, there are fundamental differences. While server components don’t contribute any JavaScript to what’s delivered to the client, islands are client-oriented constructs, and thus the JavaScript for a non-hydrated section is still fully transmitted. After all, `hydrateOn` can be dynamically updated at any time in case of using a binding property instead of a hard-coded value. An initial value of `off` therefore opens up the possibilities for completely custom hydration triggers. Furthermore, if Server Components aren’t supported by the runtime used, `hydrateOn={'off'}` offers a simple way to at least reduce the hydration overhead for certain non-interactive areas.
|
|
47
47
|
|
|
48
|
-
>
|
|
48
|
+
> **🔀️ Important:** Partial hydration support is put behind a feature toggle that can be turned on by setting the constant `PARTIAL_HYDRATION_ENABLED` to `true` (see `@salesforce/retail-react-app/app/constants`).
|
|
49
|
+
|
|
50
|
+
> **ℹ️️ Note:** `<Island/>` components only influence the hydration behavior of server-rendered content. Once an application bootstrapped on the client and returned to SPA mode, any subsequent client-side rendering is not impacted by the `<Island/>` components anymore.
|
|
51
|
+
|
|
52
|
+
> **❗ Important:** Ultimately, the approaches of `Suspense`, Server Components and the `<Island/>` component aren’t mutually exclusive; on the contrary, they can perfectly complement each other.
|
|
49
53
|
|
|
50
54
|
## `<Island/>` Nesting
|
|
51
55
|
|
|
@@ -61,8 +65,9 @@ Therefore, a frequently used approach is to present the user with server-generat
|
|
|
61
65
|
|
|
62
66
|
If not checked or not even considered during development, this behavior can lead to occasionally subtle issues:
|
|
63
67
|
|
|
64
|
-
1.
|
|
65
|
-
2.
|
|
66
|
-
3.
|
|
68
|
+
1. An `<Island/>` might contain unhydrated content that is relevant for side effects or tasks such as metrics tracking. In its unhydrated static state, certain event or beacon submissions may therefore not occur. _Please test the correct functionality of all conditionally/partially hydrated areas carefully._
|
|
69
|
+
2. Certain configurations can simply miss to make specific sections interactive in time — or at all. In such cases, end users may become frustrated when faced with seemingly interactive content that doesn’t respond to interactions or otherwise behaves unexpectedly.
|
|
70
|
+
3. An `<Island/>` may wrap sections that render different content on the server than on the client. Depending on the timing of hydration or the placement of the `<Island/>` within the user’s initial viewport, such content mismatches can cause unexpected layout shifts.
|
|
71
|
+
4. Managing focus states, keyboard navigation, and screen reader announcements across the boundary between static and interactive content requires careful coordination.
|
|
67
72
|
|
|
68
73
|
These challenges aren’t insurmountable, but they require careful architectural planning and testing to address effectively.
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
5
|
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
6
|
*/
|
|
7
|
-
/*
|
|
7
|
+
/* eslint-disable react-hooks/rules-of-hooks */
|
|
8
|
+
/* global globalThis */
|
|
8
9
|
import React, {
|
|
9
10
|
Children,
|
|
10
11
|
createContext,
|
|
@@ -17,6 +18,7 @@ import React, {
|
|
|
17
18
|
} from 'react'
|
|
18
19
|
import PropTypes from 'prop-types'
|
|
19
20
|
import {isServer} from '@salesforce/retail-react-app/app/components/island/utils'
|
|
21
|
+
import {PARTIAL_HYDRATION_ENABLED} from '@salesforce/retail-react-app/app/constants'
|
|
20
22
|
|
|
21
23
|
const IslandContext = createContext(null)
|
|
22
24
|
|
|
@@ -40,7 +42,9 @@ function findChildren(children, componentType) {
|
|
|
40
42
|
|
|
41
43
|
/**
|
|
42
44
|
* This component is intended to give developers explicit and fine-granular control over the
|
|
43
|
-
* hydration behavior of their experiences.
|
|
45
|
+
* hydration behavior of their experiences. The influence of the `<Island/>` components on the
|
|
46
|
+
* hydration behavior can be activated or deactivated using the {@link PARTIAL_HYDRATION_ENABLED}
|
|
47
|
+
* constant.
|
|
44
48
|
* @param {Object} props
|
|
45
49
|
* @param {ReactNode} [props.children] The child tree
|
|
46
50
|
* @param {('load' | 'idle' | 'visible' | 'off')} [props.hydrateOn='load'] The island's hydration strategy
|
|
@@ -79,7 +83,12 @@ function findChildren(children, componentType) {
|
|
|
79
83
|
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver}
|
|
80
84
|
*/
|
|
81
85
|
function Island(props) {
|
|
82
|
-
const {children
|
|
86
|
+
const {children} = props
|
|
87
|
+
if (!PARTIAL_HYDRATION_ENABLED) {
|
|
88
|
+
return <>{children}</>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const {hydrateOn = 'load', options, clientOnly = false} = props
|
|
83
92
|
const ssr = isServer()
|
|
84
93
|
const [hydrated, setHydrated] = useState(ssr) // Ensure SSR immediately returns the generated HTML
|
|
85
94
|
const context = useIslandContext()
|
|
@@ -90,7 +99,6 @@ function Island(props) {
|
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
if (!ssr) {
|
|
93
|
-
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
94
102
|
useLayoutEffect(() => {
|
|
95
103
|
if (
|
|
96
104
|
!hydrated &&
|
|
@@ -105,7 +113,6 @@ function Island(props) {
|
|
|
105
113
|
}
|
|
106
114
|
})
|
|
107
115
|
|
|
108
|
-
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
109
116
|
useEffect(() => {
|
|
110
117
|
if (
|
|
111
118
|
hydrated ||
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
5
|
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
6
|
*/
|
|
7
|
+
/* eslint-disable no-import-assign */
|
|
7
8
|
import React from 'react'
|
|
8
9
|
import {act, render, screen} from '@testing-library/react'
|
|
9
10
|
import {renderToString} from 'react-dom/server'
|
|
10
11
|
import Island from '@salesforce/retail-react-app/app/components/island'
|
|
11
12
|
import {isServer} from '@salesforce/retail-react-app/app/components/island/utils'
|
|
13
|
+
import * as constants from '@salesforce/retail-react-app/app/constants'
|
|
12
14
|
|
|
13
15
|
jest.mock('@salesforce/retail-react-app/app/components/island/utils', () => ({
|
|
14
16
|
...jest.requireActual('@salesforce/retail-react-app/app/components/island/utils'),
|
|
@@ -44,8 +46,15 @@ function renderServerComponent(component) {
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
describe('Island Component', () => {
|
|
49
|
+
let originalFlagValue
|
|
50
|
+
|
|
51
|
+
beforeAll(() => (originalFlagValue = constants.PARTIAL_HYDRATION_ENABLED))
|
|
52
|
+
|
|
53
|
+
afterAll(() => Reflect.set(constants, 'PARTIAL_HYDRATION_ENABLED', originalFlagValue))
|
|
54
|
+
|
|
47
55
|
beforeEach(() => {
|
|
48
56
|
jest.clearAllMocks()
|
|
57
|
+
Reflect.set(constants, 'PARTIAL_HYDRATION_ENABLED', true)
|
|
49
58
|
global.requestIdleCallback = mockRequestIdleCallback
|
|
50
59
|
global.cancelIdleCallback = mockCancelIdleCallback
|
|
51
60
|
global.IntersectionObserver = mockIntersectionObserver
|
|
@@ -61,6 +70,19 @@ describe('Island Component', () => {
|
|
|
61
70
|
isServer.mockReturnValue(true)
|
|
62
71
|
})
|
|
63
72
|
|
|
73
|
+
test('should not render an island at all if constant "PARTIAL_HYDRATION_ENABLED" is false', () => {
|
|
74
|
+
Reflect.set(constants, 'PARTIAL_HYDRATION_ENABLED', false)
|
|
75
|
+
|
|
76
|
+
const {container} = render(
|
|
77
|
+
<Island>
|
|
78
|
+
<div data-testid="server-content">Server Content</div>
|
|
79
|
+
</Island>
|
|
80
|
+
)
|
|
81
|
+
expect(screen.getByTestId('server-content')).toBeInTheDocument()
|
|
82
|
+
expect(screen.getByText('Server Content')).toBeInTheDocument()
|
|
83
|
+
expect(screen.getByTestId('server-content')).toBe(container.firstElementChild)
|
|
84
|
+
})
|
|
85
|
+
|
|
64
86
|
test('should render children immediately', () => {
|
|
65
87
|
const {container} = render(
|
|
66
88
|
<Island>
|
|
@@ -98,6 +120,19 @@ describe('Island Component', () => {
|
|
|
98
120
|
})
|
|
99
121
|
|
|
100
122
|
describe('Client-Side Rendering (CSR)', () => {
|
|
123
|
+
test('should not render an island at all if constant "PARTIAL_HYDRATION_ENABLED" is false', () => {
|
|
124
|
+
Reflect.set(constants, 'PARTIAL_HYDRATION_ENABLED', false)
|
|
125
|
+
|
|
126
|
+
const {container} = render(
|
|
127
|
+
<Island>
|
|
128
|
+
<div data-testid="server-content">Server Content</div>
|
|
129
|
+
</Island>
|
|
130
|
+
)
|
|
131
|
+
expect(screen.getByTestId('server-content')).toBeInTheDocument()
|
|
132
|
+
expect(screen.getByText('Server Content')).toBeInTheDocument()
|
|
133
|
+
expect(screen.getByTestId('server-content')).toBe(container.firstElementChild)
|
|
134
|
+
})
|
|
135
|
+
|
|
101
136
|
test('should hydrate immediately if no SSR content exists', () => {
|
|
102
137
|
const {container} = renderServerComponent(<Island hydrateOn={'visible'}></Island>)
|
|
103
138
|
|
|
@@ -12,20 +12,19 @@ import {Button, Divider, Stack, Text} from '@salesforce/retail-react-app/app/com
|
|
|
12
12
|
import LoginFields from '@salesforce/retail-react-app/app/components/forms/login-fields'
|
|
13
13
|
import StandardLogin from '@salesforce/retail-react-app/app/components/standard-login'
|
|
14
14
|
import SocialLogin from '@salesforce/retail-react-app/app/components/social-login'
|
|
15
|
-
import {LOGIN_TYPES} from '@salesforce/retail-react-app/app/constants'
|
|
16
15
|
|
|
16
|
+
const noop = () => {}
|
|
17
17
|
const PasswordlessLogin = ({
|
|
18
18
|
form,
|
|
19
19
|
handleForgotPasswordClick,
|
|
20
20
|
handlePasswordlessLoginClick,
|
|
21
21
|
isSocialEnabled = false,
|
|
22
22
|
idps = [],
|
|
23
|
-
setLoginType
|
|
23
|
+
setLoginType = noop
|
|
24
24
|
}) => {
|
|
25
25
|
const [showPasswordView, setShowPasswordView] = useState(false)
|
|
26
26
|
|
|
27
27
|
const handlePasswordButton = async (e) => {
|
|
28
|
-
setLoginType(LOGIN_TYPES.PASSWORD)
|
|
29
28
|
const isValid = await form.trigger()
|
|
30
29
|
// Manually trigger the browser native form validations
|
|
31
30
|
const domForm = e.target.closest('form')
|
|
@@ -48,8 +47,8 @@ const PasswordlessLogin = ({
|
|
|
48
47
|
/>
|
|
49
48
|
<Button
|
|
50
49
|
type="submit"
|
|
51
|
-
onClick={() => {
|
|
52
|
-
handlePasswordlessLoginClick()
|
|
50
|
+
onClick={(e) => {
|
|
51
|
+
handlePasswordlessLoginClick(e)
|
|
53
52
|
form.clearErrors('global')
|
|
54
53
|
}}
|
|
55
54
|
isLoading={form.formState.isSubmitting}
|
|
@@ -31,8 +31,7 @@ describe('PasswordlessLogin component', () => {
|
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
test('renders password input after "Password" button is clicked', async () => {
|
|
34
|
-
const
|
|
35
|
-
const {user} = renderWithProviders(<WrapperComponent setLoginType={mockSetLoginType} />)
|
|
34
|
+
const {user} = renderWithProviders(<WrapperComponent />)
|
|
36
35
|
|
|
37
36
|
await user.type(screen.getByLabelText('Email'), 'myemail@test.com')
|
|
38
37
|
await user.click(screen.getByRole('button', {name: 'Password'}))
|
|
@@ -42,8 +41,7 @@ describe('PasswordlessLogin component', () => {
|
|
|
42
41
|
})
|
|
43
42
|
|
|
44
43
|
test('stays on page when email field has form validation errors after the "Password" button is clicked', async () => {
|
|
45
|
-
const
|
|
46
|
-
const {user} = renderWithProviders(<WrapperComponent setLoginType={mockSetLoginType} />)
|
|
44
|
+
const {user} = renderWithProviders(<WrapperComponent />)
|
|
47
45
|
|
|
48
46
|
await user.type(screen.getByLabelText('Email'), 'badEmail')
|
|
49
47
|
await user.click(screen.getByRole('button', {name: 'Password'}))
|
|
@@ -78,7 +78,7 @@ export const Skeleton = () => {
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* The ProductTile is a simple visual representation of a
|
|
81
|
-
* product object. It will show
|
|
81
|
+
* product object. It will show its default image, name and price.
|
|
82
82
|
* It also supports favourite products, controlled by a heart icon.
|
|
83
83
|
*/
|
|
84
84
|
const ProductTile = (props) => {
|
|
@@ -30,7 +30,14 @@ import {useIntl} from 'react-intl'
|
|
|
30
30
|
/**
|
|
31
31
|
* A Modal that contains Product View for product bundle
|
|
32
32
|
*/
|
|
33
|
-
const BundleProductViewModal = ({
|
|
33
|
+
const BundleProductViewModal = ({
|
|
34
|
+
product: bundle,
|
|
35
|
+
isOpen,
|
|
36
|
+
onClose,
|
|
37
|
+
updateCart,
|
|
38
|
+
showDeliveryOptions,
|
|
39
|
+
...props
|
|
40
|
+
}) => {
|
|
34
41
|
const productViewModalData = useProductViewModal(bundle)
|
|
35
42
|
const {variationParams} = useDerivedProduct(bundle)
|
|
36
43
|
const childProductRefs = useRef({})
|
|
@@ -100,6 +107,7 @@ const BundleProductViewModal = ({product: bundle, isOpen, onClose, updateCart, .
|
|
|
100
107
|
<ProductView
|
|
101
108
|
showFullLink={false}
|
|
102
109
|
showImageGallery={trueIfMobile}
|
|
110
|
+
showDeliveryOptions={showDeliveryOptions}
|
|
103
111
|
product={productViewModalData.product}
|
|
104
112
|
isLoading={productViewModalData.isFetching}
|
|
105
113
|
updateCart={(product, quantity) =>
|
|
@@ -136,6 +144,7 @@ const BundleProductViewModal = ({product: bundle, isOpen, onClose, updateCart, .
|
|
|
136
144
|
}
|
|
137
145
|
}}
|
|
138
146
|
showImageGallery={false}
|
|
147
|
+
showDeliveryOptions={false}
|
|
139
148
|
isProductPartOfBundle={true}
|
|
140
149
|
showFullLink={false}
|
|
141
150
|
product={product}
|
|
@@ -169,7 +178,8 @@ BundleProductViewModal.propTypes = {
|
|
|
169
178
|
onClose: PropTypes.func.isRequired,
|
|
170
179
|
product: PropTypes.object,
|
|
171
180
|
isLoading: PropTypes.bool,
|
|
172
|
-
updateCart: PropTypes.func
|
|
181
|
+
updateCart: PropTypes.func,
|
|
182
|
+
showDeliveryOptions: PropTypes.bool
|
|
173
183
|
}
|
|
174
184
|
|
|
175
185
|
export default BundleProductViewModal
|
|
@@ -55,7 +55,10 @@ const StandardLogin = ({
|
|
|
55
55
|
)}
|
|
56
56
|
{hideEmail && (
|
|
57
57
|
<Button
|
|
58
|
-
onClick={() =>
|
|
58
|
+
onClick={() => {
|
|
59
|
+
form.resetField('password')
|
|
60
|
+
setShowPasswordView(false)
|
|
61
|
+
}}
|
|
59
62
|
borderColor="gray.500"
|
|
60
63
|
color="blue.600"
|
|
61
64
|
variant="outline"
|
package/app/constants.js
CHANGED
|
@@ -256,3 +256,6 @@ export const PASSWORDLESS_ERROR_MESSAGES = [
|
|
|
256
256
|
export const INVALID_TOKEN_ERROR = /invalid token/i
|
|
257
257
|
|
|
258
258
|
export const USER_NOT_FOUND_ERROR = /user not found/i
|
|
259
|
+
|
|
260
|
+
// Constant to enable partial hydration capabilities, i.e. `<Island/>` components
|
|
261
|
+
export const PARTIAL_HYDRATION_ENABLED = false
|
|
@@ -35,7 +35,6 @@ import {
|
|
|
35
35
|
API_ERROR_MESSAGE,
|
|
36
36
|
CREATE_ACCOUNT_FIRST_ERROR_MESSAGE,
|
|
37
37
|
FEATURE_UNAVAILABLE_ERROR_MESSAGE,
|
|
38
|
-
LOGIN_TYPES,
|
|
39
38
|
PASSWORDLESS_ERROR_MESSAGES,
|
|
40
39
|
USER_NOT_FOUND_ERROR
|
|
41
40
|
} from '@salesforce/retail-react-app/app/constants'
|
|
@@ -88,8 +87,6 @@ export const AuthModal = ({
|
|
|
88
87
|
const register = useAuthHelper(AuthHelpers.Register)
|
|
89
88
|
const appOrigin = useAppOrigin()
|
|
90
89
|
|
|
91
|
-
const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD)
|
|
92
|
-
const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState(initialEmail)
|
|
93
90
|
const {getPasswordResetToken} = usePasswordReset()
|
|
94
91
|
const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
|
|
95
92
|
const passwordlessConfigCallback = getConfig().app.login?.passwordless?.callbackURI
|
|
@@ -103,66 +100,67 @@ export const AuthModal = ({
|
|
|
103
100
|
)
|
|
104
101
|
const mergeBasket = useShopperBasketsMutation('mergeBasket')
|
|
105
102
|
|
|
106
|
-
const
|
|
103
|
+
const handlePasswordlessLogin = async (email) => {
|
|
104
|
+
try {
|
|
105
|
+
const redirectPath = window.location.pathname + (window.location.search || '')
|
|
106
|
+
await authorizePasswordlessLogin.mutateAsync({
|
|
107
|
+
userid: email,
|
|
108
|
+
callbackURI: `${callbackURL}?redirectUrl=${redirectPath}`
|
|
109
|
+
})
|
|
110
|
+
setCurrentView(EMAIL_VIEW)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const message = USER_NOT_FOUND_ERROR.test(error.message)
|
|
113
|
+
? formatMessage(CREATE_ACCOUNT_FIRST_ERROR_MESSAGE)
|
|
114
|
+
: PASSWORDLESS_ERROR_MESSAGES.some((msg) => msg.test(error.message))
|
|
115
|
+
? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE)
|
|
116
|
+
: formatMessage(API_ERROR_MESSAGE)
|
|
117
|
+
form.setError('global', {type: 'manual', message})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const submitForm = async (data, isPasswordless = false) => {
|
|
107
122
|
form.clearErrors()
|
|
108
123
|
|
|
109
124
|
const onLoginSuccess = () => {
|
|
110
125
|
navigate('/account')
|
|
111
126
|
}
|
|
112
127
|
|
|
113
|
-
const handlePasswordlessLogin = async (email) => {
|
|
114
|
-
try {
|
|
115
|
-
const redirectPath = window.location.pathname + window.location.search
|
|
116
|
-
await authorizePasswordlessLogin.mutateAsync({
|
|
117
|
-
userid: email,
|
|
118
|
-
callbackURI: `${callbackURL}?redirectUrl=${redirectPath}`
|
|
119
|
-
})
|
|
120
|
-
setCurrentView(EMAIL_VIEW)
|
|
121
|
-
} catch (error) {
|
|
122
|
-
const message = USER_NOT_FOUND_ERROR.test(error.message)
|
|
123
|
-
? formatMessage(CREATE_ACCOUNT_FIRST_ERROR_MESSAGE)
|
|
124
|
-
: PASSWORDLESS_ERROR_MESSAGES.some((msg) => msg.test(error.message))
|
|
125
|
-
? formatMessage(FEATURE_UNAVAILABLE_ERROR_MESSAGE)
|
|
126
|
-
: formatMessage(API_ERROR_MESSAGE)
|
|
127
|
-
form.setError('global', {type: 'manual', message})
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
128
|
return {
|
|
132
129
|
login: async (data) => {
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
130
|
+
if (isPasswordless) {
|
|
131
|
+
const email = data.email
|
|
132
|
+
await handlePasswordlessLogin(email)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await login.mutateAsync({
|
|
138
|
+
username: data.email,
|
|
139
|
+
password: data.password
|
|
140
|
+
})
|
|
141
|
+
const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0
|
|
142
|
+
// we only want to merge basket when the user is logged in as a recurring user
|
|
143
|
+
// only recurring users trigger the login mutation, new user triggers register mutation
|
|
144
|
+
// this logic needs to stay in this block because this is the only place that tells if a user is a recurring user
|
|
145
|
+
// if you change logic here, also change it in login page
|
|
146
|
+
const shouldMergeBasket = hasBasketItem && prevAuthType === 'guest'
|
|
147
|
+
if (shouldMergeBasket) {
|
|
148
|
+
mergeBasket.mutate({
|
|
149
|
+
headers: {
|
|
150
|
+
// This is not required since the request has no body
|
|
151
|
+
// but CommerceAPI throws a '419 - Unsupported Media Type' error if this header is removed.
|
|
152
|
+
'Content-Type': 'application/json'
|
|
153
|
+
},
|
|
154
|
+
parameters: {
|
|
155
|
+
createDestinationBasket: true
|
|
156
|
+
}
|
|
138
157
|
})
|
|
139
|
-
const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0
|
|
140
|
-
// we only want to merge basket when the user is logged in as a recurring user
|
|
141
|
-
// only recurring users trigger the login mutation, new user triggers register mutation
|
|
142
|
-
// this logic needs to stay in this block because this is the only place that tells if a user is a recurring user
|
|
143
|
-
// if you change logic here, also change it in login page
|
|
144
|
-
const shouldMergeBasket = hasBasketItem && prevAuthType === 'guest'
|
|
145
|
-
if (shouldMergeBasket) {
|
|
146
|
-
mergeBasket.mutate({
|
|
147
|
-
headers: {
|
|
148
|
-
// This is not required since the request has no body
|
|
149
|
-
// but CommerceAPI throws a '419 - Unsupported Media Type' error if this header is removed.
|
|
150
|
-
'Content-Type': 'application/json'
|
|
151
|
-
},
|
|
152
|
-
parameters: {
|
|
153
|
-
createDestinationBasket: true
|
|
154
|
-
}
|
|
155
|
-
})
|
|
156
|
-
}
|
|
157
|
-
} catch (error) {
|
|
158
|
-
const message = /Unauthorized/i.test(error.message)
|
|
159
|
-
? formatMessage(LOGIN_ERROR)
|
|
160
|
-
: formatMessage(API_ERROR_MESSAGE)
|
|
161
|
-
form.setError('global', {type: 'manual', message})
|
|
162
158
|
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const message = /Unauthorized/i.test(error.message)
|
|
161
|
+
? formatMessage(LOGIN_ERROR)
|
|
162
|
+
: formatMessage(API_ERROR_MESSAGE)
|
|
163
|
+
form.setError('global', {type: 'manual', message})
|
|
166
164
|
}
|
|
167
165
|
},
|
|
168
166
|
register: async (data) => {
|
|
@@ -198,7 +196,8 @@ export const AuthModal = ({
|
|
|
198
196
|
}
|
|
199
197
|
},
|
|
200
198
|
email: async () => {
|
|
201
|
-
|
|
199
|
+
const email = form.getValues().email || initialEmail
|
|
200
|
+
await handlePasswordlessLogin(email)
|
|
202
201
|
}
|
|
203
202
|
}[currentView](data)
|
|
204
203
|
}
|
|
@@ -206,7 +205,6 @@ export const AuthModal = ({
|
|
|
206
205
|
// Reset form and local state when opening the modal
|
|
207
206
|
useEffect(() => {
|
|
208
207
|
if (isOpen) {
|
|
209
|
-
setLoginType(LOGIN_TYPES.PASSWORD)
|
|
210
208
|
setCurrentView(initialView)
|
|
211
209
|
form.reset()
|
|
212
210
|
}
|
|
@@ -223,15 +221,14 @@ export const AuthModal = ({
|
|
|
223
221
|
fieldsRef?.[initialField]?.ref.focus()
|
|
224
222
|
}, [form.control?.fieldsRef?.current])
|
|
225
223
|
|
|
226
|
-
// Clear form state when changing views
|
|
227
224
|
useEffect(() => {
|
|
228
|
-
form
|
|
225
|
+
// we don't want to reset the form on email view
|
|
226
|
+
// because we want to pass the email to PasswordlessEmailConfirmation
|
|
227
|
+
if (currentView !== EMAIL_VIEW) {
|
|
228
|
+
form.reset()
|
|
229
|
+
}
|
|
229
230
|
}, [currentView])
|
|
230
231
|
|
|
231
|
-
useEffect(() => {
|
|
232
|
-
setPasswordlessLoginEmail(initialEmail)
|
|
233
|
-
}, [initialEmail])
|
|
234
|
-
|
|
235
232
|
useEffect(() => {
|
|
236
233
|
// Lets determine if the user has either logged in, or registed.
|
|
237
234
|
const loggingIn = currentView === LOGIN_VIEW
|
|
@@ -302,16 +299,20 @@ export const AuthModal = ({
|
|
|
302
299
|
{!form.formState.isSubmitSuccessful && currentView === LOGIN_VIEW && (
|
|
303
300
|
<LoginForm
|
|
304
301
|
form={form}
|
|
305
|
-
submitForm={
|
|
302
|
+
submitForm={(data) => {
|
|
303
|
+
const shouldUsePasswordless =
|
|
304
|
+
isPasswordlessEnabled && !data.password
|
|
305
|
+
return submitForm(data, shouldUsePasswordless)
|
|
306
|
+
}}
|
|
306
307
|
clickCreateAccount={() => setCurrentView(REGISTER_VIEW)}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
308
|
+
//TODO: potentially remove this prop in the next major release since
|
|
309
|
+
// we don't need to use this props anymore
|
|
310
|
+
handlePasswordlessLoginClick={noop}
|
|
310
311
|
handleForgotPasswordClick={() => setCurrentView(PASSWORD_VIEW)}
|
|
311
312
|
isPasswordlessEnabled={isPasswordlessEnabled}
|
|
312
313
|
isSocialEnabled={isSocialEnabled}
|
|
313
314
|
idps={idps}
|
|
314
|
-
setLoginType={
|
|
315
|
+
setLoginType={noop}
|
|
315
316
|
/>
|
|
316
317
|
)}
|
|
317
318
|
{!form.formState.isSubmitSuccessful && currentView === REGISTER_VIEW && (
|
|
@@ -332,7 +333,7 @@ export const AuthModal = ({
|
|
|
332
333
|
<PasswordlessEmailConfirmation
|
|
333
334
|
form={form}
|
|
334
335
|
submitForm={submitForm}
|
|
335
|
-
email={
|
|
336
|
+
email={form.getValues().email || initialEmail}
|
|
336
337
|
/>
|
|
337
338
|
)}
|
|
338
339
|
</ModalBody>
|