@salesforce/retail-react-app 8.3.0-nightly-20251209080224 → 8.3.0-preview.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 CHANGED
@@ -1,5 +1,6 @@
1
- ## v8.3.0-dev (Nov 05, 2025)
1
+ ## v8.3.0-preview.0 (Dec 12, 2025)
2
2
  - [Bugfix] Fix Forgot Password link not working from Account Profile password update form [#3493](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3493)
3
+ - Introduce Address Autocompletion feature in the checkout flow, powered by Google Maps Platform [#3071](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3071)
3
4
 
4
5
  ## v8.2.0 (Nov 04, 2025)
5
6
  - Add support for Rule Based Promotions for Choice of Bonus Products. We are currently supporting only one product level rule based promotion per product [#3418](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3418)
@@ -0,0 +1,189 @@
1
+ /*
2
+ * Copyright (c) 2025, salesforce.com, 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 React, {useEffect, useRef} from 'react'
8
+ import PropTypes from 'prop-types'
9
+ import {FormattedMessage} from 'react-intl'
10
+ import {getAssetUrl} from '@salesforce/pwa-kit-react-sdk/ssr/universal/utils'
11
+ import {
12
+ Box,
13
+ Flex,
14
+ Text,
15
+ IconButton,
16
+ Spinner,
17
+ Stack,
18
+ Spacer
19
+ } from '@salesforce/retail-react-app/app/components/shared/ui'
20
+ import {CloseIcon, LocationIcon} from '@salesforce/retail-react-app/app/components/icons'
21
+
22
+ /**
23
+ * Address Suggestion Dropdown Component
24
+ * Displays Google-powered address suggestions in a dropdown format
25
+ */
26
+ const AddressSuggestionDropdown = ({
27
+ suggestions = [],
28
+ isLoading = false,
29
+ onClose,
30
+ onSelectSuggestion,
31
+ isVisible = false,
32
+ position = 'absolute'
33
+ }) => {
34
+ const dropdownRef = useRef(null)
35
+
36
+ useEffect(() => {
37
+ const handleClickOutside = (event) => {
38
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
39
+ onClose()
40
+ }
41
+ }
42
+
43
+ if (isVisible) {
44
+ document.addEventListener('mousedown', handleClickOutside)
45
+ }
46
+
47
+ return () => {
48
+ document.removeEventListener('mousedown', handleClickOutside)
49
+ }
50
+ }, [isVisible, onClose])
51
+
52
+ if (!isVisible || suggestions.length === 0) {
53
+ return null
54
+ }
55
+
56
+ if (isLoading) {
57
+ return (
58
+ <Box
59
+ position="absolute"
60
+ top="100%"
61
+ left={0}
62
+ right={0}
63
+ bg="white"
64
+ border="1px solid"
65
+ borderColor="gray.200"
66
+ borderRadius="md"
67
+ boxShadow="md"
68
+ zIndex={1000}
69
+ p={4}
70
+ >
71
+ <Flex align="center" justify="center">
72
+ <Spinner size="sm" mr={2} />
73
+ <Text>Loading suggestions...</Text>
74
+ </Flex>
75
+ </Box>
76
+ )
77
+ }
78
+
79
+ return (
80
+ <Box
81
+ ref={dropdownRef}
82
+ data-testid="address-suggestion-dropdown"
83
+ position={position}
84
+ top="100%"
85
+ left={0}
86
+ right={0}
87
+ zIndex={1000}
88
+ bg="white"
89
+ border="1px solid"
90
+ borderColor="gray.200"
91
+ borderRadius="md"
92
+ boxShadow="md"
93
+ mt={1}
94
+ >
95
+ <Flex px={4} pr={0} py={2} alignItems="center">
96
+ <Text fontSize="sm" fontWeight="medium" color="gray.600">
97
+ <FormattedMessage
98
+ defaultMessage="SUGGESTED"
99
+ id="addressSuggestionDropdown.suggested"
100
+ />
101
+ </Text>
102
+ <Spacer />
103
+ <IconButton
104
+ size="sm"
105
+ variant="ghost"
106
+ icon={<CloseIcon boxSize={4} color="gray.600" />}
107
+ onClick={onClose}
108
+ aria-label="Close suggestions"
109
+ />
110
+ </Flex>
111
+ <Stack spacing={0}>
112
+ {suggestions.map((suggestion, index) => (
113
+ <Box
114
+ key={index}
115
+ px={4}
116
+ py={3}
117
+ cursor="pointer"
118
+ _hover={{bg: 'gray.50'}}
119
+ onClick={() => onSelectSuggestion(suggestion)}
120
+ role="button"
121
+ tabIndex={0}
122
+ onKeyDown={(e) => {
123
+ if (e.key === 'Enter' || e.key === ' ') {
124
+ onSelectSuggestion(suggestion)
125
+ }
126
+ }}
127
+ >
128
+ <Flex alignItems="center" gap={2}>
129
+ <LocationIcon boxSize={4} color="black" />
130
+ <Box flex={1}>
131
+ <Text fontSize="sm" noOfLines={1}>
132
+ {suggestion.description ||
133
+ `${suggestion.structured_formatting?.main_text}, ${suggestion.structured_formatting?.secondary_text}`}
134
+ </Text>
135
+ </Box>
136
+ </Flex>
137
+ </Box>
138
+ ))}
139
+ </Stack>
140
+
141
+ <Box px={4} py={3} display="flex" alignItems="center">
142
+ <img
143
+ src={getAssetUrl('static/img/GoogleMaps_Logo_Gray_4x.png')}
144
+ alt="Google Maps"
145
+ style={{width: '98px', height: '18px'}}
146
+ />
147
+ </Box>
148
+ </Box>
149
+ )
150
+ }
151
+
152
+ AddressSuggestionDropdown.propTypes = {
153
+ /** Array of address suggestions to display */
154
+ suggestions: PropTypes.arrayOf(
155
+ PropTypes.shape({
156
+ description: PropTypes.string,
157
+ place_id: PropTypes.string,
158
+ structured_formatting: PropTypes.shape({
159
+ main_text: PropTypes.string,
160
+ secondary_text: PropTypes.string
161
+ }),
162
+ terms: PropTypes.arrayOf(
163
+ PropTypes.shape({
164
+ offset: PropTypes.number,
165
+ value: PropTypes.string
166
+ })
167
+ ),
168
+ types: PropTypes.arrayOf(PropTypes.string),
169
+ placePrediction: PropTypes.object
170
+ })
171
+ ),
172
+
173
+ /** Whether the dropdown should be visible */
174
+ isVisible: PropTypes.bool,
175
+
176
+ /** Callback when close button is clicked */
177
+ onClose: PropTypes.func.isRequired,
178
+
179
+ /** Callback when a suggestion is selected */
180
+ onSelectSuggestion: PropTypes.func.isRequired,
181
+
182
+ /** CSS position property for the dropdown */
183
+ position: PropTypes.oneOf(['absolute', 'relative', 'fixed']),
184
+
185
+ /** Whether the dropdown is loading */
186
+ isLoading: PropTypes.bool
187
+ }
188
+
189
+ export default AddressSuggestionDropdown
@@ -0,0 +1,332 @@
1
+ /*
2
+ * Copyright (c) 2025, salesforce.com, 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
+
8
+ import React from 'react'
9
+ import {render, screen, fireEvent} from '@testing-library/react'
10
+ import {IntlProvider} from 'react-intl'
11
+ import '@testing-library/jest-dom'
12
+ import AddressSuggestionDropdown from '@salesforce/retail-react-app/app/components/address-suggestion-dropdown/index'
13
+
14
+ describe('AddressSuggestionDropdown', () => {
15
+ const mockSuggestions = [
16
+ {
17
+ description: '123 Main Street, New York, NY 10001, USA',
18
+ place_id: 'ChIJ1234567890',
19
+ structured_formatting: {
20
+ main_text: '123 Main Street',
21
+ secondary_text: 'New York, NY 10001, USA'
22
+ },
23
+ terms: [
24
+ {value: '123 Main Street'},
25
+ {value: 'New York'},
26
+ {value: 'NY'},
27
+ {value: '10001'},
28
+ {value: 'USA'}
29
+ ],
30
+ placePrediction: {
31
+ text: {text: '123 Main Street, New York, NY 10001, USA'},
32
+ placeId: 'ChIJ1234567890'
33
+ }
34
+ },
35
+ {
36
+ description: '456 Oak Avenue, Los Angeles, CA 90210, USA',
37
+ place_id: 'ChIJ4567890123',
38
+ structured_formatting: {
39
+ main_text: '456 Oak Avenue',
40
+ secondary_text: 'Los Angeles, CA 90210, USA'
41
+ },
42
+ terms: [
43
+ {value: '456 Oak Avenue'},
44
+ {value: 'Los Angeles'},
45
+ {value: 'CA'},
46
+ {value: '90210'},
47
+ {value: 'USA'}
48
+ ],
49
+ placePrediction: {
50
+ text: {text: '456 Oak Avenue, Los Angeles, CA 90210, USA'},
51
+ placeId: 'ChIJ4567890123'
52
+ }
53
+ }
54
+ ]
55
+
56
+ const defaultProps = {
57
+ suggestions: [],
58
+ isLoading: false,
59
+ isVisible: false,
60
+ onClose: jest.fn(),
61
+ onSelectSuggestion: jest.fn()
62
+ }
63
+
64
+ const renderWithIntl = (component) => {
65
+ return render(
66
+ <IntlProvider locale="en" messages={{}}>
67
+ {component}
68
+ </IntlProvider>
69
+ )
70
+ }
71
+
72
+ beforeEach(() => {
73
+ jest.clearAllMocks()
74
+ })
75
+
76
+ it('should not render when isVisible is false', () => {
77
+ renderWithIntl(<AddressSuggestionDropdown {...defaultProps} />)
78
+
79
+ expect(screen.queryByTestId('address-suggestion-dropdown')).not.toBeInTheDocument()
80
+ })
81
+
82
+ it('should render dropdown when isVisible is true', () => {
83
+ renderWithIntl(
84
+ <AddressSuggestionDropdown
85
+ {...defaultProps}
86
+ isVisible={true}
87
+ suggestions={mockSuggestions}
88
+ />
89
+ )
90
+
91
+ expect(screen.getByTestId('address-suggestion-dropdown')).toBeInTheDocument()
92
+ })
93
+
94
+ it('should render loading state when isLoading is true', () => {
95
+ renderWithIntl(
96
+ <AddressSuggestionDropdown
97
+ {...defaultProps}
98
+ isVisible={true}
99
+ isLoading={true}
100
+ suggestions={[
101
+ {
102
+ description: 'dummy',
103
+ place_id: 'dummy',
104
+ structured_formatting: {
105
+ main_text: 'dummy',
106
+ secondary_text: 'dummy'
107
+ },
108
+ terms: [],
109
+ placePrediction: {
110
+ text: {text: 'dummy'},
111
+ placeId: 'dummy'
112
+ }
113
+ }
114
+ ]}
115
+ />
116
+ )
117
+
118
+ expect(screen.getByText('Loading suggestions...')).toBeInTheDocument()
119
+ })
120
+
121
+ it('should render suggestions when provided', () => {
122
+ renderWithIntl(
123
+ <AddressSuggestionDropdown
124
+ {...defaultProps}
125
+ isVisible={true}
126
+ suggestions={mockSuggestions}
127
+ />
128
+ )
129
+
130
+ expect(screen.getByText('123 Main Street, New York, NY 10001, USA')).toBeInTheDocument()
131
+ expect(screen.getByText('456 Oak Avenue, Los Angeles, CA 90210, USA')).toBeInTheDocument()
132
+ })
133
+
134
+ it('should call onSelectSuggestion when a suggestion is clicked', () => {
135
+ const mockOnSelect = jest.fn()
136
+ renderWithIntl(
137
+ <AddressSuggestionDropdown
138
+ {...defaultProps}
139
+ isVisible={true}
140
+ suggestions={mockSuggestions}
141
+ onSelectSuggestion={mockOnSelect}
142
+ />
143
+ )
144
+
145
+ fireEvent.click(screen.getByText('123 Main Street, New York, NY 10001, USA'))
146
+
147
+ expect(mockOnSelect).toHaveBeenCalledWith(mockSuggestions[0])
148
+ })
149
+
150
+ it('should call onSelectSuggestion when Enter key is pressed on a suggestion', () => {
151
+ const mockOnSelect = jest.fn()
152
+ renderWithIntl(
153
+ <AddressSuggestionDropdown
154
+ {...defaultProps}
155
+ isVisible={true}
156
+ suggestions={mockSuggestions}
157
+ onSelectSuggestion={mockOnSelect}
158
+ />
159
+ )
160
+
161
+ const firstSuggestion = screen
162
+ .getByText('123 Main Street, New York, NY 10001, USA')
163
+ .closest('[role="button"]')
164
+ fireEvent.keyDown(firstSuggestion, {key: 'Enter', code: 'Enter'})
165
+
166
+ expect(mockOnSelect).toHaveBeenCalledWith(mockSuggestions[0])
167
+ })
168
+
169
+ it('should call onClose when close button is clicked', () => {
170
+ const mockOnClose = jest.fn()
171
+ renderWithIntl(
172
+ <AddressSuggestionDropdown
173
+ {...defaultProps}
174
+ isVisible={true}
175
+ suggestions={mockSuggestions}
176
+ onClose={mockOnClose}
177
+ />
178
+ )
179
+
180
+ const closeButton = screen.getByLabelText('Close suggestions')
181
+ fireEvent.click(closeButton)
182
+
183
+ expect(mockOnClose).toHaveBeenCalled()
184
+ })
185
+
186
+ it('should handle empty suggestions array', () => {
187
+ renderWithIntl(
188
+ <AddressSuggestionDropdown {...defaultProps} isVisible={true} suggestions={[]} />
189
+ )
190
+
191
+ expect(screen.queryByTestId('address-suggestion-dropdown')).not.toBeInTheDocument()
192
+ })
193
+
194
+ it('should handle suggestions with missing secondaryText', () => {
195
+ const suggestionsWithoutSecondary = [
196
+ {
197
+ description: '123 Main Street',
198
+ place_id: 'ChIJ1234567890',
199
+ structured_formatting: {
200
+ main_text: '123 Main Street',
201
+ secondary_text: null
202
+ },
203
+ terms: [{offset: 0, value: '123 Main Street'}],
204
+ types: ['street_address']
205
+ }
206
+ ]
207
+
208
+ renderWithIntl(
209
+ <AddressSuggestionDropdown
210
+ {...defaultProps}
211
+ isVisible={true}
212
+ suggestions={suggestionsWithoutSecondary}
213
+ />
214
+ )
215
+
216
+ expect(screen.getByText('123 Main Street')).toBeInTheDocument()
217
+ })
218
+
219
+ it('should handle keyboard navigation', () => {
220
+ const mockOnSelect = jest.fn()
221
+ renderWithIntl(
222
+ <AddressSuggestionDropdown
223
+ {...defaultProps}
224
+ isVisible={true}
225
+ suggestions={mockSuggestions}
226
+ onSelectSuggestion={mockOnSelect}
227
+ />
228
+ )
229
+
230
+ const firstSuggestion = screen
231
+ .getByText('123 Main Street, New York, NY 10001, USA')
232
+ .closest('[role="button"]')
233
+ fireEvent.keyDown(firstSuggestion, {key: 'Enter', code: 'Enter'})
234
+
235
+ expect(mockOnSelect).toHaveBeenCalledWith(mockSuggestions[0])
236
+ })
237
+
238
+ it('should handle mouse hover on suggestions', () => {
239
+ const mockOnSelect = jest.fn()
240
+ renderWithIntl(
241
+ <AddressSuggestionDropdown
242
+ {...defaultProps}
243
+ isVisible={true}
244
+ suggestions={mockSuggestions}
245
+ onSelectSuggestion={mockOnSelect}
246
+ />
247
+ )
248
+
249
+ const firstSuggestion = screen
250
+ .getByText('123 Main Street, New York, NY 10001, USA')
251
+ .closest('[role="button"]')
252
+
253
+ // Verify element exists before hover
254
+ expect(firstSuggestion).toBeInTheDocument()
255
+
256
+ // Simulate hover events
257
+ fireEvent.mouseEnter(firstSuggestion)
258
+ fireEvent.mouseLeave(firstSuggestion)
259
+
260
+ // Verify element is still present and functional after hover
261
+ expect(firstSuggestion).toBeInTheDocument()
262
+ expect(firstSuggestion).toHaveAttribute('role', 'button')
263
+
264
+ // Verify the suggestion is still clickable after hover
265
+ fireEvent.click(firstSuggestion)
266
+ expect(mockOnSelect).toHaveBeenCalledWith(mockSuggestions[0])
267
+ })
268
+
269
+ it('should display Google Maps placePrediction data correctly', () => {
270
+ const googleMapsSuggestions = [
271
+ {
272
+ description: '123 Main St, New York, NY 10001, USA',
273
+ place_id: 'test-place-id',
274
+ structured_formatting: {
275
+ main_text: '123 Main St',
276
+ secondary_text: 'New York, NY 10001, USA'
277
+ },
278
+ terms: [
279
+ {value: '123 Main St'},
280
+ {value: 'New York'},
281
+ {value: 'NY'},
282
+ {value: '10001'},
283
+ {value: 'USA'}
284
+ ],
285
+ placePrediction: {
286
+ text: {text: '123 Main St, New York, NY 10001, USA'},
287
+ placeId: 'test-place-id'
288
+ }
289
+ }
290
+ ]
291
+
292
+ renderWithIntl(
293
+ <AddressSuggestionDropdown
294
+ {...defaultProps}
295
+ isVisible={true}
296
+ suggestions={googleMapsSuggestions}
297
+ />
298
+ )
299
+
300
+ expect(screen.getByText('123 Main St, New York, NY 10001, USA')).toBeInTheDocument()
301
+ })
302
+
303
+ it('should fallback to structured_formatting when placePrediction is not available', () => {
304
+ const fallbackSuggestions = [
305
+ {
306
+ description: '123 Main St, New York, NY 10001, USA',
307
+ place_id: 'test-place-id',
308
+ structured_formatting: {
309
+ main_text: '123 Main St',
310
+ secondary_text: 'New York, NY 10001, USA'
311
+ },
312
+ terms: [
313
+ {value: '123 Main St'},
314
+ {value: 'New York'},
315
+ {value: 'NY'},
316
+ {value: '10001'},
317
+ {value: 'USA'}
318
+ ]
319
+ }
320
+ ]
321
+
322
+ renderWithIntl(
323
+ <AddressSuggestionDropdown
324
+ {...defaultProps}
325
+ isVisible={true}
326
+ suggestions={fallbackSuggestions}
327
+ />
328
+ )
329
+
330
+ expect(screen.getByText('123 Main St, New York, NY 10001, USA')).toBeInTheDocument()
331
+ })
332
+ })
@@ -11,12 +11,14 @@ import {
11
11
  Grid,
12
12
  GridItem,
13
13
  SimpleGrid,
14
- Stack
14
+ Stack,
15
+ Box
15
16
  } from '@salesforce/retail-react-app/app/components/shared/ui'
16
17
  import useAddressFields from '@salesforce/retail-react-app/app/components/forms/useAddressFields'
17
18
  import Field from '@salesforce/retail-react-app/app/components/field'
18
19
  import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
19
20
  import {MESSAGE_PROPTYPE} from '@salesforce/retail-react-app/app/utils/locale'
21
+ import AddressSuggestionDropdown from '@salesforce/retail-react-app/app/components/address-suggestion-dropdown'
20
22
 
21
23
  const defaultFormTitleAriaLabel = defineMessage({
22
24
  defaultMessage: 'Address Form',
@@ -51,7 +53,21 @@ const AddressFields = ({
51
53
  </SimpleGrid>
52
54
  <Field {...fields.phone} />
53
55
  <Field {...fields.countryCode} />
54
- <Field {...fields.address1} />
56
+
57
+ <Box position="relative">
58
+ <Field {...fields.address1} />
59
+ <AddressSuggestionDropdown
60
+ suggestions={fields.address1.autocomplete.suggestions}
61
+ isVisible={
62
+ fields.address1.autocomplete.showDropdown &&
63
+ !fields.address1.autocomplete.isDismissed
64
+ }
65
+ onClose={fields.address1.autocomplete.onClose}
66
+ onSelectSuggestion={fields.address1.autocomplete.onSelectSuggestion}
67
+ position="absolute"
68
+ />
69
+ </Box>
70
+
55
71
  <Field {...fields.city} />
56
72
  <Grid templateColumns="repeat(8, 1fr)" gap={5}>
57
73
  <GridItem colSpan={[4, 4, 4]}>