Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
65 changes: 35 additions & 30 deletions packages/template-retail-react-app/app/components/_app/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -354,10 +357,6 @@ const App = (props) => {

<Box id="app" display="flex" flexDirection="column" flex={1}>
<SkipNavLink zIndex="skipLink">Skip to Content</SkipNavLink>
<StoreLocatorModal
isOpen={isOpenStoreLocator}
onClose={onCloseStoreLocator}
/>
<Box {...styles.headerWrapper}>
{!isCheckout ? (
<>
Expand Down Expand Up @@ -402,32 +401,38 @@ const App = (props) => {
</Box>
{!isOnline && <OfflineBanner />}
<AddToCartModalProvider>
<SkipNavContent
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
outline: 0
}}
>
<Box
as="main"
id="app-main"
role="main"
display="flex"
flexDirection="column"
flex="1"
<StoreLocatorProvider>
<SkipNavContent
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
outline: 0
}}
>
<OfflineBoundary isOnline={false}>
{children}
</OfflineBoundary>
</Box>
</SkipNavContent>

{!isCheckout ? <Footer /> : <CheckoutFooter />}

<AuthModal {...authModal} />
<DntNotification {...dntNotification} />
<Box
as="main"
id="app-main"
role="main"
display="flex"
flexDirection="column"
flex="1"
>
<OfflineBoundary isOnline={false}>
{children}
</OfflineBoundary>
</Box>
</SkipNavContent>

{!isCheckout ? <Footer /> : <CheckoutFooter />}

<AuthModal {...authModal} />
<StoreLocatorModal
isOpen={isOpenStoreLocator}
onClose={onCloseStoreLocator}
/>
<DntNotification {...dntNotification} />
</StoreLocatorProvider>
</AddToCartModalProvider>
</Box>
</CurrencyProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -598,6 +599,7 @@ const ProductView = forwardRef(
/>
</VStack>
)}
<StoreAvailabilityText productInventories={product?.inventories} />
<Box ref={errorContainerRef}>
{!showLoading && showOptionsMessage && (
<Fade in={true}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Box gap={1} fontWeight={400} display="flex">
{isStoreSelected ? (
<>
{inStock ? (
<FormattedMessage
id={'product_view.store_availability.in_stock_at'}
defaultMessage={'In Stock at'}
/>
) : (
<FormattedMessage
id={'product_view.store_availability.out_of_stock_at'}
defaultMessage={'Out of Stock at'}
/>
)}

<Link>{selectedStore.name}</Link>
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's possible to create a store with no name. Should we throw an error in that case?

Copy link
Collaborator

@jeremy-jung1 jeremy-jung1 Feb 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a store is created with no name, that should be a concern on the BM/store creation side and not an error that the shoppers' UX should have to deal with. I think it would be fine to keep it simple and just display what the API gives.

</>
) : (
<>
<FormattedMessage
id={'product_view.store_availability.in_stock_at'}
defaultMessage={'In Stock at'}
/>
<Link>
<FormattedMessage
id={'product_view.link.select_store'}
defaultMessage={'Select Store'}
/>
</Link>
</>
)}
</Box>
)
}

StoreAvailabilityText.propTypes = {
productInventories: PropTypes.arrayOf(PropTypes.object)
}

export default StoreAvailabilityText
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* 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 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 renderWithStoreLocatorContextProvider = (selectedStore, productInventories = []) => {
return renderWithProviders(
<StoreLocatorContext.Provider value={{ selectedStore, isStoreSelected: true }}>
<StoreAvailabilityText productInventories={productInventories} />
</StoreLocatorContext.Provider>
)
}

describe('StoreAvailability', () => {
describe('store is not selected', () => {
test('renders "In Stock at Select Store"', () => {
renderWithProviders(
<StoreLocatorContext.Provider value={{ selectedStore: {} }}>
<StoreAvailabilityText />
</StoreLocatorContext.Provider>
)
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', () => {
renderWithStoreLocatorContextProvider(selectedStore)
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', () => {
renderWithStoreLocatorContextProvider({ ...selectedStore, inventoryId: null })
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', () => {
renderWithStoreLocatorContextProvider(selectedStore, [{ id: 'inventory_99', orderable: true }])
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', () => {
renderWithStoreLocatorContextProvider(selectedStore, [{ id: 'inventory_1', orderable: false }])
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', () => {
renderWithStoreLocatorContextProvider(selectedStore, [{ id: 'inventory_1', orderable: true }])
expect(screen.getByText(/In Stock at/i)).toBeInTheDocument()
expect(screen.getByText(selectedStore.name)).toBeInTheDocument()
})
})
})

Loading
Loading