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 () => {