diff --git a/packages/template-retail-react-app/app/components/address-suggestion-dropdown/index.jsx b/packages/template-retail-react-app/app/components/address-suggestion-dropdown/index.jsx
new file mode 100644
index 0000000000..87a2f8fd20
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/address-suggestion-dropdown/index.jsx
@@ -0,0 +1,209 @@
+/*
+ * Copyright (c) 2021, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import React, {useEffect, useRef} from 'react'
+import PropTypes from 'prop-types'
+import {
+ Box,
+ Flex,
+ Text,
+ IconButton,
+ VStack,
+ HStack,
+ Spinner
+} from '@salesforce/retail-react-app/app/components/shared/ui'
+import {CloseIcon} from '@salesforce/retail-react-app/app/components/icons'
+
+/**
+ * Address Suggestion Dropdown Component
+ * Displays Google-powered address suggestions in a dropdown format
+ */
+const AddressSuggestionDropdown = ({
+ suggestions = [],
+ isLoading = false,
+ onClose,
+ onSelectSuggestion,
+ isVisible = false,
+ position = 'absolute'
+}) => {
+ const dropdownRef = useRef(null)
+
+ // Handle click outside
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ onClose()
+ }
+ }
+
+ if (isVisible) {
+ document.addEventListener('mousedown', handleClickOutside)
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside)
+ }
+ }, [isVisible, onClose])
+
+ if (!isVisible || suggestions.length === 0) {
+ return null
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ Loading suggestions...
+
+
+ )
+ }
+
+ return (
+
+
+
+ Suggested
+
+ }
+ variant="ghost"
+ size="sm"
+ onClick={onClose}
+ />
+
+ {suggestions.map((suggestion, index) => (
+ onSelectSuggestion(suggestion)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ onSelectSuggestion(suggestion)
+ }
+ }}
+ >
+
+ {/* Location Marker */}
+
+
+
+
+
+ {/* Address Text */}
+
+
+ {suggestion.structured_formatting.main_text}
+
+ {suggestion.structured_formatting.secondary_text && (
+
+ {suggestion.structured_formatting.secondary_text}
+
+ )}
+
+
+
+ ))}
+
+ )
+}
+
+AddressSuggestionDropdown.propTypes = {
+ /** Array of address suggestions to display */
+ suggestions: PropTypes.arrayOf(
+ PropTypes.shape({
+ description: PropTypes.string,
+ place_id: PropTypes.string,
+ structured_formatting: PropTypes.shape({
+ main_text: PropTypes.string,
+ secondary_text: PropTypes.string
+ }),
+ terms: PropTypes.arrayOf(
+ PropTypes.shape({
+ offset: PropTypes.number,
+ value: PropTypes.string
+ })
+ ),
+ types: PropTypes.arrayOf(PropTypes.string)
+ })
+ ),
+
+ /** Whether the dropdown should be visible */
+ isVisible: PropTypes.bool,
+
+ /** Callback when close button is clicked */
+ onClose: PropTypes.func.isRequired,
+
+ /** Callback when a suggestion is selected */
+ onSelectSuggestion: PropTypes.func.isRequired,
+
+ /** CSS position property for the dropdown */
+ position: PropTypes.oneOf(['absolute', 'relative', 'fixed']),
+
+ /** Whether the dropdown is loading */
+ isLoading: PropTypes.bool
+}
+
+export default AddressSuggestionDropdown
diff --git a/packages/template-retail-react-app/app/components/address-suggestion-dropdown/index.test.jsx b/packages/template-retail-react-app/app/components/address-suggestion-dropdown/index.test.jsx
new file mode 100644
index 0000000000..d43b1fabef
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/address-suggestion-dropdown/index.test.jsx
@@ -0,0 +1,233 @@
+/*
+ * Copyright (c) 2021, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+import React from 'react'
+import {render, screen, fireEvent} from '@testing-library/react'
+import '@testing-library/jest-dom'
+import AddressSuggestionDropdown from '@salesforce/retail-react-app/../../app/components/address-suggestion-dropdown/index'
+
+describe('AddressSuggestionDropdown', () => {
+ const mockSuggestions = [
+ {
+ description: '123 Main Street, New York, NY 10001, USA',
+ place_id: 'ChIJ1234567890',
+ structured_formatting: {
+ main_text: '123 Main Street',
+ secondary_text: 'New York, NY 10001, USA'
+ },
+ terms: [
+ {offset: 0, value: '123 Main Street'},
+ {offset: 17, value: 'New York'},
+ {offset: 27, value: 'NY'},
+ {offset: 30, value: '10001'},
+ {offset: 37, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '456 Oak Avenue, Los Angeles, CA 90210, USA',
+ place_id: 'ChIJ4567890123',
+ structured_formatting: {
+ main_text: '456 Oak Avenue',
+ secondary_text: 'Los Angeles, CA 90210, USA'
+ },
+ terms: [
+ {offset: 0, value: '456 Oak Avenue'},
+ {offset: 16, value: 'Los Angeles'},
+ {offset: 29, value: 'CA'},
+ {offset: 32, value: '90210'},
+ {offset: 39, value: 'USA'}
+ ],
+ types: ['street_address']
+ }
+ ]
+
+ const defaultProps = {
+ suggestions: [],
+ isLoading: false,
+ isVisible: false,
+ onClose: jest.fn(),
+ onSelectSuggestion: jest.fn()
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should not render when isVisible is false', () => {
+ render()
+
+ expect(screen.queryByTestId('address-suggestion-dropdown')).not.toBeInTheDocument()
+ })
+
+ it('should render dropdown when isVisible is true', () => {
+ render(
+
+ )
+
+ expect(screen.getByTestId('address-suggestion-dropdown')).toBeInTheDocument()
+ })
+
+ it('should render loading state when isLoading is true', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('Loading suggestions...')).toBeInTheDocument()
+ })
+
+ it('should render suggestions when provided', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('123 Main Street')).toBeInTheDocument()
+ expect(screen.getByText('New York, NY 10001, USA')).toBeInTheDocument()
+ expect(screen.getByText('456 Oak Avenue')).toBeInTheDocument()
+ expect(screen.getByText('Los Angeles, CA 90210, USA')).toBeInTheDocument()
+ })
+
+ it('should call onSelectSuggestion when a suggestion is clicked', () => {
+ const mockOnSelect = jest.fn()
+ render(
+
+ )
+
+ fireEvent.click(screen.getByText('123 Main Street'))
+
+ expect(mockOnSelect).toHaveBeenCalledWith(mockSuggestions[0])
+ })
+
+ it('should call onSelectSuggestion when Enter key is pressed on a suggestion', () => {
+ const mockOnSelect = jest.fn()
+ render(
+
+ )
+
+ const firstSuggestion = screen.getByText('123 Main Street').closest('[role="button"]')
+ fireEvent.keyDown(firstSuggestion, {key: 'Enter', code: 'Enter'})
+
+ expect(mockOnSelect).toHaveBeenCalledWith(mockSuggestions[0])
+ })
+
+ it('should call onClose when close button is clicked', () => {
+ const mockOnClose = jest.fn()
+ render(
+
+ )
+
+ const closeButton = screen.getByLabelText('Close suggestions')
+ fireEvent.click(closeButton)
+
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+
+ it('should handle empty suggestions array', () => {
+ render()
+
+ // Should not render anything when suggestions are empty
+ expect(screen.queryByTestId('address-suggestion-dropdown')).not.toBeInTheDocument()
+ })
+
+ it('should handle suggestions with missing secondaryText', () => {
+ const suggestionsWithoutSecondary = [
+ {
+ description: '123 Main Street',
+ place_id: 'ChIJ1234567890',
+ structured_formatting: {
+ main_text: '123 Main Street',
+ secondary_text: null
+ },
+ terms: [{offset: 0, value: '123 Main Street'}],
+ types: ['street_address']
+ }
+ ]
+
+ render(
+
+ )
+
+ expect(screen.getByText('123 Main Street')).toBeInTheDocument()
+ // Should not crash when secondaryText is null
+ })
+
+ it('should handle keyboard navigation', () => {
+ const mockOnSelect = jest.fn()
+ render(
+
+ )
+
+ const firstSuggestion = screen.getByText('123 Main Street').closest('[role="button"]')
+ fireEvent.keyDown(firstSuggestion, {key: 'Enter', code: 'Enter'})
+
+ expect(mockOnSelect).toHaveBeenCalledWith(mockSuggestions[0])
+ })
+
+ it('should handle mouse hover on suggestions', () => {
+ render(
+
+ )
+
+ const firstSuggestion = screen.getByText('123 Main Street').closest('[role="button"]')
+
+ // Should not crash on hover
+ fireEvent.mouseEnter(firstSuggestion)
+ fireEvent.mouseLeave(firstSuggestion)
+ })
+})
diff --git a/packages/template-retail-react-app/app/components/forms/address-fields.jsx b/packages/template-retail-react-app/app/components/forms/address-fields.jsx
index e9e58526b8..557f56b946 100644
--- a/packages/template-retail-react-app/app/components/forms/address-fields.jsx
+++ b/packages/template-retail-react-app/app/components/forms/address-fields.jsx
@@ -11,12 +11,14 @@ import {
Grid,
GridItem,
SimpleGrid,
- Stack
+ Stack,
+ Box
} from '@salesforce/retail-react-app/app/components/shared/ui'
import useAddressFields from '@salesforce/retail-react-app/app/components/forms/useAddressFields'
import Field from '@salesforce/retail-react-app/app/components/field'
import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
import {MESSAGE_PROPTYPE} from '@salesforce/retail-react-app/app/utils/locale'
+import AddressSuggestionDropdown from '@salesforce/retail-react-app/app/components/address-suggestion-dropdown'
const defaultFormTitleAriaLabel = defineMessage({
defaultMessage: 'Address Form',
@@ -51,7 +53,24 @@ const AddressFields = ({
-
+
+ {/* Address field with autocomplete dropdown */}
+
+
+
+ {/* Address suggestion dropdown */}
+
+
+
diff --git a/packages/template-retail-react-app/app/components/forms/useAddressFields.jsx b/packages/template-retail-react-app/app/components/forms/useAddressFields.jsx
index 340f2e595a..05859aa5f2 100644
--- a/packages/template-retail-react-app/app/components/forms/useAddressFields.jsx
+++ b/packages/template-retail-react-app/app/components/forms/useAddressFields.jsx
@@ -5,12 +5,17 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {useIntl, defineMessages} from 'react-intl'
+import {useState, useRef, useCallback, useEffect} from 'react'
import {formatPhoneNumber} from '@salesforce/retail-react-app/app/utils/phone-utils'
import {
stateOptions,
provinceOptions
} from '@salesforce/retail-react-app/app/components/forms/state-province-options'
import {SHIPPING_COUNTRY_CODES} from '@salesforce/retail-react-app/app/constants'
+import {
+ getAddressSuggestions,
+ parseAddressSuggestion
+} from '@salesforce/retail-react-app/app/utils/address-suggestions' // TODO: replace with the actual API call to the address service
const messages = defineMessages({
required: {defaultMessage: 'Required', id: 'use_address_fields.error.required'},
@@ -42,14 +47,142 @@ export default function useAddressFields({
form: {
watch,
control,
+ setValue,
formState: {errors}
},
prefix = ''
}) {
const {formatMessage} = useIntl()
+ // Address autocomplete state
+ const [suggestions, setSuggestions] = useState([]) // no suggestions by default
+ const [showDropdown, setShowDropdown] = useState(false) // dropdown is initially hidden
+ const [isDismissed, setIsDismissed] = useState(false) // user has not dismissed the dropdown
+ const [isLoading, setIsLoading] = useState(false) // loading state for the API call
+
+ // Debounce timeout ref
+ const debounceTimeoutRef = useRef(null)
+
const countryCode = watch('countryCode')
+ // Reset address fields when country changes
+ useEffect(() => {
+ // Clear address fields when country changes
+ setValue(`${prefix}address1`, '')
+ setValue(`${prefix}city`, '')
+ setValue(`${prefix}stateCode`, '')
+ setValue(`${prefix}postalCode`, '')
+ // Clear autocomplete suggestions
+ setSuggestions([])
+ setShowDropdown(false)
+ setIsDismissed(false)
+ }, [countryCode, prefix, setValue])
+
+ // Handle address input changes with debouncing
+ const handleAddressInputChange = useCallback(
+ async (value) => {
+ // Clear any existing timeout
+ if (debounceTimeoutRef.current) {
+ clearTimeout(debounceTimeoutRef.current)
+ }
+
+ // If input is too short, clear suggestions
+ if (!value || value.length < 3) {
+ setSuggestions([])
+ setShowDropdown(false)
+ return
+ }
+
+ // Set loading state
+ setIsLoading(true)
+
+ // Debounce the API call
+ debounceTimeoutRef.current = setTimeout(async () => {
+ try {
+ const results = await getAddressSuggestions(value, countryCode)
+ setSuggestions(results)
+ setShowDropdown(true)
+ setIsDismissed(false)
+ } catch (error) {
+ console.error('Error fetching address suggestions:', error)
+ setSuggestions([])
+ } finally {
+ setIsLoading(false)
+ }
+ }, 300) // 300ms debounce
+ },
+ [countryCode]
+ )
+
+ // Handle address field focus when user clicks into the address field
+ const handleAddressFocus = useCallback(() => {
+ setIsDismissed(false) // Reset dismissal on new focus
+ }, [])
+
+ // Handle cut event
+ const handleAddressCut = useCallback(
+ (e) => {
+ // Get the new value after the cut operation
+ const newValue = e.target.value
+ // Trigger the address change handler with the new value
+ handleAddressInputChange(newValue)
+ },
+ [handleAddressInputChange]
+ )
+
+ // Handle click outside to close dropdown
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ // Get the address input element
+ const addressInput = document.querySelector(`input[name="${prefix}address1"]`)
+ // Get the dropdown element
+ const dropdown = document.querySelector('[data-testid="address-suggestion-dropdown"]')
+
+ // If click is outside both the input and dropdown, close the dropdown
+ if (
+ addressInput &&
+ dropdown &&
+ !addressInput.contains(event.target) &&
+ !dropdown.contains(event.target)
+ ) {
+ setShowDropdown(false)
+ setIsDismissed(true)
+ setSuggestions([])
+ }
+ }
+
+ // Add click event listener
+ document.addEventListener('mousedown', handleClickOutside)
+
+ // Cleanup
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside)
+ }
+ }, [prefix, setShowDropdown, setIsDismissed, setSuggestions])
+
+ // Handle dropdown close when user clicks outside the dropdown
+ const handleDropdownClose = useCallback(() => {
+ setShowDropdown(false)
+ setIsDismissed(true)
+ setSuggestions([])
+ }, [setShowDropdown, setIsDismissed, setSuggestions])
+
+ // Handle suggestion selection
+ const handleSuggestionSelect = useCallback(
+ (suggestion) => {
+ // Parse the address suggestion to extract individual fields
+ const parsedFields = parseAddressSuggestion(suggestion)
+
+ // Populate address field only (city/state/zip population in next PR)
+ setValue(`${prefix}address1`, parsedFields.address1)
+ setShowDropdown(false)
+ setIsDismissed(true)
+ setSuggestions([])
+ },
+ [prefix, setValue]
+ )
+
+ // Define address fields
const fields = {
firstName: {
name: `${prefix}firstName`,
@@ -130,7 +263,30 @@ export default function useAddressFields({
})
},
error: errors[`${prefix}address1`],
- control
+ control,
+
+ // inputProps with autocomplete functionality
+ inputProps: ({onChange}) => ({
+ onChange(evt) {
+ // Call original onChange first (this updates the form)
+ onChange(evt.target.value)
+ // Then handle autocomplete
+ handleAddressInputChange(evt.target.value)
+ },
+ onFocus: handleAddressFocus,
+ onCut: handleAddressCut
+ }),
+ // Autocomplete-specific props
+ autocomplete: {
+ suggestions,
+ showDropdown,
+ isLoading,
+ isDismissed,
+ onInputChange: handleAddressInputChange,
+ onFocus: handleAddressFocus,
+ onClose: handleDropdownClose,
+ onSelectSuggestion: handleSuggestionSelect
+ }
},
city: {
name: `${prefix}city`,
diff --git a/packages/template-retail-react-app/app/components/forms/useAddressFields.test.js b/packages/template-retail-react-app/app/components/forms/useAddressFields.test.js
new file mode 100644
index 0000000000..5094cacf43
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/forms/useAddressFields.test.js
@@ -0,0 +1,289 @@
+/*
+ * Copyright (c) 2021, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+import {renderHook, act} from '@testing-library/react'
+import {useForm} from 'react-hook-form'
+import useAddressFields from '../forms/useAddressFields'
+import {
+ getAddressSuggestions,
+ parseAddressSuggestion
+} from '@salesforce/retail-react-app/app/utils/address-suggestions'
+
+// Mock the address service
+jest.mock('@salesforce/retail-react-app/app/utils/address-suggestions')
+jest.mock('react-intl', () => ({
+ useIntl: () => ({
+ formatMessage: jest.fn((message) => message.defaultMessage || message.id)
+ }),
+ defineMessages: jest.fn((messages) => messages)
+}))
+
+// Mock the phone formatter
+jest.mock('@salesforce/retail-react-app/app/utils/phone-utils', () => ({
+ formatPhoneNumber: jest.fn((value) => value)
+}))
+
+// Mock the state/province options
+jest.mock('@salesforce/retail-react-app/app/components/forms/state-province-options', () => ({
+ stateOptions: [
+ {value: 'NY', label: 'New York'},
+ {value: 'CA', label: 'California'}
+ ],
+ provinceOptions: [
+ {value: 'ON', label: 'Ontario'},
+ {value: 'BC', label: 'British Columbia'}
+ ]
+}))
+
+// Mock the constants
+jest.mock('@salesforce/retail-react-app/app/constants', () => ({
+ SHIPPING_COUNTRY_CODES: [
+ {value: 'US', label: 'United States'},
+ {value: 'CA', label: 'Canada'}
+ ]
+}))
+
+describe('useAddressFields', () => {
+ let mockForm
+ let mockSetValue
+ let mockWatch
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+
+ mockSetValue = jest.fn()
+ mockWatch = jest.fn()
+
+ mockForm = {
+ watch: mockWatch,
+ control: {},
+ setValue: mockSetValue,
+ formState: {errors: {}}
+ }
+
+ // Mock the address service responses
+ getAddressSuggestions.mockResolvedValue([
+ {
+ mainText: '123 Main Street',
+ secondaryText: 'New York, NY 10001, USA',
+ country: 'US'
+ }
+ ])
+
+ parseAddressSuggestion.mockReturnValue({
+ address1: '123 Main Street',
+ city: 'New York',
+ stateCode: 'NY',
+ postalCode: '10001',
+ countryCode: 'US'
+ })
+ })
+
+ it('should return all required address fields', () => {
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ expect(result.current).toHaveProperty('firstName')
+ expect(result.current).toHaveProperty('lastName')
+ expect(result.current).toHaveProperty('phone')
+ expect(result.current).toHaveProperty('countryCode')
+ expect(result.current).toHaveProperty('address1')
+ expect(result.current).toHaveProperty('city')
+ expect(result.current).toHaveProperty('stateCode')
+ expect(result.current).toHaveProperty('postalCode')
+ expect(result.current).toHaveProperty('preferred')
+ })
+
+ it('should set default country to US', () => {
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ expect(result.current.countryCode.defaultValue).toBe('US')
+ })
+
+ it('should call getAddressSuggestions when address input changes', async () => {
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ // Simulate address input change
+ await act(async () => {
+ const inputProps = result.current.address1.inputProps({onChange: jest.fn()})
+ inputProps.onChange({
+ target: {value: '123 Main'}
+ })
+ })
+
+ // Wait for debounce
+ await new Promise((resolve) => setTimeout(resolve, 350))
+
+ expect(getAddressSuggestions).toHaveBeenCalledWith('123 Main', undefined)
+ })
+
+ it('should not call getAddressSuggestions for input shorter than 3 characters', async () => {
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ await act(async () => {
+ const inputProps = result.current.address1.inputProps({onChange: jest.fn()})
+ inputProps.onChange({
+ target: {value: '12'}
+ })
+ })
+
+ // Wait for debounce
+ await new Promise((resolve) => setTimeout(resolve, 350))
+
+ expect(getAddressSuggestions).not.toHaveBeenCalled()
+ })
+
+ it('should populate address1 field when suggestion is selected (city/state/zip in next PR)', () => {
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ const suggestion = {
+ mainText: '123 Main Street',
+ secondaryText: 'New York, NY 10001, USA',
+ country: 'US'
+ }
+
+ act(() => {
+ result.current.address1.autocomplete.onSelectSuggestion(suggestion)
+ })
+
+ expect(parseAddressSuggestion).toHaveBeenCalledWith(suggestion)
+ expect(mockSetValue).toHaveBeenCalledWith('address1', '123 Main Street')
+ // City, state, zip, and country population will be implemented in next PR
+ // expect(mockSetValue).toHaveBeenCalledWith('city', 'New York')
+ // expect(mockSetValue).toHaveBeenCalledWith('stateCode', 'NY')
+ // expect(mockSetValue).toHaveBeenCalledWith('postalCode', '10001')
+ // expect(mockSetValue).toHaveBeenCalledWith('countryCode', 'US')
+ })
+
+ it('should handle address focus correctly', () => {
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ act(() => {
+ const inputProps = result.current.address1.inputProps({onChange: jest.fn()})
+ inputProps.onFocus()
+ })
+
+ // The focus handler should reset the dismissed state
+ // This is tested by checking that the autocomplete props are available
+ expect(result.current.address1.autocomplete).toBeDefined()
+ })
+
+ it('should handle address cut event', async () => {
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ await act(async () => {
+ const inputProps = result.current.address1.inputProps({onChange: jest.fn()})
+ inputProps.onCut({
+ target: {value: '123 Main'}
+ })
+ })
+
+ // Wait for debounce
+ await new Promise((resolve) => setTimeout(resolve, 350))
+
+ expect(getAddressSuggestions).toHaveBeenCalledWith('123 Main', undefined)
+ })
+
+ it('should close dropdown when onClose is called', () => {
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ act(() => {
+ result.current.address1.autocomplete.onClose()
+ })
+
+ // The close handler should set showDropdown to false and clear suggestions
+ // This is tested by checking that the autocomplete props are available
+ expect(result.current.address1.autocomplete).toBeDefined()
+ })
+
+ it('should handle country change and reset address fields', () => {
+ mockWatch.mockReturnValue('CA') // Simulate country change to Canada
+
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ // When country changes, address fields should be reset
+ expect(mockSetValue).toHaveBeenCalledWith('address1', '')
+ expect(mockSetValue).toHaveBeenCalledWith('city', '')
+ expect(mockSetValue).toHaveBeenCalledWith('stateCode', '')
+ expect(mockSetValue).toHaveBeenCalledWith('postalCode', '')
+ })
+
+ it('should use prefix for field names when provided', () => {
+ const {result} = renderHook(() =>
+ useAddressFields({
+ form: mockForm,
+ prefix: 'shipping'
+ })
+ )
+
+ expect(result.current.firstName.name).toBe('shippingfirstName')
+ expect(result.current.lastName.name).toBe('shippinglastName')
+ expect(result.current.address1.name).toBe('shippingaddress1')
+ })
+
+ it('should handle phone number formatting', () => {
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ const mockOnChange = jest.fn()
+
+ act(() => {
+ result.current.phone.inputProps({onChange: mockOnChange}).onChange({
+ target: {value: '1234567890'}
+ })
+ })
+
+ expect(mockOnChange).toHaveBeenCalledWith('1234567890')
+ })
+
+ it('should show province label for Canada', () => {
+ mockWatch.mockReturnValue('CA')
+
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ expect(result.current.stateCode.label[0].value).toBe('Province')
+ })
+
+ it('should show state label for US', () => {
+ mockWatch.mockReturnValue('US')
+
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ expect(result.current.stateCode.label[0].value).toBe('State')
+ })
+
+ it('should show postal code label for Canada', () => {
+ mockWatch.mockReturnValue('CA')
+
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ expect(result.current.postalCode.label[0].value).toBe('Postal Code')
+ })
+
+ it('should show zip code label for US', () => {
+ mockWatch.mockReturnValue('US')
+
+ const {result} = renderHook(() => useAddressFields({form: mockForm}))
+
+ expect(result.current.postalCode.label[0].value).toBe('Zip Code')
+ })
+
+ it('should handle errors correctly', () => {
+ const mockFormWithErrors = {
+ ...mockForm,
+ formState: {
+ errors: {
+ firstName: {message: 'First name is required'},
+ address1: {message: 'Address is required'}
+ }
+ }
+ }
+
+ const {result} = renderHook(() => useAddressFields({form: mockFormWithErrors}))
+
+ expect(result.current.firstName.error).toEqual({message: 'First name is required'})
+ expect(result.current.address1.error).toEqual({message: 'Address is required'})
+ })
+})
diff --git a/packages/template-retail-react-app/app/mocks/mock-address-suggestions.js b/packages/template-retail-react-app/app/mocks/mock-address-suggestions.js
new file mode 100644
index 0000000000..920a652327
--- /dev/null
+++ b/packages/template-retail-react-app/app/mocks/mock-address-suggestions.js
@@ -0,0 +1,450 @@
+/*
+ * Copyright (c) 2021, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+/**
+ * Mock Address Data
+ * Sample address data structured like Google Places API
+ */
+
+export const mockAddresses = [
+ {
+ description: '123 Main Street, New York, NY 10001, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ1234567890',
+ reference: 'ref_1234567890',
+ structured_formatting: {
+ main_text: '123 Main Street',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'New York, NY 10001, USA'
+ },
+ terms: [
+ {offset: 0, value: '123 Main Street'},
+ {offset: 17, value: 'New York'},
+ {offset: 27, value: 'NY'},
+ {offset: 30, value: '10001'},
+ {offset: 37, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '456 Oak Avenue, Los Angeles, CA 90210, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ4567890123',
+ reference: 'ref_4567890123',
+ structured_formatting: {
+ main_text: '456 Oak Avenue',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Los Angeles, CA 90210, USA'
+ },
+ terms: [
+ {offset: 0, value: '456 Oak Avenue'},
+ {offset: 16, value: 'Los Angeles'},
+ {offset: 29, value: 'CA'},
+ {offset: 32, value: '90210'},
+ {offset: 39, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '789 Pine Road, Chicago, IL 60601, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ7890123456',
+ reference: 'ref_7890123456',
+ structured_formatting: {
+ main_text: '789 Pine Road',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Chicago, IL 60601, USA'
+ },
+ terms: [
+ {offset: 0, value: '789 Pine Road'},
+ {offset: 14, value: 'Chicago'},
+ {offset: 22, value: 'IL'},
+ {offset: 25, value: '60601'},
+ {offset: 31, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '321 Elm Street, Miami, FL 33101, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ3210987654',
+ reference: 'ref_3210987654',
+ structured_formatting: {
+ main_text: '321 Elm Street',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Miami, FL 33101, USA'
+ },
+ terms: [
+ {offset: 0, value: '321 Elm Street'},
+ {offset: 15, value: 'Miami'},
+ {offset: 21, value: 'FL'},
+ {offset: 24, value: '33101'},
+ {offset: 30, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '654 Cedar Lane, Seattle, WA 98101, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ6543210987',
+ reference: 'ref_6543210987',
+ structured_formatting: {
+ main_text: '654 Cedar Lane',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Seattle, WA 98101, USA'
+ },
+ terms: [
+ {offset: 0, value: '654 Cedar Lane'},
+ {offset: 15, value: 'Seattle'},
+ {offset: 23, value: 'WA'},
+ {offset: 26, value: '98101'},
+ {offset: 32, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '987 Maple Drive, Austin, TX 78701, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ9876543210',
+ reference: 'ref_9876543210',
+ structured_formatting: {
+ main_text: '987 Maple Drive',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Austin, TX 78701, USA'
+ },
+ terms: [
+ {offset: 0, value: '987 Maple Drive'},
+ {offset: 15, value: 'Austin'},
+ {offset: 22, value: 'TX'},
+ {offset: 25, value: '78701'},
+ {offset: 31, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '147 Broadway, New York, NY 10038, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ1472583690',
+ reference: 'ref_1472583690',
+ structured_formatting: {
+ main_text: '147 Broadway',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'New York, NY 10038, USA'
+ },
+ terms: [
+ {offset: 0, value: '147 Broadway'},
+ {offset: 13, value: 'New York'},
+ {offset: 23, value: 'NY'},
+ {offset: 26, value: '10038'},
+ {offset: 33, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '258 Market Street, San Francisco, CA 94102, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ2583691470',
+ reference: 'ref_2583691470',
+ structured_formatting: {
+ main_text: '258 Market Street',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'San Francisco, CA 94102, USA'
+ },
+ terms: [
+ {offset: 0, value: '258 Market Street'},
+ {offset: 18, value: 'San Francisco'},
+ {offset: 33, value: 'CA'},
+ {offset: 36, value: '94102'},
+ {offset: 42, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '369 State Street, Boston, MA 02101, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ3691472580',
+ reference: 'ref_3691472580',
+ structured_formatting: {
+ main_text: '369 State Street',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Boston, MA 02101, USA'
+ },
+ terms: [
+ {offset: 0, value: '369 State Street'},
+ {offset: 16, value: 'Boston'},
+ {offset: 23, value: 'MA'},
+ {offset: 26, value: '02101'},
+ {offset: 32, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '159 Washington Avenue, Philadelphia, PA 19101, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ1592583691',
+ reference: 'ref_1592583691',
+ structured_formatting: {
+ main_text: '159 Washington Avenue',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Philadelphia, PA 19101, USA'
+ },
+ terms: [
+ {offset: 0, value: '159 Washington Avenue'},
+ {offset: 22, value: 'Philadelphia'},
+ {offset: 35, value: 'PA'},
+ {offset: 38, value: '19101'},
+ {offset: 44, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '42 Wallaby Way, Sydney, NSW 2000, Australia',
+ matched_substrings: [{length: 2, offset: 0}],
+ place_id: 'ChIJ42wallabyway',
+ reference: 'ref_42wallabyway',
+ structured_formatting: {
+ main_text: '42 Wallaby Way',
+ main_text_matched_substrings: [{length: 2, offset: 0}],
+ secondary_text: 'Sydney, NSW 2000, Australia'
+ },
+ terms: [
+ {offset: 0, value: '42 Wallaby Way'},
+ {offset: 15, value: 'Sydney'},
+ {offset: 22, value: 'NSW'},
+ {offset: 26, value: '2000'},
+ {offset: 31, value: 'Australia'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '221B Baker Street, London, UK NW1 6XE',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ221bbakerstreet',
+ reference: 'ref_221bbakerstreet',
+ structured_formatting: {
+ main_text: '221B Baker Street',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'London, UK NW1 6XE'
+ },
+ terms: [
+ {offset: 0, value: '221B Baker Street'},
+ {offset: 18, value: 'London'},
+ {offset: 25, value: 'UK'},
+ {offset: 28, value: 'NW1 6XE'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '1600 Pennsylvania Avenue NW, Washington, DC 20500, USA',
+ matched_substrings: [{length: 4, offset: 0}],
+ place_id: 'ChIJ1600pennsylvania',
+ reference: 'ref_1600pennsylvania',
+ structured_formatting: {
+ main_text: '1600 Pennsylvania Avenue NW',
+ main_text_matched_substrings: [{length: 4, offset: 0}],
+ secondary_text: 'Washington, DC 20500, USA'
+ },
+ terms: [
+ {offset: 0, value: '1600 Pennsylvania Avenue NW'},
+ {offset: 28, value: 'Washington'},
+ {offset: 39, value: 'DC'},
+ {offset: 42, value: '20500'},
+ {offset: 48, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '1 Infinite Loop, Cupertino, CA 95014, USA',
+ matched_substrings: [{length: 1, offset: 0}],
+ place_id: 'ChIJ1infiniteloop',
+ reference: 'ref_1infiniteloop',
+ structured_formatting: {
+ main_text: '1 Infinite Loop',
+ main_text_matched_substrings: [{length: 1, offset: 0}],
+ secondary_text: 'Cupertino, CA 95014, USA'
+ },
+ terms: [
+ {offset: 0, value: '1 Infinite Loop'},
+ {offset: 16, value: 'Cupertino'},
+ {offset: 26, value: 'CA'},
+ {offset: 29, value: '95014'},
+ {offset: 35, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '350 Fifth Avenue, New York, NY 10118, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ350fifthavenue',
+ reference: 'ref_350fifthavenue',
+ structured_formatting: {
+ main_text: '350 Fifth Avenue',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'New York, NY 10118, USA'
+ },
+ terms: [
+ {offset: 0, value: '350 Fifth Avenue'},
+ {offset: 17, value: 'New York'},
+ {offset: 27, value: 'NY'},
+ {offset: 30, value: '10118'},
+ {offset: 36, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '1234 Tech Boulevard, San Jose, CA 95113, USA',
+ matched_substrings: [{length: 4, offset: 0}],
+ place_id: 'ChIJ1234techblvd',
+ reference: 'ref_1234techblvd',
+ structured_formatting: {
+ main_text: '1234 Tech Boulevard',
+ main_text_matched_substrings: [{length: 4, offset: 0}],
+ secondary_text: 'San Jose, CA 95113, USA'
+ },
+ terms: [
+ {offset: 0, value: '1234 Tech Boulevard'},
+ {offset: 19, value: 'San Jose'},
+ {offset: 28, value: 'CA'},
+ {offset: 31, value: '95113'},
+ {offset: 37, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '567 Innovation Drive, Mountain View, CA 94043, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ567innovation',
+ reference: 'ref_567innovation',
+ structured_formatting: {
+ main_text: '567 Innovation Drive',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Mountain View, CA 94043, USA'
+ },
+ terms: [
+ {offset: 0, value: '567 Innovation Drive'},
+ {offset: 20, value: 'Mountain View'},
+ {offset: 33, value: 'CA'},
+ {offset: 36, value: '94043'},
+ {offset: 42, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '890 Startup Circle, Palo Alto, CA 94301, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ890startup',
+ reference: 'ref_890startup',
+ structured_formatting: {
+ main_text: '890 Startup Circle',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Palo Alto, CA 94301, USA'
+ },
+ terms: [
+ {offset: 0, value: '890 Startup Circle'},
+ {offset: 18, value: 'Palo Alto'},
+ {offset: 28, value: 'CA'},
+ {offset: 31, value: '94301'},
+ {offset: 37, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '234 Venture Way, Menlo Park, CA 94025, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ234venture',
+ reference: 'ref_234venture',
+ structured_formatting: {
+ main_text: '234 Venture Way',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Menlo Park, CA 94025, USA'
+ },
+ terms: [
+ {offset: 0, value: '234 Venture Way'},
+ {offset: 16, value: 'Menlo Park'},
+ {offset: 27, value: 'CA'},
+ {offset: 30, value: '94025'},
+ {offset: 36, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '789 Silicon Valley Road, Santa Clara, CA 95054, USA',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ789silicon',
+ reference: 'ref_789silicon',
+ structured_formatting: {
+ main_text: '789 Silicon Valley Road',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Santa Clara, CA 95054, USA'
+ },
+ terms: [
+ {offset: 0, value: '789 Silicon Valley Road'},
+ {offset: 24, value: 'Santa Clara'},
+ {offset: 35, value: 'CA'},
+ {offset: 38, value: '95054'},
+ {offset: 44, value: 'USA'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '123 Yonge Street, Toronto, ON M5C 1W4, Canada',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ123yonge',
+ reference: 'ref_123yonge',
+ structured_formatting: {
+ main_text: '123 Yonge Street',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Toronto, ON M5C 1W4, Canada'
+ },
+ terms: [
+ {offset: 0, value: '123 Yonge Street'},
+ {offset: 16, value: 'Toronto'},
+ {offset: 24, value: 'ON'},
+ {offset: 27, value: 'M5C 1W4'},
+ {offset: 35, value: 'Canada'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '456 Robson Street, Vancouver, BC V6B 2A3, Canada',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ456robson',
+ reference: 'ref_456robson',
+ structured_formatting: {
+ main_text: '456 Robson Street',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Vancouver, BC V6B 2A3, Canada'
+ },
+ terms: [
+ {offset: 0, value: '456 Robson Street'},
+ {offset: 17, value: 'Vancouver'},
+ {offset: 26, value: 'BC'},
+ {offset: 29, value: 'V6B 2A3'},
+ {offset: 37, value: 'Canada'}
+ ],
+ types: ['street_address']
+ },
+ {
+ description: '789 Sainte-Catherine Street, Montreal, QC H3B 1B1, Canada',
+ matched_substrings: [{length: 3, offset: 0}],
+ place_id: 'ChIJ789sainte',
+ reference: 'ref_789sainte',
+ structured_formatting: {
+ main_text: '789 Sainte-Catherine Street',
+ main_text_matched_substrings: [{length: 3, offset: 0}],
+ secondary_text: 'Montreal, QC H3B 1B1, Canada'
+ },
+ terms: [
+ {offset: 0, value: '789 Sainte-Catherine Street'},
+ {offset: 28, value: 'Montreal'},
+ {offset: 36, value: 'QC'},
+ {offset: 39, value: 'H3B 1B1'},
+ {offset: 47, value: 'Canada'}
+ ],
+ types: ['street_address']
+ }
+]
diff --git a/packages/template-retail-react-app/app/utils/address-suggestions.js b/packages/template-retail-react-app/app/utils/address-suggestions.js
new file mode 100644
index 0000000000..22d9fd3f0d
--- /dev/null
+++ b/packages/template-retail-react-app/app/utils/address-suggestions.js
@@ -0,0 +1,133 @@
+/*
+ * Copyright (c) 2021, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+/**
+ * Address Suggestions Utility Functions
+ * Functions for handling address autocomplete functionality
+ */
+
+import {mockAddresses} from '@salesforce/retail-react-app/app/mocks/mock-address-suggestions'
+
+// Constants
+const API_DELAY_MS = 300
+
+/**
+ * Simulates API delay similar to real Google Places API
+ * @param {number} delay - Delay in milliseconds
+ */
+const simulateDelay = (delay = API_DELAY_MS) => {
+ return new Promise((resolve) => setTimeout(resolve, delay))
+}
+
+/**
+ * Mock function to get address suggestions based on input
+ * @param {string} input - User input string
+ * @param {string} countryCode - Country code to filter addresses (e.g., 'US', 'UK', 'AU')
+ * @returns {Promise} Array of address suggestions
+ */
+export const getAddressSuggestions = async (input, countryCode) => {
+ // Simulate API delay
+ await simulateDelay()
+
+ // Convert input to lowercase for case-insensitive matching
+ const searchTerm = input.toLowerCase().trim()
+
+ // Filter addresses that match the input and country
+ const filteredAddresses = mockAddresses.filter((address) => {
+ const description = address.description.toLowerCase()
+ const mainText = address.structured_formatting.main_text.toLowerCase()
+ const secondaryText = address.structured_formatting.secondary_text.toLowerCase()
+
+ // Extract country from the last term (country is always the last term)
+ const countryTerm = address.terms[address.terms.length - 1]?.value || ''
+ const isInSelectedCountry =
+ countryTerm === countryCode ||
+ (countryCode === 'US' && countryTerm === 'USA') ||
+ (countryCode === 'GB' && countryTerm === 'UK') ||
+ (countryCode === 'CA' && countryTerm === 'Canada')
+
+ // Match against description, main text, or secondary text, and country
+ const matchesSearch =
+ description.includes(searchTerm) ||
+ mainText.includes(searchTerm) ||
+ secondaryText.includes(searchTerm)
+ const matches = matchesSearch && isInSelectedCountry
+
+ return matches
+ })
+
+ return filteredAddresses
+}
+
+/**
+ * Parse address suggestion data to extract individual address fields
+ * @param {Object} suggestion - Address suggestion object from the API
+ * @returns {Object} Parsed address fields
+ */
+export const parseAddressSuggestion = (suggestion) => {
+ const {structured_formatting, terms} = suggestion
+ const {main_text, secondary_text} = structured_formatting
+
+ // Initialize parsed fields
+ const parsedFields = {
+ address1: main_text
+ }
+
+ // Extract country code from the last term
+ const countryTerm = terms[terms.length - 1]?.value || ''
+ if (countryTerm === 'USA') {
+ parsedFields.countryCode = 'US'
+ } else if (countryTerm === 'UK') {
+ parsedFields.countryCode = 'GB'
+ } else {
+ parsedFields.countryCode = countryTerm
+ }
+
+ if (!secondary_text) {
+ return parsedFields
+ }
+
+ /*
+ * Parse secondary text to extract city, state, and postal code
+ * Format examples:
+ * "New York, NY 10001, USA"
+ * "Toronto, ON M5C 1W4, Canada"
+ * "London, UK NW1 6XE"
+ * "New York" (single part)
+ */
+
+ const parts = secondary_text.split(',')
+
+ if (parts.length >= 2) {
+ // Extract city (first part)
+ parsedFields.city = parts[0].trim()
+
+ // Extract state and postal code (second part)
+ const statePostalPart = parts[1].trim()
+ const statePostalMatch = statePostalPart.match(/^([A-Z]{2})\s+([A-Z0-9\s]+)$/)
+
+ if (statePostalMatch) {
+ parsedFields.stateCode = statePostalMatch[1]
+ parsedFields.postalCode = statePostalMatch[2].trim()
+ } else {
+ // If no state/postal pattern, just use the part as state
+ parsedFields.stateCode = statePostalPart
+ }
+ } else if (parts.length === 1) {
+ // Single part - could be just city or just state
+ const singlePart = parts[0].trim()
+ const stateMatch = singlePart.match(/^[A-Z]{2}$/)
+
+ if (stateMatch) {
+ parsedFields.stateCode = singlePart
+ } else {
+ parsedFields.city = singlePart
+ }
+ }
+
+ return parsedFields
+}