Skip to content

Commit a12b0f8

Browse files
@W-18811979 BOPIS (buy online pick up in store) (#2646)
1 parent 2ca3bd0 commit a12b0f8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+7128
-675
lines changed

packages/template-retail-react-app/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Updated 6 new languages [#2495](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2495)
66
- Fix Einstein event tracking for `addToCart` event [#2558](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2558)
77
- Update latest translations for all languages [#2616](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2616)
8+
- Added support for Buy Online Pick up In Store (BOPIS) [#2646](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2646)
89
- Load active data scripts on demand only [#2623](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2623)
910
- Provide base image for convenient perf optimizations [#2642](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2642)
1011

packages/template-retail-react-app/app/components/_app/index.jsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ import {
7474
THEME_COLOR,
7575
CAT_MENU_DEFAULT_NAV_SSR_DEPTH,
7676
CAT_MENU_DEFAULT_ROOT_CATEGORY,
77-
DEFAULT_LOCALE
77+
DEFAULT_LOCALE,
78+
STORE_LOCATOR_IS_ENABLED
7879
} from '@salesforce/retail-react-app/app/constants'
7980

8081
import Seo from '@salesforce/retail-react-app/app/components/seo'
@@ -354,10 +355,12 @@ const App = (props) => {
354355

355356
<Box id="app" display="flex" flexDirection="column" flex={1}>
356357
<SkipNavLink zIndex="skipLink">Skip to Content</SkipNavLink>
357-
<StoreLocatorModal
358-
isOpen={isOpenStoreLocator}
359-
onClose={onCloseStoreLocator}
360-
/>
358+
{STORE_LOCATOR_IS_ENABLED && (
359+
<StoreLocatorModal
360+
isOpen={isOpenStoreLocator}
361+
onClose={onCloseStoreLocator}
362+
/>
363+
)}
361364
<Box {...styles.headerWrapper}>
362365
{!isCheckout ? (
363366
<>

packages/template-retail-react-app/app/components/product-view-modal/bundle.test.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,17 +166,22 @@ test('bundle product view modal disables update button when quantity exceeds chi
166166
expect(swingTankProductView).toBeInTheDocument()
167167
expect(sizeSelectBtn).toBeInTheDocument()
168168
expect(quantityInput).toBeInTheDocument()
169-
expect(updateBtn).toBeEnabled()
169+
expect(updateBtn).toBeInTheDocument()
170170
})
171171

172172
// Set product bundle quantity selection to 4
173173
fireEvent.change(quantityInput, {target: {value: '4'}})
174174
fireEvent.keyDown(quantityInput, {key: 'Enter', code: 'Enter', charCode: 13})
175175

176+
// Wait for quantity change to be processed
177+
await waitFor(() => {
178+
expect(screen.getByRole('spinbutton', {name: /quantity/i})).toHaveValue('4')
179+
})
180+
181+
// Now click the size selection
176182
fireEvent.click(sizeSelectBtn)
177183

178184
await waitFor(() => {
179-
expect(screen.getByRole('spinbutton', {name: /quantity/i})).toHaveValue('4')
180185
expect(within(swingTankProductView).getAllByText('L')).toHaveLength(2)
181186
expect(updateBtn).toBeDisabled()
182187
expect(screen.getByText('Only 1 left!')).toBeInTheDocument()

packages/template-retail-react-app/app/components/product-view/index.jsx

Lines changed: 187 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,20 @@ import {
1919
Text,
2020
VStack,
2121
Fade,
22+
Stack,
23+
Radio,
24+
RadioGroup,
2225
useTheme
2326
} from '@salesforce/retail-react-app/app/components/shared/ui'
27+
28+
// Constants
29+
const DELIVERY_OPTIONS = {
30+
SHIP: 'ship',
31+
PICKUP: 'pickup'
32+
}
2433
import {useCurrency, useDerivedProduct} from '@salesforce/retail-react-app/app/hooks'
2534
import {useAddToCartModalContext} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal'
35+
import {STORE_LOCATOR_IS_ENABLED} from '@salesforce/retail-react-app/app/constants'
2636

2737
// project components
2838
import ImageGallery from '@salesforce/retail-react-app/app/components/image-gallery'
@@ -84,7 +94,8 @@ ProductViewHeader.propTypes = {
8494
category: PropTypes.array,
8595
priceData: PropTypes.object,
8696
product: PropTypes.object,
87-
isProductPartOfBundle: PropTypes.bool
97+
isProductPartOfBundle: PropTypes.bool,
98+
showDeliveryOptions: PropTypes.bool
8899
}
89100

90101
const ButtonWithRegistration = withRegistration(Button)
@@ -118,7 +129,11 @@ const ProductView = forwardRef(
118129
!isProductLoading && variant?.orderable && quantity > 0 && quantity <= stockLevel,
119130
showImageGallery = true,
120131
setSelectedBundleQuantity = () => {},
121-
selectedBundleParentQuantity = 1
132+
selectedBundleParentQuantity = 1,
133+
pickupInStore = false,
134+
setPickupInStore = () => {},
135+
onOpenStoreLocator = () => {},
136+
showDeliveryOptions = true
122137
},
123138
ref
124139
) => {
@@ -146,7 +161,9 @@ const ProductView = forwardRef(
146161
stockLevel,
147162
stepQuantity,
148163
isOutOfStock,
149-
unfulfillable
164+
unfulfillable,
165+
isSelectedStoreOutOfStock,
166+
selectedStore
150167
} = useDerivedProduct(product, isProductPartOfSet, isProductPartOfBundle)
151168
const priceData = useMemo(() => {
152169
return getPriceData(product, {quantity})
@@ -155,6 +172,9 @@ const ProductView = forwardRef(
155172
const isProductASet = product?.type.set
156173
const isProductABundle = product?.type.bundle
157174
const errorContainerRef = useRef(null)
175+
const [pickupEnabled, setPickupEnabled] = useState(false)
176+
const storeName = selectedStore?.name
177+
const inventoryId = selectedStore?.inventoryId
158178

159179
const {disableButton, customInventoryMessage} = useMemo(() => {
160180
let shouldDisableButton = showInventoryMessage
@@ -272,14 +292,17 @@ const ProductView = forwardRef(
272292
return
273293
}
274294
try {
275-
const itemsAdded = await addToCart(variant, quantity)
295+
const itemsAdded = await addToCart([{variant, quantity}])
276296
// Open modal only when `addToCart` returns some data
277297
// It's possible that the item has been added to cart, but we don't want to open the modal.
278298
// See wishlist_primary_action for example.
279299
if (itemsAdded) {
280300
onAddToCartModalOpen({
281301
product,
282-
itemsAdded,
302+
itemsAdded: itemsAdded.map((item) => ({
303+
...item,
304+
product // attach the full product object
305+
})),
283306
selectedQuantity: quantity
284307
})
285308
}
@@ -399,6 +422,18 @@ const ProductView = forwardRef(
399422
}
400423
}, [showInventoryMessage, inventoryMessage])
401424

425+
// Auto-switch off pickup in store when product becomes unavailable at selected store
426+
useEffect(() => {
427+
setPickupEnabled(!!selectedStore?.inventoryId)
428+
if (pickupInStore && isSelectedStoreOutOfStock) {
429+
setPickupInStore(false)
430+
}
431+
}, [selectedStore])
432+
433+
const handleDeliveryOptionChange = (value) => {
434+
setPickupInStore(value === DELIVERY_OPTIONS.PICKUP)
435+
}
436+
402437
return (
403438
<Flex direction={'column'} data-testid="product-view" ref={ref}>
404439
{/* Basic information etc. title, price, breadcrumb*/}
@@ -641,12 +676,148 @@ const ProductView = forwardRef(
641676
</Text>
642677
</Fade>
643678
)}
644-
<Box
645-
display={
646-
isProductPartOfSet ? 'block' : ['none', 'none', 'none', 'block']
647-
}
648-
>
649-
{renderActionButtons()}
679+
<Box>
680+
{showDeliveryOptions && (
681+
<>
682+
<Box mb={4}>
683+
<Text fontWeight={600} mb={3}>
684+
<FormattedMessage
685+
defaultMessage="Delivery:"
686+
id="product_view.label.delivery"
687+
/>
688+
</Text>
689+
<RadioGroup
690+
value={
691+
pickupInStore
692+
? DELIVERY_OPTIONS.PICKUP
693+
: DELIVERY_OPTIONS.SHIP
694+
}
695+
onChange={handleDeliveryOptionChange}
696+
mb={1}
697+
>
698+
<Stack direction="column" spacing={2}>
699+
<Radio
700+
value={DELIVERY_OPTIONS.SHIP}
701+
isDisabled={disableButton}
702+
>
703+
<FormattedMessage
704+
defaultMessage="Ship to Address"
705+
id="product_view.label.ship_to_address"
706+
/>
707+
</Radio>
708+
{STORE_LOCATOR_IS_ENABLED && (
709+
<Radio
710+
value={DELIVERY_OPTIONS.PICKUP}
711+
isDisabled={
712+
!pickupEnabled ||
713+
(storeName &&
714+
inventoryId &&
715+
isSelectedStoreOutOfStock)
716+
}
717+
>
718+
<FormattedMessage
719+
defaultMessage="Pickup in Store"
720+
id="product_view.label.pickup_in_store"
721+
/>
722+
</Radio>
723+
)}
724+
</Stack>
725+
</RadioGroup>
726+
</Box>
727+
728+
{STORE_LOCATOR_IS_ENABLED && (
729+
<>
730+
{storeName && inventoryId && (
731+
<Text
732+
color="black"
733+
fontWeight={600}
734+
mb={2}
735+
data-testid="store-stock-status-msg"
736+
>
737+
{!isSelectedStoreOutOfStock
738+
? intl.formatMessage(
739+
{
740+
id: 'product_view.status.in_stock_at_store',
741+
defaultMessage:
742+
'In Stock at {storeName}'
743+
},
744+
{
745+
storeName: (
746+
<Link
747+
as="button"
748+
color="blue.600"
749+
textDecoration="underline"
750+
onClick={
751+
onOpenStoreLocator
752+
}
753+
>
754+
{storeName}
755+
</Link>
756+
)
757+
}
758+
)
759+
: intl.formatMessage(
760+
{
761+
id: 'product_view.status.out_of_stock_at_store',
762+
defaultMessage:
763+
'Out of Stock at {storeName}'
764+
},
765+
{
766+
storeName: (
767+
<Link
768+
as="button"
769+
color="blue.600"
770+
textDecoration="underline"
771+
onClick={
772+
onOpenStoreLocator
773+
}
774+
>
775+
{storeName}
776+
</Link>
777+
)
778+
}
779+
)}
780+
</Text>
781+
)}
782+
783+
{/* Show label if pickup is disabled due to no store/inventoryId */}
784+
{!pickupEnabled && !storeName && !inventoryId && (
785+
<Text
786+
color="black"
787+
fontWeight={600}
788+
mb={3}
789+
data-testid="pickup-select-store-msg"
790+
>
791+
<FormattedMessage
792+
defaultMessage="Pickup in "
793+
id="product_view.label.pickup_in_select_store_prefix"
794+
/>{' '}
795+
<Link
796+
as="button"
797+
color="blue.600"
798+
textDecoration="underline"
799+
onClick={onOpenStoreLocator}
800+
>
801+
<FormattedMessage
802+
defaultMessage="Select Store"
803+
id="product_view.label.select_store_link"
804+
/>
805+
</Link>
806+
</Text>
807+
)}
808+
</>
809+
)}
810+
</>
811+
)}
812+
<Box
813+
display={
814+
isProductPartOfSet
815+
? 'block'
816+
: ['none', 'none', 'none', 'block']
817+
}
818+
>
819+
{renderActionButtons()}
820+
</Box>
650821
</Box>
651822
</Box>
652823
</VStack>
@@ -698,7 +869,11 @@ ProductView.propTypes = {
698869
validateOrderability: PropTypes.func,
699870
showImageGallery: PropTypes.bool,
700871
setSelectedBundleQuantity: PropTypes.func,
701-
selectedBundleParentQuantity: PropTypes.number
872+
selectedBundleParentQuantity: PropTypes.number,
873+
pickupInStore: PropTypes.bool,
874+
setPickupInStore: PropTypes.func,
875+
onOpenStoreLocator: PropTypes.func,
876+
showDeliveryOptions: PropTypes.bool
702877
}
703878

704879
export default ProductView

0 commit comments

Comments
 (0)