diff --git a/packages/template-retail-react-app/app/components/_app-config/index.jsx b/packages/template-retail-react-app/app/components/_app-config/index.jsx index e28be6db6a..242c95a60e 100644 --- a/packages/template-retail-react-app/app/components/_app-config/index.jsx +++ b/packages/template-retail-react-app/app/components/_app-config/index.jsx @@ -21,7 +21,7 @@ import {ChakraProvider} from '@salesforce/retail-react-app/app/components/shared import 'focus-visible/dist/focus-visible' import theme from '@salesforce/retail-react-app/app/theme' -import {MultiSiteProvider} from '@salesforce/retail-react-app/app/contexts' +import {MultiSiteProvider, StoreLocatorProvider} from '@salesforce/retail-react-app/app/contexts' import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin' import { resolveSiteFromUrl, @@ -35,7 +35,17 @@ import {CommerceApiProvider} from '@salesforce/commerce-sdk-react' import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/components/with-react-query' import {useCorrelationId} from '@salesforce/pwa-kit-react-sdk/ssr/universal/hooks' import {ReactQueryDevtools} from '@tanstack/react-query-devtools' -import {DEFAULT_DNT_STATE} from '@salesforce/retail-react-app/app/constants' +import { + DEFAULT_DNT_STATE, + STORE_LOCATOR_RADIUS, + STORE_LOCATOR_RADIUS_UNIT, + STORE_LOCATOR_DEFAULT_COUNTRY, + STORE_LOCATOR_DEFAULT_COUNTRY_CODE, + STORE_LOCATOR_DEFAULT_POSTAL_CODE, + STORE_LOCATOR_DEFAULT_PAGE_SIZE, + STORE_LOCATOR_SUPPORTED_COUNTRIES +} from '@salesforce/retail-react-app/app/constants' + /** * Use the AppConfig component to inject extra arguments into the getProps * methods for all Route Components in the app – typically you'd want to do this @@ -56,6 +66,16 @@ const AppConfig = ({children, locals = {}}) => { const passwordlessCallback = locals.appConfig.login?.passwordless?.callbackURI + const storeLocatorConfig = { + radius: STORE_LOCATOR_RADIUS, + radiusUnit: STORE_LOCATOR_RADIUS_UNIT, + defaultCountry: STORE_LOCATOR_DEFAULT_COUNTRY, + defaultCountryCode: STORE_LOCATOR_DEFAULT_COUNTRY_CODE, + defaultPostalCode: STORE_LOCATOR_DEFAULT_POSTAL_CODE, + defaultPageSize: STORE_LOCATOR_DEFAULT_PAGE_SIZE, + supportedCountries: STORE_LOCATOR_SUPPORTED_COUNTRIES + } + return ( { logger={createLogger({packageName: 'commerce-sdk-react'})} > - {children} + + {children} + diff --git a/packages/template-retail-react-app/app/components/_app/index.jsx b/packages/template-retail-react-app/app/components/_app/index.jsx index a841ef4930..6745dfb92e 100644 --- a/packages/template-retail-react-app/app/components/_app/index.jsx +++ b/packages/template-retail-react-app/app/components/_app/index.jsx @@ -46,7 +46,7 @@ import {DrawerMenu} from '@salesforce/retail-react-app/app/components/drawer-men import {ListMenu, ListMenuContent} from '@salesforce/retail-react-app/app/components/list-menu' import {HideOnDesktop, HideOnMobile} from '@salesforce/retail-react-app/app/components/responsive' import AboveHeader from '@salesforce/retail-react-app/app/components/_app/partials/above-header' -import StoreLocatorModal from '@salesforce/retail-react-app/app/components/store-locator-modal' +import {StoreLocatorModal} from '@salesforce/retail-react-app/app/components/store-locator' // Hooks import {AuthModal, useAuthModal} from '@salesforce/retail-react-app/app/hooks/use-auth-modal' import { diff --git a/packages/template-retail-react-app/app/components/store-locator-modal/index.jsx b/packages/template-retail-react-app/app/components/store-locator-modal/index.jsx deleted file mode 100644 index 8bb5920cbb..0000000000 --- a/packages/template-retail-react-app/app/components/store-locator-modal/index.jsx +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import React, {useState, createContext} from 'react' -import PropTypes from 'prop-types' - -// Components -import { - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - useBreakpointValue -} from '@salesforce/retail-react-app/app/components/shared/ui' -import StoreLocatorContent from '@salesforce/retail-react-app/app/components/store-locator-modal/store-locator-content' - -// Others -import { - DEFAULT_STORE_LOCATOR_COUNTRY, - DEFAULT_STORE_LOCATOR_POSTAL_CODE, - STORE_LOCATOR_NUM_STORES_PER_LOAD -} from '@salesforce/retail-react-app/app/constants' - -export const StoreLocatorContext = createContext() -export const useStoreLocator = () => { - const [userHasSetManualGeolocation, setUserHasSetManualGeolocation] = useState(false) - const [automaticGeolocationHasFailed, setAutomaticGeolocationHasFailed] = useState(false) - const [userWantsToShareLocation, setUserWantsToShareLocation] = useState(false) - - const [searchStoresParams, setSearchStoresParams] = useState({ - countryCode: DEFAULT_STORE_LOCATOR_COUNTRY.countryCode, - postalCode: DEFAULT_STORE_LOCATOR_POSTAL_CODE, - limit: STORE_LOCATOR_NUM_STORES_PER_LOAD - }) - - return { - userHasSetManualGeolocation, - setUserHasSetManualGeolocation, - automaticGeolocationHasFailed, - setAutomaticGeolocationHasFailed, - userWantsToShareLocation, - setUserWantsToShareLocation, - searchStoresParams, - setSearchStoresParams - } -} - -const StoreLocatorModal = ({isOpen, onClose}) => { - const storeLocator = useStoreLocator() - const isDesktopView = useBreakpointValue({base: false, lg: true}) - - return ( - - {isDesktopView ? ( - - - - - - - - - ) : ( - - - - - - - - - )} - - ) -} - -StoreLocatorModal.propTypes = { - isOpen: PropTypes.bool, - onClose: PropTypes.func -} - -export default StoreLocatorModal diff --git a/packages/template-retail-react-app/app/components/store-locator-modal/index.test.jsx b/packages/template-retail-react-app/app/components/store-locator-modal/index.test.jsx deleted file mode 100644 index 2fadf4cb89..0000000000 --- a/packages/template-retail-react-app/app/components/store-locator-modal/index.test.jsx +++ /dev/null @@ -1,212 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import React from 'react' -import StoreLocatorModal from '@salesforce/retail-react-app/app/components/store-locator-modal/index' -import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' -import {rest} from 'msw' - -const mockStoresData = [ - { - address1: '162 University Ave', - city: 'Palo Alto', - countryCode: 'US', - distance: 0.0, - distanceUnit: 'km', - id: '00041', - latitude: 37.189396, - longitude: -121.705327, - name: 'Palo Alto Store', - posEnabled: false, - postalCode: '94301', - stateCode: 'CA', - storeHours: 'THIS IS ENGLISH STORE HOURS', - storeLocatorEnabled: true, - c_countryCodeValue: 'US' - }, - { - address1: 'Holstenstraße 1', - city: 'Kiel', - countryCode: 'DE', - distance: 8847.61, - distanceUnit: 'km', - id: '00031', - inventoryId: 'inventory_m_store_store23', - latitude: 54.3233, - longitude: 10.1394, - name: 'Kiel Electronics Store', - phone: '+49 431 123456', - posEnabled: false, - postalCode: '24103', - storeHours: - 'Monday 9 AM–7 PM\nTuesday 9 AM–7 PM\nWednesday 9 AM–7 PM\nThursday 9 AM–8 PM\nFriday 9 AM–7 PM\nSaturday 9 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Heiligengeiststraße 2', - city: 'Oldenburg', - countryCode: 'DE', - distance: 8873.75, - distanceUnit: 'km', - id: '00036', - inventoryId: 'inventory_m_store_store28', - latitude: 53.1445, - longitude: 8.2146, - name: 'Oldenburg Tech Depot', - phone: '+49 441 876543', - posEnabled: false, - postalCode: '26121', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Obernstraße 2', - city: 'Bremen', - countryCode: 'DE', - distance: 8904.18, - distanceUnit: 'km', - id: '00011', - inventoryId: 'inventory_m_store_store2', - latitude: 53.0765, - longitude: 8.8085, - name: 'Bremen Tech Store', - phone: '+49 421 234567', - posEnabled: false, - postalCode: '28195', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Sögestraße 40', - city: 'Bremen', - countryCode: 'DE', - distance: 8904.19, - distanceUnit: 'km', - id: '00026', - inventoryId: 'inventory_m_store_store18', - latitude: 53.0758, - longitude: 8.8072, - name: 'Bremen Tech World', - phone: '+49 421 567890', - posEnabled: false, - postalCode: '28195', - storeHours: - 'Monday 9 AM–8 PM\nTuesday 9 AM–8 PM\nWednesday 9 AM–8 PM\nThursday 9 AM–9 PM\nFriday 9 AM–8 PM\nSaturday 9 AM–7 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Jungfernstieg 12', - city: 'Hamburg', - countryCode: 'DE', - distance: 8910.05, - distanceUnit: 'km', - id: '00005', - inventoryId: 'inventory_m_store_store5', - latitude: 53.553405, - longitude: 9.992196, - name: 'Hamburg Electronics Outlet', - phone: '+49 40 444444444', - posEnabled: false, - postalCode: '20354', - storeHours: - 'Monday 10 AM–8 PM\nTuesday 10 AM–8 PM\nWednesday 10 AM–8 PM\nThursday 10 AM–9 PM\nFriday 10 AM–8 PM\nSaturday 10 AM–7 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Große Straße 40', - city: 'Osnabrück', - countryCode: 'DE', - distance: 8942.1, - distanceUnit: 'km', - id: '00037', - inventoryId: 'inventory_m_store_store29', - latitude: 52.2799, - longitude: 8.0472, - name: 'Osnabrück Tech Mart', - phone: '+49 541 654321', - posEnabled: false, - postalCode: '49074', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Kröpeliner Straße 48', - city: 'Rostock', - countryCode: 'DE', - distance: 8945.47, - distanceUnit: 'km', - id: '00032', - inventoryId: 'inventory_m_store_store24', - latitude: 54.0899, - longitude: 12.1349, - name: 'Rostock Tech Store', - phone: '+49 381 234567', - posEnabled: false, - postalCode: '18055', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Kennedyplatz 7', - city: 'Essen', - countryCode: 'DE', - distance: 8969.09, - distanceUnit: 'km', - id: '00013', - inventoryId: 'inventory_m_store_store4', - latitude: 51.4566, - longitude: 7.0125, - name: 'Essen Electronics Depot', - phone: '+49 201 456789', - posEnabled: false, - postalCode: '45127', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Kettwiger Straße 17', - city: 'Essen', - countryCode: 'DE', - distance: 8969.13, - distanceUnit: 'km', - id: '00030', - inventoryId: 'inventory_m_store_store22', - latitude: 51.4556, - longitude: 7.0116, - name: 'Essen Tech Hub', - phone: '+49 201 654321', - posEnabled: false, - postalCode: '45127', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - } -] -const mockStores = { - limit: 10, - data: mockStoresData, - offset: 0, - total: 30 -} - -describe('StoreLocatorModal', () => { - test('renders without crashing', () => { - global.server.use( - rest.get('*/shopper-stores/v1/organizations/*', (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(200), ctx.json(mockStores)) - }) - ) - expect(() => { - renderWithProviders() - }).not.toThrow() - }) -}) diff --git a/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-content.jsx b/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-content.jsx deleted file mode 100644 index 10bf5b80f2..0000000000 --- a/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-content.jsx +++ /dev/null @@ -1,213 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import React, {useState, useContext} from 'react' -import {useIntl} from 'react-intl' - -// Components -import { - Heading, - Accordion, - AccordionItem, - Box, - Button -} from '@salesforce/retail-react-app/app/components/shared/ui' -import StoresList from '@salesforce/retail-react-app/app/components/store-locator-modal/stores-list' -import StoreLocatorInput from '@salesforce/retail-react-app/app/components/store-locator-modal/store-locator-input' - -// Others -import { - SUPPORTED_STORE_LOCATOR_COUNTRIES, - DEFAULT_STORE_LOCATOR_COUNTRY, - STORE_LOCATOR_DISTANCE, - STORE_LOCATOR_NUM_STORES_PER_LOAD, - STORE_LOCATOR_DISTANCE_UNIT -} from '@salesforce/retail-react-app/app/constants' - -//This is an API limit and is therefore not configurable -const NUM_STORES_PER_REQUEST_API_MAX = 200 - -// Hooks -import {useSearchStores} from '@salesforce/commerce-sdk-react' -import {useForm} from 'react-hook-form' - -import {StoreLocatorContext} from '@salesforce/retail-react-app/app/components/store-locator-modal/index' - -const StoreLocatorContent = () => { - const { - searchStoresParams, - setSearchStoresParams, - userHasSetManualGeolocation, - setUserHasSetManualGeolocation - } = useContext(StoreLocatorContext) - const {countryCode, postalCode, latitude, longitude, limit} = searchStoresParams - const intl = useIntl() - const form = useForm({ - mode: 'onChange', - reValidateMode: 'onChange', - defaultValues: { - countryCode: userHasSetManualGeolocation ? countryCode : '', - postalCode: userHasSetManualGeolocation ? postalCode : '' - } - }) - - const [numStoresToShow, setNumStoresToShow] = useState(limit) - // Either the countryCode & postalCode or latitude & longitude are defined, never both - const { - data: searchStoresData, - isLoading, - refetch, - isFetching - } = useSearchStores({ - parameters: { - countryCode: countryCode, - postalCode: postalCode, - latitude: latitude, - longitude: longitude, - locale: intl.locale, - maxDistance: STORE_LOCATOR_DISTANCE, - limit: NUM_STORES_PER_REQUEST_API_MAX, - distanceUnit: STORE_LOCATOR_DISTANCE_UNIT - } - }) - - const storesInfo = - isLoading || isFetching - ? undefined - : searchStoresData?.data?.slice(0, numStoresToShow) || [] - const numStores = searchStoresData?.total || 0 - - const submitForm = async (formData) => { - const {postalCode, countryCode} = formData - if (postalCode !== '') { - if (countryCode !== '') { - setSearchStoresParams({ - postalCode: postalCode, - countryCode: countryCode, - limit: STORE_LOCATOR_NUM_STORES_PER_LOAD - }) - setUserHasSetManualGeolocation(true) - } else { - if (SUPPORTED_STORE_LOCATOR_COUNTRIES.length === 0) { - setSearchStoresParams({ - postalCode: postalCode, - countryCode: DEFAULT_STORE_LOCATOR_COUNTRY.countryCode, - limit: STORE_LOCATOR_NUM_STORES_PER_LOAD - }) - setUserHasSetManualGeolocation(true) - } - } - } - // Reset the number of stores in the UI - setNumStoresToShow(STORE_LOCATOR_NUM_STORES_PER_LOAD) - - // Ensures API call is made regardless of caching to provide UX feedback on click - refetch() - } - - const displayStoreLocatorStatusMessage = () => { - if (storesInfo === undefined) - return intl.formatMessage({ - id: 'store_locator.description.loading_locations', - defaultMessage: 'Loading locations...' - }) - if (storesInfo.length === 0) - return intl.formatMessage({ - id: 'store_locator.description.no_locations', - defaultMessage: 'Sorry, there are no locations in this area' - }) - if (searchStoresParams.postalCode !== undefined) - return `${intl.formatMessage( - { - id: 'store_locator.description.viewing_near_postal_code', - defaultMessage: - 'Viewing stores within {distance}{distanceUnit} of {postalCode} in ' - }, - { - distance: STORE_LOCATOR_DISTANCE, - distanceUnit: STORE_LOCATOR_DISTANCE_UNIT, - postalCode: searchStoresParams.postalCode - } - )} - ${ - SUPPORTED_STORE_LOCATOR_COUNTRIES.length !== 0 - ? intl.formatMessage( - SUPPORTED_STORE_LOCATOR_COUNTRIES.find( - (o) => o.countryCode === searchStoresParams.countryCode - ).countryName - ) - : intl.formatMessage(DEFAULT_STORE_LOCATOR_COUNTRY.countryName) - }` - else - return intl.formatMessage({ - id: 'store_locator.description.viewing_near_your_location', - defaultMessage: 'Viewing stores near your location' - }) - } - - return ( - <> - - {intl.formatMessage({ - id: 'store_locator.title', - defaultMessage: 'Find a Store' - })} - - - - {/* Details */} - - - {displayStoreLocatorStatusMessage()} - - - - - {!isFetching && - numStoresToShow < numStores && - numStoresToShow < NUM_STORES_PER_REQUEST_API_MAX ? ( - - - - ) : ( - '' - )} - - ) -} - -StoreLocatorContent.propTypes = {} - -export default StoreLocatorContent diff --git a/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-content.test.jsx b/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-content.test.jsx deleted file mode 100644 index 38e8c8458c..0000000000 --- a/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-content.test.jsx +++ /dev/null @@ -1,399 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import React, {useEffect} from 'react' -import StoreLocatorContent from '@salesforce/retail-react-app/app/components/store-locator-modal/store-locator-content' -import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' -import {waitFor, screen} from '@testing-library/react' -import PropTypes from 'prop-types' -import {STORE_LOCATOR_NUM_STORES_PER_LOAD} from '@salesforce/retail-react-app/app/constants' -import {rest} from 'msw' -import {StoreLocatorContext} from '@salesforce/retail-react-app/app/components/store-locator-modal/index' -import {useStoreLocator} from '@salesforce/retail-react-app/app/components/store-locator-modal/index' -const mockStoresData = [ - { - address1: '162 University Ave', - city: 'Palo Alto', - countryCode: 'US', - distance: 0.0, - distanceUnit: 'km', - id: '00041', - latitude: 37.189396, - longitude: -121.705327, - name: 'Palo Alto Store', - posEnabled: false, - postalCode: '94301', - stateCode: 'CA', - storeHours: 'THIS IS ENGLISH STORE HOURS', - storeLocatorEnabled: true, - c_countryCodeValue: 'US' - }, - { - address1: 'Holstenstraße 1', - city: 'Kiel', - countryCode: 'DE', - distance: 8847.61, - distanceUnit: 'km', - id: '00031', - inventoryId: 'inventory_m_store_store23', - latitude: 54.3233, - longitude: 10.1394, - name: 'Kiel Electronics Store', - phone: '+49 431 123456', - posEnabled: false, - postalCode: '24103', - storeHours: - 'Monday 9 AM–7 PM\nTuesday 9 AM–7 PM\nWednesday 9 AM–7 PM\nThursday 9 AM–8 PM\nFriday 9 AM–7 PM\nSaturday 9 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Heiligengeiststraße 2', - city: 'Oldenburg', - countryCode: 'DE', - distance: 8873.75, - distanceUnit: 'km', - id: '00036', - inventoryId: 'inventory_m_store_store28', - latitude: 53.1445, - longitude: 8.2146, - name: 'Oldenburg Tech Depot', - phone: '+49 441 876543', - posEnabled: false, - postalCode: '26121', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Obernstraße 2', - city: 'Bremen', - countryCode: 'DE', - distance: 8904.18, - distanceUnit: 'km', - id: '00011', - inventoryId: 'inventory_m_store_store2', - latitude: 53.0765, - longitude: 8.8085, - name: 'Bremen Tech Store', - phone: '+49 421 234567', - posEnabled: false, - postalCode: '28195', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Sögestraße 40', - city: 'Bremen', - countryCode: 'DE', - distance: 8904.19, - distanceUnit: 'km', - id: '00026', - inventoryId: 'inventory_m_store_store18', - latitude: 53.0758, - longitude: 8.8072, - name: 'Bremen Tech World', - phone: '+49 421 567890', - posEnabled: false, - postalCode: '28195', - storeHours: - 'Monday 9 AM–8 PM\nTuesday 9 AM–8 PM\nWednesday 9 AM–8 PM\nThursday 9 AM–9 PM\nFriday 9 AM–8 PM\nSaturday 9 AM–7 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Jungfernstieg 12', - city: 'Hamburg', - countryCode: 'DE', - distance: 8910.05, - distanceUnit: 'km', - id: '00005', - inventoryId: 'inventory_m_store_store5', - latitude: 53.553405, - longitude: 9.992196, - name: 'Hamburg Electronics Outlet', - phone: '+49 40 444444444', - posEnabled: false, - postalCode: '20354', - storeHours: - 'Monday 10 AM–8 PM\nTuesday 10 AM–8 PM\nWednesday 10 AM–8 PM\nThursday 10 AM–9 PM\nFriday 10 AM–8 PM\nSaturday 10 AM–7 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Große Straße 40', - city: 'Osnabrück', - countryCode: 'DE', - distance: 8942.1, - distanceUnit: 'km', - id: '00037', - inventoryId: 'inventory_m_store_store29', - latitude: 52.2799, - longitude: 8.0472, - name: 'Osnabrück Tech Mart', - phone: '+49 541 654321', - posEnabled: false, - postalCode: '49074', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Kröpeliner Straße 48', - city: 'Rostock', - countryCode: 'DE', - distance: 8945.47, - distanceUnit: 'km', - id: '00032', - inventoryId: 'inventory_m_store_store24', - latitude: 54.0899, - longitude: 12.1349, - name: 'Rostock Tech Store', - phone: '+49 381 234567', - posEnabled: false, - postalCode: '18055', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Kennedyplatz 7', - city: 'Essen', - countryCode: 'DE', - distance: 8969.09, - distanceUnit: 'km', - id: '00013', - inventoryId: 'inventory_m_store_store4', - latitude: 51.4566, - longitude: 7.0125, - name: 'Essen Electronics Depot', - phone: '+49 201 456789', - posEnabled: false, - postalCode: '45127', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Kettwiger Straße 17', - city: 'Essen', - countryCode: 'DE', - distance: 8969.13, - distanceUnit: 'km', - id: '00030', - inventoryId: 'inventory_m_store_store22', - latitude: 51.4556, - longitude: 7.0116, - name: 'Essen Tech Hub', - phone: '+49 201 654321', - posEnabled: false, - postalCode: '45127', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - } -] -const mockStoresTotalIsHigherThanLimit = { - limit: 10, - data: mockStoresData, - offset: 0, - total: 30 -} - -const mockStoresTotalIsEqualToLimit = { - limit: 10, - data: mockStoresData, - offset: 0, - total: 10 -} - -const mockNoStores = { - limit: 0, - total: 0 -} - -const WrapperComponent = ({searchStoresParams, userHasSetManualGeolocation}) => { - const storeLocator = useStoreLocator() - useEffect(() => { - storeLocator.setSearchStoresParams(searchStoresParams) - storeLocator.setUserHasSetManualGeolocation(userHasSetManualGeolocation) - }, []) - return ( - - - - ) -} -WrapperComponent.propTypes = { - storesInfo: PropTypes.array, - userHasSetManualGeolocation: PropTypes.bool, - searchStoresParams: PropTypes.object -} - -describe('StoreLocatorContent', () => { - test('renders without crashing', () => { - global.server.use( - rest.get('*/shopper-stores/v1/organizations/*', (req, res, ctx) => { - return res( - ctx.delay(0), - ctx.status(200), - ctx.json(mockStoresTotalIsHigherThanLimit) - ) - }) - ) - expect(() => { - renderWithProviders( - - ) - }).not.toThrow() - }) - - test('Expected information exists', async () => { - global.server.use( - rest.get('*/shopper-stores/v1/organizations/*', (req, res, ctx) => { - return res( - ctx.delay(0), - ctx.status(200), - ctx.json(mockStoresTotalIsHigherThanLimit) - ) - }) - ) - renderWithProviders( - - ) - - await waitFor(async () => { - const findButton = screen.getByRole('button', {name: /Find/i}) - const useMyLocationButton = screen.getByRole('button', {name: /Use My Location/i}) - const descriptionFindAStore = screen.getByText(/Find a Store/i) - const viewing = screen.getByText(/Viewing stores within 100km of 10178 in Germany/i) - - expect(findButton).toBeInTheDocument() - expect(useMyLocationButton).toBeInTheDocument() - expect(descriptionFindAStore).toBeInTheDocument() - expect(viewing).toBeInTheDocument() - }) - }) - - test('No stores text exists', async () => { - global.server.use( - rest.get('*/shopper-stores/v1/organizations/*', (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(200), ctx.json(mockNoStores)) - }) - ) - renderWithProviders( - - ) - - await waitFor(async () => { - const noLocations = screen.getByText(/Sorry, there are no locations in this area/i) - - expect(noLocations).toBeInTheDocument() - }) - }) - - test('Near your location text exists', async () => { - global.server.use( - rest.get('*/shopper-stores/v1/organizations/*', (req, res, ctx) => { - return res( - ctx.delay(0), - ctx.status(200), - ctx.json(mockStoresTotalIsHigherThanLimit) - ) - }) - ) - - renderWithProviders( - - ) - - await waitFor(async () => { - const nearYourLocation = screen.getByText(/Viewing stores near your location/i) - - expect(nearYourLocation).toBeInTheDocument() - }) - }) - - test('Load More button exists when total stores is higher than display limit', async () => { - global.server.use( - rest.get('*/shopper-stores/v1/organizations/*', (req, res, ctx) => { - return res( - ctx.delay(0), - ctx.status(200), - ctx.json(mockStoresTotalIsHigherThanLimit) - ) - }) - ) - - renderWithProviders( - - ) - await waitFor(async () => { - const findButton = screen.getByRole('button', {name: /Find/i}) - const aStore = screen.getByText(/162 University Ave/i) - const loadMore = screen.getByText(/Load More/i) - expect(findButton).toBeInTheDocument() - expect(aStore).toBeInTheDocument() - expect(loadMore).toBeInTheDocument() - }) - }) - - test('Load More button doesnt exist when total stores is equal to display limit', async () => { - global.server.use( - rest.get('*/shopper-stores/v1/organizations/*', (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(200), ctx.json(mockStoresTotalIsEqualToLimit)) - }) - ) - renderWithProviders( - - ) - await waitFor(() => { - const loadMore = screen.queryByText(/Load More/i) - expect(loadMore).not.toBeInTheDocument() - }) - }) -}) diff --git a/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-input.jsx b/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-input.jsx deleted file mode 100644 index c7c4bc6321..0000000000 --- a/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-input.jsx +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import React, {useEffect, useContext} from 'react' -import {useIntl} from 'react-intl' -import PropTypes from 'prop-types' - -// Components -import { - Button, - InputGroup, - Select, - Box, - Input, - FormControl, - FormErrorMessage -} from '@salesforce/retail-react-app/app/components/shared/ui' -import {AlertIcon} from '@salesforce/retail-react-app/app/components/icons' -import {Controller} from 'react-hook-form' - -// Others -import { - SUPPORTED_STORE_LOCATOR_COUNTRIES, - STORE_LOCATOR_NUM_STORES_PER_LOAD -} from '@salesforce/retail-react-app/app/constants' -import {StoreLocatorContext} from '@salesforce/retail-react-app/app/components/store-locator-modal/index' - -const useGeolocation = () => { - const { - setSearchStoresParams, - setAutomaticGeolocationHasFailed, - setUserHasSetManualGeolocation, - userHasSetManualGeolocation - } = useContext(StoreLocatorContext) - - const getGeolocationError = () => { - setAutomaticGeolocationHasFailed(true) - } - const getGeolocationSuccess = (position) => { - setAutomaticGeolocationHasFailed(false) - setSearchStoresParams({ - latitude: position.coords.latitude, - longitude: position.coords.longitude, - limit: STORE_LOCATOR_NUM_STORES_PER_LOAD - }) - } - - const getUserGeolocation = () => { - if (navigator?.geolocation) { - navigator.geolocation.getCurrentPosition(getGeolocationSuccess, getGeolocationError) - setUserHasSetManualGeolocation(false) - } else { - console.log('Geolocation not supported') - } - } - - useEffect(() => { - if (!userHasSetManualGeolocation) getUserGeolocation() - }, []) - - return getUserGeolocation -} - -const StoreLocatorInput = ({form, submitForm}) => { - const { - searchStoresParams, - userHasSetManualGeolocation, - automaticGeolocationHasFailed, - setUserWantsToShareLocation, - userWantsToShareLocation - } = useContext(StoreLocatorContext) - - const getUserGeolocation = useGeolocation() - const {control} = form - const intl = useIntl() - return ( -
- - {SUPPORTED_STORE_LOCATOR_COUNTRIES.length > 0 && ( - { - return SUPPORTED_STORE_LOCATOR_COUNTRIES.length !== 0 ? ( - - - {form.formState.errors.countryCode && ( - - - )} - - ) : ( - <> - ) - }} - > - )} - - - { - return ( - - - {form.formState.errors.postalCode && ( - - - )} - - ) - }} - > - - - - {intl.formatMessage({ - id: 'store_locator.description.or', - defaultMessage: 'Or' - })} - - - - - - -
- ) -} - -StoreLocatorInput.propTypes = { - form: PropTypes.object, - submitForm: PropTypes.func -} - -export default StoreLocatorInput diff --git a/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-input.test.jsx b/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-input.test.jsx deleted file mode 100644 index 03d1c6880b..0000000000 --- a/packages/template-retail-react-app/app/components/store-locator-modal/store-locator-input.test.jsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import React, {useEffect} from 'react' -import StoreLocatorInput from '@salesforce/retail-react-app/app/components/store-locator-modal/store-locator-input' -import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' -import {waitFor, screen} from '@testing-library/react' -import {useForm} from 'react-hook-form' -import PropTypes from 'prop-types' -import {StoreLocatorContext} from '@salesforce/retail-react-app/app/components/store-locator-modal/index' -import {useStoreLocator} from '@salesforce/retail-react-app/app/components/store-locator-modal/index' -import {STORE_LOCATOR_NUM_STORES_PER_LOAD} from '@salesforce/retail-react-app/app/constants' - -const WrapperComponent = ({userHasSetManualGeolocation}) => { - const form = useForm({ - mode: 'onChange', - reValidateMode: 'onChange', - defaultValues: { - countryCode: 'DE', - postalCode: '10178' - } - }) - const storeLocator = useStoreLocator() - useEffect(() => { - storeLocator.setUserHasSetManualGeolocation(userHasSetManualGeolocation) - storeLocator.setSearchStoresParams({ - postalCode: '10178', - countryCode: 'DE', - limit: STORE_LOCATOR_NUM_STORES_PER_LOAD - }) - }, []) - - return ( - - - - ) -} -WrapperComponent.propTypes = { - storesInfo: PropTypes.array, - userHasSetManualGeolocation: PropTypes.bool, - getUserGeolocation: PropTypes.func -} - -describe('StoreLocatorInput', () => { - afterEach(() => { - jest.clearAllMocks() - jest.resetModules() - }) - test('Renders without crashing', () => { - expect(() => { - renderWithProviders( - - ) - }).not.toThrow() - }) - - test('Expected information exists', async () => { - renderWithProviders( - - ) - - await waitFor(async () => { - const findButton = screen.getByRole('button', {name: /Find/i}) - const useMyLocationButton = screen.getByRole('button', {name: /Use My Location/i}) - - expect(findButton).toBeInTheDocument() - expect(useMyLocationButton).toBeInTheDocument() - }) - }) -}) diff --git a/packages/template-retail-react-app/app/components/store-locator-modal/stores-list.jsx b/packages/template-retail-react-app/app/components/store-locator-modal/stores-list.jsx deleted file mode 100644 index a3fbd0a289..0000000000 --- a/packages/template-retail-react-app/app/components/store-locator-modal/stores-list.jsx +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import React, {useEffect, useState} from 'react' -import {useIntl} from 'react-intl' -import PropTypes from 'prop-types' - -// Components -import { - AccordionItem, - AccordionButton, - AccordionIcon, - AccordionPanel, - Box, - HStack, - Radio, - RadioGroup -} from '@salesforce/retail-react-app/app/components/shared/ui' - -// Hooks -import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site' - -const StoresList = ({storesInfo}) => { - const intl = useIntl() - const {site} = useMultiSite() - const storeInfoKey = `store_${site.id}` - const [selectedStore, setSelectedStore] = useState('') - - useEffect(() => { - setSelectedStore(JSON.parse(window.localStorage.getItem(storeInfoKey))?.id || '') - }, [storeInfoKey]) - - const handleChange = (storeId) => { - setSelectedStore(storeId) - const store = storesInfo.find((store) => store.id === storeId) - window.localStorage.setItem( - storeInfoKey, - JSON.stringify({ - id: storeId, - name: store.name || null, - inventoryId: store.inventoryId || null - }) - ) - } - - return ( - - {storesInfo?.map((store, index) => { - return ( - - - - - {store.name && {store.name}} - - {store.address1} - - - {store.city}, {store.stateCode ? store.stateCode : ''}{' '} - {store.postalCode} - - {store.distance !== undefined && ( - <> -
- - {store.distance} {store.distanceUnit}{' '} - {intl.formatMessage({ - id: 'store_locator.description.away', - defaultMessage: 'away' - })} - - - )} - {store.phone && ( - <> -
- - {intl.formatMessage({ - id: 'store_locator.description.phone', - defaultMessage: 'Phone:' - })}{' '} - {store.phone} - - - )} - {store.storeHours && ( - <> - {' '} - - - {intl.formatMessage({ - id: 'store_locator.action.viewMore', - defaultMessage: 'View More' - })} - - - - -
- {' '} - - )} - - - - ) - })} - - ) -} - -StoresList.propTypes = { - storesInfo: PropTypes.array -} - -export default StoresList diff --git a/packages/template-retail-react-app/app/components/store-locator-modal/stores-list.test.jsx b/packages/template-retail-react-app/app/components/store-locator-modal/stores-list.test.jsx deleted file mode 100644 index a326ef38c0..0000000000 --- a/packages/template-retail-react-app/app/components/store-locator-modal/stores-list.test.jsx +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import React from 'react' -import StoresList from '@salesforce/retail-react-app/app/components/store-locator-modal/stores-list' -import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' -import {waitFor, screen, fireEvent} from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import {Accordion} from '@salesforce/retail-react-app/app/components/shared/ui' -import mockConfig from '@salesforce/retail-react-app/config/mocks/default' - -const mockSearchStoresData = [ - { - address1: 'Kirchgasse 12', - city: 'Wiesbaden', - countryCode: 'DE', - distance: 0.74, - distanceUnit: 'km', - id: '00019', - inventoryId: 'inventory_m_store_store11', - latitude: 50.0826, - longitude: 8.24, - name: 'Wiesbaden Tech Depot', - phone: '+49 611 876543', - posEnabled: false, - postalCode: '65185', - storeHours: 'Monday 9 AM to 7 PM', - storeLocatorEnabled: true - }, - { - address1: 'Schaumainkai 63', - city: 'Frankfurt am Main', - countryCode: 'DE', - distance: 30.78, - distanceUnit: 'km', - id: '00002', - inventoryId: 'inventory_m_store_store4', - latitude: 50.097416, - longitude: 8.669059, - name: 'Frankfurt Electronics Store', - phone: '+49 69 111111111', - posEnabled: false, - postalCode: '60596', - storeHours: - 'Monday 10 AM–6 PM\nTuesday 10 AM–6 PM\nWednesday 10 AM–6 PM\nThursday 10 AM–9 PM\nFriday 10 AM–6 PM\nSaturday 10 AM–6 PM\nSunday 10 AM–6 PM', - storeLocatorEnabled: true - }, - { - address1: 'Löhrstraße 87', - city: 'Koblenz', - countryCode: 'DE', - distance: 55.25, - distanceUnit: 'km', - id: '00035', - inventoryId: 'inventory_m_store_store27', - latitude: 50.3533, - longitude: 7.5946, - name: 'Koblenz Electronics Store', - phone: '+49 261 123456', - posEnabled: false, - postalCode: '56068', - storeHours: - 'Monday 9 AM–7 PM\nTuesday 9 AM–7 PM\nWednesday 9 AM–7 PM\nThursday 9 AM–8 PM\nFriday 9 AM–7 PM\nSaturday 9 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Hauptstraße 47', - city: 'Heidelberg', - countryCode: 'DE', - distance: 81.1, - distanceUnit: 'km', - id: '00021', - latitude: 49.4077, - longitude: 8.6908, - name: 'Store with no inventoryId', - phone: '+49 6221 123456', - posEnabled: false, - postalCode: '69117', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - } -] - -describe('StoresList', () => { - test('renders without crashing', () => { - expect(() => { - renderWithProviders( - - - - ) - }).not.toThrow() - }) - - test('Expected information exists', async () => { - renderWithProviders( - - - - ) - - expect(screen.queryAllByRole('radio')).toHaveLength(mockSearchStoresData.length) - - await waitFor(async () => { - mockSearchStoresData.forEach((store) => { - const storeName = screen.getByText(store.name) - const storeAddress = screen.getByText(store.address1) - const storeCityAndPostalCode = screen.getByText( - `${store.city}, ${store.postalCode}` - ) - const storeDistance = screen.getByText( - `${store.distance} ${store.distanceUnit} away` - ) - const storePhoneNumber = screen.getByText(`Phone: ${store.phone}`) - - expect(storeName).toBeInTheDocument() - expect(storeAddress).toBeInTheDocument() - expect(storeCityAndPostalCode).toBeInTheDocument() - expect(storeDistance).toBeInTheDocument() - expect(storePhoneNumber).toBeInTheDocument() - }) - }) - }) - - test('Clicking View More opens store hours', async () => { - renderWithProviders( - - - - ) - - await waitFor(async () => { - const viewMoreButtons = screen.getAllByRole('button', {name: /View More/i}) - - // Click on the first button - await userEvent.click(viewMoreButtons[0]) - - const aStoreOpenHours = screen.getByText(/Monday\s*9\s*AM\s*to\s*7\s*PM/i) - expect(aStoreOpenHours).toBeInTheDocument() - }) - }) - - test('Is sorted by distance away', async () => { - renderWithProviders( - - - - ) - await waitFor(async () => { - const numbers = [ - screen.getByText(/\s*0\.74\s*km\s*away\s*/), - screen.getByText(/\s*30\.78\s*km\s*away\s*/), - screen.getByText(/\s*55\.25\s*km\s*away\s*/), - screen.getByText(/\s*81\.1\s*km\s*away\s*/) - ] - - // Check that the numbers are in the document - numbers.forEach((number) => { - expect(number).toBeInTheDocument() - }) - - // Check that the numbers are in the correct order - const numberTexts = numbers.map((number) => number.textContent) - expect(numberTexts).toEqual([ - '0.74 km away', - '30.78 km away', - '55.25 km away', - '81.1 km away' - ]) - // Check that the numbers are in the correct visual order - const positions = numbers.map((number) => number.getBoundingClientRect().top) - expect(positions).toEqual([...positions].sort((a, b) => a - b)) - }) - }) - - test('Can select store', async () => { - renderWithProviders( - - - - ) - - await waitFor(async () => { - const {id, name, inventoryId} = mockSearchStoresData[1] - const radioButton = screen.getByDisplayValue(id) - fireEvent.click(radioButton) - - const expectedStoreInfo = {id, name, inventoryId} - expect(localStorage.getItem(`store_${mockConfig.app.defaultSite}`)).toEqual( - JSON.stringify(expectedStoreInfo) - ) - }) - }) -}) diff --git a/packages/template-retail-react-app/app/components/store-locator/diagram.png b/packages/template-retail-react-app/app/components/store-locator/diagram.png new file mode 100644 index 0000000000..5cc09de330 Binary files /dev/null and b/packages/template-retail-react-app/app/components/store-locator/diagram.png differ diff --git a/packages/template-retail-react-app/app/components/store-locator/form.jsx b/packages/template-retail-react-app/app/components/store-locator/form.jsx new file mode 100644 index 0000000000..c4c602fafb --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/form.jsx @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2024, 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} from 'react' +import { + Box, + Button, + InputGroup, + Select, + FormControl, + FormErrorMessage, + Input +} from '@chakra-ui/react' +import {useForm, Controller} from 'react-hook-form' +import {useStoreLocator} from '@salesforce/retail-react-app/app/hooks/use-store-locator' +import {useGeolocation} from '@salesforce/retail-react-app/app/hooks/use-geo-location' + +export const StoreLocatorForm = () => { + const {config, formValues, setFormValues, setDeviceCoordinates} = useStoreLocator() + const {coordinates, error, refresh} = useGeolocation() + const form = useForm({ + mode: 'onChange', + reValidateMode: 'onChange', + defaultValues: { + countryCode: formValues.countryCode, + postalCode: formValues.postalCode + } + }) + const {control} = form + useEffect(() => { + if (coordinates.latitude && coordinates.longitude) { + setDeviceCoordinates(coordinates) + } + }, [coordinates]) + + const showCountrySelector = config.supportedCountries.length > 0 + + const submitForm = (formValues) => { + setFormValues(formValues) + } + + const clearForm = () => { + form.reset() + setFormValues({ + countryCode: '', + postalCode: '' + }) + } + + return ( +
{ + e.preventDefault() + void form.handleSubmit(submitForm)(e) + }} + > + + {showCountrySelector && ( + { + return ( + + + {form.formState.errors.countryCode && ( + + {form.formState.errors.countryCode.message} + + )} + + ) + }} + /> + )} + + + { + return ( + + + {form.formState.errors.postalCode && ( + + {form.formState.errors.postalCode.message} + + )} + + ) + }} + /> + + + + Or + + + + + Please agree to share your location + + +
+ ) +} diff --git a/packages/template-retail-react-app/app/components/store-locator/form.test.jsx b/packages/template-retail-react-app/app/components/store-locator/form.test.jsx new file mode 100644 index 0000000000..9382508945 --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/form.test.jsx @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2024, 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} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {StoreLocatorForm} from '@salesforce/retail-react-app/app/components/store-locator/form' +import {useStoreLocator} from '@salesforce/retail-react-app/app/hooks/use-store-locator' +import {useGeolocation} from '@salesforce/retail-react-app/app/hooks/use-geo-location' + +jest.mock('@salesforce/retail-react-app/app/hooks/use-store-locator', () => ({ + useStoreLocator: jest.fn() +})) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-geo-location', () => ({ + useGeolocation: jest.fn() +})) + +describe('StoreLocatorForm', () => { + const mockConfig = { + supportedCountries: [ + {countryCode: 'US', countryName: 'United States'}, + {countryCode: 'CA', countryName: 'Canada'} + ] + } + + const mockSetFormValues = jest.fn() + const mockSetDeviceCoordinates = jest.fn() + let user + + beforeEach(() => { + jest.clearAllMocks() + user = userEvent.setup() + + useStoreLocator.mockImplementation(() => ({ + config: mockConfig, + formValues: {countryCode: '', postalCode: ''}, + setFormValues: mockSetFormValues, + setDeviceCoordinates: mockSetDeviceCoordinates + })) + + useGeolocation.mockImplementation(() => ({ + coordinates: {latitude: null, longitude: null}, + error: null, + refresh: jest.fn() + })) + }) + + it('renders postal code input field', () => { + render() + const postalCodeInput = screen.queryByPlaceholderText('Enter postal code') + expect(postalCodeInput).not.toBeNull() + }) + + it('renders country selector when supportedCountries exist', () => { + render() + const countrySelect = screen.queryByText('Select a country') + expect(countrySelect).not.toBeNull() + }) + + it('renders "Use My Location" button', () => { + render() + const locationButton = screen.queryByText('Use My Location') + expect(locationButton).not.toBeNull() + }) + + it('submits form with entered values', async () => { + render() + + const countrySelect = screen.getByRole('combobox') + const postalCodeInput = screen.getByPlaceholderText('Enter postal code') + + await user.selectOptions(countrySelect, 'US') + await user.type(postalCodeInput, '12345') + + const findButton = screen.getByText('Find') + await user.click(findButton) + + expect(mockSetFormValues).toHaveBeenCalledWith({ + countryCode: 'US', + postalCode: '12345' + }) + }) + + it('shows validation error for empty postal code', async () => { + render() + + const findButton = screen.getByText('Find') + await user.click(findButton) + + const errorMessage = screen.queryByText('Please enter a postal code.') + expect(errorMessage).not.toBeNull() + }) + + it('clears form when "Use My Location" is clicked', async () => { + const mockRefresh = jest.fn() + useGeolocation.mockImplementation(() => ({ + coordinates: {latitude: null, longitude: null}, + error: null, + refresh: mockRefresh + })) + + render() + + const countrySelect = screen.getByRole('combobox') + const postalCodeInput = screen.getByPlaceholderText('Enter postal code') + + await user.selectOptions(countrySelect, 'US') + await user.type(postalCodeInput, '12345') + + const locationButton = screen.getByText('Use My Location') + await user.click(locationButton) + + expect(mockSetFormValues).toHaveBeenCalledWith({ + countryCode: '', + postalCode: '' + }) + expect(mockRefresh).toHaveBeenCalled() + }) + + it('updates device coordinates when geolocation is successful', () => { + const mockCoordinates = {latitude: 37.7749, longitude: -122.4194} + useGeolocation.mockImplementation(() => ({ + coordinates: mockCoordinates, + error: null, + refresh: jest.fn() + })) + + render() + + expect(mockSetDeviceCoordinates).toHaveBeenCalledWith(mockCoordinates) + }) + + it('shows geolocation error message when permission is denied', () => { + useGeolocation.mockImplementation(() => ({ + coordinates: {latitude: null, longitude: null}, + error: new Error('Geolocation permission denied'), + refresh: jest.fn() + })) + + render() + + const errorMessage = screen.queryByText('Please agree to share your location') + expect(errorMessage).not.toBeNull() + }) +}) diff --git a/packages/template-retail-react-app/app/components/store-locator/heading.jsx b/packages/template-retail-react-app/app/components/store-locator/heading.jsx new file mode 100644 index 0000000000..19e5184a1a --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/heading.jsx @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2024, 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 {Heading} from '@chakra-ui/react' + +export const StoreLocatorHeading = () => { + return ( + <> + + Find a Store + + + ) +} diff --git a/packages/template-retail-react-app/app/components/store-locator/heading.test.jsx b/packages/template-retail-react-app/app/components/store-locator/heading.test.jsx new file mode 100644 index 0000000000..15e769bda3 --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/heading.test.jsx @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2024, 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} from '@testing-library/react' +import {StoreLocatorHeading} from '@salesforce/retail-react-app/app/components/store-locator/heading' + +describe('StoreLocatorHeading', () => { + test('renders heading with correct text', () => { + render() + + const heading = screen.getByText('Find a Store') + expect(heading).toBeTruthy() + }) +}) diff --git a/packages/template-retail-react-app/app/components/store-locator/index.js b/packages/template-retail-react-app/app/components/store-locator/index.js new file mode 100644 index 0000000000..9ff04d62ff --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/index.js @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2024, 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 {StoreLocator} from './main' +export {StoreLocatorModal} from './modal' diff --git a/packages/template-retail-react-app/app/components/store-locator/list-item.jsx b/packages/template-retail-react-app/app/components/store-locator/list-item.jsx new file mode 100644 index 0000000000..489650447e --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/list-item.jsx @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024, 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 PropTypes from 'prop-types' +import {AccordionItem, AccordionButton, AccordionIcon, AccordionPanel, Box} from '@chakra-ui/react' + +export const StoreLocatorListItem = ({store}) => { + return ( + + + {store.name && {store.name}} + + {store.address1} + + + {store.city}, {store.stateCode ? store.stateCode : ''} {store.postalCode} + + {store.distance !== undefined && ( + <> +
+ + {store.distance} {store.distanceUnit} + {' away'} + + + )} + {store.phone && ( + <> +
+ + {'Phone: '} + {store.phone} + + + )} + {store.storeHours && ( + <> + + View More + + + +
+ + + )} + + + ) +} + +StoreLocatorListItem.propTypes = { + store: PropTypes.shape({ + name: PropTypes.string, + address1: PropTypes.string.isRequired, + city: PropTypes.string.isRequired, + stateCode: PropTypes.string, + postalCode: PropTypes.string.isRequired, + distance: PropTypes.number, + distanceUnit: PropTypes.string, + phone: PropTypes.string, + storeHours: PropTypes.string + }).isRequired +} diff --git a/packages/template-retail-react-app/app/components/store-locator/list-item.test.jsx b/packages/template-retail-react-app/app/components/store-locator/list-item.test.jsx new file mode 100644 index 0000000000..95c2a71744 --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/list-item.test.jsx @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024, 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 {screen} from '@testing-library/react' +import {Accordion} from '@chakra-ui/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import {StoreLocatorListItem} from '@salesforce/retail-react-app/app/components/store-locator/list-item' + +describe('StoreLocatorListItem', () => { + const mockStore = { + name: 'Test Store', + address1: '123 Test St', + city: 'San Francisco', + stateCode: 'CA', + postalCode: '94105', + phone: '555-1234', + distance: 0.5, + distanceUnit: 'mi', + storeHours: '

