diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md
index adf739b66e..3e372b2e29 100644
--- a/packages/template-retail-react-app/CHANGELOG.md
+++ b/packages/template-retail-react-app/CHANGELOG.md
@@ -1,4 +1,5 @@
## v6.0.0
+- [BOPIS] Added text indicating the product's stock status at the selected store on the PDP and filtered products based on the selected store on the PLP. [#2226](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2226)
- DNT Consent Banner: [#2203](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2203)
## v5.1.0-dev (TBD)
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 458180dbd2..5c7705e7cb 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,10 @@ 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, {
+ StoreLocatorProvider
+} from '@salesforce/retail-react-app/app/components/store-locator-modal'
+
// Hooks
import {AuthModal, useAuthModal} from '@salesforce/retail-react-app/app/hooks/use-auth-modal'
import {
@@ -354,10 +357,6 @@ const App = (props) => {
Skip to Content
-
{!isCheckout ? (
<>
@@ -402,32 +401,38 @@ const App = (props) => {
{!isOnline && }
-
-
+
-
- {children}
-
-
-
-
- {!isCheckout ? : }
-
-
-
+
+
+ {children}
+
+
+
+
+ {!isCheckout ? : }
+
+
+
+
+
diff --git a/packages/template-retail-react-app/app/components/product-view/index.jsx b/packages/template-retail-react-app/app/components/product-view/index.jsx
index 2da2feaa93..f7d008deb2 100644
--- a/packages/template-retail-react-app/app/components/product-view/index.jsx
+++ b/packages/template-retail-react-app/app/components/product-view/index.jsx
@@ -39,6 +39,7 @@ import Swatch from '@salesforce/retail-react-app/app/components/swatch-group/swa
import SwatchGroup from '@salesforce/retail-react-app/app/components/swatch-group'
import {getPriceData} from '@salesforce/retail-react-app/app/utils/product-utils'
import PromoCallout from '@salesforce/retail-react-app/app/components/product-tile/promo-callout'
+import StoreAvailabilityText from '@salesforce/retail-react-app/app/components/store-availability-text'
const ProductViewHeader = ({
name,
@@ -598,6 +599,7 @@ const ProductView = forwardRef(
/>
)}
+
{!showLoading && showOptionsMessage && (
diff --git a/packages/template-retail-react-app/app/components/product-view/index.test.js b/packages/template-retail-react-app/app/components/product-view/index.test.js
index 22c864b04d..1da086b6ef 100644
--- a/packages/template-retail-react-app/app/components/product-view/index.test.js
+++ b/packages/template-retail-react-app/app/components/product-view/index.test.js
@@ -54,6 +54,8 @@ test('ProductView Component renders properly', async () => {
expect(screen.getAllByText(/Add to cart/i)).toHaveLength(2)
expect(screen.getAllByRole('radiogroup')).toHaveLength(3)
expect(screen.getAllByText(/add to cart/i)).toHaveLength(2)
+ expect(screen.getByText(/In Stock at/i)).toBeInTheDocument()
+ expect(screen.getByText(/Select Store/i)).toBeInTheDocument()
})
test('ProductView Component renders with addToCart event handler', async () => {
diff --git a/packages/template-retail-react-app/app/components/store-availability-text/index.jsx b/packages/template-retail-react-app/app/components/store-availability-text/index.jsx
new file mode 100644
index 0000000000..f3c6c28428
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/store-availability-text/index.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, {useContext} from 'react'
+import PropTypes from 'prop-types'
+import {FormattedMessage} from 'react-intl'
+
+// Components
+import {Box, Link} from '@salesforce/retail-react-app/app/components/shared/ui'
+
+// Others
+import {StoreLocatorContext} from '@salesforce/retail-react-app/app/components/store-locator-modal'
+
+const StoreAvailabilityText = ({productInventories}) => {
+ const {selectedStore, isStoreSelected} = useContext(StoreLocatorContext)
+ const inStock =
+ selectedStore.inventoryId &&
+ Boolean(
+ productInventories?.find(
+ (inventory) => inventory.id === selectedStore.inventoryId && inventory.orderable
+ )
+ )
+
+ return (
+
+ {isStoreSelected ? (
+ <>
+ {inStock ? (
+
+ ) : (
+
+ )}
+
+ {selectedStore.name}
+ >
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+ )
+}
+
+StoreAvailabilityText.propTypes = {
+ productInventories: PropTypes.arrayOf(PropTypes.object)
+}
+
+export default StoreAvailabilityText
diff --git a/packages/template-retail-react-app/app/components/store-availability-text/index.test.js b/packages/template-retail-react-app/app/components/store-availability-text/index.test.js
new file mode 100644
index 0000000000..b4a02296ee
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/store-availability-text/index.test.js
@@ -0,0 +1,93 @@
+/*
+ * 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 PropTypes from 'prop-types'
+
+import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
+import StoreAvailabilityText from '@salesforce/retail-react-app/app/components/store-availability-text'
+import {StoreLocatorContext} from '@salesforce/retail-react-app/app/components/store-locator-modal'
+
+const selectedStore = {
+ name: 'Test Store',
+ id: 'test_store',
+ inventoryId: 'inventory_1'
+}
+
+const MockComponent = ({selectedStore, isStoreSelected = true, productInventories}) => {
+ return (
+
+
+
+ )
+}
+MockComponent.propTypes = {
+ selectedStore: PropTypes.object,
+ isStoreSelected: PropTypes.bool,
+ productInventories: PropTypes.array
+}
+
+describe('StoreAvailability', () => {
+ describe('store is not selected', () => {
+ test('renders "In Stock at Select Store"', () => {
+ renderWithProviders()
+ expect(screen.getByText(/In Stock at/i)).toBeInTheDocument()
+ expect(screen.getByText(/Select Store/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('store is selected', () => {
+ test('renders "Out of Stock at Test Store" when productInventories is undefined', () => {
+ renderWithProviders()
+ expect(screen.getByText(/Out of Stock at/i)).toBeInTheDocument()
+ expect(screen.getByText(selectedStore.name)).toBeInTheDocument()
+ })
+
+ test('renders "Out of Stock at Test Store" when selectedStore does not have an inventoryId', () => {
+ const storeWithNoInventoryId = {...selectedStore, inventoryId: null}
+ renderWithProviders()
+ expect(screen.getByText(/Out of Stock at/i)).toBeInTheDocument()
+ expect(screen.getByText(selectedStore.name)).toBeInTheDocument()
+ })
+
+ test('renders "Out of Stock at Test Store" if selectedStore inventoryId is not in productInventories', () => {
+ const productInventories = [{id: 'inventory_99', orderable: true}]
+ renderWithProviders(
+
+ )
+ expect(screen.getByText(/Out of Stock at/i)).toBeInTheDocument()
+ expect(screen.getByText(selectedStore.name)).toBeInTheDocument()
+ })
+
+ test('renders "Out of Stock at Test Store" if orderable is false', () => {
+ const productInventories = [{id: 'inventory_1', orderable: false}]
+ renderWithProviders(
+
+ )
+ expect(screen.getByText(/Out of Stock at/i)).toBeInTheDocument()
+ expect(screen.getByText(selectedStore.name)).toBeInTheDocument()
+ })
+
+ test('renders "In Stock at Test Store" if orderable is true', () => {
+ const productInventories = [{id: 'inventory_1', orderable: true}]
+ renderWithProviders(
+
+ )
+ expect(screen.getByText(/In Stock at/i)).toBeInTheDocument()
+ expect(screen.getByText(selectedStore.name)).toBeInTheDocument()
+ })
+ })
+})
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
index 8bb5920cbb..05cbec2416 100644
--- 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
@@ -1,11 +1,11 @@
/*
- * Copyright (c) 2021, salesforce.com, inc.
+ * Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
-import React, {useState, createContext} from 'react'
+import React, {createContext} from 'react'
import PropTypes from 'prop-types'
// Components
@@ -18,79 +18,53 @@ import {
} 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'
+// Hooks
+import {useStoreLocator} from '@salesforce/retail-react-app/app/hooks/use-store-locator'
export const StoreLocatorContext = createContext()
-export const useStoreLocator = () => {
- const [userHasSetManualGeolocation, setUserHasSetManualGeolocation] = useState(false)
- const [automaticGeolocationHasFailed, setAutomaticGeolocationHasFailed] = useState(false)
- const [userWantsToShareLocation, setUserWantsToShareLocation] = useState(false)
+export const StoreLocatorProvider = ({children}) => {
+ const storeLocator = useStoreLocator()
- const [searchStoresParams, setSearchStoresParams] = useState({
- countryCode: DEFAULT_STORE_LOCATOR_COUNTRY.countryCode,
- postalCode: DEFAULT_STORE_LOCATOR_POSTAL_CODE,
- limit: STORE_LOCATOR_NUM_STORES_PER_LOAD
- })
+ return (
+ {children}
+ )
+}
- return {
- userHasSetManualGeolocation,
- setUserHasSetManualGeolocation,
- automaticGeolocationHasFailed,
- setAutomaticGeolocationHasFailed,
- userWantsToShareLocation,
- setUserWantsToShareLocation,
- searchStoresParams,
- setSearchStoresParams
- }
+StoreLocatorProvider.propTypes = {
+ children: PropTypes.node.isRequired
}
const StoreLocatorModal = ({isOpen, onClose}) => {
- const storeLocator = useStoreLocator()
const isDesktopView = useBreakpointValue({base: false, lg: true})
- return (
-
- {isDesktopView ? (
-
-
-
-
-
-
-
-
- ) : (
-
-
-
-
-
-
-
-
- )}
-
+ return isDesktopView ? (
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
)
}
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
index 2fadf4cb89..abefaaf2a6 100644
--- 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
@@ -8,6 +8,7 @@
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 {act, fireEvent, screen} from '@testing-library/react'
import {rest} from 'msw'
const mockStoresData = [
@@ -199,14 +200,40 @@ const mockStores = {
}
describe('StoreLocatorModal', () => {
- test('renders without crashing', () => {
+ beforeEach(() => {
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()
+ })
+
+ test('does not render when isOpen is false', () => {
+ renderWithProviders()
+ expect(screen.queryByText(/Find a Store/i)).not.toBeInTheDocument()
+ })
+
+ test('renders when isOpen is true', async () => {
+ renderWithProviders()
+ expect(screen.getByText(/Find a Store/i)).toBeInTheDocument()
+
+ await act(async () => {
+ // Select the country
+ const countrySelect = screen.getByRole('combobox')
+ fireEvent.change(countrySelect, {target: {value: 'US'}})
+
+ // Enter a postal code
+ const postalCodeInput = screen.getByPlaceholderText(/enter postal code/i)
+ fireEvent.change(postalCodeInput, {target: {value: '12345'}})
+
+ // Click the "Find" button
+ const findButton = screen.getByRole('button', {name: /find/i})
+ fireEvent.click(findButton)
+ })
+
+ mockStoresData.forEach((store) => {
+ expect(screen.getByText(store.name)).toBeInTheDocument()
+ expect(screen.getByText(store.address1)).toBeInTheDocument()
+ })
})
})
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
index 7f50d4aea7..98b505845a 100644
--- 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
@@ -13,7 +13,7 @@ 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'
+import {useStoreLocator} from '@salesforce/retail-react-app/app/hooks/use-store-locator'
const mockStoresData = [
{
address1: '162 University Ave',
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
index 03d1c6880b..ef85d23efb 100644
--- 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
@@ -12,7 +12,7 @@ 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 {useStoreLocator} from '@salesforce/retail-react-app/app/hooks/use-store-locator'
import {STORE_LOCATOR_NUM_STORES_PER_LOAD} from '@salesforce/retail-react-app/app/constants'
const WrapperComponent = ({userHasSetManualGeolocation}) => {
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
index a3fbd0a289..b3c76517a1 100644
--- 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
@@ -5,7 +5,7 @@
* 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 React, {useContext} from 'react'
import {useIntl} from 'react-intl'
import PropTypes from 'prop-types'
@@ -21,34 +21,20 @@ import {
RadioGroup
} from '@salesforce/retail-react-app/app/components/shared/ui'
-// Hooks
-import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
+// Others
+import {StoreLocatorContext} from '@salesforce/retail-react-app/app/components/store-locator-modal'
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 {selectedStore, setStore} = useContext(StoreLocatorContext)
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
- })
- )
+ setStore(store)
}
return (
-
+
{storesInfo?.map((store, index) => {
return (
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..081ffaf665
--- /dev/null
+++ b/packages/template-retail-react-app/app/hooks/use-store-locator.js
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2025, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+import {useEffect, useState} from 'react'
+
+// Hooks
+import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
+
+// Others
+import {
+ DEFAULT_STORE_LOCATOR_COUNTRY,
+ DEFAULT_STORE_LOCATOR_POSTAL_CODE,
+ STORE_LOCATOR_NUM_STORES_PER_LOAD
+} from '@salesforce/retail-react-app/app/constants'
+
+/*
+ * This hook returns all store locator search and selection parameters
+ * relevant to the StoreLocator component.
+ */
+export const useStoreLocator = () => {
+ // Store Locator
+ 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
+ })
+
+ // Store Selection
+ const {site} = useMultiSite()
+ const storeInfoKey = `store_${site.id}`
+ const [selectedStore, setSelectedStore] = useState({})
+
+ useEffect(() => {
+ setSelectedStore(JSON.parse(window.localStorage.getItem(storeInfoKey)) || {})
+ }, [storeInfoKey])
+
+ const setStore = (store) => {
+ const storeInfo = {
+ id: store.id,
+ name: store.name || null,
+ inventoryId: store.inventoryId || null
+ }
+ window.localStorage.setItem(storeInfoKey, JSON.stringify(storeInfo))
+ setSelectedStore(storeInfo)
+ }
+
+ return {
+ userHasSetManualGeolocation,
+ setUserHasSetManualGeolocation,
+ automaticGeolocationHasFailed,
+ setAutomaticGeolocationHasFailed,
+ userWantsToShareLocation,
+ setUserWantsToShareLocation,
+ searchStoresParams,
+ setSearchStoresParams,
+ selectedStore,
+ setStore,
+ isStoreSelected: !!selectedStore.id
+ }
+}
diff --git a/packages/template-retail-react-app/app/hooks/use-store-locator.test.js b/packages/template-retail-react-app/app/hooks/use-store-locator.test.js
new file mode 100644
index 0000000000..24da7981d0
--- /dev/null
+++ b/packages/template-retail-react-app/app/hooks/use-store-locator.test.js
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2025, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import {act} from 'react'
+import {renderHook} from '@testing-library/react'
+import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
+import {useStoreLocator} from '@salesforce/retail-react-app/app/hooks/use-store-locator'
+import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
+import {
+ DEFAULT_STORE_LOCATOR_COUNTRY,
+ DEFAULT_STORE_LOCATOR_POSTAL_CODE,
+ STORE_LOCATOR_NUM_STORES_PER_LOAD
+} from '@salesforce/retail-react-app/app/constants'
+
+jest.mock('./use-multi-site', () => jest.fn())
+useMultiSite.mockImplementation(() => ({site: {id: mockConfig.app.defaultSite}}))
+
+beforeEach(() => {
+ localStorage.clear()
+ jest.spyOn(window.localStorage, 'setItem')
+})
+
+describe('useStoreLocator', () => {
+ test('initial state with no store selected', () => {
+ const {result} = renderHook(() => useStoreLocator())
+ expect(result.current.selectedStore).toEqual({})
+ expect(result.current.isStoreSelected).toBe(false)
+ })
+
+ test('selecting a store', () => {
+ const store = {
+ id: 'test-store',
+ name: 'Test Store',
+ inventoryId: 'inventory'
+ }
+ const expectedStoreData = JSON.stringify(store)
+
+ const {result} = renderHook(() => useStoreLocator())
+
+ act(() => {
+ result.current.setStore(store)
+ })
+
+ expect(result.current.selectedStore).toEqual(store)
+ expect(result.current.isStoreSelected).toBe(true)
+ expect(localStorage.setItem).toHaveBeenCalledWith(
+ `store_${mockConfig.app.defaultSite}`,
+ expectedStoreData
+ )
+ })
+
+ test('set user has set manual geolocation', () => {
+ const {result} = renderHook(() => useStoreLocator())
+ expect(result.current.userHasSetManualGeolocation).toBe(false)
+ act(() => {
+ result.current.setUserHasSetManualGeolocation(true)
+ })
+ expect(result.current.userHasSetManualGeolocation).toBe(true)
+ })
+
+ test('set automatic geolocation has failed', () => {
+ const {result} = renderHook(() => useStoreLocator())
+ expect(result.current.automaticGeolocationHasFailed).toBe(false)
+ act(() => {
+ result.current.setAutomaticGeolocationHasFailed(true)
+ })
+ expect(result.current.automaticGeolocationHasFailed).toBe(true)
+ })
+
+ test('set user wants to share location', () => {
+ const {result} = renderHook(() => useStoreLocator())
+ expect(result.current.userWantsToShareLocation).toBe(false)
+ act(() => {
+ result.current.setUserWantsToShareLocation(true)
+ })
+ expect(result.current.userWantsToShareLocation).toBe(true)
+ })
+
+ test('set search stores params', () => {
+ const {result} = renderHook(() => useStoreLocator())
+ expect(result.current.searchStoresParams).toEqual({
+ countryCode: DEFAULT_STORE_LOCATOR_COUNTRY.countryCode,
+ postalCode: DEFAULT_STORE_LOCATOR_POSTAL_CODE,
+ limit: STORE_LOCATOR_NUM_STORES_PER_LOAD
+ })
+
+ const newSearchStoresParams = {
+ countryCode: 'US',
+ postalCode: '94105',
+ limit: 10
+ }
+
+ act(() => {
+ result.current.setSearchStoresParams(newSearchStoresParams)
+ })
+
+ expect(result.current.searchStoresParams).toEqual(newSearchStoresParams)
+ })
+})
diff --git a/packages/template-retail-react-app/app/pages/product-detail/index.jsx b/packages/template-retail-react-app/app/pages/product-detail/index.jsx
index ea7c8be085..14c03fcc70 100644
--- a/packages/template-retail-react-app/app/pages/product-detail/index.jsx
+++ b/packages/template-retail-react-app/app/pages/product-detail/index.jsx
@@ -5,7 +5,7 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
-import React, {Fragment, useCallback, useEffect, useState} from 'react'
+import React, {Fragment, useCallback, useContext, useEffect, useState} from 'react'
import PropTypes from 'prop-types'
import {Helmet} from 'react-helmet'
import {FormattedMessage, useIntl} from 'react-intl'
@@ -55,6 +55,7 @@ import {rebuildPathWithParams} from '@salesforce/retail-react-app/app/utils/url'
import {useHistory, useLocation, useParams} from 'react-router-dom'
import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
import {useWishList} from '@salesforce/retail-react-app/app/hooks/use-wish-list'
+import {StoreLocatorContext} from '@salesforce/retail-react-app/app/components/store-locator-modal'
const ProductDetail = () => {
const {formatMessage} = useIntl()
@@ -81,6 +82,7 @@ const ProductDetail = () => {
/*************************** Product Detail and Category ********************/
const {productId} = useParams()
const urlParams = new URLSearchParams(location.search)
+ const {selectedStore} = useContext(StoreLocatorContext)
const {
data: product,
isLoading: isProductLoading,
@@ -101,7 +103,8 @@ const ProductDetail = () => {
'set_products',
'bundled_products'
],
- allImages: true
+ allImages: true,
+ inventoryIds: selectedStore?.inventoryId || []
}
},
{
diff --git a/packages/template-retail-react-app/app/pages/product-list/index.jsx b/packages/template-retail-react-app/app/pages/product-list/index.jsx
index d96422c7c7..b2b9ed6c33 100644
--- a/packages/template-retail-react-app/app/pages/product-list/index.jsx
+++ b/packages/template-retail-react-app/app/pages/product-list/index.jsx
@@ -54,6 +54,7 @@ import ProductTile, {
import {HideOnDesktop} from '@salesforce/retail-react-app/app/components/responsive'
import Refinements from '@salesforce/retail-react-app/app/pages/product-list/partials/refinements'
import CategoryLinks from '@salesforce/retail-react-app/app/pages/product-list/partials/category-links'
+import StoreAvailabilityRefinement from '@salesforce/retail-react-app/app/pages/product-list/partials/store-availability-refinement'
import SelectedRefinements from '@salesforce/retail-react-app/app/pages/product-list/partials/selected-refinements'
import EmptySearchResults from '@salesforce/retail-react-app/app/pages/product-list/partials/empty-results'
import PageHeader from '@salesforce/retail-react-app/app/pages/product-list/partials/page-header'
@@ -526,11 +527,16 @@ const ProductList = (props) => {
,
+ ...(category?.categories
? []
- : undefined
- }
+ : [])
+ ]}
isLoading={filtersLoading}
toggleFilter={toggleFilter}
filters={productSearchResult?.refinements}
diff --git a/packages/template-retail-react-app/app/pages/product-list/index.test.js b/packages/template-retail-react-app/app/pages/product-list/index.test.js
index 33f18203e1..82cedf2e7d 100644
--- a/packages/template-retail-react-app/app/pages/product-list/index.test.js
+++ b/packages/template-retail-react-app/app/pages/product-list/index.test.js
@@ -13,6 +13,7 @@ import {
mockedEmptyCustomerProductList,
mockCategories
} from '@salesforce/retail-react-app/app/mocks/mock-data'
+import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
import {screen, waitFor} from '@testing-library/react'
import {Route, Switch} from 'react-router-dom'
import {
@@ -307,3 +308,18 @@ test('clicking a filter on search result will change url', async () => {
)
)
})
+
+test('clicking store availability filter will change url', async () => {
+ window.history.pushState({}, 'ProductList', '/uk/en-GB/category/mens-clothing-jackets')
+ window.localStorage.setItem(
+ `store_${mockConfig.app.defaultSite}`,
+ JSON.stringify({id: 'test_store', name: 'Test Store', inventoryId: 'inventory_1'})
+ )
+ const {user} = renderWithProviders()
+ await user.click(screen.getByText(/Test Store/i))
+ await waitFor(() =>
+ expect(window.location.search).toBe(
+ '?limit=25&refine=ilids%3Dinventory_1&sort=best-matches'
+ )
+ )
+})
diff --git a/packages/template-retail-react-app/app/pages/product-list/partials/store-availability-refinement.jsx b/packages/template-retail-react-app/app/pages/product-list/partials/store-availability-refinement.jsx
new file mode 100644
index 0000000000..4ab9e78632
--- /dev/null
+++ b/packages/template-retail-react-app/app/pages/product-list/partials/store-availability-refinement.jsx
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2023, 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, {useContext} from 'react'
+import PropTypes from 'prop-types'
+import {FormattedMessage} from 'react-intl'
+
+// Project Components
+import {
+ AccordionItem,
+ AccordionButton,
+ AccordionPanel,
+ AccordionIcon,
+ Heading
+} from '@salesforce/retail-react-app/app/components/shared/ui'
+import CheckboxRefinements from '@salesforce/retail-react-app/app/pages/product-list/partials/checkbox-refinements'
+
+// Others
+import {StoreLocatorContext} from '@salesforce/retail-react-app/app/components/store-locator-modal/index'
+
+const INVENTORY_ATTRIBUTE_ID = 'ilids'
+
+const StoreAvailabilityRefinement = ({toggleFilter, selectedFilters}) => {
+ const {selectedStore, isStoreSelected} = useContext(StoreLocatorContext)
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+StoreAvailabilityRefinement.propTypes = {
+ toggleFilter: PropTypes.func,
+ selectedFilters: PropTypes.array
+}
+
+export default StoreAvailabilityRefinement
diff --git a/packages/template-retail-react-app/app/pages/product-list/partials/store-availability-refinements.test.js b/packages/template-retail-react-app/app/pages/product-list/partials/store-availability-refinements.test.js
new file mode 100644
index 0000000000..54d36937e3
--- /dev/null
+++ b/packages/template-retail-react-app/app/pages/product-list/partials/store-availability-refinements.test.js
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2022, 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 PropTypes from 'prop-types'
+
+import {Accordion} from '@salesforce/retail-react-app/app/components/shared/ui'
+import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
+import StoreAvailabilityRefinement from '@salesforce/retail-react-app/app/pages/product-list/partials/store-availability-refinement'
+import {StoreLocatorContext} from '@salesforce/retail-react-app/app/components/store-locator-modal'
+
+const toggleFilter = jest.fn()
+
+const MockComponent = ({selectedStore, isStoreSelected, ...props}) => {
+ return (
+
+
+
+
+
+ )
+}
+MockComponent.propTypes = {
+ selectedStore: PropTypes.object,
+ isStoreSelected: PropTypes.bool
+}
+
+const selectedStore = {name: 'Test Store', id: 'test_store', inventoryId: '123'}
+
+describe('StoreAvailabilityRefinement', function () {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('renders properly when there is no selected store', () => {
+ renderWithProviders(
+
+ )
+ expect(screen.getByText(/Shop By Availability/i)).toBeInTheDocument()
+ expect(screen.getByText(/Select Store/i)).toBeInTheDocument()
+ })
+
+ test('renders properly when there is a selected store', async () => {
+ renderWithProviders(
+
+ )
+ expect(screen.getByText(/Shop By Availability/i)).toBeInTheDocument()
+ expect(screen.getByText(selectedStore.name)).toBeInTheDocument()
+ })
+})
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..ea4ce85e07 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
@@ -12,33 +12,23 @@ import {Box, Container} from '@salesforce/retail-react-app/app/components/shared
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()
-
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
)
}
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 24a43bb743..16966caf41 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
@@ -2801,6 +2801,24 @@
"value": "See full details"
}
],
+ "product_view.link.select_store": [
+ {
+ "type": 0,
+ "value": "Select Store"
+ }
+ ],
+ "product_view.store_availability.in_stock_at": [
+ {
+ "type": 0,
+ "value": "In Stock at"
+ }
+ ],
+ "product_view.store_availability.out_of_stock_at": [
+ {
+ "type": 0,
+ "value": "Out of Stock at"
+ }
+ ],
"profile_card.info.profile_updated": [
{
"type": 0,
@@ -3135,6 +3153,12 @@
"value": "Are you sure you want to sign out? You will need to sign back in to proceed with your current order."
}
],
+ "store_availability_refinement.button_text": [
+ {
+ "type": 0,
+ "value": "Shop By Availability"
+ }
+ ],
"store_locator.action.find": [
{
"type": 0,
diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json
index 24a43bb743..16966caf41 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
@@ -2801,6 +2801,24 @@
"value": "See full details"
}
],
+ "product_view.link.select_store": [
+ {
+ "type": 0,
+ "value": "Select Store"
+ }
+ ],
+ "product_view.store_availability.in_stock_at": [
+ {
+ "type": 0,
+ "value": "In Stock at"
+ }
+ ],
+ "product_view.store_availability.out_of_stock_at": [
+ {
+ "type": 0,
+ "value": "Out of Stock at"
+ }
+ ],
"profile_card.info.profile_updated": [
{
"type": 0,
@@ -3135,6 +3153,12 @@
"value": "Are you sure you want to sign out? You will need to sign back in to proceed with your current order."
}
],
+ "store_availability_refinement.button_text": [
+ {
+ "type": 0,
+ "value": "Shop By Availability"
+ }
+ ],
"store_locator.action.find": [
{
"type": 0,
diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json
index 0c06c2e3ae..56ccd6da50 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
@@ -5937,6 +5937,48 @@
"value": "]"
}
],
+ "product_view.link.select_store": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Şḗḗŀḗḗƈŧ Şŧǿǿřḗḗ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "product_view.store_availability.in_stock_at": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Īƞ Şŧǿǿƈķ ȧȧŧ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
+ "product_view.store_availability.out_of_stock_at": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Ǿŭŭŧ ǿǿƒ Şŧǿǿƈķ ȧȧŧ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
"profile_card.info.profile_updated": [
{
"type": 0,
@@ -6655,6 +6697,20 @@
"value": "]"
}
],
+ "store_availability_refinement.button_text": [
+ {
+ "type": 0,
+ "value": "["
+ },
+ {
+ "type": 0,
+ "value": "Şħǿǿƥ Ɓẏ Ȧṽȧȧīŀȧȧƀīŀīŧẏ"
+ },
+ {
+ "type": 0,
+ "value": "]"
+ }
+ ],
"store_locator.action.find": [
{
"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..6127350e48 100644
--- a/packages/template-retail-react-app/app/utils/test-utils.js
+++ b/packages/template-retail-react-app/app/utils/test-utils.js
@@ -21,6 +21,7 @@ import fallbackMessages from '@salesforce/retail-react-app/app/static/translatio
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
// Contexts
import {CurrencyProvider, MultiSiteProvider} from '@salesforce/retail-react-app/app/contexts'
+import {StoreLocatorProvider} from '@salesforce/retail-react-app/app/components/store-locator-modal'
import {createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url'
import {getSiteByReference} from '@salesforce/retail-react-app/app/utils/site-utils'
@@ -137,7 +138,9 @@ export const TestProviders = ({
- {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 38dc12ed05..7b530f68cd 100644
--- a/packages/template-retail-react-app/translations/en-GB.json
+++ b/packages/template-retail-react-app/translations/en-GB.json
@@ -1189,6 +1189,15 @@
"product_view.link.full_details": {
"defaultMessage": "See full details"
},
+ "product_view.link.select_store": {
+ "defaultMessage": "Select Store"
+ },
+ "product_view.store_availability.in_stock_at": {
+ "defaultMessage": "In Stock at"
+ },
+ "product_view.store_availability.out_of_stock_at": {
+ "defaultMessage": "Out of Stock at"
+ },
"profile_card.info.profile_updated": {
"defaultMessage": "Profile updated"
},
@@ -1334,6 +1343,9 @@
"signout_confirmation_dialog.message.sure_to_sign_out": {
"defaultMessage": "Are you sure you want to sign out? You will need to sign back in to proceed with your current order."
},
+ "store_availability_refinement.button_text": {
+ "defaultMessage": "Shop By Availability"
+ },
"store_locator.action.find": {
"defaultMessage": "Find"
},
diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json
index 38dc12ed05..7b530f68cd 100644
--- a/packages/template-retail-react-app/translations/en-US.json
+++ b/packages/template-retail-react-app/translations/en-US.json
@@ -1189,6 +1189,15 @@
"product_view.link.full_details": {
"defaultMessage": "See full details"
},
+ "product_view.link.select_store": {
+ "defaultMessage": "Select Store"
+ },
+ "product_view.store_availability.in_stock_at": {
+ "defaultMessage": "In Stock at"
+ },
+ "product_view.store_availability.out_of_stock_at": {
+ "defaultMessage": "Out of Stock at"
+ },
"profile_card.info.profile_updated": {
"defaultMessage": "Profile updated"
},
@@ -1334,6 +1343,9 @@
"signout_confirmation_dialog.message.sure_to_sign_out": {
"defaultMessage": "Are you sure you want to sign out? You will need to sign back in to proceed with your current order."
},
+ "store_availability_refinement.button_text": {
+ "defaultMessage": "Shop By Availability"
+ },
"store_locator.action.find": {
"defaultMessage": "Find"
},