@salesforce/retail-react-app 7.1.0-preview.1 → 8.0.0-dev

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 (113) hide show
  1. package/CHANGELOG.md +8 -2
  2. package/app/components/_app/index.jsx +9 -7
  3. package/app/components/_app/index.test.js +2 -2
  4. package/app/components/_app-config/index.jsx +9 -3
  5. package/app/components/drawer-menu/drawer-menu.jsx +3 -1
  6. package/app/components/footer/index.jsx +3 -1
  7. package/app/components/header/index.jsx +3 -1
  8. package/app/components/header/index.test.js +2 -2
  9. package/app/components/island/README.md +1 -1
  10. package/app/components/island/index.jsx +3 -1
  11. package/app/components/island/index.test.js +94 -5
  12. package/app/components/item-variant/item-attributes.jsx +12 -3
  13. package/app/components/multiship/multiship-order-summary.jsx +137 -0
  14. package/app/components/multiship/multiship-order-summary.test.js +121 -0
  15. package/app/components/order-summary/index.jsx +2 -4
  16. package/app/components/pickup-or-delivery/index.jsx +80 -0
  17. package/app/components/pickup-or-delivery/index.test.jsx +182 -0
  18. package/app/components/product-item/index.jsx +26 -16
  19. package/app/components/product-item/index.test.js +29 -2
  20. package/app/components/product-item-list/index.jsx +10 -0
  21. package/app/components/product-item-list/index.test.jsx +14 -0
  22. package/app/components/product-view/index.jsx +9 -6
  23. package/app/components/product-view/index.test.js +25 -21
  24. package/app/components/quantity-picker/index.test.jsx +12 -12
  25. package/app/components/reset-password/index.test.js +1 -1
  26. package/app/components/shared/ui/AlertDescription/index.jsx +8 -0
  27. package/app/components/shared/ui/index.jsx +1 -0
  28. package/app/components/store-display/index.jsx +28 -4
  29. package/app/components/store-display/index.test.js +71 -0
  30. package/app/components/store-locator/form.test.jsx +16 -4
  31. package/app/components/store-locator/list.jsx +9 -4
  32. package/app/components/toggle-card/index.jsx +14 -0
  33. package/app/components/unavailable-product-confirmation-modal/index.jsx +19 -5
  34. package/app/components/unavailable-product-confirmation-modal/index.test.js +122 -1
  35. package/app/constants.js +20 -6
  36. package/app/contexts/store-locator-provider.jsx +7 -1
  37. package/app/contexts/store-locator-provider.test.jsx +36 -1
  38. package/app/hooks/use-address-form.js +155 -0
  39. package/app/hooks/use-address-form.test.js +501 -0
  40. package/app/hooks/use-auth-modal.js +2 -6
  41. package/app/hooks/use-current-basket.js +71 -2
  42. package/app/hooks/use-current-basket.test.js +37 -1
  43. package/app/hooks/use-dnt-notification.js +4 -4
  44. package/app/hooks/use-dnt-notification.test.js +5 -5
  45. package/app/hooks/use-item-shipment-management.js +233 -0
  46. package/app/hooks/use-item-shipment-management.test.js +696 -0
  47. package/app/hooks/use-multiship.js +589 -0
  48. package/app/hooks/use-multiship.test.js +776 -0
  49. package/app/hooks/use-pickup-shipment.js +70 -106
  50. package/app/hooks/use-pickup-shipment.test.js +345 -209
  51. package/app/hooks/use-product-address-assignment.js +280 -0
  52. package/app/hooks/use-product-address-assignment.test.js +414 -0
  53. package/app/hooks/use-product-inventory.js +100 -0
  54. package/app/hooks/use-product-inventory.test.js +254 -0
  55. package/app/hooks/use-shipment-operations.js +168 -0
  56. package/app/hooks/use-shipment-operations.test.js +385 -0
  57. package/app/hooks/use-store-locator.js +24 -2
  58. package/app/hooks/use-store-locator.test.jsx +109 -1
  59. package/app/pages/account/index.test.js +1 -1
  60. package/app/pages/account/profile.test.js +0 -2
  61. package/app/pages/cart/index.jsx +397 -157
  62. package/app/pages/cart/index.test.js +353 -2
  63. package/app/pages/cart/partials/bonus-products-title.jsx +10 -8
  64. package/app/pages/cart/partials/cart-secondary-button-group.test.js +1 -1
  65. package/app/pages/cart/partials/order-type-display.jsx +68 -0
  66. package/app/pages/cart/partials/order-type-display.test.js +241 -0
  67. package/app/pages/checkout/confirmation.jsx +79 -158
  68. package/app/pages/checkout/index.jsx +34 -9
  69. package/app/pages/checkout/index.test.js +245 -118
  70. package/app/pages/checkout/partials/contact-info.jsx +2 -6
  71. package/app/pages/checkout/partials/contact-info.test.js +93 -7
  72. package/app/pages/checkout/partials/payment.jsx +19 -5
  73. package/app/pages/checkout/partials/pickup-address.jsx +340 -70
  74. package/app/pages/checkout/partials/pickup-address.test.js +1075 -82
  75. package/app/pages/checkout/partials/product-shipping-address-card.jsx +382 -0
  76. package/app/pages/checkout/partials/shipment-details.jsx +209 -0
  77. package/app/pages/checkout/partials/shipment-details.test.js +246 -0
  78. package/app/pages/checkout/partials/shipping-address.jsx +156 -68
  79. package/app/pages/checkout/partials/shipping-address.test.js +673 -0
  80. package/app/pages/checkout/partials/shipping-method-options.jsx +180 -0
  81. package/app/pages/checkout/partials/shipping-methods.jsx +403 -0
  82. package/app/pages/checkout/partials/shipping-methods.test.js +472 -0
  83. package/app/pages/checkout/partials/shipping-multi-address.jsx +259 -0
  84. package/app/pages/checkout/partials/shipping-multi-address.test.js +2088 -0
  85. package/app/pages/checkout/partials/shipping-product-cards.jsx +101 -0
  86. package/app/pages/checkout/util/checkout-context.js +25 -18
  87. package/app/pages/login/index.jsx +2 -6
  88. package/app/pages/product-detail/index.jsx +96 -81
  89. package/app/pages/product-detail/index.test.js +103 -19
  90. package/app/pages/product-list/index.jsx +3 -1
  91. package/app/pages/product-list/partials/inventory-filter.jsx +18 -21
  92. package/app/pages/product-list/partials/inventory-filter.test.js +15 -17
  93. package/app/pages/product-list/partials/selected-refinements.jsx +3 -1
  94. package/app/ssr.js +1 -1
  95. package/app/static/translations/compiled/en-GB.json +316 -30
  96. package/app/static/translations/compiled/en-US.json +316 -30
  97. package/app/static/translations/compiled/en-XA.json +673 -75
  98. package/app/utils/address-utils.js +112 -0
  99. package/app/utils/address-utils.test.js +484 -0
  100. package/app/utils/product-utils.js +17 -5
  101. package/app/utils/product-utils.test.js +17 -8
  102. package/app/utils/sfdc-user-agent-utils.js +32 -0
  103. package/app/utils/sfdc-user-agent-utils.test.js +82 -0
  104. package/app/utils/shipment-utils.js +196 -0
  105. package/app/utils/shipment-utils.test.js +458 -0
  106. package/app/utils/test-utils.js +4 -4
  107. package/app/utils/utils.js +6 -1
  108. package/config/default.js +4 -1
  109. package/config/mocks/default.js +3 -1
  110. package/package.json +9 -9
  111. package/translations/en-GB.json +127 -10
  112. package/translations/en-US.json +127 -10
  113. package/app/pages/checkout/partials/shipping-options.jsx +0 -269
