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 +}