diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md index 06516ef61f..eed3d8d91d 100644 --- a/packages/commerce-sdk-react/CHANGELOG.md +++ b/packages/commerce-sdk-react/CHANGELOG.md @@ -1,5 +1,7 @@ ## v4.3.0-dev (Nov 05, 2025) +- Upgrade to commerce-sdk-isomorphic v4.2.0 and introduce Shopper Configurations SCAPI integration [#3071](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3071) + ## v4.2.0 (Nov 04, 2025) - Upgrade to commerce-sdk-isomorphic v4.0.1 [#3449](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3449) diff --git a/packages/commerce-sdk-react/package-lock.json b/packages/commerce-sdk-react/package-lock.json index fece606b74..115a931715 100644 --- a/packages/commerce-sdk-react/package-lock.json +++ b/packages/commerce-sdk-react/package-lock.json @@ -9,7 +9,7 @@ "version": "4.3.0-dev", "license": "See license in LICENSE", "dependencies": { - "commerce-sdk-isomorphic": "^4.0.1", + "commerce-sdk-isomorphic": "4.2.0", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, @@ -846,10 +846,9 @@ "dev": true }, "node_modules/commerce-sdk-isomorphic": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-4.0.1.tgz", - "integrity": "sha512-V5LD6QanLJGrHNvrq93XH+nS3ZSea1cjRuqGcskoqCcectj4+taqphFr0Nm0akA/m73q6+kQx/CWDC6LYiuQRw==", - "license": "BSD-3-Clause", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/commerce-sdk-isomorphic/-/commerce-sdk-isomorphic-4.2.0.tgz", + "integrity": "sha512-DB2nP5nuCG3o2hFqUkx4NR/8nLeoTVY/RVYkN8Y8ALv3EnLL5iFUa2cohnxBxMz/+dB52DqS2grgZXWHAinMww==", "dependencies": { "nanoid": "^3.3.8", "node-fetch": "2.6.13", diff --git a/packages/commerce-sdk-react/package.json b/packages/commerce-sdk-react/package.json index 414e866698..39eb5df32e 100644 --- a/packages/commerce-sdk-react/package.json +++ b/packages/commerce-sdk-react/package.json @@ -40,7 +40,7 @@ "version": "node ./scripts/version.js" }, "dependencies": { - "commerce-sdk-isomorphic": "^4.0.1", + "commerce-sdk-isomorphic": "4.2.0", "js-cookie": "^3.0.1", "jwt-decode": "^4.0.0" }, diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index 582bb8217f..19039e5eb0 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -174,6 +174,14 @@ const DATA_MAP: AuthDataMap = { storageType: 'local', key: 'idp_access_token' }, + idp_refresh_token: { + storageType: 'local', + key: 'idp_refresh_token' + }, + dnt: { + storageType: 'local', + key: 'dnt' + }, token_type: { storageType: 'local', key: 'token_type' diff --git a/packages/commerce-sdk-react/src/constant.ts b/packages/commerce-sdk-react/src/constant.ts index 7e5884f973..2e6b332995 100644 --- a/packages/commerce-sdk-react/src/constant.ts +++ b/packages/commerce-sdk-react/src/constant.ts @@ -56,5 +56,6 @@ export const CLIENT_KEYS = { SHOPPER_PROMOTIONS: 'shopperPromotions', SHOPPER_SEARCH: 'shopperSearch', SHOPPER_SEO: 'shopperSeo', - SHOPPER_STORES: 'shopperStores' + SHOPPER_STORES: 'shopperStores', + SHOPPER_CONFIGURATIONS: 'shopperConfigurations' } as const diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/cache.ts b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/cache.ts new file mode 100644 index 0000000000..c596dee4bc --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/cache.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2023, Salesforce, 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 {CLIENT_KEYS} from '../../constant' +import {ApiClients, CacheUpdateMatrix} from '../types' + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONFIGURATIONS +type Client = NonNullable + +// ShopperConfigurations API is primarily for reading configuration data +// No mutations are currently supported +export const cacheUpdateMatrix: CacheUpdateMatrix = {} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/index.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/index.test.ts new file mode 100644 index 0000000000..8e50f2dfda --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/index.test.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2023, Salesforce, 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 {useConfigurations} from './query' + +describe('ShopperConfigurations', () => { + describe('useConfigurations', () => { + it('should be defined', () => { + expect(useConfigurations).toBeDefined() + }) + }) +}) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/index.ts b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/index.ts new file mode 100644 index 0000000000..df1d7e713c --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2023, Salesforce, 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 + */ +export * from './query' diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/query.test.ts b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/query.test.ts new file mode 100644 index 0000000000..54074e5b64 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/query.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2023, Salesforce, 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 nock from 'nock' +import { + mockQueryEndpoint, + renderHookWithProviders, + waitAndExpectError, + waitAndExpectSuccess, + createQueryClient +} from '../../test-utils' + +import {Argument} from '../types' +import * as queries from './query' + +jest.mock('../../auth/index.ts', () => { + const {default: mockAuth} = jest.requireActual('../../auth/index.ts') + mockAuth.prototype.ready = jest.fn().mockResolvedValue({access_token: 'access_token'}) + return mockAuth +}) + +type Queries = typeof queries +const configurationsEndpoint = '/organizations/' +// Not all endpoints use all parameters, but unused parameters are safely discarded +const OPTIONS: Argument = { + parameters: {organizationId: 'f_ecom_zzrmy_orgf_001'} +} + +// Mock data for configurations +const mockConfigurationsData = { + configurations: [ + { + id: 'gcp', + value: 'test-gcp-api-key' + }, + { + id: 'einstein', + value: 'test-einstein-api-key' + } + ] +} + +describe('Shopper Configurations query hooks', () => { + beforeEach(() => nock.cleanAll()) + afterEach(() => { + expect(nock.pendingMocks()).toHaveLength(0) + }) + + test('`useConfigurations` has meta.displayName defined', async () => { + mockQueryEndpoint(configurationsEndpoint, mockConfigurationsData) + const queryClient = createQueryClient() + const {result} = renderHookWithProviders( + () => { + return queries.useConfigurations(OPTIONS) + }, + {queryClient} + ) + await waitAndExpectSuccess(() => result.current) + expect(queryClient.getQueryCache().getAll()[0].meta?.displayName).toBe('useConfigurations') + }) + + test('`useConfigurations` returns data on success', async () => { + mockQueryEndpoint(configurationsEndpoint, mockConfigurationsData) + const {result} = renderHookWithProviders(() => { + return queries.useConfigurations(OPTIONS) + }) + await waitAndExpectSuccess(() => result.current) + expect(result.current.data).toEqual(mockConfigurationsData) + }) + + test('`useConfigurations` returns error on error', async () => { + mockQueryEndpoint(configurationsEndpoint, {}, 400) + const {result} = renderHookWithProviders(() => { + return queries.useConfigurations(OPTIONS) + }) + await waitAndExpectError(() => result.current) + }) + + test('`useConfigurations` handles 500 server error', async () => { + mockQueryEndpoint(configurationsEndpoint, {}, 500) + const {result} = renderHookWithProviders(() => { + return queries.useConfigurations(OPTIONS) + }) + await waitAndExpectError(() => result.current) + }) +}) diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/query.ts b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/query.ts new file mode 100644 index 0000000000..0ccd45b414 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/query.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023, Salesforce, 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 {UseQueryResult} from '@tanstack/react-query' +import {ShopperConfigurations} from 'commerce-sdk-isomorphic' +import {ApiClients, ApiQueryOptions, Argument, DataType, NullableParameters} from '../types' +import {useQuery} from '../useQuery' +import {mergeOptions, omitNullableParameters, pickValidParams} from '../utils' +import * as queryKeyHelpers from './queryKeyHelpers' +import {CLIENT_KEYS} from '../../constant' +import useCommerceApi from '../useCommerceApi' + +const CLIENT_KEY = CLIENT_KEYS.SHOPPER_CONFIGURATIONS +type Client = NonNullable + +/** + * Gets configuration information that encompasses toggles, preferences, and configuration that allow the application to be reactive to changes performed by the merchant, admin, or support engineer. + * + * @group ShopperConfigurations + * @category Query + * @parameter apiOptions - Options to pass through to `commerce-sdk-isomorphic`, with `null` accepted for unset API parameters. + * @parameter queryOptions - TanStack Query query options, with `enabled` by default set to check that all required API parameters have been set. + * @returns A TanStack Query query hook with data from the Shopper Configurations `getConfigurations` endpoint. + * @see {@link https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-configurations?meta=getConfigurations| Salesforce Developer Center} for more information about the API endpoint. + * @see {@link https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/classes/shopperconfigurations.shopperconfigurations-1.html#getconfigurations | `commerce-sdk-isomorphic` documentation} for more information on the parameters and returned data type. + * @see {@link https://tanstack.com/query/latest/docs/react/reference/useQuery | TanStack Query `useQuery` reference} for more information about the return value. + */ +export const useConfigurations = ( + apiOptions: NullableParameters>, + queryOptions: ApiQueryOptions = {} +): UseQueryResult> => { + type Options = Argument + type Data = DataType + const client = useCommerceApi(CLIENT_KEY) + const methodName = 'getConfigurations' + const requiredParameters = ShopperConfigurations.paramKeys[`${methodName}Required`] + + // Parameters can be set in `apiOptions` or `client.clientConfig` + // we must merge them in order to generate the correct query key. + const netOptions = omitNullableParameters(mergeOptions(client, apiOptions || {})) + const parameters = pickValidParams( + netOptions.parameters, + ShopperConfigurations.paramKeys[methodName] + ) + const queryKey = queryKeyHelpers[methodName].queryKey(netOptions.parameters) + // We don't use `netOptions` here because we manipulate the options in `useQuery`. + const method = async (options: Options) => await client[methodName](options) + + queryOptions.meta = { + displayName: 'useConfigurations', + ...queryOptions.meta + } + + // For some reason, if we don't explicitly set these generic parameters, the inferred type for + // `Data` sometimes, but not always, includes `Response`, which is incorrect. I don't know why. + return useQuery({...netOptions, parameters}, queryOptions, { + method, + queryKey, + requiredParameters + }) +} diff --git a/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/queryKeyHelpers.ts b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/queryKeyHelpers.ts new file mode 100644 index 0000000000..d6c00d2011 --- /dev/null +++ b/packages/commerce-sdk-react/src/hooks/ShopperConfigurations/queryKeyHelpers.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023, Salesforce, 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 {ShopperConfigurations} from 'commerce-sdk-isomorphic' +import {Argument, ExcludeTail} from '../types' +import {pickValidParams} from '../utils' + +// We must use a client with no parameters in order to have required/optional match the API spec +type Client = ShopperConfigurations<{shortCode: string}> +type Params = Partial['parameters']> +export type QueryKeys = { + getConfigurations: [ + '/commerce-sdk-react', + '/organizations/', + string | undefined, + '/configurations', + Params<'getConfigurations'> + ] +} + +// This is defined here, rather than `types.ts`, because it relies on `Client` and `QueryKeys`, +// and making those generic would add too much complexity. +type QueryKeyHelper = { + /** Generates the path component of the query key for an endpoint. */ + path: (params: Params) => ExcludeTail + /** Generates the full query key for an endpoint. */ + queryKey: (params: Params) => QueryKeys[T] +} + +export const getConfigurations: QueryKeyHelper<'getConfigurations'> = { + path: (params) => [ + '/commerce-sdk-react', + '/organizations/', + params?.organizationId, + '/configurations' + ], + queryKey: (params: Params<'getConfigurations'>) => { + return [ + ...getConfigurations.path(params), + pickValidParams(params || {}, ShopperConfigurations.paramKeys.getConfigurations) + ] + } +} diff --git a/packages/commerce-sdk-react/src/hooks/index.ts b/packages/commerce-sdk-react/src/hooks/index.ts index aa05cda84e..c285391d0c 100644 --- a/packages/commerce-sdk-react/src/hooks/index.ts +++ b/packages/commerce-sdk-react/src/hooks/index.ts @@ -17,6 +17,7 @@ export * from './ShopperSearch' export * from './ShopperStores' export * from './ShopperSEO' export * from './useAuthHelper' +export * from './ShopperConfigurations' export {default as useAccessToken} from './useAccessToken' export {default as useCommerceApi} from './useCommerceApi' export {default as useEncUserId} from './useEncUserId' diff --git a/packages/commerce-sdk-react/src/hooks/types.ts b/packages/commerce-sdk-react/src/hooks/types.ts index 7023f464aa..ea0b069156 100644 --- a/packages/commerce-sdk-react/src/hooks/types.ts +++ b/packages/commerce-sdk-react/src/hooks/types.ts @@ -7,6 +7,7 @@ import {InvalidateQueryFilters, QueryFilters, Updater, UseQueryOptions} from '@tanstack/react-query' import { ShopperBaskets, + ShopperConfigurations, ShopperContexts, ShopperCustomers, ShopperExperience, @@ -96,6 +97,7 @@ export interface ApiClients { shopperSearch?: ShopperSearch shopperSeo?: ShopperSEO shopperStores?: ShopperStores + shopperConfigurations?: ShopperConfigurations } export type ApiClient = NonNullable diff --git a/packages/commerce-sdk-react/src/provider.tsx b/packages/commerce-sdk-react/src/provider.tsx index 8973079e70..11605f6e8a 100644 --- a/packages/commerce-sdk-react/src/provider.tsx +++ b/packages/commerce-sdk-react/src/provider.tsx @@ -12,6 +12,7 @@ import {DWSID_COOKIE_NAME, SERVER_AFFINITY_HEADER_KEY} from './constant' import { ShopperBaskets, ShopperContexts, + ShopperConfigurations, ShopperCustomers, ShopperExperience, ShopperGiftCertificates, @@ -257,6 +258,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { return { shopperBaskets: new ShopperBaskets(config), shopperContexts: new ShopperContexts(config), + shopperConfigurations: new ShopperConfigurations(config), shopperCustomers: new ShopperCustomers(config), shopperExperience: new ShopperExperience(config), shopperGiftCertificates: new ShopperGiftCertificates(config), diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index fcbfc5027e..132fe72a11 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,5 +1,6 @@ ## v8.3.0-dev (Nov 05, 2025) - [Bugfix] Fix Forgot Password link not working from Account Profile password update form [#3493](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3493) +- Introduce Address Autocompletion feature in the checkout flow, powered by Google Maps Platform [#3071](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3071) ## v8.2.0 (Nov 04, 2025) - 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) 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..4f8de7a689 --- /dev/null +++ b/packages/template-retail-react-app/app/components/address-suggestion-dropdown/index.jsx @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2025, 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 {FormattedMessage} from 'react-intl' +import {getAssetUrl} from '@salesforce/pwa-kit-react-sdk/ssr/universal/utils' +import { + Box, + Flex, + Text, + IconButton, + Spinner, + Stack, + Spacer +} from '@salesforce/retail-react-app/app/components/shared/ui' +import {CloseIcon, LocationIcon} 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) + + 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 ( + + + + + + + } + onClick={onClose} + aria-label="Close suggestions" + /> + + + {suggestions.map((suggestion, index) => ( + onSelectSuggestion(suggestion)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + onSelectSuggestion(suggestion) + } + }} + > + + + + + {suggestion.description || + `${suggestion.structured_formatting?.main_text}, ${suggestion.structured_formatting?.secondary_text}`} + + + + + ))} + + + + Google Maps + + + ) +} + +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), + placePrediction: PropTypes.object + }) + ), + + /** 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..5cdeeaeb7e --- /dev/null +++ b/packages/template-retail-react-app/app/components/address-suggestion-dropdown/index.test.jsx @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2025, 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 {IntlProvider} from 'react-intl' +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: [ + {value: '123 Main Street'}, + {value: 'New York'}, + {value: 'NY'}, + {value: '10001'}, + {value: 'USA'} + ], + placePrediction: { + text: {text: '123 Main Street, New York, NY 10001, USA'}, + placeId: 'ChIJ1234567890' + } + }, + { + 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: [ + {value: '456 Oak Avenue'}, + {value: 'Los Angeles'}, + {value: 'CA'}, + {value: '90210'}, + {value: 'USA'} + ], + placePrediction: { + text: {text: '456 Oak Avenue, Los Angeles, CA 90210, USA'}, + placeId: 'ChIJ4567890123' + } + } + ] + + const defaultProps = { + suggestions: [], + isLoading: false, + isVisible: false, + onClose: jest.fn(), + onSelectSuggestion: jest.fn() + } + + const renderWithIntl = (component) => { + return render( + + {component} + + ) + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should not render when isVisible is false', () => { + renderWithIntl() + + expect(screen.queryByTestId('address-suggestion-dropdown')).not.toBeInTheDocument() + }) + + it('should render dropdown when isVisible is true', () => { + renderWithIntl( + + ) + + expect(screen.getByTestId('address-suggestion-dropdown')).toBeInTheDocument() + }) + + it('should render loading state when isLoading is true', () => { + renderWithIntl( + + ) + + expect(screen.getByText('Loading suggestions...')).toBeInTheDocument() + }) + + it('should render suggestions when provided', () => { + renderWithIntl( + + ) + + expect(screen.getByText('123 Main Street, New York, NY 10001, USA')).toBeInTheDocument() + expect(screen.getByText('456 Oak Avenue, Los Angeles, CA 90210, USA')).toBeInTheDocument() + }) + + it('should call onSelectSuggestion when a suggestion is clicked', () => { + const mockOnSelect = jest.fn() + renderWithIntl( + + ) + + fireEvent.click(screen.getByText('123 Main Street, New York, NY 10001, USA')) + + expect(mockOnSelect).toHaveBeenCalledWith(mockSuggestions[0]) + }) + + it('should call onSelectSuggestion when Enter key is pressed on a suggestion', () => { + const mockOnSelect = jest.fn() + renderWithIntl( + + ) + + const firstSuggestion = screen + .getByText('123 Main Street, New York, NY 10001, USA') + .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() + renderWithIntl( + + ) + + const closeButton = screen.getByLabelText('Close suggestions') + fireEvent.click(closeButton) + + expect(mockOnClose).toHaveBeenCalled() + }) + + it('should handle empty suggestions array', () => { + renderWithIntl( + + ) + + 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'] + } + ] + + renderWithIntl( + + ) + + expect(screen.getByText('123 Main Street')).toBeInTheDocument() + }) + + it('should handle keyboard navigation', () => { + const mockOnSelect = jest.fn() + renderWithIntl( + + ) + + const firstSuggestion = screen + .getByText('123 Main Street, New York, NY 10001, USA') + .closest('[role="button"]') + fireEvent.keyDown(firstSuggestion, {key: 'Enter', code: 'Enter'}) + + expect(mockOnSelect).toHaveBeenCalledWith(mockSuggestions[0]) + }) + + it('should handle mouse hover on suggestions', () => { + const mockOnSelect = jest.fn() + renderWithIntl( + + ) + + const firstSuggestion = screen + .getByText('123 Main Street, New York, NY 10001, USA') + .closest('[role="button"]') + + // Verify element exists before hover + expect(firstSuggestion).toBeInTheDocument() + + // Simulate hover events + fireEvent.mouseEnter(firstSuggestion) + fireEvent.mouseLeave(firstSuggestion) + + // Verify element is still present and functional after hover + expect(firstSuggestion).toBeInTheDocument() + expect(firstSuggestion).toHaveAttribute('role', 'button') + + // Verify the suggestion is still clickable after hover + fireEvent.click(firstSuggestion) + expect(mockOnSelect).toHaveBeenCalledWith(mockSuggestions[0]) + }) + + it('should display Google Maps placePrediction data correctly', () => { + const googleMapsSuggestions = [ + { + description: '123 Main St, New York, NY 10001, USA', + place_id: 'test-place-id', + structured_formatting: { + main_text: '123 Main St', + secondary_text: 'New York, NY 10001, USA' + }, + terms: [ + {value: '123 Main St'}, + {value: 'New York'}, + {value: 'NY'}, + {value: '10001'}, + {value: 'USA'} + ], + placePrediction: { + text: {text: '123 Main St, New York, NY 10001, USA'}, + placeId: 'test-place-id' + } + } + ] + + renderWithIntl( + + ) + + expect(screen.getByText('123 Main St, New York, NY 10001, USA')).toBeInTheDocument() + }) + + it('should fallback to structured_formatting when placePrediction is not available', () => { + const fallbackSuggestions = [ + { + description: '123 Main St, New York, NY 10001, USA', + place_id: 'test-place-id', + structured_formatting: { + main_text: '123 Main St', + secondary_text: 'New York, NY 10001, USA' + }, + terms: [ + {value: '123 Main St'}, + {value: 'New York'}, + {value: 'NY'}, + {value: '10001'}, + {value: 'USA'} + ] + } + ] + + renderWithIntl( + + ) + + expect(screen.getByText('123 Main St, New York, NY 10001, USA')).toBeInTheDocument() + }) +}) 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..6d9ec5e111 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,21 @@ const AddressFields = ({ - + + + + + + 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..20c2a0cc6f 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,18 @@ * 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, 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 { + processAddressSuggestion, + setAddressFieldValues +} from '@salesforce/retail-react-app/app/utils/address-suggestions' +import {useAutocompleteSuggestions} from '@salesforce/retail-react-app/app/hooks/useAutocompleteSuggestions' const messages = defineMessages({ required: {defaultMessage: 'Required', id: 'use_address_fields.error.required'}, @@ -42,14 +48,128 @@ export default function useAddressFields({ form: { watch, control, + setValue, formState: {errors} }, prefix = '' }) { const {formatMessage} = useIntl() + const [showDropdown, setShowDropdown] = useState(false) + const [isDismissed, setIsDismissed] = useState(false) + const [currentInput, setCurrentInput] = useState('') + const [isAutocompleted, setIsAutocompleted] = useState(false) + const [previousCountry, setPreviousCountry] = useState(undefined) + const countryCode = watch('countryCode') + const {suggestions, isLoading, resetSession} = useAutocompleteSuggestions( + currentInput, + countryCode + ) + + const clearAddressFields = useCallback(() => { + setValue(`${prefix}address1`, '') + setValue(`${prefix}city`, '') + setValue(`${prefix}stateCode`, '') + setValue(`${prefix}postalCode`, '') + setCurrentInput('') + setShowDropdown(false) + setIsDismissed(false) + resetSession() + }, [prefix, setValue, resetSession]) + + useEffect(() => { + if (isAutocompleted) { + return + } + + // Only clear fields if the country actually changed from a previous value (not initial load) + if (countryCode && previousCountry !== undefined && countryCode !== previousCountry) { + clearAddressFields() + } + + setPreviousCountry(countryCode) + }, [countryCode, clearAddressFields, isAutocompleted, previousCountry]) + + const handleAddressInputChange = useCallback((value) => { + setCurrentInput(value) + + if (!value || value.length < 3) { + setShowDropdown(false) + } else { + setShowDropdown(true) + setIsDismissed(false) + } + }, []) + + const handleAddressFocus = useCallback(() => { + setIsDismissed(false) // Reset dismissal on new focus + }, []) + + const handleAddressCut = useCallback( + (e) => { + const newValue = e.target.value + handleAddressInputChange(newValue) + }, + [handleAddressInputChange] + ) + + useEffect(() => { + const handleClickOutside = (event) => { + const addressInput = document.querySelector(`input[name="${prefix}address1"]`) + const dropdown = document.querySelector('[data-testid="address-suggestion-dropdown"]') + + if ( + addressInput && + dropdown && + !addressInput.contains(event.target) && + !dropdown.contains(event.target) + ) { + setShowDropdown(false) + setIsDismissed(true) + resetSession() + } + } + + document.addEventListener('mousedown', handleClickOutside) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [prefix, setShowDropdown, setIsDismissed, resetSession]) + + const handleDropdownClose = useCallback(() => { + setShowDropdown(false) + setIsDismissed(true) + resetSession() + }, [setShowDropdown, setIsDismissed, resetSession]) + + const handleSuggestionSelect = useCallback( + async (suggestion) => { + try { + setIsAutocompleted(true) + + const addressFields = await processAddressSuggestion(suggestion) + + setAddressFieldValues(setValue, prefix, addressFields) + + resetSession() + setShowDropdown(false) + setIsDismissed(true) + setCurrentInput('') + + setTimeout(() => { + setIsAutocompleted(false) + }, 100) + } catch (error) { + console.error('Error parsing address suggestion:', error) + setIsAutocompleted(false) + } + }, + [prefix, setValue, resetSession, setIsAutocompleted] + ) + const fields = { firstName: { name: `${prefix}firstName`, @@ -130,7 +250,25 @@ export default function useAddressFields({ }) }, error: errors[`${prefix}address1`], - control + control, + inputProps: ({onChange}) => ({ + onChange(evt) { + onChange(evt.target.value) + handleAddressInputChange(evt.target.value) + }, + onFocus: handleAddressFocus, + onCut: handleAddressCut + }), + 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..ae7283583b --- /dev/null +++ b/packages/template-retail-react-app/app/components/forms/useAddressFields.test.js @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2025, 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 useAddressFields from '../forms/useAddressFields' +import { + processAddressSuggestion, + setAddressFieldValues +} from '@salesforce/retail-react-app/app/utils/address-suggestions' +import {useAutocompleteSuggestions} from '@salesforce/retail-react-app/app/hooks/useAutocompleteSuggestions' + +jest.mock('@salesforce/retail-react-app/app/utils/address-suggestions') + +jest.mock('@salesforce/retail-react-app/app/hooks/useAutocompleteSuggestions', () => ({ + useAutocompleteSuggestions: jest.fn() +})) + +jest.mock('react-intl', () => ({ + useIntl: () => ({ + formatMessage: jest.fn((message) => message.defaultMessage || message.id) + }), + defineMessages: jest.fn((messages) => messages) +})) + +jest.mock('@salesforce/retail-react-app/app/utils/phone-utils', () => ({ + formatPhoneNumber: jest.fn((value) => value) +})) + +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'} + ] +})) + +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 + let mockUseAutocompleteSuggestions + + beforeEach(() => { + jest.clearAllMocks() + + mockSetValue = jest.fn() + mockWatch = jest.fn() + + mockForm = { + watch: mockWatch, + control: {}, + setValue: mockSetValue, + formState: {errors: {}} + } + + mockUseAutocompleteSuggestions = { + suggestions: [], + isLoading: false, + resetSession: jest.fn() + } + + useAutocompleteSuggestions.mockReturnValue(mockUseAutocompleteSuggestions) + + processAddressSuggestion.mockResolvedValue({ + address1: '123 Main Street', + city: 'New York', + stateCode: 'NY', + postalCode: '10001', + countryCode: 'US' + }) + + setAddressFieldValues.mockImplementation((setValue, prefix, addressFields) => { + setValue(`${prefix}address1`, addressFields.address1) + if (addressFields.city) { + setValue(`${prefix}city`, addressFields.city) + } + if (addressFields.stateCode) { + setValue(`${prefix}stateCode`, addressFields.stateCode) + } + if (addressFields.postalCode) { + setValue(`${prefix}postalCode`, addressFields.postalCode) + } + if (addressFields.countryCode) { + setValue(`${prefix}countryCode`, addressFields.countryCode) + } + }) + }) + + 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 handle address input changes', () => { + const {result} = renderHook(() => useAddressFields({form: mockForm})) + + act(() => { + const inputProps = result.current.address1.inputProps({onChange: jest.fn()}) + inputProps.onChange({ + target: {value: '123 Main'} + }) + }) + + expect(result.current.address1.autocomplete).toBeDefined() + }) + + it('should handle address input changes for short input', () => { + const {result} = renderHook(() => useAddressFields({form: mockForm})) + + act(() => { + const inputProps = result.current.address1.inputProps({onChange: jest.fn()}) + inputProps.onChange({ + target: {value: '12'} + }) + }) + + expect(result.current.address1.autocomplete).toBeDefined() + }) + + it('should populate all address fields when suggestion is selected', async () => { + const {result} = renderHook(() => useAddressFields({form: mockForm})) + + const suggestion = { + mainText: '123 Main Street', + secondaryText: 'New York, NY 10001, USA', + country: 'US' + } + + await act(async () => { + await result.current.address1.autocomplete.onSelectSuggestion(suggestion) + }) + + expect(processAddressSuggestion).toHaveBeenCalledWith(suggestion) + expect(setAddressFieldValues).toHaveBeenCalledWith(mockSetValue, '', { + address1: '123 Main Street', + city: 'New York', + stateCode: 'NY', + postalCode: '10001', + countryCode: 'US' + }) + }) + + it('should handle partial address data when some fields are missing', async () => { + const {result} = renderHook(() => useAddressFields({form: mockForm})) + + processAddressSuggestion.mockResolvedValue({ + address1: '456 Oak Avenue', + city: 'Toronto' + }) + + const suggestion = { + mainText: '456 Oak Avenue', + secondaryText: 'Toronto, Canada', + country: 'CA' + } + + await act(async () => { + await result.current.address1.autocomplete.onSelectSuggestion(suggestion) + }) + + expect(processAddressSuggestion).toHaveBeenCalledWith(suggestion) + expect(setAddressFieldValues).toHaveBeenCalledWith(mockSetValue, '', { + address1: '456 Oak Avenue', + city: 'Toronto' + }) + }) + + it('should handle address focus correctly', () => { + const {result} = renderHook(() => useAddressFields({form: mockForm})) + + act(() => { + const inputProps = result.current.address1.inputProps({onChange: jest.fn()}) + inputProps.onFocus() + }) + + expect(result.current.address1.autocomplete).toBeDefined() + }) + + it('should handle address cut event', () => { + const {result} = renderHook(() => useAddressFields({form: mockForm})) + + act(() => { + const inputProps = result.current.address1.inputProps({onChange: jest.fn()}) + inputProps.onCut({ + target: {value: '123 Main'} + }) + }) + + expect(result.current.address1.autocomplete).toBeDefined() + }) + + it('should close dropdown when onClose is called', () => { + const {result} = renderHook(() => useAddressFields({form: mockForm})) + + act(() => { + result.current.address1.autocomplete.onClose() + }) + + expect(result.current.address1.autocomplete).toBeDefined() + expect(result.current.address1.autocomplete.onClose).toBeDefined() + }) + + it('should handle country change and reset address fields', () => { + let callCount = 0 + mockWatch.mockImplementation(() => { + callCount++ + return callCount === 1 ? 'US' : 'CA' + }) + + const {rerender} = renderHook(() => useAddressFields({form: mockForm})) + + rerender() + + expect(mockSetValue).toHaveBeenCalledWith('address1', '') + expect(mockSetValue).toHaveBeenCalledWith('city', '') + expect(mockSetValue).toHaveBeenCalledWith('stateCode', '') + expect(mockSetValue).toHaveBeenCalledWith('postalCode', '') + + expect(mockUseAutocompleteSuggestions.resetSession).toHaveBeenCalled() + }) + + 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 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'}) + }) + + it('should call useAutocompleteSuggestions with correct parameters', () => { + mockWatch.mockReturnValue('US') + + renderHook(() => useAddressFields({form: mockForm})) + + expect(useAutocompleteSuggestions).toHaveBeenCalledWith('', 'US') + }) + + it('should call useAutocompleteSuggestions with prefix when provided', () => { + mockWatch.mockReturnValue('CA') + + renderHook(() => useAddressFields({form: mockForm, prefix: 'shipping'})) + + expect(useAutocompleteSuggestions).toHaveBeenCalledWith('', 'CA') + }) +}) diff --git a/packages/template-retail-react-app/app/hooks/useAutocompleteSuggestions.js b/packages/template-retail-react-app/app/hooks/useAutocompleteSuggestions.js new file mode 100644 index 0000000000..a26b024072 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/useAutocompleteSuggestions.js @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025, 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 {useState, useRef, useCallback, useEffect} from 'react' +import {useMapsLibrary} from '@vis.gl/react-google-maps' +import {convertGoogleMapsSuggestions} from '@salesforce/retail-react-app/app/utils/address-suggestions' + +const DEBOUNCE_DELAY = 300 + +/** + * Custom hook for Google Maps Places autocomplete suggestions + * @param {string} inputString - The input string to search for + * @param {string} countryCode - Country code to filter results (e.g., 'US', 'CA') + * @param {Object} requestOptions - Additional request options for the API + * @returns {Object} Object containing suggestions, loading state, and reset function + */ +export const useAutocompleteSuggestions = ( + inputString = '', + countryCode = '', + requestOptions = {} +) => { + const places = useMapsLibrary('places') + + const sessionTokenRef = useRef(null) + const debounceTimeoutRef = useRef(null) + + const cacheRef = useRef(new Map()) + + const [suggestions, setSuggestions] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + // Key format: `${inputString.toLowerCase().trim()}_${countryCode}` + const getCacheKey = useCallback((input, country) => { + return `${input.toLowerCase().trim()}_${country || ''}` + }, []) + + const fetchSuggestions = useCallback( + async (input) => { + if (!places || !input || input.length < 3) { + setSuggestions([]) + return + } + + const cacheKey = getCacheKey(input, countryCode) + + // Check cache first + if (cacheRef.current.has(cacheKey)) { + const cachedSuggestions = cacheRef.current.get(cacheKey) + setSuggestions(cachedSuggestions) + setIsLoading(false) + return + } + + setIsLoading(true) + + try { + const {AutocompleteSessionToken, AutocompleteSuggestion} = places + + if (!sessionTokenRef.current) { + sessionTokenRef.current = new AutocompleteSessionToken() + } + + const request = { + ...requestOptions, + input: input, + includedPrimaryTypes: ['street_address'], + sessionToken: sessionTokenRef.current + } + + if (countryCode) { + request.includedRegionCodes = [countryCode] + } + + const response = await AutocompleteSuggestion.fetchAutocompleteSuggestions(request) + + const googleSuggestions = convertGoogleMapsSuggestions(response.suggestions) + + // Store in cache for future use + cacheRef.current.set(cacheKey, googleSuggestions) + + setSuggestions(googleSuggestions) + } catch (error) { + setSuggestions([]) + } finally { + setIsLoading(false) + } + }, + [places, countryCode, getCacheKey] + ) + + const resetSession = useCallback(() => { + sessionTokenRef.current = null + setSuggestions([]) + setIsLoading(false) + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current) + } + }, []) + + useEffect(() => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current) + } + + if (!inputString || inputString.length < 3) { + setSuggestions([]) + return + } + + debounceTimeoutRef.current = setTimeout(() => { + fetchSuggestions(inputString) + }, DEBOUNCE_DELAY) + + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current) + } + } + }, [inputString, fetchSuggestions]) + + return { + suggestions, + isLoading, + resetSession, + fetchSuggestions + } +} diff --git a/packages/template-retail-react-app/app/hooks/useAutocompleteSuggestions.test.js b/packages/template-retail-react-app/app/hooks/useAutocompleteSuggestions.test.js new file mode 100644 index 0000000000..736e6e5049 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/useAutocompleteSuggestions.test.js @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2025, 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 {useAutocompleteSuggestions} from '@salesforce/retail-react-app/app/hooks/useAutocompleteSuggestions' +import {useMapsLibrary} from '@vis.gl/react-google-maps' + +// Import the mocked useCheckout function +import {useCheckout} from '../pages/checkout/util/checkout-context' + +jest.mock('@vis.gl/react-google-maps', () => ({ + useMapsLibrary: jest.fn() +})) + +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ + getConfig: jest.fn(() => ({ + app: { + googleCloudAPI: { + apiKey: 'test-api-key' + } + } + })) +})) + +jest.mock('../pages/checkout/util/checkout-context', () => ({ + useCheckout: jest.fn(() => ({ + configurations: { + configurations: [ + { + id: 'gcp', + value: 'test-api-key' + } + ] + } + })) +})) + +describe('useAutocompleteSuggestions', () => { + let mockPlaces + let mockAutocompleteSessionToken + let mockAutocompleteSuggestion + + const waitForDebounce = async (time = 300) => { + await act(async () => { + jest.advanceTimersByTime(time) + await Promise.resolve() + }) + } + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + + mockAutocompleteSessionToken = jest.fn() + + mockAutocompleteSuggestion = { + fetchAutocompleteSuggestions: jest.fn() + } + + mockPlaces = { + AutocompleteSessionToken: mockAutocompleteSessionToken, + AutocompleteSuggestion: mockAutocompleteSuggestion + } + + useMapsLibrary.mockReturnValue(mockPlaces) + + // Reset useCheckout mock to default + useCheckout.mockReturnValue({ + configurations: { + configurations: [ + { + id: 'gcp', + value: 'test-api-key' + } + ] + } + }) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should return initial state', () => { + const {result} = renderHook(() => useAutocompleteSuggestions('', '')) + + expect(result.current.suggestions).toEqual([]) + expect(result.current.isLoading).toBe(false) + expect(typeof result.current.resetSession).toBe('function') + expect(typeof result.current.fetchSuggestions).toBe('function') + }) + + it('should not fetch suggestions for input shorter than 3 characters', async () => { + const {result} = renderHook(() => useAutocompleteSuggestions('ab', 'US')) + + await waitForDebounce() + + expect(mockAutocompleteSuggestion.fetchAutocompleteSuggestions).not.toHaveBeenCalled() + expect(result.current.suggestions).toEqual([]) + }) + + it('should fetch suggestions for valid input', async () => { + const mockResponse = { + suggestions: [ + { + placePrediction: { + text: {text: '123 Main St, New York, NY 10001, USA'}, + placeId: 'test-place-id' + } + } + ] + } + + mockAutocompleteSuggestion.fetchAutocompleteSuggestions.mockResolvedValue(mockResponse) + + const {result} = renderHook(() => useAutocompleteSuggestions('123 Main', 'US')) + + await waitForDebounce() + + expect(mockAutocompleteSuggestion.fetchAutocompleteSuggestions).toHaveBeenCalledWith({ + input: '123 Main', + includedPrimaryTypes: ['street_address'], + sessionToken: expect.any(Object), + includedRegionCodes: ['US'] + }) + + expect(result.current.suggestions).toHaveLength(1) + expect(result.current.suggestions[0]).toMatchObject({ + description: '123 Main St, New York, NY 10001, USA', + place_id: 'test-place-id', + structured_formatting: { + main_text: '123 Main St', + secondary_text: 'New York, NY 10001, USA' + } + }) + }) + + it('should filter suggestions by country for US', async () => { + const mockResponse = { + suggestions: [ + { + placePrediction: { + text: {text: '123 Main St, New York, NY 10001, USA'}, + placeId: 'us-place-id' + } + } + ] + } + + mockAutocompleteSuggestion.fetchAutocompleteSuggestions.mockResolvedValue(mockResponse) + + const {result} = renderHook(() => useAutocompleteSuggestions('123 Main', 'US')) + + await waitForDebounce() + + expect(result.current.suggestions).toHaveLength(1) + expect(result.current.suggestions[0].description).toContain('USA') + }) + + it('should filter suggestions by country for Canada', async () => { + const mockResponse = { + suggestions: [ + { + placePrediction: { + text: {text: '456 Oak Ave, Toronto, ON M5C 1W4, Canada'}, + placeId: 'ca-place-id' + } + } + ] + } + + mockAutocompleteSuggestion.fetchAutocompleteSuggestions.mockResolvedValue(mockResponse) + + const {result} = renderHook(() => useAutocompleteSuggestions('456 Oak', 'CA')) + + await waitForDebounce() + + expect(result.current.suggestions).toHaveLength(1) + expect(result.current.suggestions[0].description).toContain('Canada') + }) + + it('should handle API errors gracefully', async () => { + mockAutocompleteSuggestion.fetchAutocompleteSuggestions.mockRejectedValue( + new Error('API Error') + ) + + const {result} = renderHook(() => useAutocompleteSuggestions('123 Main', 'US')) + + await waitForDebounce() + + expect(result.current.suggestions).toEqual([]) + expect(result.current.isLoading).toBe(false) + }) + + it('should reset session when resetSession is called', async () => { + const {result} = renderHook(() => useAutocompleteSuggestions('123 Main', 'US')) + + const mockResponse = { + suggestions: [ + { + placePrediction: { + text: {text: '123 Main St, New York, NY 10001, USA'}, + placeId: 'test-place-id' + } + } + ] + } + + mockAutocompleteSuggestion.fetchAutocompleteSuggestions.mockResolvedValue(mockResponse) + + await waitForDebounce() + + expect(result.current.suggestions).toHaveLength(1) + + act(() => { + result.current.resetSession() + }) + + expect(result.current.suggestions).toEqual([]) + expect(result.current.isLoading).toBe(false) + }) + + it('should not fetch suggestions when places library is not available', async () => { + useMapsLibrary.mockReturnValue(null) + + const {result} = renderHook(() => useAutocompleteSuggestions('123 Main', 'US')) + + await waitForDebounce() + + expect(mockAutocompleteSuggestion.fetchAutocompleteSuggestions).not.toHaveBeenCalled() + expect(result.current.suggestions).toEqual([]) + }) + + it('should not fetch suggestions when the places API is undefined', async () => { + useMapsLibrary.mockReturnValue(undefined) + + const {result} = renderHook(() => useAutocompleteSuggestions('123 Main', 'US')) + + await waitForDebounce() + + expect(mockAutocompleteSuggestion.fetchAutocompleteSuggestions).not.toHaveBeenCalled() + expect(result.current.suggestions).toEqual([]) + }) + + it('should debounce API calls', async () => { + const mockPlaces = { + AutocompleteSessionToken: jest.fn(() => ({})), + AutocompleteSuggestion: { + fetchAutocompleteSuggestions: + mockAutocompleteSuggestion.fetchAutocompleteSuggestions + } + } + useMapsLibrary.mockReturnValue(mockPlaces) + + const mockResponse = { + suggestions: [ + { + placePrediction: { + text: {text: '123 Main St, New York, NY 10001, USA'}, + placeId: 'test-place-id' + } + } + ] + } + + mockAutocompleteSuggestion.fetchAutocompleteSuggestions.mockResolvedValue(mockResponse) + + const {rerender} = renderHook( + ({input, country}) => useAutocompleteSuggestions(input, country), + {initialProps: {input: '123', country: 'US'}} + ) + + await act(async () => { + rerender({input: '1234', country: 'US'}) + }) + + await act(async () => { + rerender({input: '12345', country: 'US'}) + }) + + expect(mockAutocompleteSuggestion.fetchAutocompleteSuggestions).not.toHaveBeenCalled() + + await waitForDebounce() + + expect(mockAutocompleteSuggestion.fetchAutocompleteSuggestions).toHaveBeenCalledTimes(1) + expect(mockAutocompleteSuggestion.fetchAutocompleteSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + input: '12345' + }) + ) + }) +}) 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..848374b51f --- /dev/null +++ b/packages/template-retail-react-app/app/mocks/mock-address-suggestions.js @@ -0,0 +1,445 @@ +/* + * Copyright (c) 2025, 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 + */ + +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/pages/checkout/index.jsx b/packages/template-retail-react-app/app/pages/checkout/index.jsx index bf6f99b320..cbc300c7f3 100644 --- a/packages/template-retail-react-app/app/pages/checkout/index.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/index.jsx @@ -41,6 +41,7 @@ import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast' import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading-spinner' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {useMultiship} from '@salesforce/retail-react-app/app/hooks/use-multiship' +import {GoogleAPIProvider} from '@salesforce/retail-react-app/app/pages/checkout/util/google-api-provider' const Checkout = () => { const {formatMessage} = useIntl() @@ -253,8 +254,9 @@ const CheckoutContainer = () => { return ( {isDeletingUnavailableItem && } - - + + + ({ + useMapsLibrary: jest.fn(), + APIProvider: ({children}) => children +})) // This is a flaky test file! jest.retryTimes(5) @@ -512,6 +519,205 @@ test('Can proceed through checkout as registered customer', async () => { document.cookie = '' }) +test('Uses google address autocomplete when a platform provided google API key is provided by the shopper configuration API', async () => { + // Mock Google Maps API and useAutocompleteSuggestions hook + const mockFetchAutocompleteSuggestions = jest.fn() + const mockAutocompleteSuggestion = { + fetchAutocompleteSuggestions: mockFetchAutocompleteSuggestions + } + const mockAutocompleteSessionToken = jest.fn() + const mockPlaces = { + AutocompleteSessionToken: mockAutocompleteSessionToken, + AutocompleteSuggestion: mockAutocompleteSuggestion + } + + useMapsLibrary.mockReturnValue(mockPlaces) + + // Keep a *deep* copy of the initial mocked basket. Our mocked fetch responses will continuously + // update this object, which essentially mimics a saved basket on the backend. + let currentBasket = JSON.parse(JSON.stringify(scapiBasketWithItem)) + + // Set the default shipping method in the initial basket state + currentBasket.shipments[0].shippingMethod = defaultShippingMethod + + // Set up additional requests for intercepting/mocking for just this test. + global.server.use( + // mock adding guest email to basket + rest.put('*/baskets/:basketId/customer', (req, res, ctx) => { + currentBasket.customerInfo.email = 'test@test.com' + return res(ctx.json(currentBasket)) + }), + + rest.get('*/configuration/shopper-configurations/*/configurations', (req, res, ctx) => { + const configurations = { + configurations: [ + { + id: 'gcp', + value: 'platform-provided-key' + } + ] + } + return res(ctx.json(configurations)) + }), + + // mock add shipping and billing address to basket + rest.put('*/shipping-address', (req, res, ctx) => { + const shippingBillingAddress = { + address1: '123 Main St', + city: 'Tampa', + countryCode: 'US', + firstName: 'Tester', + fullName: 'Tester McTesting', + id: '047b18d4aaaf4138f693a4b931', + lastName: 'McTesting', + phone: '(727) 555-1234', + postalCode: '33610', + stateCode: 'FL' + } + currentBasket.shipments[0].shippingAddress = shippingBillingAddress + currentBasket.billingAddress = shippingBillingAddress + return res(ctx.json(currentBasket)) + }), + + // mock add billing address to basket + rest.put('*/billing-address', (req, res, ctx) => { + const shippingBillingAddress = { + address1: '123 Main St', + city: 'Tampa', + countryCode: 'US', + firstName: 'Tester', + fullName: 'Tester McTesting', + id: '047b18d4aaaf4138f693a4b931', + lastName: 'McTesting', + phone: '(727) 555-1234', + postalCode: '33610', + stateCode: 'FL' + } + currentBasket.shipments[0].shippingAddress = shippingBillingAddress + currentBasket.billingAddress = shippingBillingAddress + return res(ctx.json(currentBasket)) + }), + + // mock add shipping method + rest.put('*/shipments/me/shipping-method', (req, res, ctx) => { + currentBasket.shipments[0].shippingMethod = defaultShippingMethod + return res(ctx.json(currentBasket)) + }), + + // mock add payment instrument + rest.post('*/baskets/:basketId/payment-instruments', (req, res, ctx) => { + currentBasket.paymentInstruments = [ + { + amount: 0, + paymentCard: { + cardType: 'Visa', + creditCardExpired: false, + expirationMonth: 1, + expirationYear: 2040, + holder: 'Testy McTester', + maskedNumber: '************1111', + numberLastDigits: '1111', + validFromMonth: 1, + validFromYear: 2020 + }, + paymentInstrumentId: '875cae2724408c9a3eb45715ba', + paymentMethodId: 'CREDIT_CARD' + } + ] + return res(ctx.json(currentBasket)) + }), + + // mock place order + rest.post('*/orders', (req, res, ctx) => { + const response = { + ...currentBasket, + ...scapiOrderResponse, + customerInfo: {...scapiOrderResponse.customerInfo, email: 'customer@test.com'}, + status: 'created' + } + return res(ctx.json(response)) + }), + + rest.get('*/baskets', (req, res, ctx) => { + const baskets = { + baskets: [currentBasket], + total: 1 + } + return res(ctx.json(baskets)) + }) + ) + + // Set the initial browser router path and render our component tree. + window.history.pushState({}, 'Checkout', createPathWithDefaults('/checkout')) + const {user} = renderWithProviders(, { + wrapperProps: {isGuest: true, siteAlias: 'uk', appConfig: mockConfig.app} + }) + + // Wait for checkout to load and display first step + await screen.findByText(/checkout as guest/i) + + // Verify cart products display + await user.click(screen.getByText(/2 items in cart/i)) + expect(await screen.findByText(/Long Sleeve Crew Neck$/i)).toBeInTheDocument() + + // Verify password field is reset if customer toggles login form + const loginToggleButton = screen.getByText(/Already have an account\? Log in/i) + await user.click(loginToggleButton) + // Provide customer email and submit + const passwordInput = document.querySelector('input[type="password"]') + await user.type(passwordInput, 'Password1!') + + const checkoutAsGuestButton = screen.getByText(/Checkout as guest/i) + await user.click(checkoutAsGuestButton) + + // Provide customer email and submit + const emailInput = screen.getByLabelText(/email/i) + const submitBtn = screen.getByText(/checkout as guest/i) + await user.type(emailInput, 'test@test.com') + await user.click(submitBtn) + + // Wait for next step to render + await waitFor(() => { + expect(screen.getByTestId('sf-toggle-card-step-1-content')).not.toBeEmptyDOMElement() + }) + + // Email should be displayed in previous step summary + expect(screen.getByText('test@test.com')).toBeInTheDocument() + + // Shipping Address Form must be present + expect(screen.getByLabelText('Shipping Address Form')).toBeInTheDocument() + + // Fill out shipping address form and submit + await user.type(screen.getByLabelText(/first name/i), 'Tester') + await user.type(screen.getByLabelText(/last name/i), 'McTesting') + await user.type(screen.getByLabelText(/phone/i), '(727) 555-1234') + await user.type(screen.getAllByLabelText(/address/i)[0], '123 Main St') + await user.type(screen.getByLabelText(/city/i), 'Tampa') + await user.selectOptions(screen.getByLabelText(/state/i), ['FL']) + await user.type(screen.getByLabelText(/zip code/i), '33610') + await user.click(screen.getByText(/continue to shipping method/i)) + + // Wait for next step to render + await waitFor(() => { + expect(screen.getByTestId('sf-toggle-card-step-2-content')).not.toBeEmptyDOMElement() + expect(mockFetchAutocompleteSuggestions).toHaveBeenCalled() + }) + + // Shipping address displayed in previous step summary + const step1Content = within(screen.getByTestId('sf-toggle-card-step-1-content')) + expect(step1Content.getByText('Tester McTesting')).toBeInTheDocument() + expect(step1Content.getByText('123 Main St')).toBeInTheDocument() + expect(step1Content.getByText('Tampa, FL 33610')).toBeInTheDocument() + expect(step1Content.getByText('US')).toBeInTheDocument() + + // Applied shipping method should be displayed in previous step summary + expect(screen.getByText(defaultShippingMethod.name)).toBeInTheDocument() + + // Verify checkout container is present + expect(screen.getByTestId('sf-checkout-container')).toBeInTheDocument() + document.cookie = '' +}) + test('Can edit address during checkout as a registered customer', async () => { // Set the initial browser router path and render our component tree. window.history.pushState({}, 'Checkout', createPathWithDefaults('/checkout')) diff --git a/packages/template-retail-react-app/app/pages/checkout/util/checkout-context.js b/packages/template-retail-react-app/app/pages/checkout/util/checkout-context.js index 168bdc4485..d1d69acc15 100644 --- a/packages/template-retail-react-app/app/pages/checkout/util/checkout-context.js +++ b/packages/template-retail-react-app/app/pages/checkout/util/checkout-context.js @@ -11,12 +11,14 @@ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-cur import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {useConfigurations} from '@salesforce/commerce-sdk-react' const CheckoutContext = React.createContext() export const CheckoutProvider = ({children}) => { const {data: customer} = useCurrentCustomer() const {data: basket, derivedData, isLoading: isBasketLoading} = useCurrentBasket() + const {data: configurations} = useConfigurations() const einstein = useEinstein() const [step, setStep] = useState() const storeLocatorEnabled = getConfig()?.app?.storeLocatorEnabled ?? STORE_LOCATOR_IS_ENABLED @@ -105,7 +107,8 @@ export const CheckoutProvider = ({children}) => { step, STEPS, goToNextStep, - goToStep + goToStep, + configurations } return {children} diff --git a/packages/template-retail-react-app/app/pages/checkout/util/google-api-provider.js b/packages/template-retail-react-app/app/pages/checkout/util/google-api-provider.js new file mode 100644 index 0000000000..06f2b2f59b --- /dev/null +++ b/packages/template-retail-react-app/app/pages/checkout/util/google-api-provider.js @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025, Salesforce, 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 PropTypes from 'prop-types' +import {APIProvider} from '@vis.gl/react-google-maps' +import {useCheckout} from '@salesforce/retail-react-app/app/pages/checkout/util/checkout-context' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' + +/** + * Resolve the Google Cloud API key from the configurations + * User-defined environment variable (GOOGLE_CLOUD_API_KEY) always take precedence over the platform provided key + * If no custom key is set, the platform provided key is used (only if feature toggle is enabled in production) + */ +function resolveGoogleCloudAPIKey(configurations) { + const customKey = getConfig()?.app?.googleCloudAPI?.apiKey + if (customKey) { + return customKey + } + + const platformProvidedKey = configurations?.configurations?.find( + (config) => config.id === 'gcp' + )?.value + + return platformProvidedKey || null +} + +export const GoogleAPIProvider = ({children}) => { + const {configurations} = useCheckout() + const googleCloudAPIKey = resolveGoogleCloudAPIKey(configurations) + + return googleCloudAPIKey ? ( + {children} + ) : ( + children + ) +} + +GoogleAPIProvider.propTypes = { + googleCloudAPIKey: PropTypes.string, + children: PropTypes.node.isRequired +} diff --git a/packages/template-retail-react-app/app/pages/checkout/util/google-api-provider.test.js b/packages/template-retail-react-app/app/pages/checkout/util/google-api-provider.test.js new file mode 100644 index 0000000000..06496409b2 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/checkout/util/google-api-provider.test.js @@ -0,0 +1,395 @@ +/* + * Copyright (c) 2025, Salesforce, 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} from '@testing-library/react' +import {GoogleAPIProvider} from '@salesforce/retail-react-app/app/pages/checkout/util/google-api-provider' + +// Mock the dependencies +const mockUseCheckout = jest.fn() +const mockGetConfig = jest.fn() + +jest.mock('@salesforce/retail-react-app/app/pages/checkout/util/checkout-context', () => ({ + useCheckout: () => mockUseCheckout() +})) + +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ + getConfig: () => mockGetConfig() +})) + +jest.mock('@vis.gl/react-google-maps', () => ({ + // eslint-disable-next-line react/prop-types + APIProvider: ({children, apiKey}) => ( +
+ {children} +
+ ) +})) + +describe('GoogleAPIProvider', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.resetModules() + }) + + test('should render children directly when platform provided key is not present and no custom key', () => { + mockUseCheckout.mockReturnValue({ + configurations: { + configurations: [] + } + }) + + mockGetConfig.mockReturnValue({ + app: {} + }) + + const {getByTestId, queryByTestId} = render( + +
Test Child
+
+ ) + + expect(getByTestId('test-child')).toBeInTheDocument() + expect(queryByTestId('api-provider')).not.toBeInTheDocument() + }) + + test('should render children directly when configurations is undefined and no custom key', () => { + mockUseCheckout.mockReturnValue({ + configurations: undefined + }) + + mockGetConfig.mockReturnValue({ + app: {} + }) + + const {getByTestId, queryByTestId} = render( + +
Test Child
+
+ ) + + expect(getByTestId('test-child')).toBeInTheDocument() + expect(queryByTestId('api-provider')).not.toBeInTheDocument() + }) + + test('should render children directly when configurations is null and no custom key', () => { + mockUseCheckout.mockReturnValue({ + configurations: null + }) + + mockGetConfig.mockReturnValue({ + app: {} + }) + + const {getByTestId, queryByTestId} = render( + +
Test Child
+
+ ) + + expect(getByTestId('test-child')).toBeInTheDocument() + expect(queryByTestId('api-provider')).not.toBeInTheDocument() + }) + + test('should wrap children in APIProvider when platform provided key is available and no custom key is configured', () => { + mockUseCheckout.mockReturnValue({ + configurations: { + configurations: [ + { + id: 'gcp', + value: 'platform-provided-key' + } + ] + } + }) + + mockGetConfig.mockReturnValue({ + app: {} + }) + + const {getByTestId} = render( + +
Test Child
+
+ ) + + const apiProvider = getByTestId('api-provider') + expect(apiProvider).toBeInTheDocument() + expect(apiProvider).toHaveAttribute('data-api-key', 'platform-provided-key') + expect(getByTestId('test-child')).toBeInTheDocument() + }) + + test('should wrap children in APIProvider when platform provided key is available and custom key is not configured', () => { + mockUseCheckout.mockReturnValue({ + configurations: { + configurations: [ + { + id: 'gcp', + value: 'platform-provided-key' + } + ] + } + }) + + mockGetConfig.mockReturnValue({ + app: {} + }) + + const {getByTestId} = render( + +
Test Child
+
+ ) + + const apiProvider = getByTestId('api-provider') + expect(apiProvider).toBeInTheDocument() + expect(apiProvider).toHaveAttribute('data-api-key', 'platform-provided-key') + expect(getByTestId('test-child')).toBeInTheDocument() + }) + + test('should wrap children in APIProvider with platform key when custom key is empty string', () => { + mockUseCheckout.mockReturnValue({ + configurations: { + configurations: [ + { + id: 'gcp', + value: 'platform-provided-key' + } + ] + } + }) + + mockGetConfig.mockReturnValue({ + app: { + googleCloudAPI: { + apiKey: '' + } + } + }) + + const {getByTestId} = render( + +
Test Child
+
+ ) + + const apiProvider = getByTestId('api-provider') + expect(apiProvider).toBeInTheDocument() + expect(apiProvider).toHaveAttribute('data-api-key', 'platform-provided-key') + expect(getByTestId('test-child')).toBeInTheDocument() + }) + + test('should wrap children in APIProvider with platform key when custom key is null', () => { + mockUseCheckout.mockReturnValue({ + configurations: { + configurations: [ + { + id: 'gcp', + value: 'platform-provided-key' + } + ] + } + }) + + mockGetConfig.mockReturnValue({ + app: { + googleCloudAPI: { + apiKey: null + } + } + }) + + const {getByTestId} = render( + +
Test Child
+
+ ) + + const apiProvider = getByTestId('api-provider') + expect(apiProvider).toBeInTheDocument() + expect(apiProvider).toHaveAttribute('data-api-key', 'platform-provided-key') + expect(getByTestId('test-child')).toBeInTheDocument() + }) + + test('should wrap children in APIProvider with platform key when custom key is undefined', () => { + mockUseCheckout.mockReturnValue({ + configurations: { + configurations: [ + { + id: 'gcp', + value: 'platform-provided-key' + } + ] + } + }) + + mockGetConfig.mockReturnValue({ + app: { + googleCloudAPI: {} + } + }) + + const {getByTestId} = render( + +
Test Child
+
+ ) + + const apiProvider = getByTestId('api-provider') + expect(apiProvider).toBeInTheDocument() + expect(apiProvider).toHaveAttribute('data-api-key', 'platform-provided-key') + expect(getByTestId('test-child')).toBeInTheDocument() + }) + + test('should wrap children in APIProvider with custom key when custom key is configured', () => { + mockUseCheckout.mockReturnValue({ + configurations: { + configurations: [ + { + id: 'gcp', + value: 'platform-provided-key' + } + ] + } + }) + + mockGetConfig.mockReturnValue({ + app: { + googleCloudAPI: { + apiKey: 'custom-api-key' + } + } + }) + + const {getByTestId} = render( + +
Test Child
+
+ ) + + const apiProvider = getByTestId('api-provider') + expect(apiProvider).toBeInTheDocument() + expect(apiProvider).toHaveAttribute('data-api-key', 'custom-api-key') + expect(getByTestId('test-child')).toBeInTheDocument() + }) + + test('should wrap children in APIProvider with custom key when custom key is configured and platform key is not present', () => { + mockUseCheckout.mockReturnValue({ + configurations: { + configurations: [] + } + }) + + mockGetConfig.mockReturnValue({ + app: { + googleCloudAPI: { + apiKey: 'custom-api-key' + } + } + }) + + const {getByTestId} = render( + +
Test Child
+
+ ) + + const apiProvider = getByTestId('api-provider') + expect(apiProvider).toBeInTheDocument() + expect(apiProvider).toHaveAttribute('data-api-key', 'custom-api-key') + expect(getByTestId('test-child')).toBeInTheDocument() + }) + + test('should wrap children in APIProvider with custom key when custom key is configured and configurations is undefined', () => { + mockUseCheckout.mockReturnValue({ + configurations: undefined + }) + + mockGetConfig.mockReturnValue({ + app: { + googleCloudAPI: { + apiKey: 'custom-api-key' + } + } + }) + + const {getByTestId} = render( + +
Test Child
+
+ ) + + const apiProvider = getByTestId('api-provider') + expect(apiProvider).toBeInTheDocument() + expect(apiProvider).toHaveAttribute('data-api-key', 'custom-api-key') + expect(getByTestId('test-child')).toBeInTheDocument() + }) + + test('should wrap children in APIProvider with custom key when custom key is configured and configurations is null', () => { + mockUseCheckout.mockReturnValue({ + configurations: null + }) + + mockGetConfig.mockReturnValue({ + app: { + googleCloudAPI: { + apiKey: 'custom-api-key' + } + } + }) + + const {getByTestId} = render( + +
Test Child
+
+ ) + + const apiProvider = getByTestId('api-provider') + expect(apiProvider).toBeInTheDocument() + expect(apiProvider).toHaveAttribute('data-api-key', 'custom-api-key') + expect(getByTestId('test-child')).toBeInTheDocument() + }) + + test('should handle multiple configurations and find the correct gcp one', () => { + mockUseCheckout.mockReturnValue({ + configurations: { + configurations: [ + { + id: 'other-config', + value: 'other-value' + }, + { + id: 'gcp', + value: 'platform-provided-key' + }, + { + id: 'another-config', + value: 'another-value' + } + ] + } + }) + + mockGetConfig.mockReturnValue({ + app: {} + }) + + const {getByTestId} = render( + +
Test Child
+
+ ) + + const apiProvider = getByTestId('api-provider') + expect(apiProvider).toBeInTheDocument() + expect(apiProvider).toHaveAttribute('data-api-key', 'platform-provided-key') + expect(getByTestId('test-child')).toBeInTheDocument() + }) +}) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 4d39d3fa29..98bb6b9aee 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -359,13 +359,17 @@ const {handler} = runtime.createHandler(options, (app) => { ], 'script-src': [ // Used by the service worker in /worker/main.js - 'storage.googleapis.com' + 'storage.googleapis.com', + 'maps.googleapis.com', + 'places.googleapis.com' ], 'connect-src': [ // Connect to Einstein APIs 'api.cquotient.com', // Connect to DataCloud APIs '*.c360a.salesforce.com', + 'maps.googleapis.com', + 'places.googleapis.com', // Connect to SCRT2 URLs '*.salesforce-scrt.com' ], diff --git a/packages/template-retail-react-app/app/static/img/GoogleMaps_Logo_Gray_4x.png b/packages/template-retail-react-app/app/static/img/GoogleMaps_Logo_Gray_4x.png new file mode 100644 index 0000000000..005857e92d Binary files /dev/null and b/packages/template-retail-react-app/app/static/img/GoogleMaps_Logo_Gray_4x.png differ diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index 6243b8e50b..d7f06f9b88 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -407,6 +407,12 @@ "value": "You Might Also Like" } ], + "addressSuggestionDropdown.suggested": [ + { + "type": 0, + "value": "SUGGESTED" + } + ], "auth_modal.button.close.assistive_msg": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index 6243b8e50b..d7f06f9b88 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -407,6 +407,12 @@ "value": "You Might Also Like" } ], + "addressSuggestionDropdown.suggested": [ + { + "type": 0, + "value": "SUGGESTED" + } + ], "auth_modal.button.close.assistive_msg": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index 816d66b4c8..c206e3f9df 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -839,6 +839,20 @@ "value": "]" } ], + "addressSuggestionDropdown.suggested": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "ŞŬƓƓḖŞŦḖḒ" + }, + { + "type": 0, + "value": "]" + } + ], "auth_modal.button.close.assistive_msg": [ { "type": 0, 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..40d0218582 --- /dev/null +++ b/packages/template-retail-react-app/app/utils/address-suggestions.js @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2025, 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 + */ + +const COUNTRY_CODE_MAP = { + USA: 'US', + Canada: 'CA' +} + +/** + * Resolve country code from country name + * @param {string} countryName - Full country name from address + * @returns {string} Standardized country code + */ +const resolveCountryCode = (countryName) => { + return COUNTRY_CODE_MAP[countryName] || countryName +} + +/** + * Convert Google Maps API suggestions to our expected format + * @param {Array} suggestions - Array of suggestions from Google Maps API + * @returns {Array} Converted suggestions in our expected format + */ +export const convertGoogleMapsSuggestions = (suggestions) => { + return suggestions.map((suggestion) => ({ + description: suggestion.placePrediction.text.text, + place_id: suggestion.placePrediction.placeId, + structured_formatting: { + main_text: + suggestion.placePrediction.text.text.split(',')[0] || + suggestion.placePrediction.text.text, + secondary_text: suggestion.placePrediction.text.text + .split(',') + .slice(1) + .join(',') + .trim() + }, + terms: suggestion.placePrediction.text.text + .split(',') + .map((term) => ({value: term.trim()})), + placePrediction: suggestion.placePrediction + })) +} + +/** + * 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 = async (suggestion) => { + const {structured_formatting, terms} = suggestion + const {main_text, secondary_text} = structured_formatting + + const parsedFields = { + address1: main_text + } + + const countryTerm = terms[terms.length - 1]?.value || '' + parsedFields.countryCode = resolveCountryCode(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 +} + +/** + * Extract address fields from Google Maps place and return structured object + * @param {Object} place - Google Maps place object + * @returns {Promise} Structured address fields + */ +export const extractAddressFieldsFromPlace = async (place) => { + await place.fetchFields({ + fields: ['formattedAddress'] + }) + + const formattedAddress = place.formattedAddress || '' + + // Parse the formatted address to extract individual fields + return parseFormattedAddress(formattedAddress) +} + +/** + * Parse formatted address string to extract individual address fields + * @param {string} formattedAddress - Full formatted address string + * @returns {Object} Structured address fields following adr microformat + */ +export const parseFormattedAddress = (formattedAddress) => { + if (!formattedAddress) { + return {address1: ''} + } + + // Split by comma + const parts = formattedAddress.split(',').map((part) => part.trim()) + + // Initialize with microformat structure following adr specification + const addressFields = { + 'street-address': parts[0] || '', // street-address (adr microformat) + locality: '', // locality (adr microformat) + region: '', // region (adr microformat) + 'postal-code': '', // postal-code (adr microformat) + 'country-name': '' // country-name (adr microformat) + } + + // Map parts to microformat fields based on adr specification + if (parts.length >= 4) { + // Format: "123 Main St, New York, NY 10001, USA" OR "123 Main St, New York, CA, USA" + addressFields['locality'] = parts[1] // City + const statePostalPart = parts[2] + const statePostalSplit = statePostalPart.split(' ') + + if (statePostalSplit.length >= 2) { + // Has both state and postal code + addressFields['region'] = statePostalSplit[0] // State (first part) + addressFields['postal-code'] = statePostalSplit.slice(1).join(' ') // Postal code + } else { + // Just state code + addressFields['region'] = statePostalPart + } + addressFields['country-name'] = parts[3] + } else if (parts.length === 3) { + // Format: "123 Main St, New York, NY" or "123 Main St, New York, USA" + addressFields['locality'] = parts[1] + const lastPart = parts[2] + + if (COUNTRY_CODE_MAP[lastPart]) { + addressFields['country-name'] = lastPart + } else { + // Parse state and postal code + const statePostalSplit = lastPart.split(' ') + if (statePostalSplit.length >= 2) { + addressFields['region'] = statePostalSplit[0] + addressFields['postal-code'] = statePostalSplit.slice(1).join(' ') + } else { + addressFields['region'] = lastPart + } + } + } else if (parts.length === 2) { + // Format: "123 Main St, New York" + addressFields['locality'] = parts[1] + } + + // Convert microformat fields to expected format + return { + address1: addressFields['street-address'], + city: addressFields['locality'], + stateCode: addressFields['region'], + postalCode: addressFields['postal-code'], + countryCode: resolveCountryCode(addressFields['country-name']) + } +} + +/** + * Set address field values in form + * @param {Function} setValue - Form setValue function + * @param {string} prefix - Field prefix + * @param {Object} addressFields - Address fields object + */ +export const setAddressFieldValues = (setValue, prefix, addressFields) => { + setValue(`${prefix}address1`, addressFields.address1) + if (addressFields.city) { + setValue(`${prefix}city`, addressFields.city) + } + if (addressFields.stateCode) { + setValue(`${prefix}stateCode`, addressFields.stateCode) + } + if (addressFields.postalCode) { + setValue(`${prefix}postalCode`, addressFields.postalCode) + } + if (addressFields.countryCode) { + setValue(`${prefix}countryCode`, addressFields.countryCode) + } +} + +/** + * Process address suggestion and extract structured address fields + * This unified method handles both placePrediction.toPlace() and fallback scenarios + * @param {Object} suggestion - Address suggestion object from the API + * @returns {Promise} Structured address fields + */ +export const processAddressSuggestion = async (suggestion) => { + let addressFields + + // If we have the placePrediction, get detailed place information using toPlace() + if (suggestion.placePrediction) { + const place = suggestion.placePrediction.toPlace() + addressFields = await extractAddressFieldsFromPlace(place) + } else { + // Fallback to parsing from structured_formatting when placePrediction is not available + addressFields = await parseAddressSuggestion(suggestion) + } + + return addressFields +} diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index cf172df1e1..7cf202a3db 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -79,7 +79,10 @@ module.exports = { } }, storeLocatorEnabled: true, - multishipEnabled: true + multishipEnabled: true, + googleCloudAPI: { + apiKey: process.env.GOOGLE_CLOUD_API_KEY + } }, envBasePath: '/', externals: [], diff --git a/packages/template-retail-react-app/package-lock.json b/packages/template-retail-react-app/package-lock.json index ccfd5d226f..465fd2a578 100644 --- a/packages/template-retail-react-app/package-lock.json +++ b/packages/template-retail-react-app/package-lock.json @@ -2571,6 +2571,12 @@ "@types/ms": "*" } }, + "node_modules/@types/google.maps": { + "version": "3.58.1", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", + "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", + "license": "MIT" + }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", @@ -2741,6 +2747,20 @@ "@types/node": "*" } }, + "node_modules/@vis.gl/react-google-maps": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.5.4.tgz", + "integrity": "sha512-pD3e2wDtOfd439mamkacRgrM6I2B/lue61QCR0pGQT8MVaG9pz9/LajHbsjZW2lms8Ao8mf2PQJeiGC2FxI0Fw==", + "license": "MIT", + "dependencies": { + "@types/google.maps": "^3.54.10", + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "react": ">=16.8.0 || ^19.0 || ^19.0.0-rc", + "react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -4350,6 +4370,12 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index 7a51362e35..c1616dd759 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -56,6 +56,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "14.4.3", + "@vis.gl/react-google-maps": "^1.5.4", "babel-plugin-module-resolver": "5.0.2", "base64-arraybuffer": "^0.2.0", "bundlesize2": "^0.0.35", @@ -104,7 +105,7 @@ }, { "path": "build/vendor.js", - "maxSize": "335 kB" + "maxSize": "340 kB" } ] } diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index 9338201cea..207e2ff4fe 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -162,6 +162,9 @@ "add_to_cart_modal.recommended_products.title.might_also_like": { "defaultMessage": "You Might Also Like" }, + "addressSuggestionDropdown.suggested": { + "defaultMessage": "SUGGESTED" + }, "auth_modal.button.close.assistive_msg": { "defaultMessage": "Close login form" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 9338201cea..207e2ff4fe 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -162,6 +162,9 @@ "add_to_cart_modal.recommended_products.title.might_also_like": { "defaultMessage": "You Might Also Like" }, + "addressSuggestionDropdown.suggested": { + "defaultMessage": "SUGGESTED" + }, "auth_modal.button.close.assistive_msg": { "defaultMessage": "Close login form" },