@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +9 -8
  2. package/app/components/dynamic-image/index.jsx +91 -16
  3. package/app/components/dynamic-image/index.test.js +214 -30
  4. package/app/components/image/index.jsx +5 -13
  5. package/app/components/image/index.test.js +6 -3
  6. package/app/components/island/README.md +15 -10
  7. package/app/components/island/index.jsx +12 -5
  8. package/app/components/island/index.test.js +35 -0
  9. package/app/components/passwordless-login/index.jsx +4 -5
  10. package/app/components/passwordless-login/index.test.js +2 -4
  11. package/app/components/product-tile/index.jsx +1 -1
  12. package/app/components/product-view-modal/bundle.jsx +12 -2
  13. package/app/components/social-login/index.jsx +1 -0
  14. package/app/components/standard-login/index.jsx +4 -1
  15. package/app/constants.js +3 -0
  16. package/app/hooks/use-auth-modal.js +68 -67
  17. package/app/hooks/use-auth-modal.test.js +93 -23
  18. package/app/hooks/use-datacloud.js +169 -192
  19. package/app/hooks/use-datacloud.test.js +273 -17
  20. package/app/pages/cart/index.jsx +2 -1
  21. package/app/pages/cart/partials/cart-secondary-button-group.jsx +8 -10
  22. package/app/pages/cart/partials/cart-secondary-button-group.test.js +2 -3
  23. package/app/pages/checkout/partials/contact-info.jsx +9 -8
  24. package/app/pages/checkout/partials/contact-info.test.js +41 -4
  25. package/app/pages/checkout/partials/login-state.jsx +3 -3
  26. package/app/pages/home/index.test.js +2 -1
  27. package/app/pages/login/index.jsx +37 -37
  28. package/app/pages/login/index.test.js +42 -0
  29. package/app/pages/product-detail/index.jsx +64 -73
  30. package/app/pages/product-list/index.jsx +19 -9
  31. package/app/pages/product-list/index.test.js +153 -19
  32. package/app/utils/image.js +29 -0
  33. package/app/utils/image.test.js +141 -1
  34. package/app/utils/responsive-image.js +197 -115
  35. package/app/utils/responsive-image.test.js +483 -133
  36. package/config/default.js +2 -2
  37. package/config/mocks/default.js +2 -2
  38. 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, 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.**
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, [Server Components](https://react.dev/reference/rsc/server-components) are now another official concept in React for addressing the all-or-nothing problem with hydration. However, as the name suggests, Server Components are components that are rendered exclusively on the server and therefore don’t receive any downstream client-side hydration.
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, 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**.
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
- > Ultimately, the approaches of Suspense, Server Components and the `<Island/>` component aren’t mutually exclusive; on the contrary, they can perfectly complement each other.
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. 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.
65
- 2. 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 users initial viewport, such content mismatches can cause unexpected layout shifts.
66
- 3. Managing focus states, keyboard navigation, and screen reader announcements across the boundary between static and interactive content requires careful coordination.
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 doesnt 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
- /*global globalThis*/
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, hydrateOn = 'load', options, clientOnly = false} = props
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 mockSetLoginType = jest.fn()
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 mockSetLoginType = jest.fn()
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 it's default image, name and price.
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 = ({product: bundle, isOpen, onClose, updateCart, ...props}) => {
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
@@ -99,6 +99,7 @@ const SocialLogin = ({form, idps = []}) => {
99
99
  return (
100
100
  config && (
101
101
  <Button
102
+ key={name}
102
103
  onClick={() => {
103
104
  onSocialLoginClick(name)
104
105
  }}
@@ -55,7 +55,10 @@ const StandardLogin = ({
55
55
  )}
56
56
  {hideEmail && (
57
57
  <Button
58
- onClick={() => setShowPasswordView(false)}
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 submitForm = async (data) => {
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 (loginType === LOGIN_TYPES.PASSWORD) {
134
- try {
135
- await login.mutateAsync({
136
- username: data.email,
137
- password: data.password
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
- } else if (loginType === LOGIN_TYPES.PASSWORDLESS) {
164
- setPasswordlessLoginEmail(data.email)
165
- await handlePasswordlessLogin(data.email)
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
- await handlePasswordlessLogin(passwordlessLoginEmail)
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.reset()
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={submitForm}
302
+ submitForm={(data) => {
303
+ const shouldUsePasswordless =
304
+ isPasswordlessEnabled && !data.password
305
+ return submitForm(data, shouldUsePasswordless)
306
+ }}
306
307
  clickCreateAccount={() => setCurrentView(REGISTER_VIEW)}
307
- handlePasswordlessLoginClick={() =>
308
- setLoginType(LOGIN_TYPES.PASSWORDLESS)
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={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={passwordlessLoginEmail}
336
+ email={form.getValues().email || initialEmail}
336
337
  />
337
338
  )}
338
339
  </ModalBody>