Mon-Fri: 9AM-9PM

' + } + + const renderWithAccordion = (component) => { + return renderWithProviders({component}) + } + + it('renders store information correctly', () => { + renderWithAccordion() + + expect(screen.getByText('Test Store')).toBeTruthy() + expect(screen.getByText('123 Test St')).toBeTruthy() + expect(screen.getByText(/San Francisco, CA 94105/)).toBeTruthy() + expect(screen.getByText('0.5 mi away')).toBeTruthy() + expect(screen.getByText('Phone: 555-1234')).toBeTruthy() + expect(screen.getByText('View More')).toBeTruthy() + }) + + it('handles missing optional fields', () => { + const storeWithMissingFields = { + name: 'Basic Store', + address1: '789 Basic St', + city: 'Simple City', + postalCode: '12345' + } + + renderWithAccordion() + + expect(screen.getByText('Basic Store')).toBeTruthy() + expect(screen.getByText('789 Basic St')).toBeTruthy() + expect(screen.getByText(/Simple City/)).toBeTruthy() + expect(screen.queryByText(/away/)).toBeNull() + expect(screen.queryByText(/Phone:/)).toBeNull() + expect(screen.queryByText('View More')).toBeNull() + }) +}) diff --git a/packages/template-retail-react-app/app/components/store-locator/list.jsx b/packages/template-retail-react-app/app/components/store-locator/list.jsx new file mode 100644 index 0000000000..432f3a665f --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/list.jsx @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2024, 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, useState} from 'react' +import {Accordion, AccordionItem, Box, Button} from '@chakra-ui/react' +import {StoreLocatorListItem} from '@salesforce/retail-react-app/app/components/store-locator/list-item' +import {useStoreLocator} from '@salesforce/retail-react-app/app/hooks/use-store-locator' + +export const StoreLocatorList = () => { + const {data, isLoading, config, formValues, mode} = useStoreLocator() + const [page, setPage] = useState(1) + useEffect(() => { + setPage(1) + }, [data]) + + const displayStoreLocatorStatusMessage = () => { + if (isLoading) return 'Loading locations...' + if (data?.total === 0) return 'Sorry, there are no locations in this area' + + if (mode === 'input') { + const countryName = + config.supportedCountries.length !== 0 + ? config.supportedCountries.find( + (o) => o.countryCode === formValues.countryCode + )?.countryName || config.defaultCountry + : config.defaultCountry + + return `Viewing stores within ${String(config.radius)}${String( + config.radiusUnit + )} of ${String(data?.data[0].postalCode)} in ${String(countryName)}` + } + + return 'Viewing stores near your location' + } + + const showNumberOfStores = page * config.defaultPageSize + const showLoadMoreButton = data?.total > showNumberOfStores + const storesToShow = data?.data?.slice(0, showNumberOfStores) || [] + + return ( + <> + + + + {displayStoreLocatorStatusMessage()} + + + {storesToShow?.map((store, index) => ( + + ))} + + {showLoadMoreButton && ( + + + + )} + + ) +} diff --git a/packages/template-retail-react-app/app/components/store-locator/list.test.jsx b/packages/template-retail-react-app/app/components/store-locator/list.test.jsx new file mode 100644 index 0000000000..b4fb00ea0f --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/list.test.jsx @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2024, 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 {StoreLocatorList} from '@salesforce/retail-react-app/app/components/store-locator/list' +import {useStoreLocator} from '@salesforce/retail-react-app/app/hooks/use-store-locator' + +// Mock the useStoreLocator hook +jest.mock('@salesforce/retail-react-app/app/hooks/use-store-locator') + +// Mock store data +const mockStores = { + total: 3, + data: [ + { + name: 'Store 1', + address1: '123 Main St', + city: 'Boston', + stateCode: 'MA', + postalCode: '02108', + phone: '555-0123' + }, + { + name: 'Store 2', + address1: '456 Oak St', + city: 'Boston', + stateCode: 'MA', + postalCode: '02109', + phone: '555-0124' + }, + { + name: 'Store 3', + address1: '789 Pine St', + city: 'Boston', + stateCode: 'MA', + postalCode: '02110', + phone: '555-0125' + } + ] +} + +const defaultConfig = { + radius: 10, + radiusUnit: 'mi', + defaultPageSize: 2, + defaultCountry: 'United States', + supportedCountries: [{countryCode: 'US', countryName: 'United States'}] +} + +describe('StoreLocatorList', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('renders loading state', () => { + useStoreLocator.mockReturnValue({ + isLoading: true, + data: null, + config: defaultConfig, + formValues: {}, + mode: 'input' + }) + + render() + expect(screen.getByText('Loading locations...')).toBeTruthy() + }) + + test('renders no locations message', () => { + useStoreLocator.mockReturnValue({ + isLoading: false, + data: {total: 0, data: []}, + config: defaultConfig, + formValues: {}, + mode: 'input' + }) + + render() + expect(screen.getByText('Sorry, there are no locations in this area')).toBeTruthy() + }) + + test('renders stores with pagination', () => { + useStoreLocator.mockReturnValue({ + isLoading: false, + data: mockStores, + config: defaultConfig, + formValues: {countryCode: 'US'}, + mode: 'input' + }) + + render() + + // Initially shows only first 2 stores (defaultPageSize) + expect(screen.getByText('Store 1')).toBeTruthy() + expect(screen.getByText('Store 2')).toBeTruthy() + expect(screen.queryByText('Store 3')).toBeNull() + + // Load more button should be visible + const loadMoreButton = screen.getByText('Load More') + expect(loadMoreButton).toBeTruthy() + + // Click load more + fireEvent.click(loadMoreButton) + + // Should now show all 3 stores + expect(screen.getByText('Store 1')).toBeTruthy() + expect(screen.getByText('Store 2')).toBeTruthy() + expect(screen.getByText('Store 3')).toBeTruthy() + }) + + test('renders correct status message for input mode', () => { + useStoreLocator.mockReturnValue({ + isLoading: false, + data: mockStores, + config: defaultConfig, + formValues: {countryCode: 'US'}, + mode: 'input' + }) + + render() + expect( + screen.getByText(/Viewing stores within 10mi of 02108 in United States/) + ).toBeTruthy() + }) + + test('renders correct status message for geolocation mode', () => { + useStoreLocator.mockReturnValue({ + isLoading: false, + data: mockStores, + config: defaultConfig, + formValues: {}, + mode: 'geolocation' + }) + + render() + expect(screen.getByText('Viewing stores near your location')).toBeTruthy() + }) +}) diff --git a/packages/template-retail-react-app/app/components/store-locator/main.jsx b/packages/template-retail-react-app/app/components/store-locator/main.jsx new file mode 100644 index 0000000000..71750db949 --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/main.jsx @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024, 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 {StoreLocatorList} from '@salesforce/retail-react-app/app/components/store-locator/list' +import {StoreLocatorForm} from '@salesforce/retail-react-app/app/components/store-locator/form' +import {StoreLocatorHeading} from '@salesforce/retail-react-app/app/components/store-locator/heading' + +export const StoreLocator = () => { + return ( + <> + + + + + ) +} diff --git a/packages/template-retail-react-app/app/components/store-locator/main.test.jsx b/packages/template-retail-react-app/app/components/store-locator/main.test.jsx new file mode 100644 index 0000000000..00d2e011ca --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/main.test.jsx @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024, 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} from '@testing-library/react' +import {StoreLocator} from '@salesforce/retail-react-app/app/components/store-locator/main' + +jest.mock('./list', () => ({ + StoreLocatorList: () =>
Store List Mock
+})) + +jest.mock('./form', () => ({ + StoreLocatorForm: () =>
Store Form Mock
+})) + +jest.mock('./heading', () => ({ + StoreLocatorHeading: () =>
Store Heading Mock
+})) + +describe('StoreLocatorContent', () => { + it('renders all child components', () => { + render() + + // Verify that all child components are rendered + expect(screen.queryByTestId('store-locator-heading')).not.toBeNull() + expect(screen.queryByTestId('store-locator-form')).not.toBeNull() + expect(screen.queryByTestId('store-locator-list')).not.toBeNull() + }) +}) diff --git a/packages/template-retail-react-app/app/components/store-locator/modal.jsx b/packages/template-retail-react-app/app/components/store-locator/modal.jsx new file mode 100644 index 0000000000..eeb90b5d86 --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/modal.jsx @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024, 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 PropTypes from 'prop-types' +import { + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + useBreakpointValue +} from '@chakra-ui/react' +import {StoreLocator} from '@salesforce/retail-react-app/app/components/store-locator/main' + +export const StoreLocatorModal = ({isOpen, onClose}) => { + const isDesktopView = useBreakpointValue({base: false, lg: true}) + + return isDesktopView ? ( + + + + + + + + + ) : ( + + + + + + + + + ) +} + +StoreLocatorModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired +} diff --git a/packages/template-retail-react-app/app/components/store-locator/modal.test.jsx b/packages/template-retail-react-app/app/components/store-locator/modal.test.jsx new file mode 100644 index 0000000000..ea33e91dc2 --- /dev/null +++ b/packages/template-retail-react-app/app/components/store-locator/modal.test.jsx @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024, 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 {screen} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import {StoreLocatorModal} from '@salesforce/retail-react-app/app/components/store-locator/modal' + +const mockUseBreakpointValue = jest.fn() +jest.mock('@chakra-ui/react', () => { + const originalModule = jest.requireActual('@chakra-ui/react') + return { + ...originalModule, + useBreakpointValue: () => mockUseBreakpointValue + } +}) + +jest.mock('./main', () => ({ + StoreLocator: () =>
Store Locator Content
+})) + +describe('StoreLocatorModal', () => { + const mockProps = { + isOpen: true, + onClose: jest.fn() + } + + beforeEach(() => { + jest.clearAllMocks() + mockUseBreakpointValue.mockReturnValue(true) // Default to desktop view + }) + + it('renders desktop view correctly', () => { + mockUseBreakpointValue.mockReturnValue(true) // Desktop view + renderWithProviders() + + expect(screen.getByText('Store Locator Content')).toBeTruthy() + expect(screen.getByTestId('store-locator-content')).toBeTruthy() + }) + + it('renders mobile view correctly', () => { + mockUseBreakpointValue.mockReturnValue(false) // Mobile view + renderWithProviders() + + expect(screen.getByText('Store Locator Content')).toBeTruthy() + expect(screen.getByTestId('store-locator-content')).toBeTruthy() + }) + + it('does not render when closed', () => { + renderWithProviders() + + expect(screen.queryByText('Store Locator Content')).toBeNull() + expect(screen.queryByTestId('store-locator-content')).toBeNull() + }) + + it('calls onClose when close button is clicked', () => { + const onClose = jest.fn() + renderWithProviders() + + const closeButton = screen.getByLabelText('Close') + closeButton.click() + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/packages/template-retail-react-app/app/constants.js b/packages/template-retail-react-app/app/constants.js index ad8602e346..7db2216f5f 100644 --- a/packages/template-retail-react-app/app/constants.js +++ b/packages/template-retail-react-app/app/constants.js @@ -185,35 +185,24 @@ export const REMOVE_UNAVAILABLE_CART_ITEM_DIALOG_CONFIG = { onPrimaryAction: noop } -export const SUPPORTED_STORE_LOCATOR_COUNTRIES = [ +export const STORE_LOCATOR_IS_ENABLED = true +export const STORE_LOCATOR_SUPPORTED_COUNTRIES = [ { countryCode: 'US', - countryName: defineMessage({ - defaultMessage: 'United States', - id: 'store_locator.dropdown.united_states' - }) + countryName: 'United States' }, { countryCode: 'DE', - countryName: defineMessage({ - defaultMessage: 'Germany', - id: 'store_locator.dropdown.germany' - }) + countryName: 'Germany' } ] - -export const DEFAULT_STORE_LOCATOR_COUNTRY = { - countryCode: 'DE', - countryName: defineMessage({ - defaultMessage: 'Germany', - id: 'store_locator.dropdown.germany' - }) -} -export const DEFAULT_STORE_LOCATOR_POSTAL_CODE = '10178' -export const STORE_LOCATOR_DISTANCE = 100 -export const STORE_LOCATOR_NUM_STORES_PER_LOAD = 10 -export const STORE_LOCATOR_DISTANCE_UNIT = 'km' -export const STORE_LOCATOR_IS_ENABLED = true +export const STORE_LOCATOR_DEFAULT_POSTAL_CODE = '10178' +export const STORE_LOCATOR_RADIUS = 100 +export const STORE_LOCATOR_RADIUS_UNIT = 'km' +export const STORE_LOCATOR_DEFAULT_COUNTRY = 'DE' +export const STORE_LOCATOR_DEFAULT_COUNTRY_CODE = 'DE' +export const STORE_LOCATOR_DEFAULT_PAGE_SIZE = 10 +export const STORE_LOCATOR_NUM_STORES_PER_REQUEST_API_MAX = 200 // This is an API limit and is therefore not configurable export const DEFAULT_DNT_STATE = true // Constants for shopper context diff --git a/packages/template-retail-react-app/app/contexts/index.js b/packages/template-retail-react-app/app/contexts/index.js index 622a78b5e8..d542c27cfb 100644 --- a/packages/template-retail-react-app/app/contexts/index.js +++ b/packages/template-retail-react-app/app/contexts/index.js @@ -7,6 +7,10 @@ import React, {useState} from 'react' import PropTypes from 'prop-types' +export { + StoreLocatorContext, + StoreLocatorProvider +} from '@salesforce/retail-react-app/app/contexts/store-locator-provider' /** * This is the global state for the multiples sites and locales supported in the App. diff --git a/packages/template-retail-react-app/app/contexts/store-locator-provider.jsx b/packages/template-retail-react-app/app/contexts/store-locator-provider.jsx new file mode 100644 index 0000000000..51a8a88331 --- /dev/null +++ b/packages/template-retail-react-app/app/contexts/store-locator-provider.jsx @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024, 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, {useState, createContext} from 'react' +import PropTypes from 'prop-types' + +export const StoreLocatorContext = createContext(null) + +export const StoreLocatorProvider = ({config, children}) => { + const [state, setState] = useState({ + mode: 'input', + formValues: { + countryCode: config.defaultCountryCode, + postalCode: config.defaultPostalCode + }, + deviceCoordinates: { + latitude: null, + longitude: null + }, + config + }) + + const value = { + state, + setState + } + + return {children} +} + +StoreLocatorProvider.propTypes = { + config: PropTypes.shape({ + defaultCountryCode: PropTypes.string.isRequired, + defaultPostalCode: PropTypes.string.isRequired + }).isRequired, + children: PropTypes.node +} diff --git a/packages/template-retail-react-app/app/contexts/store-locator-provider.test.jsx b/packages/template-retail-react-app/app/contexts/store-locator-provider.test.jsx new file mode 100644 index 0000000000..43c0eca3af --- /dev/null +++ b/packages/template-retail-react-app/app/contexts/store-locator-provider.test.jsx @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024, 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, act} from '@testing-library/react' +import { + StoreLocatorProvider, + StoreLocatorContext +} from '@salesforce/retail-react-app/app/contexts/store-locator-provider' + +describe('StoreLocatorProvider', () => { + const mockConfig = { + defaultCountryCode: 'US', + defaultPostalCode: '10178' + } + + it('provides the expected context value', () => { + let contextValue + const TestComponent = () => { + contextValue = React.useContext(StoreLocatorContext) + return null + } + + render( + + + + ) + + expect(contextValue).toBeTruthy() + expect(contextValue?.state).toEqual({ + mode: 'input', + formValues: { + countryCode: mockConfig.defaultCountryCode, + postalCode: mockConfig.defaultPostalCode + }, + deviceCoordinates: { + latitude: null, + longitude: null + }, + config: mockConfig + }) + expect(typeof contextValue?.setState).toBe('function') + }) + + it('updates state correctly when setState is called', () => { + let contextValue + const TestComponent = () => { + contextValue = React.useContext(StoreLocatorContext) + return null + } + + render( + + + + ) + + act(() => { + contextValue?.setState((prev) => ({ + ...prev, + mode: 'device', + formValues: { + countryCode: 'US', + postalCode: '94105' + } + })) + }) + + expect(contextValue?.state.mode).toBe('device') + expect(contextValue?.state.formValues).toEqual({ + countryCode: 'US', + postalCode: '94105' + }) + }) + + it('renders children correctly', () => { + const TestChild = () =>
Test Child
+ + const {getByText} = render( + + + + ) + + expect(getByText('Test Child')).toBeTruthy() + }) +}) diff --git a/packages/template-retail-react-app/app/hooks/use-geo-location.js b/packages/template-retail-react-app/app/hooks/use-geo-location.js new file mode 100644 index 0000000000..5e6bab8e16 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-geo-location.js @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024, 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 {useEffect, useState} from 'react' + +export function useGeolocation(options = {}) { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [coordinates, setCoordinates] = useState({ + latitude: null, + longitude: null + }) + + const getLocation = () => { + setLoading(true) + setError(null) + + try { + if (!navigator.geolocation) { + throw new Error('Geolocation is not supported by this browser.') + } + navigator.geolocation.getCurrentPosition( + (position) => { + setCoordinates({ + latitude: position.coords.latitude, + longitude: position.coords.longitude + }) + setLoading(false) + }, + (err) => { + setError(err) + setLoading(false) + }, + options + ) + } catch (err) { + setError(err) + setLoading(false) + } + } + + useEffect(() => { + getLocation() + }, []) + + return { + coordinates, + loading, + error, + refresh: getLocation + } +} diff --git a/packages/template-retail-react-app/app/hooks/use-geo-location.test.js b/packages/template-retail-react-app/app/hooks/use-geo-location.test.js new file mode 100644 index 0000000000..8f122940a4 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-geo-location.test.js @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2024, 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 {renderHook, act} from '@testing-library/react' +import {useGeolocation} from '@salesforce/retail-react-app/app/hooks/use-geo-location' + +describe('useGeolocation', () => { + const mockGeolocation = { + getCurrentPosition: jest.fn() + } + + beforeEach(() => { + // Mock GeolocationPositionError if it's not defined + if (!global.GeolocationPositionError) { + global.GeolocationPositionError = function () { + this.code = 1 + this.message = '' + this.PERMISSION_DENIED = 1 + this.POSITION_UNAVAILABLE = 2 + this.TIMEOUT = 3 + } + } + + // Setup mock for navigator.geolocation + Object.defineProperty(global.navigator, 'geolocation', { + value: mockGeolocation, + writable: true + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('initializes with default values', () => { + const {result} = renderHook(() => useGeolocation()) + + expect(result.current).toEqual({ + coordinates: {latitude: null, longitude: null}, + loading: true, + error: null, + refresh: expect.any(Function) + }) + }) + + it('updates coordinates on successful geolocation', async () => { + const mockPosition = { + coords: { + latitude: 37.7749, + longitude: -122.4194, + accuracy: 0, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null + }, + timestamp: Date.now() + } + + mockGeolocation.getCurrentPosition.mockImplementation((success) => { + success(mockPosition) + }) + + const {result} = renderHook(() => useGeolocation()) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(result.current).toEqual({ + coordinates: { + latitude: 37.7749, + longitude: -122.4194 + }, + loading: false, + error: null, + refresh: expect.any(Function) + }) + }) + + it('handles geolocation errors', async () => { + const mockError = new global.GeolocationPositionError() + mockError.code = 1 + mockError.message = 'User denied geolocation' + + mockGeolocation.getCurrentPosition.mockImplementation((_success, error) => { + error(mockError) + }) + + const {result} = renderHook(() => useGeolocation()) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(result.current).toEqual({ + coordinates: {latitude: null, longitude: null}, + loading: false, + error: mockError, + refresh: expect.any(Function) + }) + }) + + it('handles refresh function call', async () => { + const mockPosition = { + coords: { + latitude: 37.7749, + longitude: -122.4194, + accuracy: 0, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null + }, + timestamp: Date.now() + } + + mockGeolocation.getCurrentPosition.mockImplementation((success) => { + success(mockPosition) + }) + + const {result} = renderHook(() => useGeolocation()) + + act(() => { + result.current.refresh() + }) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(mockGeolocation.getCurrentPosition).toHaveBeenCalledTimes(2) + expect(result.current.coordinates).toEqual({ + latitude: 37.7749, + longitude: -122.4194 + }) + }) + + it('handles case when geolocation is not supported', async () => { + // First clear the mock + Object.defineProperty(global.navigator, 'geolocation', { + value: undefined, + writable: true + }) + + const {result} = renderHook(() => useGeolocation()) + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + // Just check that we're in an error state with null coordinates + expect(result.current.loading).toBe(false) + expect(result.current.coordinates).toEqual({ + latitude: null, + longitude: null + }) + }) +}) diff --git a/packages/template-retail-react-app/app/hooks/use-store-locator.js b/packages/template-retail-react-app/app/hooks/use-store-locator.js new file mode 100644 index 0000000000..4c05057eec --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-store-locator.js @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2024, 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 {useContext} from 'react' +import {useSearchStores} from '@salesforce/commerce-sdk-react' +import {StoreLocatorContext} from '@salesforce/retail-react-app/app/contexts/store-locator-provider' + +const useStores = (state) => { + //This is an API limit and is therefore not configurable + const NUM_STORES_PER_REQUEST_API_MAX = 200 + const apiParameters = + state.mode === 'input' + ? { + countryCode: state.formValues.countryCode, + postalCode: state.formValues.postalCode, + maxDistance: state.config.radius, + limit: NUM_STORES_PER_REQUEST_API_MAX, + distanceUnit: state.config.radiusUnit + } + : { + latitude: state.deviceCoordinates.latitude, + longitude: state.deviceCoordinates.longitude, + maxDistance: state.config.radius, + limit: NUM_STORES_PER_REQUEST_API_MAX, + distanceUnit: state.config.radiusUnit + } + const shouldFetchStores = + Boolean( + state.mode === 'input' && state.formValues.countryCode && state.formValues.postalCode + ) || + Boolean( + state.mode === 'device' && + state.deviceCoordinates.latitude && + state.deviceCoordinates.longitude + ) + return useSearchStores( + { + parameters: apiParameters + }, + { + enabled: shouldFetchStores + } + ) +} + +export const useStoreLocator = () => { + const context = useContext(StoreLocatorContext) + if (!context) { + throw new Error('useStoreLocator must be used within a StoreLocatorProvider') + } + + const {state, setState} = context + const {data, isLoading} = useStores(state) + + // There are two modes, input and device. + // The input mode is when the user is searching for a store + // by entering a postal code and country code. + // The device mode is when the user is searching for a store by sharing their location. + // The mode is implicitly set by user's action. + const setFormValues = (formValues) => { + setState((prev) => ({...prev, formValues, mode: 'input'})) + } + + const setDeviceCoordinates = (coordinates) => { + setState((prev) => ({ + ...prev, + deviceCoordinates: coordinates, + mode: 'device', + formValues: {countryCode: '', postalCode: ''} + })) + } + + return { + ...state, + data, + isLoading, + // Actions + setFormValues, + setDeviceCoordinates + } +} diff --git a/packages/template-retail-react-app/app/hooks/use-store-locator.test.jsx b/packages/template-retail-react-app/app/hooks/use-store-locator.test.jsx new file mode 100644 index 0000000000..b899a49ba7 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-store-locator.test.jsx @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2024, 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 {renderHook, act} from '@testing-library/react' +import {useStoreLocator} from '@salesforce/retail-react-app/app/hooks/use-store-locator' +import {StoreLocatorProvider} from '@salesforce/retail-react-app/app/contexts/store-locator-provider' +import {useSearchStores} from '@salesforce/commerce-sdk-react' + +// Mock the commerce-sdk-react hook +jest.mock('@salesforce/commerce-sdk-react', () => ({ + useSearchStores: jest.fn() +})) + +const config = { + radius: 100, + radiusUnit: 'mi', + defaultCountryCode: 'US', + defaultPostalCode: '10178' +} + +const wrapper = ({children}) => { + return {children} +} + +describe('useStoreLocator', () => { + beforeEach(() => { + useSearchStores.mockReset() + // Default mock implementation + useSearchStores.mockReturnValue({ + data: undefined, + isLoading: false + }) + }) + + it('throws error when used outside provider', () => { + let error + try { + renderHook(() => useStoreLocator()) + } catch (err) { + error = err + } + + expect(error).toEqual(Error('useStoreLocator must be used within a StoreLocatorProvider')) + }) + + it('initializes with default values', () => { + const {result} = renderHook(() => useStoreLocator(), {wrapper}) + + expect(result.current).toMatchObject({ + mode: 'input', + formValues: { + countryCode: config.defaultCountryCode, + postalCode: config.defaultPostalCode + }, + deviceCoordinates: {latitude: null, longitude: null}, + isLoading: false, + data: undefined + }) + }) + + it('updates form values and switches to input mode', () => { + const {result} = renderHook(() => useStoreLocator(), {wrapper}) + + act(() => { + result.current.setFormValues({ + countryCode: 'US', + postalCode: '94105' + }) + }) + + expect(result.current.mode).toBe('input') + expect(result.current.formValues).toEqual({ + countryCode: 'US', + postalCode: '94105' + }) + }) + + it('updates device coordinates and switches to device mode', () => { + const {result} = renderHook(() => useStoreLocator(), {wrapper}) + + act(() => { + result.current.setDeviceCoordinates({ + latitude: 37.7749, + longitude: -122.4194 + }) + }) + + expect(result.current.mode).toBe('device') + expect(result.current.deviceCoordinates).toEqual({ + latitude: 37.7749, + longitude: -122.4194 + }) + // Should reset form values when switching to device mode + expect(result.current.formValues).toEqual({ + countryCode: '', + postalCode: '' + }) + }) + + it('calls useSearchStores with correct parameters in input mode', () => { + const {result} = renderHook(() => useStoreLocator(), {wrapper}) + + act(() => { + result.current.setFormValues({ + countryCode: 'US', + postalCode: '94105' + }) + }) + + expect(useSearchStores).toHaveBeenCalledWith( + { + parameters: { + countryCode: 'US', + postalCode: '94105', + maxDistance: 100, + limit: 200, + distanceUnit: 'mi' + } + }, + { + enabled: true + } + ) + }) + + it('calls useSearchStores with correct parameters in device mode', () => { + const {result} = renderHook(() => useStoreLocator(), {wrapper}) + + act(() => { + result.current.setDeviceCoordinates({ + latitude: 37.7749, + longitude: -122.4194 + }) + }) + + expect(useSearchStores).toHaveBeenCalledWith( + { + parameters: { + latitude: 37.7749, + longitude: -122.4194, + maxDistance: 100, + limit: 200, + distanceUnit: 'mi' + } + }, + { + enabled: true + } + ) + }) + + it('handles loading state', () => { + useSearchStores.mockReturnValue({ + data: undefined, + isLoading: true + }) + + const {result} = renderHook(() => useStoreLocator(), {wrapper}) + + expect(result.current.isLoading).toBe(true) + }) + + it('handles store data', () => { + const mockStoreData = [ + { + id: '1', + name: 'Test Store', + address: { + address1: '123 Test St', + city: 'Test City', + stateCode: 'CA', + postalCode: '94105' + } + } + ] + + useSearchStores.mockReturnValue({ + data: mockStoreData, + isLoading: false + }) + + const {result} = renderHook(() => useStoreLocator(), {wrapper}) + + expect(result.current.data).toEqual(mockStoreData) + }) +}) diff --git a/packages/template-retail-react-app/app/pages/store-locator/index.jsx b/packages/template-retail-react-app/app/pages/store-locator/index.jsx index d568a5f807..16fbff6a77 100644 --- a/packages/template-retail-react-app/app/pages/store-locator/index.jsx +++ b/packages/template-retail-react-app/app/pages/store-locator/index.jsx @@ -1,49 +1,35 @@ /* - * Copyright (c) 2021, salesforce.com, inc. + * Copyright (c) 2024, 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 {Box, Container} from '@chakra-ui/react' +import {StoreLocator} from '@salesforce/retail-react-app/app/components/store-locator' -// Components -import {Box, Container} from '@salesforce/retail-react-app/app/components/shared/ui' -import Seo from '@salesforce/retail-react-app/app/components/seo' -import StoreLocatorContent from '@salesforce/retail-react-app/app/components/store-locator-modal/store-locator-content' - -// Others -import { - StoreLocatorContext, - useStoreLocator -} from '@salesforce/retail-react-app/app/components/store-locator-modal/index' - -const StoreLocator = () => { - const storeLocator = useStoreLocator() - +const StoreLocatorPage = () => { return ( - - - - - - - - + + + + + ) } -StoreLocator.getTemplateName = () => 'store-locator' +StoreLocatorPage.getTemplateName = () => 'store-locator' -StoreLocator.propTypes = {} +StoreLocatorPage.propTypes = {} -export default StoreLocator +export default StoreLocatorPage diff --git a/packages/template-retail-react-app/app/pages/store-locator/index.test.jsx b/packages/template-retail-react-app/app/pages/store-locator/index.test.jsx index 959ad891cc..60f037b201 100644 --- a/packages/template-retail-react-app/app/pages/store-locator/index.test.jsx +++ b/packages/template-retail-react-app/app/pages/store-locator/index.test.jsx @@ -1,256 +1,36 @@ /* - * Copyright (c) 2021, salesforce.com, inc. + * 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 + */ +/* + * Copyright (c) 2024, 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 {screen, waitFor, within} from '@testing-library/react' -import {rest} from 'msw' -import { - createPathWithDefaults, - renderWithProviders -} from '@salesforce/retail-react-app/app/utils/test-utils' -import StoreLocator from '.' -import mockConfig from '@salesforce/retail-react-app/config/mocks/default' - -const mockStores = { - limit: 4, - data: [ - { - address1: 'Kirchgasse 12', - city: 'Wiesbaden', - countryCode: 'DE', - distance: 0.74, - distanceUnit: 'km', - id: '00019', - inventoryId: 'inventory_m_store_store11', - latitude: 50.0826, - longitude: 8.24, - name: 'Wiesbaden Tech Depot', - phone: '+49 611 876543', - posEnabled: false, - postalCode: '65185', - storeHours: - 'Monday 9 AM–7 PM\nTuesday 9 AM–7 PM\nWednesday 9 AM–7 PM\nThursday 9 AM–8 PM\nFriday 9 AM–7 PM\nSaturday 9 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Schaumainkai 63', - city: 'Frankfurt am Main', - countryCode: 'DE', - distance: 30.78, - distanceUnit: 'km', - id: '00002', - inventoryId: 'inventory_m_store_store4', - latitude: 50.097416, - longitude: 8.669059, - name: 'Frankfurt Electronics Store', - phone: '+49 69 111111111', - posEnabled: false, - postalCode: '60596', - storeHours: - 'Monday 10 AM–6 PM\nTuesday 10 AM–6 PM\nWednesday 10 AM–6 PM\nThursday 10 AM–9 PM\nFriday 10 AM–6 PM\nSaturday 10 AM–6 PM\nSunday 10 AM–6 PM', - storeLocatorEnabled: true - }, - { - address1: 'Löhrstraße 87', - city: 'Koblenz', - countryCode: 'DE', - distance: 55.25, - distanceUnit: 'km', - id: '00035', - inventoryId: 'inventory_m_store_store27', - latitude: 50.3533, - longitude: 7.5946, - name: 'Koblenz Electronics Store', - phone: '+49 261 123456', - posEnabled: false, - postalCode: '56068', - storeHours: - 'Monday 9 AM–7 PM\nTuesday 9 AM–7 PM\nWednesday 9 AM–7 PM\nThursday 9 AM–8 PM\nFriday 9 AM–7 PM\nSaturday 9 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - }, - { - address1: 'Hauptstraße 47', - city: 'Heidelberg', - countryCode: 'DE', - distance: 81.1, - distanceUnit: 'km', - id: '00021', - inventoryId: 'inventory_m_store_store13', - latitude: 49.4077, - longitude: 8.6908, - name: 'Heidelberg Tech Mart', - phone: '+49 6221 123456', - posEnabled: false, - postalCode: '69117', - storeHours: - 'Monday 10 AM–7 PM\nTuesday 10 AM–7 PM\nWednesday 10 AM–7 PM\nThursday 10 AM–8 PM\nFriday 10 AM–7 PM\nSaturday 10 AM–6 PM\nSunday Closed', - storeLocatorEnabled: true - } - ], - offset: 0, - total: 4 -} - -const mockNoStores = { - limit: 4, - total: 0 -} - -const MockedComponent = () => { - return ( -
- -
- ) -} - -// Set up and clean up -beforeEach(() => { - jest.resetModules() - window.history.pushState({}, 'Store locator', createPathWithDefaults('/store-locator')) -}) -afterEach(() => { - jest.resetModules() - localStorage.clear() - jest.clearAllMocks() -}) - -test('Allows customer to go to store locator page', async () => { - global.server.use( - rest.get( - '*/shopper-stores/v1/organizations/v1/organizations/f_ecom_zzrf_001/store-search', - (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(200), ctx.json(mockStores)) - } - ) - ) - - // render our test component - const {user} = renderWithProviders(, { - wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} - }) - - await user.click(await screen.findByText('Find a Store')) - - await waitFor(() => { - expect(window.location.pathname).toBe('/uk/en-GB/store-locator') - }) -}) - -test('Allows customer to go to store locator page and then select a new store', async () => { - global.server.use( - rest.get( - 'https://www.domain.com/mobify/proxy/api/store/shopper-stores/v1/organizations/:organizationId/store-search', - (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(200), ctx.json(mockStores)) - } - ) - ) - - const {user} = renderWithProviders(, { - wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} - }) - - await user.click(await screen.findByText('Find a Store')) - - const countrySelect = await screen.findByDisplayValue('Select a country') - await user.selectOptions(countrySelect, 'DE') - - await user.type(screen.getByPlaceholderText(/Enter postal code/i), '69117') - - const findButtonInForm = screen.getByRole('button', {name: 'Find'}) - await user.click(findButtonInForm) - - const storeToSelect = 'Heidelberg Tech Mart' - await waitFor(() => { - expect(screen.getByText(storeToSelect)).toBeInTheDocument() - }) - - const storeNameElement = await screen.findByText(storeToSelect) - - const storeAccordionItem = storeNameElement.closest('.chakra-accordion__item') - if (!storeAccordionItem) { - throw new Error(`Could not find parent .chakra-accordion__item for store: ${storeToSelect}`) - } - - const storeRadio = within(storeAccordionItem).getByRole('radio') - - await user.click(storeRadio) - expect(storeRadio).toBeChecked() -}) - -test('Show no stores are found if there are no stores', async () => { - global.server.use( - rest.get( - '*/shopper-stores/v1/organizations/v1/organizations/f_ecom_zzrf_001/store-search', - (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(200), ctx.json(mockNoStores)) - } - ) - ) - - // render our test component - renderWithProviders(, { - wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} - }) - - await waitFor(() => { - const descriptionFindAStore = screen.getByText(/Find a Store/i) - const noLocationsInThisArea = screen.getByText( - /Sorry, there are no locations in this area/i - ) - expect(descriptionFindAStore).toBeInTheDocument() - expect(noLocationsInThisArea).toBeInTheDocument() - - expect(window.location.pathname).toBe('/uk/en-GB/store-locator') - }) -}) - -test('Allows customer to search for stores and expand to view store details', async () => { - global.server.use( - rest.get('*/shopper-stores/v1/organizations/*/store-search', (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(200), ctx.json(mockStores)) - }) - ) - const {user} = renderWithProviders(, { - wrapperProps: {siteAlias: 'uk', appConfig: mockConfig.app} - }) - - await user.click(await screen.findByText('Find a Store')) - const countrySelect = await screen.findByDisplayValue('Select a country') - await user.selectOptions(countrySelect, 'DE') +import React from 'react' +import {render, screen} from '@testing-library/react' +import StoreLocatorPage from '@salesforce/retail-react-app/app/pages/store-locator/index' - const postalCodeInput = screen.getByPlaceholderText(/Enter postal code/i) - await user.type(postalCodeInput, '65185') +jest.mock('@salesforce/retail-react-app/app/components/store-locator', () => ({ + StoreLocator: () =>
Mock Content
+})) - const findButton = screen.getByRole('button', {name: 'Find'}) - await user.click(findButton) +describe('StoreLocatorPage', () => { + it('renders the store locator page with content', () => { + render() - await waitFor(() => { - expect(screen.getByText('Wiesbaden Tech Depot')).toBeInTheDocument() - expect(screen.getByText('Frankfurt Electronics Store')).toBeInTheDocument() - }) + // Verify the page wrapper is rendered + expect(screen.getByTestId('store-locator-page')).toBeTruthy() - await waitFor(() => { - expect(screen.getByText('Kirchgasse 12')).toBeInTheDocument() - expect(screen.getByText(/Phone:\s*\+49 611 876543/)).toBeInTheDocument() - expect(screen.getByText('0.74 km away')).toBeInTheDocument() + // Verify the mocked content is rendered + expect(screen.getByTestId('mock-store-locator-content')).toBeTruthy() }) - const wiesbadenStoreElement = await screen.findByText('Wiesbaden Tech Depot') - const wiesbadenAccordionItem = wiesbadenStoreElement.closest('.chakra-accordion__item') - - expect(wiesbadenAccordionItem).toBeTruthy() - - const viewMoreButton = within(wiesbadenAccordionItem).getByText('View More') - await user.click(viewMoreButton) - - await waitFor(() => { - expect(within(wiesbadenAccordionItem).getByText(/Monday 9 AM–7 PM/)).toBeInTheDocument() - expect(within(wiesbadenAccordionItem).getByText(/Sunday Closed/)).toBeInTheDocument() + it('returns correct template name', () => { + expect(StoreLocatorPage.getTemplateName()).toBe('store-locator') }) }) 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 352d183530..5856856e31 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 @@ -3289,140 +3289,6 @@ "value": " to proceed." } ], - "store_locator.action.find": [ - { - "type": 0, - "value": "Find" - } - ], - "store_locator.action.select_a_country": [ - { - "type": 0, - "value": "Select a country" - } - ], - "store_locator.action.use_my_location": [ - { - "type": 0, - "value": "Use My Location" - } - ], - "store_locator.action.viewMore": [ - { - "type": 0, - "value": "View More" - } - ], - "store_locator.description.away": [ - { - "type": 0, - "value": "away" - } - ], - "store_locator.description.loading_locations": [ - { - "type": 0, - "value": "Loading locations..." - } - ], - "store_locator.description.no_locations": [ - { - "type": 0, - "value": "Sorry, there are no locations in this area" - } - ], - "store_locator.description.or": [ - { - "type": 0, - "value": "Or" - } - ], - "store_locator.description.phone": [ - { - "type": 0, - "value": "Phone:" - } - ], - "store_locator.description.viewing_near_postal_code": [ - { - "type": 0, - "value": "Viewing stores within " - }, - { - "type": 1, - "value": "distance" - }, - { - "type": 1, - "value": "distanceUnit" - }, - { - "type": 0, - "value": " of " - }, - { - "type": 1, - "value": "postalCode" - }, - { - "type": 0, - "value": " in" - } - ], - "store_locator.description.viewing_near_your_location": [ - { - "type": 0, - "value": "Viewing stores near your location" - } - ], - "store_locator.dropdown.germany": [ - { - "type": 0, - "value": "Germany" - } - ], - "store_locator.dropdown.united_states": [ - { - "type": 0, - "value": "United States" - } - ], - "store_locator.error.agree_to_share_your_location": [ - { - "type": 0, - "value": "Please agree to share your location" - } - ], - "store_locator.error.please_enter_a_postal_code": [ - { - "type": 0, - "value": "Please enter a postal code." - } - ], - "store_locator.error.please_select_a_country": [ - { - "type": 0, - "value": "Please select a country." - } - ], - "store_locator.field.placeholder.enter_postal_code": [ - { - "type": 0, - "value": "Enter postal code" - } - ], - "store_locator.pagination.load_more": [ - { - "type": 0, - "value": "Load More" - } - ], - "store_locator.title": [ - { - "type": 0, - "value": "Find a Store" - } - ], "swatch_group.selected.label": [ { "type": 1, 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 352d183530..5856856e31 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 @@ -3289,140 +3289,6 @@ "value": " to proceed." } ], - "store_locator.action.find": [ - { - "type": 0, - "value": "Find" - } - ], - "store_locator.action.select_a_country": [ - { - "type": 0, - "value": "Select a country" - } - ], - "store_locator.action.use_my_location": [ - { - "type": 0, - "value": "Use My Location" - } - ], - "store_locator.action.viewMore": [ - { - "type": 0, - "value": "View More" - } - ], - "store_locator.description.away": [ - { - "type": 0, - "value": "away" - } - ], - "store_locator.description.loading_locations": [ - { - "type": 0, - "value": "Loading locations..." - } - ], - "store_locator.description.no_locations": [ - { - "type": 0, - "value": "Sorry, there are no locations in this area" - } - ], - "store_locator.description.or": [ - { - "type": 0, - "value": "Or" - } - ], - "store_locator.description.phone": [ - { - "type": 0, - "value": "Phone:" - } - ], - "store_locator.description.viewing_near_postal_code": [ - { - "type": 0, - "value": "Viewing stores within " - }, - { - "type": 1, - "value": "distance" - }, - { - "type": 1, - "value": "distanceUnit" - }, - { - "type": 0, - "value": " of " - }, - { - "type": 1, - "value": "postalCode" - }, - { - "type": 0, - "value": " in" - } - ], - "store_locator.description.viewing_near_your_location": [ - { - "type": 0, - "value": "Viewing stores near your location" - } - ], - "store_locator.dropdown.germany": [ - { - "type": 0, - "value": "Germany" - } - ], - "store_locator.dropdown.united_states": [ - { - "type": 0, - "value": "United States" - } - ], - "store_locator.error.agree_to_share_your_location": [ - { - "type": 0, - "value": "Please agree to share your location" - } - ], - "store_locator.error.please_enter_a_postal_code": [ - { - "type": 0, - "value": "Please enter a postal code." - } - ], - "store_locator.error.please_select_a_country": [ - { - "type": 0, - "value": "Please select a country." - } - ], - "store_locator.field.placeholder.enter_postal_code": [ - { - "type": 0, - "value": "Enter postal code" - } - ], - "store_locator.pagination.load_more": [ - { - "type": 0, - "value": "Load More" - } - ], - "store_locator.title": [ - { - "type": 0, - "value": "Find a Store" - } - ], "swatch_group.selected.label": [ { "type": 1, 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 75acacecc2..e2ed079b0b 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 @@ -7001,292 +7001,6 @@ "value": "]" } ], - "store_locator.action.find": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ƒīƞḓ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.action.select_a_country": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Şḗḗŀḗḗƈŧ ȧȧ ƈǿǿŭŭƞŧřẏ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.action.use_my_location": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ŭşḗḗ Ḿẏ Ŀǿǿƈȧȧŧīǿǿƞ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.action.viewMore": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ṽīḗḗẇ Ḿǿǿřḗḗ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.description.away": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "ȧȧẇȧȧẏ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.description.loading_locations": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ŀǿǿȧȧḓīƞɠ ŀǿǿƈȧȧŧīǿǿƞş..." - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.description.no_locations": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Şǿǿřřẏ, ŧħḗḗřḗḗ ȧȧřḗḗ ƞǿǿ ŀǿǿƈȧȧŧīǿǿƞş īƞ ŧħīş ȧȧřḗḗȧȧ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.description.or": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ǿř" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.description.phone": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ƥħǿǿƞḗḗ:" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.description.viewing_near_postal_code": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ṽīḗḗẇīƞɠ şŧǿǿřḗḗş ẇīŧħīƞ " - }, - { - "type": 1, - "value": "distance" - }, - { - "type": 1, - "value": "distanceUnit" - }, - { - "type": 0, - "value": " ǿǿƒ " - }, - { - "type": 1, - "value": "postalCode" - }, - { - "type": 0, - "value": " īƞ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.description.viewing_near_your_location": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ṽīḗḗẇīƞɠ şŧǿǿřḗḗş ƞḗḗȧȧř ẏǿǿŭŭř ŀǿǿƈȧȧŧīǿǿƞ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.dropdown.germany": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ɠḗḗřḿȧȧƞẏ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.dropdown.united_states": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ŭƞīŧḗḗḓ Şŧȧȧŧḗḗş" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.error.agree_to_share_your_location": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ƥŀḗḗȧȧşḗḗ ȧȧɠřḗḗḗḗ ŧǿǿ şħȧȧřḗḗ ẏǿǿŭŭř ŀǿǿƈȧȧŧīǿǿƞ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.error.please_enter_a_postal_code": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ƥŀḗḗȧȧşḗḗ ḗḗƞŧḗḗř ȧȧ ƥǿǿşŧȧȧŀ ƈǿǿḓḗḗ." - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.error.please_select_a_country": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ƥŀḗḗȧȧşḗḗ şḗḗŀḗḗƈŧ ȧȧ ƈǿǿŭŭƞŧřẏ." - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.field.placeholder.enter_postal_code": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ḗƞŧḗḗř ƥǿǿşŧȧȧŀ ƈǿǿḓḗḗ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.pagination.load_more": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ŀǿǿȧȧḓ Ḿǿǿřḗḗ" - }, - { - "type": 0, - "value": "]" - } - ], - "store_locator.title": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Ƒīƞḓ ȧȧ Şŧǿǿřḗḗ" - }, - { - "type": 0, - "value": "]" - } - ], "swatch_group.selected.label": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/utils/test-utils.js b/packages/template-retail-react-app/app/utils/test-utils.js index c6105b6f37..f9b496e11e 100644 --- a/packages/template-retail-react-app/app/utils/test-utils.js +++ b/packages/template-retail-react-app/app/utils/test-utils.js @@ -20,10 +20,23 @@ import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/compon import fallbackMessages from '@salesforce/retail-react-app/app/static/translations/compiled/en-GB.json' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' // Contexts -import {CurrencyProvider, MultiSiteProvider} from '@salesforce/retail-react-app/app/contexts' +import { + CurrencyProvider, + MultiSiteProvider, + StoreLocatorProvider +} from '@salesforce/retail-react-app/app/contexts' import {createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url' import {getSiteByReference} from '@salesforce/retail-react-app/app/utils/site-utils' +import { + STORE_LOCATOR_RADIUS, + STORE_LOCATOR_RADIUS_UNIT, + STORE_LOCATOR_DEFAULT_COUNTRY, + STORE_LOCATOR_DEFAULT_COUNTRY_CODE, + STORE_LOCATOR_DEFAULT_POSTAL_CODE, + STORE_LOCATOR_DEFAULT_PAGE_SIZE, + STORE_LOCATOR_SUPPORTED_COUNTRIES +} from '@salesforce/retail-react-app/app/constants' import jwt from 'jsonwebtoken' import userEvent from '@testing-library/user-event' // This JWT's payload is special @@ -120,6 +133,16 @@ export const TestProviders = ({ locale.alias || locale.id ) + const storeLocatorConfig = { + radius: STORE_LOCATOR_RADIUS, + radiusUnit: STORE_LOCATOR_RADIUS_UNIT, + defaultCountry: STORE_LOCATOR_DEFAULT_COUNTRY, + defaultCountryCode: STORE_LOCATOR_DEFAULT_COUNTRY_CODE, + defaultPostalCode: STORE_LOCATOR_DEFAULT_POSTAL_CODE, + defaultPageSize: STORE_LOCATOR_DEFAULT_PAGE_SIZE, + supportedCountries: STORE_LOCATOR_SUPPORTED_COUNTRIES + } + return ( @@ -135,11 +158,13 @@ export const TestProviders = ({ fetchedToken={bypassAuth ? (isGuest ? guestToken : registerUserToken) : ''} > - - - {children} - - + + + + {children} + + + diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index c6e5c939e7..ba6307226d 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -1406,63 +1406,6 @@ "social_login_redirect.message.redirect_link": { "defaultMessage": "If you are not automatically redirected, click this link to proceed." }, - "store_locator.action.find": { - "defaultMessage": "Find" - }, - "store_locator.action.select_a_country": { - "defaultMessage": "Select a country" - }, - "store_locator.action.use_my_location": { - "defaultMessage": "Use My Location" - }, - "store_locator.action.viewMore": { - "defaultMessage": "View More" - }, - "store_locator.description.away": { - "defaultMessage": "away" - }, - "store_locator.description.loading_locations": { - "defaultMessage": "Loading locations..." - }, - "store_locator.description.no_locations": { - "defaultMessage": "Sorry, there are no locations in this area" - }, - "store_locator.description.or": { - "defaultMessage": "Or" - }, - "store_locator.description.phone": { - "defaultMessage": "Phone:" - }, - "store_locator.description.viewing_near_postal_code": { - "defaultMessage": "Viewing stores within {distance}{distanceUnit} of {postalCode} in" - }, - "store_locator.description.viewing_near_your_location": { - "defaultMessage": "Viewing stores near your location" - }, - "store_locator.dropdown.germany": { - "defaultMessage": "Germany" - }, - "store_locator.dropdown.united_states": { - "defaultMessage": "United States" - }, - "store_locator.error.agree_to_share_your_location": { - "defaultMessage": "Please agree to share your location" - }, - "store_locator.error.please_enter_a_postal_code": { - "defaultMessage": "Please enter a postal code." - }, - "store_locator.error.please_select_a_country": { - "defaultMessage": "Please select a country." - }, - "store_locator.field.placeholder.enter_postal_code": { - "defaultMessage": "Enter postal code" - }, - "store_locator.pagination.load_more": { - "defaultMessage": "Load More" - }, - "store_locator.title": { - "defaultMessage": "Find a Store" - }, "swatch_group.selected.label": { "defaultMessage": "{label}:" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index c6e5c939e7..ba6307226d 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -1406,63 +1406,6 @@ "social_login_redirect.message.redirect_link": { "defaultMessage": "If you are not automatically redirected, click this link to proceed." }, - "store_locator.action.find": { - "defaultMessage": "Find" - }, - "store_locator.action.select_a_country": { - "defaultMessage": "Select a country" - }, - "store_locator.action.use_my_location": { - "defaultMessage": "Use My Location" - }, - "store_locator.action.viewMore": { - "defaultMessage": "View More" - }, - "store_locator.description.away": { - "defaultMessage": "away" - }, - "store_locator.description.loading_locations": { - "defaultMessage": "Loading locations..." - }, - "store_locator.description.no_locations": { - "defaultMessage": "Sorry, there are no locations in this area" - }, - "store_locator.description.or": { - "defaultMessage": "Or" - }, - "store_locator.description.phone": { - "defaultMessage": "Phone:" - }, - "store_locator.description.viewing_near_postal_code": { - "defaultMessage": "Viewing stores within {distance}{distanceUnit} of {postalCode} in" - }, - "store_locator.description.viewing_near_your_location": { - "defaultMessage": "Viewing stores near your location" - }, - "store_locator.dropdown.germany": { - "defaultMessage": "Germany" - }, - "store_locator.dropdown.united_states": { - "defaultMessage": "United States" - }, - "store_locator.error.agree_to_share_your_location": { - "defaultMessage": "Please agree to share your location" - }, - "store_locator.error.please_enter_a_postal_code": { - "defaultMessage": "Please enter a postal code." - }, - "store_locator.error.please_select_a_country": { - "defaultMessage": "Please select a country." - }, - "store_locator.field.placeholder.enter_postal_code": { - "defaultMessage": "Enter postal code" - }, - "store_locator.pagination.load_more": { - "defaultMessage": "Load More" - }, - "store_locator.title": { - "defaultMessage": "Find a Store" - }, "swatch_group.selected.label": { "defaultMessage": "{label}:" },