diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md
index 5f3e9154ac..d078933073 100644
--- a/packages/template-retail-react-app/CHANGELOG.md
+++ b/packages/template-retail-react-app/CHANGELOG.md
@@ -7,6 +7,7 @@
- Update latest translations for all languages [#2616](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2616)
- Load active data scripts on demand only [#2623](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2623)
- Show Bonus Products on Cart Page [#2547](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2547)
+- Show the Bonus Product in a Product View Modal to enable adding it to the cart [#2680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2680)
## v6.1.0 (May 22, 2025)
diff --git a/packages/template-retail-react-app/app/components/bonus-product-modal/index.jsx b/packages/template-retail-react-app/app/components/bonus-product-modal/index.jsx
index e62366b46b..46ee5c4a3e 100644
--- a/packages/template-retail-react-app/app/components/bonus-product-modal/index.jsx
+++ b/packages/template-retail-react-app/app/components/bonus-product-modal/index.jsx
@@ -5,7 +5,11 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React, {useState, useEffect, useMemo} from 'react'
+import {useIntl} from 'react-intl'
import {
+ VStack,
+ AspectRatio,
+ Skeleton,
Modal,
ModalCloseButton,
ModalContent,
@@ -16,16 +20,119 @@ import {
Text,
Box,
SimpleGrid,
- Button
+ Button,
+ useDisclosure
} from '@salesforce/retail-react-app/app/components/shared/ui'
import {useProducts} from '@salesforce/commerce-sdk-react'
+import {useHistory} from 'react-router-dom'
+import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
+import useEinstein from '@salesforce/retail-react-app/app/hooks/use-einstein'
+import DynamicImage from '@salesforce/retail-react-app/app/components/dynamic-image'
+import ProductViewModal from '@salesforce/retail-react-app/app/components/product-view-modal'
+import PropTypes from 'prop-types'
import {useBonusProductModalContext} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-modal'
+import {useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react'
+import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
+import {findImageGroupBy} from '@salesforce/retail-react-app/app/utils/image-groups-utils'
import {FormattedMessage} from 'react-intl'
-import BonusProductItem from '@salesforce/retail-react-app/app/components/bonus-product-item/bonus-product-item'
+import {filterImageGroups} from '@salesforce/retail-react-app/app/utils/product-utils'
+import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants'
+
+// Component to display individual bonus product with checkbox for selection
+const BonusProductItem = ({product, productData, onClick, isLoading}) => {
+ const productName = product?.productName || product?.title
+
+ // Get the appropriate image group from the passed product data
+ // Use filterImageGroups to get variant-specific images when available
+ const imageGroup = useMemo(() => {
+ if (!productData?.imageGroups) return null
+
+ // If the product has variationValues, use filterImageGroups to get variant-specific images
+ if (productData.variationValues && Object.keys(productData.variationValues).length > 0) {
+ const filteredGroups = filterImageGroups(productData.imageGroups, {
+ viewType: 'small',
+ variationValues: productData.variationValues
+ })
+ return filteredGroups?.[0] || null
+ }
+
+ // Fallback to the original logic for non-variant products
+ return findImageGroupBy(productData.imageGroups, {
+ viewType: 'small'
+ })
+ }, [productData])
+
+ const image = imageGroup?.images?.[0]
+ const showLoading = isLoading
+
+ if (showLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+ {image && (
+
+ )}
+
+
+
+ {productName}
+
+
+
+ )
+}
+
+BonusProductItem.propTypes = {
+ product: PropTypes.object.isRequired,
+ productData: PropTypes.object,
+ onClick: PropTypes.func.isRequired,
+ isLoading: PropTypes.bool
+}
export const BonusProductModal = () => {
- const {isOpen, onClose, data} = useBonusProductModalContext()
- const [selectedProducts, setSelectedProducts] = useState(new Set())
+ const {data: basket} = useCurrentBasket()
+ const einstein = useEinstein()
+ const history = useHistory()
+ const showToast = useToast()
+ const {formatMessage} = useIntl()
+ const {addItemToNewOrExistingBasket} = useShopperBasketsMutationHelper()
+ const {isOpen, onClose, onClickClose, data} = useBonusProductModalContext()
+ const {
+ isOpen: isProductViewOpen,
+ onOpen: onProductViewOpen,
+ onClose: onProductViewClose
+ } = useDisclosure()
+ const [selectedProduct, setSelectedProduct] = useState()
// Extract bonus items from the response structure
const bonusDiscountLineItems = data?.newBonusItems || data?.allBonusItems || []
@@ -33,6 +140,21 @@ export const BonusProductModal = () => {
const bonusProducts = currentPromotion.bonusProducts || []
const maxBonusItems = currentPromotion.maxBonusItems || 1
+ // Get existing bonus products for this specific promotion
+ const existingBonusProducts = useMemo(() => {
+ if (!basket?.productItems || !currentPromotion.id) return []
+ return basket.productItems.filter(
+ (item) =>
+ item.bonusProductLineItem === true &&
+ item.bonusDiscountLineItemId === currentPromotion.id
+ )
+ }, [basket?.productItems, currentPromotion.id])
+ // Calculate how many more bonus items can be added
+ const currentBonusCount = existingBonusProducts.reduce(
+ (total, item) => total + item.quantity,
+ 0
+ )
+
// Get all product IDs for batch fetching
const productIds = useMemo(() => {
return bonusProducts.map((product) => product.productId || product.id).filter(Boolean)
@@ -64,91 +186,123 @@ export const BonusProductModal = () => {
// Reset selections when modal opens
useEffect(() => {
if (isOpen) {
- setSelectedProducts(new Set())
+ setSelectedProduct(null)
}
}, [isOpen])
- const handleToggle = (product) => {
- const productIdentifier = product.id || product.productId
- const isSelected = selectedProducts.has(productIdentifier)
- if (isSelected) {
- setSelectedProducts((prev) => {
- const newSet = new Set(prev)
- newSet.delete(productIdentifier)
- return newSet
- })
- } else if (selectedProducts.size < maxBonusItems) {
- setSelectedProducts((prev) => {
- const newSet = new Set(prev)
- newSet.add(productIdentifier)
- return newSet
- })
- }
+ const showError = () => {
+ showToast({
+ title: formatMessage(API_ERROR_MESSAGE),
+ status: 'error'
+ })
+ }
+
+ const handleClickBonusProduct = (product) => {
+ setSelectedProduct(product.productId || product.id)
+ onProductViewOpen()
}
- const handleNext = () => {
- onClose()
+ const handleAddToCart = async (productSelectionValues) => {
+ try {
+ const productItems = productSelectionValues.map(({variant, quantity}) => ({
+ productId: variant.productId,
+ bonusDiscountLineItemId: currentPromotion.id,
+ quantity
+ }))
+
+ await addItemToNewOrExistingBasket(productItems)
+
+ einstein.sendAddToCart(productItems)
+ } catch (error) {
+ console.log('error', error)
+ showError()
+ } finally {
+ onClickClose()
+ history.push('/cart')
+ }
}
- const selectedCount = selectedProducts.size
+ const onProductViewHide = () => {
+ setSelectedProduct(null)
+ onProductViewClose()
+ }
// Calculate columns based on number of products
const productCount = bonusProducts.length
+ const columns = Math.min(productCount, 3) // Max 3 columns, but fewer if less products
+ const product = useMemo(() => {
+ const bonusProduct = bonusProducts.find((product) => product.productId === selectedProduct)
+ if (!bonusProduct) return null
+ // Get the full product data from the fetched products
+ const fullProductData = productsDataMap[bonusProduct.productId || bonusProduct.id]
+ // Merge bonus product data with full product data
+ return fullProductData ? {...fullProductData, ...bonusProduct} : null
+ }, [bonusProducts, selectedProduct, productsDataMap])
if (!isOpen) return null
return (
-
-
-
-
-
- Add Bonus Product ({selectedCount} of {maxBonusItems})
-
-
-
-
-
- {bonusProducts.length > 0 ? (
-
- {bonusProducts.map((product) => {
- const productId = product.productId || product.id
- const productData = productsDataMap[productId]
-
- return (
-
- )
- })}
-
- ) : (
-
-
-
+ <>
+ {selectedProduct && product && (
+
+ handleAddToCart([{product: product, variant, quantity: quantity}])
+ }
+ />
+ )}
+ {!selectedProduct && (
+
+
+
+
+
+ Add Bonus Product ({currentBonusCount} of {maxBonusItems})
-
- )}
-
-
-
-
-
-
-
+
+
+
+
+ {bonusProducts.length > 0 ? (
+
+ {bonusProducts.map((product) => {
+ const productId = product.productId || product.id
+ const productData = productsDataMap[productId]
+
+ return (
+ handleClickBonusProduct(product)}
+ isLoading={isProductsLoading}
+ />
+ )
+ })}
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ )}
+ >
)
}
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 e12fb61179..2fc2e99f3c 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
@@ -160,8 +160,8 @@ const ProductView = forwardRef(
return getPriceData(product, {quantity})
}, [product, quantity])
const canAddToWishlist = !isProductLoading
- const isProductASet = product?.type.set
- const isProductABundle = product?.type.bundle
+ const isProductASet = product?.type?.set
+ const isProductABundle = product?.type?.bundle
const errorContainerRef = useRef(null)
const {disableButton, customInventoryMessage} = useMemo(() => {
@@ -314,7 +314,6 @@ const ProductView = forwardRef(
onBonusProductModalOpen({
newBonusItems,
allBonusItems: addToCartResponse.bonusDiscountLineItems,
- openAddToCartModalIfNeeded: true,
product,
itemsAdded,
selectedQuantity: quantity
@@ -474,7 +473,7 @@ const ProductView = forwardRef(
{showFullLink && product && (
>
) : (
- variationAttributes.map(({id, name, selectedValue, values}) => {
+ variationAttributes?.map(({id, name, selectedValue, values}) => {
const swatches = values.map(
({href, name, image, value, orderable}, index) => {
const content = image ? (
@@ -661,7 +660,7 @@ const ProductView = forwardRef(
{showFullLink && product && (
{
// Whenever the selected index changes ensure that we call the change handler.
useEffect(() => {
const childrenArray = Children.toArray(children)
- const newValue = childrenArray[selectedIndex].props.value
+ const newValue = childrenArray[selectedIndex]?.props?.value
handleChange(newValue)
}, [selectedIndex])
@@ -105,7 +105,7 @@ const SwatchGroup = (props) => {
)}
{Children.toArray(children).map((child, index) => {
- const selected = child.props.value === value
+ const selected = child.props?.value === value
return React.cloneElement(child, {
handleSelect: handleChange,
selected,
diff --git a/packages/template-retail-react-app/app/hooks/use-bonus-product-modal.js b/packages/template-retail-react-app/app/hooks/use-bonus-product-modal.js
index 7294cc3055..e0241d6619 100644
--- a/packages/template-retail-react-app/app/hooks/use-bonus-product-modal.js
+++ b/packages/template-retail-react-app/app/hooks/use-bonus-product-modal.js
@@ -7,7 +7,6 @@
import React, {useContext, useState, useEffect} from 'react'
import {useLocation} from 'react-router-dom'
import PropTypes from 'prop-types'
-import {useAddToCartModalContext} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal'
import {BonusProductModal} from '@salesforce/retail-react-app/app/components/bonus-product-modal'
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
@@ -38,7 +37,6 @@ export const useBonusState = (basket) => {
bonusProducts: basket?.bonusDiscountLineItems || []
})
const {pathname} = useLocation()
- const {onOpen: onAddToCartModalOpen} = useAddToCartModalContext()
useEffect(() => {
if (state.isOpen) {
@@ -79,21 +77,14 @@ export const useBonusState = (basket) => {
data: state.data,
bonusProducts: state.bonusProducts,
addBonusProducts,
- onClose: () => {
+ onClickClose: () => {
setState((prev) => ({
...prev,
isOpen: false,
data: {}
}))
-
- if (state.data.openAddToCartModalIfNeeded && state.data.product) {
- onAddToCartModalOpen({
- product: state.data.product,
- itemsAdded: state.data.itemsAdded,
- selectedQuantity: state.data.selectedQuantity
- })
- }
},
+ onClose: () => {},
onOpen: (data) => {
setState((prev) => ({
...prev,
diff --git a/packages/template-retail-react-app/app/hooks/use-bonus-product-modal.test.js b/packages/template-retail-react-app/app/hooks/use-bonus-product-modal.test.js
index 3a7e2e4c57..51eefbb905 100644
--- a/packages/template-retail-react-app/app/hooks/use-bonus-product-modal.test.js
+++ b/packages/template-retail-react-app/app/hooks/use-bonus-product-modal.test.js
@@ -74,7 +74,6 @@ describe('useBonusState', () => {
test('onClose calls onAddToCartModalOpen if needed', () => {
const {result} = renderHook(() => useBonusState())
const data = {
- openAddToCartModalIfNeeded: true,
product: {id: 'p1'},
itemsAdded: 2,
selectedQuantity: 1
diff --git a/packages/template-retail-react-app/app/pages/cart/partials/cart-bonus-products.jsx b/packages/template-retail-react-app/app/pages/cart/partials/cart-bonus-products.jsx
index d6eb6c42b2..8891c9c807 100644
--- a/packages/template-retail-react-app/app/pages/cart/partials/cart-bonus-products.jsx
+++ b/packages/template-retail-react-app/app/pages/cart/partials/cart-bonus-products.jsx
@@ -36,8 +36,7 @@ const BonusProductsSelection = ({basket}) => {
const {onOpen: onBonusProductModalOpen} = useBonusProductModalContext()
const handleBonusButtonClick = (bonusOffers) => {
onBonusProductModalOpen({
- newBonusItems: bonusOffers,
- openAddToCartModalIfNeeded: false
+ newBonusItems: bonusOffers
})
}
// Memoize bonus logic so it only recalculates when basket changes
diff --git a/packages/template-retail-react-app/app/pages/cart/partials/cart-bonus-products.test.js b/packages/template-retail-react-app/app/pages/cart/partials/cart-bonus-products.test.js
index b8832032c8..f917ef6590 100644
--- a/packages/template-retail-react-app/app/pages/cart/partials/cart-bonus-products.test.js
+++ b/packages/template-retail-react-app/app/pages/cart/partials/cart-bonus-products.test.js
@@ -136,24 +136,6 @@ describe('Bonus Products Selection', () => {
content.replace(/\s+/g, ' ').includes('(0 of 1 selected)')
).length
).toBeGreaterThan(0)
-
- // Click the first bonus button
- await user.click(bonusButtons[0])
- expect(mockOnOpen).toHaveBeenCalledTimes(1)
- let {newBonusItems, openAddToCartModalIfNeeded} = mockOnOpen.mock.calls[0][0]
- expect(Array.isArray(newBonusItems)).toBe(true)
- expect(newBonusItems).toHaveLength(1)
- expect(newBonusItems[0].maxBonusItems).toBe(2)
- expect(openAddToCartModalIfNeeded).toBe(false)
-
- // Click the second bonus button
- await user.click(bonusButtons[1])
- expect(mockOnOpen).toHaveBeenCalledTimes(2)
- ;({newBonusItems, openAddToCartModalIfNeeded} = mockOnOpen.mock.calls[1][0])
- expect(Array.isArray(newBonusItems)).toBe(true)
- expect(newBonusItems).toHaveLength(1)
- expect(newBonusItems[0].maxBonusItems).toBe(1)
- expect(openAddToCartModalIfNeeded).toBe(false)
})
test('verifies counts and offers map with existing bonus items in cart', async () => {