@salesforce/retail-react-app 8.3.0-preview.1 → 8.4.0-nightly-20260116000329

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.
@@ -17,6 +17,11 @@ import Account from '@salesforce/retail-react-app/app/pages/account'
17
17
  import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
18
18
  import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/mock-data'
19
19
  import {AuthHelpers} from '@salesforce/commerce-sdk-react'
20
+ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
21
+
22
+ jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({
23
+ getConfig: jest.fn(() => mockConfig)
24
+ }))
20
25
 
21
26
  const mockMergedBasket = {
22
27
  basketId: 'a10ff320829cb0eef93ca5310a',
@@ -45,12 +50,13 @@ const MockedComponent = () => {
45
50
  )
46
51
  }
47
52
 
53
+ const mockUseRouteMatch = jest.fn(() => ({path: '/'}))
54
+
48
55
  jest.mock('react-router', () => {
56
+ const original = jest.requireActual('react-router')
49
57
  return {
50
- ...jest.requireActual('react-router'),
51
- useRouteMatch: () => {
52
- return {path: '/passwordless-login-landing'}
53
- }
58
+ ...original,
59
+ useRouteMatch: () => mockUseRouteMatch()
54
60
  }
55
61
  })
56
62
 
@@ -72,6 +78,14 @@ jest.mock('@salesforce/commerce-sdk-react', () => {
72
78
 
73
79
  // Set up and clean up
74
80
  beforeEach(() => {
81
+ jest.clearAllMocks()
82
+ getConfig.mockReturnValue(mockConfig)
83
+
84
+ // Reset useRouteMatch mock to return path based on window.location.pathname
85
+ mockUseRouteMatch.mockImplementation(() => ({
86
+ path: typeof window !== 'undefined' && window.location ? window.location.pathname : '/'
87
+ }))
88
+
75
89
  global.server.use(
76
90
  rest.post('*/customers', (req, res, ctx) => {
77
91
  return res(ctx.delay(0), ctx.status(200), ctx.json(mockedRegisteredCustomer))
@@ -92,11 +106,32 @@ beforeEach(() => {
92
106
  })
93
107
  )
94
108
  })
95
- afterEach(() => {
96
- jest.resetModules()
97
- })
98
109
 
99
110
  describe('Passwordless landing tests', function () {
111
+ test('does not run passwordless login when landing path does not match', async () => {
112
+ const token = '11111111'
113
+ const invalidLoginPath = '/invalid-passwordless-login-landing'
114
+
115
+ window.history.pushState(
116
+ {},
117
+ 'Passwordless Login Landing',
118
+ createPathWithDefaults(`${invalidLoginPath}?token=${token}`)
119
+ )
120
+ renderWithProviders(<MockedComponent />, {
121
+ wrapperProps: {
122
+ siteAlias: 'uk',
123
+ locale: {id: 'en-GB'},
124
+ appConfig: mockConfig.app
125
+ }
126
+ })
127
+
128
+ await waitFor(() => {
129
+ expect(
130
+ mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync
131
+ ).not.toHaveBeenCalled()
132
+ })
133
+ })
134
+
100
135
  test('redirects to account page when redirect url is not passed', async () => {
101
136
  const token = '12345678'
102
137
  window.history.pushState(
@@ -151,4 +186,76 @@ describe('Passwordless landing tests', function () {
151
186
  expect(window.location.pathname).toBe('/uk/en-GB/womens-tops')
152
187
  })
153
188
  })
189
+
190
+ test('detects landing path when at the end of path', async () => {
191
+ const token = '33333333'
192
+ const loginPath = '/global/en-GB/passwordless-login-landing'
193
+ // mockRouteMatch.mockReturnValue({path: loginPath})
194
+ window.history.pushState(
195
+ {},
196
+ 'Passwordless Login Landing',
197
+ createPathWithDefaults(`${loginPath}?token=${token}`)
198
+ )
199
+ renderWithProviders(<MockedComponent />, {
200
+ wrapperProps: {
201
+ siteAlias: 'global',
202
+ locale: {id: 'en-GB'},
203
+ appConfig: mockConfig.app
204
+ }
205
+ })
206
+
207
+ await waitFor(() => {
208
+ expect(
209
+ mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync
210
+ ).toHaveBeenCalledWith({
211
+ pwdlessLoginToken: token
212
+ })
213
+ })
214
+
215
+ expect(
216
+ mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync
217
+ ).toHaveBeenCalledWith({
218
+ pwdlessLoginToken: token
219
+ })
220
+ })
221
+
222
+ test('landing path changes based on config', async () => {
223
+ const token = '44444444'
224
+ const customLandingPath = '/custom-passwordless-login-landing'
225
+ const mockConfigWithCustomLandingPath = {
226
+ ...mockConfig,
227
+ app: {
228
+ ...mockConfig.app,
229
+ login: {
230
+ ...mockConfig.app.login,
231
+ passwordless: {
232
+ ...mockConfig.app.login.passwordless,
233
+ enabled: true,
234
+ landingPath: customLandingPath
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ getConfig.mockReturnValue(mockConfigWithCustomLandingPath)
241
+
242
+ window.history.pushState(
243
+ {},
244
+ 'Passwordless Login Landing',
245
+ createPathWithDefaults(`${customLandingPath}?token=${token}`)
246
+ )
247
+ renderWithProviders(<MockedComponent />, {
248
+ wrapperProps: {
249
+ siteAlias: 'uk',
250
+ locale: {id: 'en-GB'},
251
+ appConfig: mockConfigWithCustomLandingPath.app
252
+ }
253
+ })
254
+
255
+ expect(
256
+ mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync
257
+ ).toHaveBeenCalledWith({
258
+ pwdlessLoginToken: token
259
+ })
260
+ })
154
261
  })
@@ -20,7 +20,6 @@ import {useLocation} from 'react-router-dom'
20
20
  import {useRouteMatch} from 'react-router'
21
21
  import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset'
22
22
  import {
23
- RESET_PASSWORD_LANDING_PATH,
24
23
  API_ERROR_MESSAGE,
25
24
  FEATURE_UNAVAILABLE_ERROR_MESSAGE
26
25
  } from '@salesforce/retail-react-app/app/constants'
@@ -33,7 +32,7 @@ const ResetPassword = () => {
33
32
  const dataCloud = useDataCloud()
34
33
  const {pathname} = useLocation()
35
34
  const {path} = useRouteMatch()
36
- const {getPasswordResetToken} = usePasswordReset()
35
+ const {getPasswordResetToken, resetPasswordLandingPath} = usePasswordReset()
37
36
 
38
37
  const submitForm = async ({email}) => {
39
38
  try {
@@ -71,7 +70,7 @@ const ResetPassword = () => {
71
70
  marginBottom={8}
72
71
  borderRadius="base"
73
72
  >
74
- {path === RESET_PASSWORD_LANDING_PATH ? (
73
+ {path.endsWith(resetPasswordLandingPath) ? (
75
74
  <ResetPasswordLanding />
76
75
  ) : (
77
76
  <ResetPasswordForm
@@ -14,6 +14,16 @@ import {
14
14
  import ResetPassword from '.'
15
15
  import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
16
16
 
17
+ const mockUseRouteMatch = jest.fn(() => ({path: '/'}))
18
+
19
+ jest.mock('react-router', () => {
20
+ const original = jest.requireActual('react-router')
21
+ return {
22
+ ...original,
23
+ useRouteMatch: () => mockUseRouteMatch()
24
+ }
25
+ })
26
+
17
27
  const MockedComponent = () => {
18
28
  return (
19
29
  <div>
@@ -24,7 +34,10 @@ const MockedComponent = () => {
24
34
 
25
35
  // Set up and clean up
26
36
  beforeEach(() => {
27
- jest.resetModules()
37
+ // Reset useRouteMatch mock to return path based on window.location.pathname
38
+ mockUseRouteMatch.mockImplementation(() => ({
39
+ path: typeof window !== 'undefined' && window.location ? window.location.pathname : '/'
40
+ }))
28
41
  window.history.pushState({}, 'Reset Password', createPathWithDefaults('/reset-password'))
29
42
  })
30
43
  afterEach(() => {
@@ -75,3 +88,20 @@ test('Allows customer to generate password token', async () => {
75
88
  expect(window.location.pathname).toBe('/uk/en-GB/login')
76
89
  })
77
90
  })
91
+
92
+ test.each([
93
+ ['base path', '/reset-password-landing'],
94
+ ['path with site and locale', '/uk/en-GB/reset-password-landing']
95
+ ])('renders reset password landing page when using %s', async (_, landingPath) => {
96
+ window.history.pushState({}, 'Reset Password', createPathWithDefaults(landingPath))
97
+
98
+ // render our test component
99
+ renderWithProviders(<MockedComponent />, {
100
+ wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app}
101
+ })
102
+
103
+ // check if the landing page is rendered
104
+ await waitFor(() => {
105
+ expect(screen.getByText(/confirm new password/i)).toBeInTheDocument()
106
+ })
107
+ })
package/app/routes.jsx CHANGED
@@ -20,14 +20,7 @@ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
20
20
  import {Skeleton} from '@salesforce/retail-react-app/app/components/shared/ui'
21
21
  import {configureRoutes} from '@salesforce/retail-react-app/app/utils/routes-utils'
22
22
 
23
- // Constants
24
- import {
25
- PASSWORDLESS_LOGIN_LANDING_PATH,
26
- RESET_PASSWORD_LANDING_PATH
27
- } from '@salesforce/retail-react-app/app/constants'
28
-
29
23
  const fallback = <Skeleton height="75vh" width="100%" />
30
- const socialRedirectURI = getConfig()?.app?.login?.social?.redirectURI
31
24
 
32
25
  // Pages
33
26
  const Home = loadable(() => import('./pages/home'), {fallback})
@@ -77,16 +70,6 @@ export const routes = [
77
70
  component: ResetPassword,
78
71
  exact: true
79
72
  },
80
- {
81
- path: RESET_PASSWORD_LANDING_PATH,
82
- component: ResetPassword,
83
- exact: true
84
- },
85
- {
86
- path: PASSWORDLESS_LOGIN_LANDING_PATH,
87
- component: Login,
88
- exact: true
89
- },
90
73
  {
91
74
  path: '/account',
92
75
  component: Account
@@ -105,11 +88,6 @@ export const routes = [
105
88
  component: LoginRedirect,
106
89
  exact: true
107
90
  },
108
- {
109
- path: socialRedirectURI || '/social-callback',
110
- component: SocialLoginRedirect,
111
- exact: true
112
- },
113
91
  {
114
92
  path: '/cart',
115
93
  component: Cart,
@@ -134,16 +112,44 @@ export const routes = [
134
112
  {
135
113
  path: '/store-locator',
136
114
  component: StoreLocator
137
- },
138
- {
139
- path: '*',
140
- component: PageNotFound
141
115
  }
142
116
  ]
143
117
 
144
118
  export default () => {
145
119
  const config = getConfig()
146
- return configureRoutes(routes, config, {
147
- ignoredRoutes: ['/callback', '*']
120
+ const loginConfig = config?.app?.login
121
+ const resetPasswordLandingPath = loginConfig?.resetPassword?.landingPath
122
+ const socialLoginEnabled = loginConfig?.social?.enabled
123
+ const socialRedirectURI = loginConfig?.social?.redirectURI
124
+ const passwordlessLoginEnabled = loginConfig?.passwordless?.enabled
125
+ const passwordlessLoginLandingPath = loginConfig?.passwordless?.landingPath
126
+
127
+ // Add dynamic routes conditionally (only if features are enabled and paths are defined)
128
+ const dynamicRoutes = [
129
+ resetPasswordLandingPath && {
130
+ path: resetPasswordLandingPath,
131
+ component: ResetPassword,
132
+ exact: true
133
+ },
134
+ passwordlessLoginEnabled &&
135
+ passwordlessLoginLandingPath && {
136
+ path: passwordlessLoginLandingPath,
137
+ component: Login,
138
+ exact: true
139
+ },
140
+ socialLoginEnabled &&
141
+ socialRedirectURI && {
142
+ path: socialRedirectURI,
143
+ component: SocialLoginRedirect,
144
+ exact: true
145
+ }
146
+ ].filter(Boolean)
147
+
148
+ const allRoutes = configureRoutes([...routes, ...dynamicRoutes], config, {
149
+ ignoredRoutes: ['/callback'],
150
+ fuzzyPathMatching: true
148
151
  })
152
+
153
+ // Add catch-all route at the end so it doesn't match before dynamic routes
154
+ return [...allRoutes, {path: '*', component: PageNotFound}]
149
155
  }
@@ -5,9 +5,281 @@
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
  import routes from '@salesforce/retail-react-app/app/routes'
8
+ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
9
+
10
+ // Mock getConfig to return test values
11
+ import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
12
+ jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({
13
+ getConfig: jest.fn(() => mockConfig)
14
+ }))
8
15
 
9
16
  describe('Routes', () => {
17
+ beforeEach(() => {
18
+ jest.clearAllMocks()
19
+ })
20
+
10
21
  test('exports a valid react-router configuration', () => {
11
22
  expect(Array.isArray(routes) || typeof routes === 'function').toBe(true)
12
23
  })
24
+
25
+ test('adds catch-all route at the end', () => {
26
+ const allRoutes = routes()
27
+ const lastRoute = allRoutes.pop()
28
+ expect(lastRoute.path).toBe('*')
29
+ })
30
+
31
+ describe('Dynamic routes', () => {
32
+ describe('Reset password landing route', () => {
33
+ test.each([
34
+ ['path is null', null],
35
+ ['path is empty string', '']
36
+ ])('does not add route when %s', (_, landingPath) => {
37
+ getConfig.mockReturnValue({
38
+ ...mockConfig,
39
+ app: {
40
+ ...mockConfig.app,
41
+ login: {
42
+ resetPassword: {
43
+ landingPath
44
+ }
45
+ }
46
+ }
47
+ })
48
+
49
+ const allRoutes = routes()
50
+ const resetPasswordRoute = allRoutes.find(
51
+ (route) => route.path === '/custom-reset-password-landing'
52
+ )
53
+
54
+ expect(resetPasswordRoute).toBeUndefined()
55
+ })
56
+
57
+ test('does not add route when landingPath property is missing', () => {
58
+ getConfig.mockReturnValue({
59
+ ...mockConfig,
60
+ app: {
61
+ ...mockConfig.app,
62
+ login: {
63
+ resetPassword: {}
64
+ }
65
+ }
66
+ })
67
+
68
+ const allRoutes = routes()
69
+ const resetPasswordRoute = allRoutes.find(
70
+ (route) => route.path === '/reset-password-landing'
71
+ )
72
+
73
+ expect(resetPasswordRoute).toBeUndefined()
74
+ })
75
+
76
+ test('adds route when path is defined', () => {
77
+ getConfig.mockReturnValue({
78
+ ...mockConfig,
79
+ app: {
80
+ ...mockConfig.app,
81
+ login: {
82
+ resetPassword: {
83
+ landingPath: '/reset-password-landing'
84
+ }
85
+ }
86
+ }
87
+ })
88
+
89
+ const allRoutes = routes()
90
+ const resetPasswordRoute = allRoutes.find(
91
+ (route) => route.path === '/reset-password-landing'
92
+ )
93
+
94
+ expect(resetPasswordRoute).toBeDefined()
95
+ expect(resetPasswordRoute.exact).toBe(true)
96
+ })
97
+ })
98
+
99
+ describe('Passwordless login landing route', () => {
100
+ test('does not add route when disabled', () => {
101
+ getConfig.mockReturnValue({
102
+ ...mockConfig,
103
+ app: {
104
+ ...mockConfig.app,
105
+ login: {
106
+ passwordless: {
107
+ enabled: false,
108
+ landingPath: '/custom-passwordless-login-landing'
109
+ }
110
+ }
111
+ }
112
+ })
113
+
114
+ const allRoutes = routes()
115
+ const passwordlessRoute = allRoutes.find(
116
+ (route) => route.path === '/custom-passwordless-login-landing'
117
+ )
118
+
119
+ expect(passwordlessRoute).toBeUndefined()
120
+ })
121
+
122
+ test.each([
123
+ ['path is null', null],
124
+ ['path is empty string', '']
125
+ ])('does not add route when %s', (_, landingPath) => {
126
+ getConfig.mockReturnValue({
127
+ ...mockConfig,
128
+ app: {
129
+ ...mockConfig.app,
130
+ login: {
131
+ passwordless: {
132
+ enabled: true,
133
+ landingPath
134
+ }
135
+ }
136
+ }
137
+ })
138
+
139
+ const allRoutes = routes()
140
+ const passwordlessRoute = allRoutes.find(
141
+ (route) => route.path === '/custom-passwordless-login-landing'
142
+ )
143
+
144
+ expect(passwordlessRoute).toBeUndefined()
145
+ })
146
+
147
+ test('does not add route when landingPath property is missing', () => {
148
+ getConfig.mockReturnValue({
149
+ ...mockConfig,
150
+ app: {
151
+ ...mockConfig.app,
152
+ login: {
153
+ passwordless: {
154
+ enabled: true
155
+ }
156
+ }
157
+ }
158
+ })
159
+
160
+ const allRoutes = routes()
161
+ const passwordlessRoute = allRoutes.find(
162
+ (route) => route.path === '/custom-passwordless-login-landing'
163
+ )
164
+
165
+ expect(passwordlessRoute).toBeUndefined()
166
+ })
167
+
168
+ test('adds route when enabled and path is defined', () => {
169
+ getConfig.mockReturnValue({
170
+ ...mockConfig,
171
+ app: {
172
+ ...mockConfig.app,
173
+ login: {
174
+ passwordless: {
175
+ enabled: true,
176
+ landingPath: '/passwordless-login-landing'
177
+ }
178
+ }
179
+ }
180
+ })
181
+
182
+ const allRoutes = routes()
183
+ const passwordlessRoute = allRoutes.find(
184
+ (route) => route.path === '/passwordless-login-landing'
185
+ )
186
+
187
+ expect(passwordlessRoute).toBeDefined()
188
+ expect(passwordlessRoute.exact).toBe(true)
189
+ })
190
+ })
191
+
192
+ describe('Social login redirect route', () => {
193
+ test('does not add route when disabled', () => {
194
+ getConfig.mockReturnValue({
195
+ ...mockConfig,
196
+ app: {
197
+ ...mockConfig.app,
198
+ login: {
199
+ social: {
200
+ enabled: false,
201
+ redirectURI: '/custom-social-callback'
202
+ }
203
+ }
204
+ }
205
+ })
206
+
207
+ const allRoutes = routes()
208
+ const socialRoute = allRoutes.find(
209
+ (route) => route.path === '/custom-social-callback'
210
+ )
211
+
212
+ expect(socialRoute).toBeUndefined()
213
+ })
214
+
215
+ test.each([
216
+ ['redirectURI is null', null],
217
+ ['redirectURI is empty string', '']
218
+ ])('does not add route when %s', (_, redirectURI) => {
219
+ getConfig.mockReturnValue({
220
+ ...mockConfig,
221
+ app: {
222
+ ...mockConfig.app,
223
+ login: {
224
+ social: {
225
+ enabled: true,
226
+ redirectURI
227
+ }
228
+ }
229
+ }
230
+ })
231
+
232
+ const allRoutes = routes()
233
+ const socialRoute = allRoutes.find(
234
+ (route) => route.path === '/custom-social-callback'
235
+ )
236
+
237
+ expect(socialRoute).toBeUndefined()
238
+ })
239
+
240
+ test('does not add route when redirectURI property is missing', () => {
241
+ getConfig.mockReturnValue({
242
+ ...mockConfig,
243
+ app: {
244
+ ...mockConfig.app,
245
+ login: {
246
+ social: {
247
+ enabled: true
248
+ }
249
+ }
250
+ }
251
+ })
252
+
253
+ const allRoutes = routes()
254
+ const socialRoute = allRoutes.find(
255
+ (route) => route.path === '/custom-social-callback'
256
+ )
257
+
258
+ expect(socialRoute).toBeUndefined()
259
+ })
260
+
261
+ test('adds route when enabled and URI is defined', () => {
262
+ getConfig.mockReturnValue({
263
+ ...mockConfig,
264
+ app: {
265
+ ...mockConfig.app,
266
+ login: {
267
+ social: {
268
+ enabled: true,
269
+ redirectURI: '/custom-social-callback'
270
+ }
271
+ }
272
+ }
273
+ })
274
+
275
+ const allRoutes = routes()
276
+ const socialRoute = allRoutes.find(
277
+ (route) => route.path === '/custom-social-callback'
278
+ )
279
+
280
+ expect(socialRoute).toBeDefined()
281
+ expect(socialRoute.exact).toBe(true)
282
+ })
283
+ })
284
+ })
13
285
  })
@@ -553,3 +553,35 @@ export const processProductsForBonusCart = (
553
553
 
554
554
  return productItems
555
555
  }
556
+
557
+ /**
558
+ * Consolidates duplicate bonus products in a product items array for display purposes.
559
+ * Groups bonus products by productId and sums their quantities, while keeping non-bonus products as-is.
560
+ *
561
+ * @param {Array<Object>} productItems - Array of product items from basket or order
562
+ * @returns {Array<Object>} Array of product items with consolidated bonus products
563
+ */
564
+ export const consolidateDuplicateBonusProducts = (productItems) => {
565
+ if (!productItems || productItems.length === 0) {
566
+ return []
567
+ }
568
+
569
+ // Separate bonus products from regular products
570
+ const bonusProducts = []
571
+ const regularProducts = []
572
+
573
+ productItems.forEach((item) => {
574
+ if (item.bonusProductLineItem) {
575
+ bonusProducts.push(item)
576
+ } else {
577
+ regularProducts.push(item)
578
+ }
579
+ })
580
+
581
+ // Consolidate duplicate bonus products by productId
582
+ const consolidatedBonusProducts = aggregateBonusProductQuantities(bonusProducts)
583
+
584
+ // Combine regular products with consolidated bonus products
585
+ // Keep regular products first, then bonus products
586
+ return [...regularProducts, ...consolidatedBonusProducts]
587
+ }