@@ -43,6 +43,53 @@ const mockProductsWithUnavailableProducts = {
43
43
  ]
44
44
  }
45
45
 
46
+ const mockProductsWithBopisInventories = {
47
+ limit: 0,
48
+ total: 1,
49
+ data: [
50
+ {
51
+ currency: 'GBP',
52
+ id: '701642889830M',
53
+ imageGroups: [],
54
+ inventories: [
55
+ {
56
+ id: 'bopis-store-1',
57
+ orderable: true,
58
+ stockLevel: 10
59
+ }
60
+ ]
61
+ }
62
+ ]
63
+ }
64
+
65
+ const mockProductWithBopisUnavailableInOneStore = {
66
+ limit: 0,
67
+ total: 1,
68
+ data: [
69
+ {
70
+ currency: 'GBP',
71
+ id: 'product-a',
72
+ imageGroups: [],
73
+ inventories: [
74
+ {
75
+ id: 'store-1',
76
+ orderable: true,
77
+ stockLevel: 10
78
+ },
79
+ {
80
+ id: 'store-2',
81
+ orderable: false, // Not orderable from store-2
82
+ stockLevel: 0
83
+ }
84
+ ],
85
+ inventory: {
86
+ orderable: true,
87
+ stockLevel: 100
88
+ }
89
+ }
90
+ ]
91
+ }
92
+
46
93
  describe('UnavailableProductConfirmationModal', () => {
47
94
  test('renders without crashing', () => {
48
95
  prependHandlersToServer([
@@ -103,7 +150,6 @@ describe('UnavailableProductConfirmationModal', () => {
103
150
  })
104
151
 
105
152
  test('opens confirmation modal when unavailable products are found with defined productIds prop', async () => {
106
- const mockProductIds = ['701642889899M', '701642889830M']
107
153
  prependHandlersToServer([
108
154
  {
109
155
  path: '*/products',
@@ -147,3 +193,78 @@ describe('UnavailableProductConfirmationModal', () => {
147
193
  expect(removeBtn).not.toBeInTheDocument()
148
194
  })
149
195
  })
196
+
197
+ test('does not open confirmation modal with sufficient bopis inventory', async () => {
198
+ prependHandlersToServer([
199
+ {
200
+ path: '*/products',
201
+ res: () => {
202
+ return mockProductsWithBopisInventories
203
+ }
204
+ }
205
+ ])
206
+ const mockFunc = jest.fn()
207
+ const basket = {
208
+ productItems: [
209
+ {
210
+ productId: '701642889830M',
211
+ quantity: 2,
212
+ inventoryId: 'bopis-store-1' // available
213
+ }
214
+ ]
215
+ }
216
+ const {queryByText} = renderWithProviders(
217
+ <UnavailableProductConfirmationModal
218
+ productItems={basket.productItems}
219
+ handleUnavailableProducts={mockFunc}
220
+ />
221
+ )
222
+
223
+ await waitFor(() => {
224
+ expect(queryByText(/Items Unavailable/i)).not.toBeInTheDocument()
225
+ })
226
+ expect(mockFunc).not.toHaveBeenCalled()
227
+ })
228
+
229
+ test('opens confirmation modal for bopis item unavailable in selected store', async () => {
230
+ prependHandlersToServer([
231
+ {
232
+ path: '*/products',
233
+ res: () => {
234
+ return mockProductWithBopisUnavailableInOneStore
235
+ }
236
+ }
237
+ ])
238
+ const mockFunc = jest.fn()
239
+ const basket = {
240
+ productItems: [
241
+ {
242
+ productId: 'product-a',
243
+ quantity: 1,
244
+ inventoryId: 'store-2' // unavailable in this store
245
+ }
246
+ ]
247
+ }
248
+ const {getByText, queryByText, queryByRole, user} = renderWithProviders(
249
+ <UnavailableProductConfirmationModal
250
+ productItems={basket.productItems}
251
+ handleUnavailableProducts={mockFunc}
252
+ />
253
+ )
254
+
255
+ await waitFor(async () => {
256
+ expect(getByText(/^Items Unavailable$/i)).toBeInTheDocument()
257
+ })
258
+ const removeBtn = queryByRole('button')
259
+
260
+ expect(removeBtn).toBeInTheDocument()
261
+ await user.click(removeBtn)
262
+ await waitFor(async () => {
263
+ expect(mockFunc).toHaveBeenCalledWith(['product-a'])
264
+ })
265
+
266
+ await waitFor(async () => {
267
+ expect(queryByText(/Items Unavailable/i)).not.toBeInTheDocument()
268
+ })
269
+ expect(removeBtn).not.toBeInTheDocument()
270
+ })
package/app/constants.js CHANGED
@@ -42,6 +42,9 @@ export const HOME_SHOP_PRODUCTS_LIMIT = 10
42
42
  export const CAT_MENU_DEFAULT_NAV_SSR_DEPTH = 1
43
43
  export const CAT_MENU_DEFAULT_ROOT_CATEGORY = 'root'
44
44
 
45
+ // Constants for shipments
46
+ export const DEFAULT_SHIPMENT_ID = 'me'
47
+
45
48
  // Default details of badge labels and the corresponding product custom properties that enable badges.
46
49
  export const PRODUCT_BADGE_DETAILS = [
47
50
  {
@@ -99,11 +102,6 @@ export const FEATURE_UNAVAILABLE_ERROR_MESSAGE = defineMessage({
99
102
  defaultMessage: 'This feature is not currently available.',
100
103
  id: 'global.error.feature_unavailable'
101
104
  })
102
- export const CREATE_ACCOUNT_FIRST_ERROR_MESSAGE = defineMessage({
103
- defaultMessage:
104
- 'This feature is not currently available. You must create an account to access this feature.',
105
- id: 'global.error.create_account'
106
- })
107
105
 
108
106
  export const HOME_HREF = '/'
109
107
 
@@ -144,6 +142,11 @@ export const TOAST_MESSAGE_REMOVED_FROM_WISHLIST = defineMessage({
144
142
  defaultMessage: 'Item removed from wishlist'
145
143
  })
146
144
 
145
+ export const TOAST_MESSAGE_STORE_INSUFFICIENT_INVENTORY = defineMessage({
146
+ id: 'global.info.store_insufficient_inventory',
147
+ defaultMessage: "Some items aren't available for pickup at this store."
148
+ })
149
+
147
150
  // Einstein recommender constants used in <RecommendedProducts/>
148
151
  export const EINSTEIN_RECOMMENDERS = {
149
152
  ADD_TO_CART_MODAL: 'pdp-similar-items',
@@ -184,6 +187,10 @@ export const REMOVE_UNAVAILABLE_CART_ITEM_DIALOG_CONFIG = {
184
187
  }),
185
188
  onPrimaryAction: noop
186
189
  }
190
+ /**
191
+ * Constant to enable the store locator and shop the store feature.
192
+ * @deprecated Use `storeLocatorEnabled` in the config file instead
193
+ */
187
194
  export const STORE_LOCATOR_IS_ENABLED = true
188
195
  export const STORE_LOCATOR_SUPPORTED_COUNTRIES = [
189
196
  {
@@ -255,7 +262,14 @@ export const PASSWORDLESS_ERROR_MESSAGES = [
255
262
 
256
263
  export const INVALID_TOKEN_ERROR = /invalid token/i
257
264
 
265
+ /**
266
+ * @deprecated The SLAS private client proxy will mask user not found errors
267
+ * so this variable should not be used as the app will not see these errors.
268
+ */
258
269
  export const USER_NOT_FOUND_ERROR = /user not found/i
259
270
 
260
- // Constant to enable partial hydration capabilities, i.e. `<Island/>` components
271
+ /**
272
+ * Constant to enable partial hydration capabilities, i.e. `<Island/>` components
273
+ * @deprecated Use `partialHydrationEnabled` in the config file instead
274
+ */
261
275
  export const PARTIAL_HYDRATION_ENABLED = false
@@ -8,6 +8,7 @@
8
8
  import React, {useState, useEffect, createContext} from 'react'
9
9
  import PropTypes from 'prop-types'
10
10
  import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
11
+ import {useDisclosure} from '@salesforce/retail-react-app/app/components/shared/ui'
11
12
 
12
13
  const onClient = typeof window !== 'undefined'
13
14
 
@@ -26,6 +27,7 @@ export const StoreLocatorProvider = ({config, children}) => {
26
27
  const {site} = useMultiSite()
27
28
  const selectedStoreBySiteId = `selectedStore_${site?.id}`
28
29
  const selectedStoreId = readValue(selectedStoreBySiteId)
30
+ const {isOpen, onOpen, onClose} = useDisclosure()
29
31
 
30
32
  const [state, setState] = useState({
31
33
  mode: 'input',
@@ -49,7 +51,11 @@ export const StoreLocatorProvider = ({config, children}) => {
49
51
 
50
52
  const value = {
51
53
  state,
52
- setState
54
+ setState,
55
+ // Modal actions
56
+ isOpen,
57
+ onOpen,
58
+ onClose
53
59
  }
54
60
 
55
61
  return <StoreLocatorContext.Provider value={value}>{children}</StoreLocatorContext.Provider>
@@ -7,6 +7,7 @@
7
7
  import React from 'react'
8
8
  import PropTypes from 'prop-types'
9
9
  import {render, act} from '@testing-library/react'
10
+ import {MemoryRouter} from 'react-router-dom'
10
11
  import {
11
12
  StoreLocatorProvider,
12
13
  StoreLocatorContext
@@ -47,7 +48,9 @@ describe('StoreLocatorProvider', () => {
47
48
 
48
49
  const TestWrapper = ({children}) => (
49
50
  <MultiSiteProvider site={mockSite}>
50
- <StoreLocatorProvider config={mockConfig}>{children}</StoreLocatorProvider>
51
+ <MemoryRouter>
52
+ <StoreLocatorProvider config={mockConfig}>{children}</StoreLocatorProvider>
53
+ </MemoryRouter>
51
54
  </MultiSiteProvider>
52
55
  )
53
56
 
@@ -83,6 +86,9 @@ describe('StoreLocatorProvider', () => {
83
86
  config: mockConfig
84
87
  })
85
88
  expect(typeof contextValue?.setState).toBe('function')
89
+ expect(typeof contextValue?.isOpen).toBe('boolean')
90
+ expect(typeof contextValue?.onOpen).toBe('function')
91
+ expect(typeof contextValue?.onClose).toBe('function')
86
92
  })
87
93
 
88
94
  it('initializes with stored selectedStoreId from localStorage', () => {
@@ -169,4 +175,33 @@ describe('StoreLocatorProvider', () => {
169
175
 
170
176
  expect(getByText('Test Child')).toBeTruthy()
171
177
  })
178
+
179
+ it('handles modal state correctly', () => {
180
+ let contextValue
181
+ const TestComponent = () => {
182
+ contextValue = React.useContext(StoreLocatorContext)
183
+ return null
184
+ }
185
+
186
+ render(
187
+ <TestWrapper>
188
+ <TestComponent />
189
+ </TestWrapper>
190
+ )
191
+
192
+ // Initially modal should be closed
193
+ expect(contextValue?.isOpen).toBe(false)
194
+
195
+ // Open modal
196
+ act(() => {
197
+ contextValue?.onOpen()
198
+ })
199
+ expect(contextValue?.isOpen).toBe(true)
200
+
201
+ // Close modal
202
+ act(() => {
203
+ contextValue?.onClose()
204
+ })
205
+ expect(contextValue?.isOpen).toBe(false)
206
+ })
172
207
  })
@@ -0,0 +1,155 @@
1
+ /*
2
+ * Copyright (c) 2025, Salesforce, Inc.
3
+ * All rights reserved.
4
+ * SPDX-License-Identifier: BSD-3-Clause
5
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6
+ */
7
+ import {useState, useCallback, useMemo} from 'react'
8
+ import {useForm} from 'react-hook-form'
9
+ import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
10
+ import {useIntl} from 'react-intl'
11
+ import {useShopperCustomersMutation} from '@salesforce/commerce-sdk-react'
12
+ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
13
+ import {
14
+ areAddressesEqual,
15
+ sanitizedCustomerAddress
16
+ } from '@salesforce/retail-react-app/app/utils/address-utils'
17
+ import {nanoid} from 'nanoid'
18
+
19
+ export const useAddressForm = (
20
+ addGuestAddress,
21
+ isGuest,
22
+ setAddressesForItems,
23
+ availableAddresses,
24
+ deliveryItems
25
+ ) => {
26
+ const {formatMessage} = useIntl()
27
+ const showToast = useToast()
28
+ const {data: customer, refetch: refetchCustomer} = useCurrentCustomer()
29
+ const [formStateByItemId, setFormStateByItemId] = useState({})
30
+
31
+ const createCustomerAddress = useShopperCustomersMutation('createCustomerAddress')
32
+
33
+ const form = useForm({
34
+ mode: 'onSubmit',
35
+ defaultValues: {
36
+ firstName: '',
37
+ lastName: '',
38
+ phone: '',
39
+ countryCode: 'US',
40
+ address1: '',
41
+ city: '',
42
+ stateCode: '',
43
+ postalCode: '',
44
+ preferred: false
45
+ }
46
+ })
47
+
48
+ const isAddressFormOpen = useMemo(() => {
49
+ return Object.keys(formStateByItemId).filter((key) => formStateByItemId[key])?.length > 0
50
+ }, [formStateByItemId])
51
+
52
+ const handleCreateAddress = useCallback(
53
+ async (addressData, itemId) => {
54
+ try {
55
+ const isDuplicate = availableAddresses.some((existingAddr) =>
56
+ areAddressesEqual(addressData, existingAddr)
57
+ )
58
+
59
+ if (isDuplicate) {
60
+ showToast({
61
+ title: formatMessage({
62
+ id: 'shipping_multi_address.error.duplicate_address',
63
+ defaultMessage: 'The address you entered already exists.'
64
+ }),
65
+ status: 'info'
66
+ })
67
+ setFormStateByItemId((prev) => ({...prev, [itemId]: false}))
68
+ form.reset()
69
+ form.clearErrors()
70
+ return null
71
+ }
72
+
73
+ let newAddress
74
+
75
+ if (isGuest) {
76
+ newAddress = addGuestAddress(addressData)
77
+ } else {
78
+ const apiAddressData = {
79
+ ...sanitizedCustomerAddress(addressData),
80
+ addressId: `addr_${nanoid()}`
81
+ }
82
+
83
+ const createdAddress = await createCustomerAddress.mutateAsync({
84
+ body: apiAddressData,
85
+ parameters: {customerId: customer.customerId}
86
+ })
87
+ await refetchCustomer()
88
+ newAddress = createdAddress
89
+ }
90
+
91
+ showToast({
92
+ title: formatMessage({
93
+ id: 'shipping_multi_address.success.address_saved',
94
+ defaultMessage: 'Address saved successfully'
95
+ }),
96
+ status: 'success'
97
+ })
98
+
99
+ // Assign the address to items
100
+ if (availableAddresses.length === 0) {
101
+ // If first address, apply it to all items
102
+ const itemIds = deliveryItems.map((item) => item.itemId)
103
+ setAddressesForItems(itemIds, newAddress.addressId)
104
+ } else {
105
+ setAddressesForItems(itemId, newAddress.addressId)
106
+ }
107
+
108
+ setFormStateByItemId((prev) => ({...prev, [itemId]: false}))
109
+ form.reset()
110
+ form.clearErrors()
111
+
112
+ return newAddress
113
+ } catch (error) {
114
+ showToast({
115
+ title: formatMessage({
116
+ id: 'shipping_multi_address.error.save_failed',
117
+ defaultMessage: "Couldn't save the address."
118
+ }),
119
+ status: 'error'
120
+ })
121
+ }
122
+ },
123
+ [
124
+ isGuest,
125
+ addGuestAddress,
126
+ customer?.customerId,
127
+ setAddressesForItems,
128
+ availableAddresses,
129
+ deliveryItems
130
+ ]
131
+ )
132
+
133
+ const openForm = useCallback((itemId) => {
134
+ setFormStateByItemId((prev) => ({...prev, [itemId]: true}))
135
+ }, [])
136
+
137
+ const closeForm = useCallback(
138
+ (itemId) => {
139
+ setFormStateByItemId((prev) => ({...prev, [itemId]: false}))
140
+ form.clearErrors()
141
+ },
142
+ [form]
143
+ )
144
+
145
+ return {
146
+ form,
147
+ formStateByItemId,
148
+ isSubmitting: form.formState.isSubmitting,
149
+ openForm,
150
+ closeForm,
151
+ handleCreateAddress,
152
+ isAddressFormOpen,
153
+ formErrors: form.formState.errors
154
+ }
155
+ }