diff --git a/.gitignore b/.gitignore
index 0b3080e087..e347b314e4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,8 @@ lerna-debug.log
/test-results/
/playwright-report/
/playwright/.cache/
+
+# Build directories and distribution files
+build/
+**/build/
+**/dist/
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 c3cf7de680..05e8cb7d3d 100644
--- a/packages/template-retail-react-app/app/components/_app/index.jsx
+++ b/packages/template-retail-react-app/app/components/_app/index.jsx
@@ -57,6 +57,7 @@ import {
useDntNotification
} from '@salesforce/retail-react-app/app/hooks/use-dnt-notification'
import {AddToCartModalProvider} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal'
+import {BonusProductSelectionModalProvider} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-selection-modal'
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
@@ -430,34 +431,36 @@ const App = (props) => {
{!isOnline && }
-
-
+
-
- {children}
-
-
-
-
-
- {!isCheckout ? : }
-
-
-
-
+
+
+ {children}
+
+
+
+
+
+ {!isCheckout ? : }
+
+
+
+
+
diff --git a/packages/template-retail-react-app/app/components/bonus-product-view-modal/index.jsx b/packages/template-retail-react-app/app/components/bonus-product-view-modal/index.jsx
new file mode 100644
index 0000000000..839522312e
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/bonus-product-view-modal/index.jsx
@@ -0,0 +1,244 @@
+/*
+ * Copyright (c) 2021, 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, {useMemo, useCallback} from 'react'
+import PropTypes from 'prop-types'
+import {
+ Modal,
+ ModalOverlay,
+ ModalContent,
+ ModalBody,
+ ModalCloseButton,
+ Button,
+ Box,
+ Text
+} from '@salesforce/retail-react-app/app/components/shared/ui'
+import ProductView from '@salesforce/retail-react-app/app/components/product-view'
+import {useProductViewModal} from '@salesforce/retail-react-app/app/hooks/use-product-view-modal'
+import {useIntl} from 'react-intl'
+import {useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react'
+import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
+import {findAvailableBonusDiscountLineItemId} from '@salesforce/retail-react-app/app/utils/bonus-product-utils'
+import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
+import {useLocation} from 'react-router-dom'
+import {productViewModalTheme} from '@salesforce/retail-react-app/app/theme/components/project/product-view-modal'
+
+/**
+ * A Modal that contains Bonus Product View
+ */
+const BonusProductViewModal = ({
+ product,
+ isOpen,
+ onClose,
+ bonusDiscountLineItemId,
+ promotionId,
+ ...props
+}) => {
+ // Ensure a safe product shape for the modal hook
+ const safeProduct = useMemo(() => {
+ if (!product) return {productId: undefined, variants: [], variationAttributes: []}
+ const id = product.productId || product.id
+ return {
+ productId: id,
+ id,
+ variants: product.variants || [],
+ variationAttributes: product.variationAttributes || [],
+ imageGroups: product.imageGroups || [],
+ type: product.type || {set: false, bundle: false},
+ price: product.price,
+ name: product.name || product.productName
+ }
+ }, [product])
+
+ const productViewModalData = useProductViewModal(safeProduct)
+ const {addItemToNewOrExistingBasket} = useShopperBasketsMutationHelper()
+ const {data: basket} = useCurrentBasket()
+ const navigate = useNavigation()
+ const location = useLocation()
+
+ const intl = useIntl()
+ const {formatMessage} = intl
+
+ const messages = useMemo(
+ () => ({
+ modalLabel: formatMessage(
+ {
+ id: 'bonus_product_view_modal.modal_label',
+ defaultMessage: 'Bonus product selection modal for {productName}'
+ },
+ {productName: productViewModalData?.product?.name}
+ ),
+ viewCart: formatMessage({
+ id: 'bonus_product_view_modal.button.view_cart',
+ defaultMessage: 'View Cart'
+ })
+ }),
+ [intl]
+ )
+
+ // Determine context for navigation behavior
+ const isFromAddToCartModal = location.pathname !== '/cart'
+
+ // Custom addToCart handler for bonus products that includes bonusDiscountLineItemId
+ const handleAddToCart = useCallback(
+ async (variant, quantity) => {
+ try {
+ // Default quantity to 1 if not provided or invalid
+ const finalQuantity = quantity && quantity > 0 ? quantity : 1
+
+ // Find the first available bonus discount line item with capacity
+ const availableBonusDiscountLineItemId = findAvailableBonusDiscountLineItemId(
+ basket,
+ promotionId,
+ finalQuantity,
+ bonusDiscountLineItemId // fallback to originally passed id
+ )
+
+ if (!availableBonusDiscountLineItemId) {
+ console.warn('No available bonus discount line item found')
+ return null
+ }
+
+ const productItems = [
+ {
+ productId: variant?.productId || product?.productId || product?.id,
+ price: variant?.price || product?.price,
+ quantity: parseInt(finalQuantity, 10),
+ bonusDiscountLineItemId: availableBonusDiscountLineItemId
+ }
+ ]
+
+ const result = await addItemToNewOrExistingBasket(productItems)
+
+ // Navigate to cart page after successful add to cart
+ if (result) {
+ // Close modal immediately and navigate with proper delay
+ onClose()
+ // Always use a delay to ensure modal closes cleanly
+ setTimeout(() => {
+ navigate('/cart', 'push')
+ }, 200)
+ }
+
+ return result
+ } catch (error) {
+ console.error('Error adding bonus product to cart:', error)
+ return null
+ }
+ },
+ [
+ addItemToNewOrExistingBasket,
+ product,
+ bonusDiscountLineItemId,
+ promotionId,
+ basket,
+ onClose,
+ navigate,
+ isFromAddToCartModal
+ ]
+ )
+
+ // Custom buttons for the ProductView
+ const handleViewCart = useCallback(() => {
+ // Close modal immediately and navigate with proper delay
+ onClose()
+ // Always use a delay to ensure modal closes cleanly
+ setTimeout(() => {
+ navigate('/cart', 'push')
+ }, 200)
+ }, [onClose, navigate])
+
+ const customButtons = useMemo(
+ () => [
+
+ ],
+ [messages.viewCart, handleViewCart]
+ )
+
+ // Aggressively clean product data to prevent SwatchGroup errors while preserving essential fields
+ const productToRender = useMemo(() => {
+ const baseProduct = productViewModalData.product || safeProduct
+ return {
+ ...baseProduct,
+ variationAttributes: [], // Force empty array
+ variants: [], // Also remove variants to be safe
+ variationParams: {},
+ selectedVariationAttributes: {},
+ type: {...baseProduct.type, variant: false, master: false},
+ // Ensure proper inventory and quantity defaults for bonus products
+ inventory: {
+ ...baseProduct.inventory,
+ orderable: true,
+ stockLevel: 999 // High stock level for bonus products
+ },
+ minOrderQuantity: 1,
+ stepQuantity: 1,
+ // Ensure the product is orderable
+ orderable: true
+ }
+ }, [productViewModalData.product, safeProduct])
+
+ return (
+
+
+
+
+ {productViewModalData.isFetching && !productViewModalData.product ? (
+
+ Loading product details...
+
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
+
+BonusProductViewModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onOpen: PropTypes.func,
+ onClose: PropTypes.func.isRequired,
+ product: PropTypes.object,
+ isLoading: PropTypes.bool,
+ bonusDiscountLineItemId: PropTypes.string, // The 'id' from bonusDiscountLineItems
+ promotionId: PropTypes.string // The promotion ID to filter promotions in PromoCallout
+}
+
+export default BonusProductViewModal
diff --git a/packages/template-retail-react-app/app/components/product-item-list/index.jsx b/packages/template-retail-react-app/app/components/product-item-list/index.jsx
index 76e3f5ddbb..0f8f5b5683 100644
--- a/packages/template-retail-react-app/app/components/product-item-list/index.jsx
+++ b/packages/template-retail-react-app/app/components/product-item-list/index.jsx
@@ -28,7 +28,9 @@ const ProductItemList = ({
localQuantity = {},
localIsGiftItems = {},
isCartItemLoading = false,
- selectedItem = null
+ selectedItem = null,
+ // Styling options
+ hideBorder = false
}) => {
return (
@@ -65,6 +67,7 @@ const ProductItemList = ({
isCartItemLoading && selectedItem?.itemId === productItem.itemId
}
handleRemoveItem={onRemoveItemClick}
+ hideBorder={hideBorder}
/>
)
})}
@@ -82,7 +85,8 @@ ProductItemList.propTypes = {
localQuantity: PropTypes.object,
localIsGiftItems: PropTypes.object,
isCartItemLoading: PropTypes.bool,
- selectedItem: PropTypes.object
+ selectedItem: PropTypes.object,
+ hideBorder: PropTypes.bool
}
export default ProductItemList
diff --git a/packages/template-retail-react-app/app/components/product-item/index.jsx b/packages/template-retail-react-app/app/components/product-item/index.jsx
index ec92669f03..75dd31ae72 100644
--- a/packages/template-retail-react-app/app/components/product-item/index.jsx
+++ b/packages/template-retail-react-app/app/components/product-item/index.jsx
@@ -41,7 +41,8 @@ const ProductItem = ({
primaryAction,
secondaryActions,
onItemQuantityChange = noop,
- showLoading = false
+ showLoading = false,
+ hideBorder = false
}) => {
const {stepQuantity, showInventoryMessage, inventoryMessage, quantity, setQuantity} =
useDerivedProduct(product)
@@ -53,7 +54,7 @@ const ProductItem = ({
>
{showLoading && }
-
+
@@ -124,7 +125,8 @@ ProductItem.propTypes = {
showLoading: PropTypes.bool,
isWishlistItem: PropTypes.bool,
primaryAction: PropTypes.node,
- secondaryActions: PropTypes.node
+ secondaryActions: PropTypes.node,
+ hideBorder: PropTypes.bool
}
export default ProductItem
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 a737a23671..ff982d5893 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
@@ -146,7 +146,9 @@ const ProductView = forwardRef(
pickupInStore = false,
setPickupInStore = () => {},
onOpenStoreLocator = () => {},
- showDeliveryOptions = true
+ showDeliveryOptions = true,
+ customButtons = [],
+ promotionId
},
ref
) => {
@@ -376,6 +378,19 @@ const ProductView = forwardRef(
)
}
+ // Add custom buttons if provided
+ if (customButtons && customButtons.length > 0) {
+ customButtons.forEach((customButton, index) => {
+ buttons.push(
+ React.cloneElement(customButton, {
+ key: `custom-button-${index}`,
+ width: customButton.props.width || '100%',
+ marginBottom: customButton.props.marginBottom || 4
+ })
+ )
+ })
+ }
+
return buttons
}
@@ -889,7 +904,9 @@ ProductView.propTypes = {
pickupInStore: PropTypes.bool,
setPickupInStore: PropTypes.func,
onOpenStoreLocator: PropTypes.func,
- showDeliveryOptions: PropTypes.bool
+ showDeliveryOptions: PropTypes.bool,
+ customButtons: PropTypes.array,
+ promotionId: PropTypes.string
}
export default ProductView
diff --git a/packages/template-retail-react-app/app/components/select-bonus-products-button/index.jsx b/packages/template-retail-react-app/app/components/select-bonus-products-button/index.jsx
new file mode 100644
index 0000000000..140d447ff7
--- /dev/null
+++ b/packages/template-retail-react-app/app/components/select-bonus-products-button/index.jsx
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2021, 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 from 'react'
+import PropTypes from 'prop-types'
+import {Button} from '@salesforce/retail-react-app/app/components/shared/ui'
+import {useIntl} from 'react-intl'
+
+const SelectBonusProductsButton = ({
+ bonusDiscountLineItems,
+ product,
+ itemsAdded,
+ onOpenBonusModal,
+ onClose,
+ ...buttonProps
+}) => {
+ const intl = useIntl()
+
+ const handleClick = () => {
+ if (onOpenBonusModal) {
+ onOpenBonusModal({
+ bonusDiscountLineItems,
+ product,
+ itemsAdded
+ })
+ }
+ if (onClose) onClose()
+ }
+
+ return (
+
+ )
+}
+
+SelectBonusProductsButton.propTypes = {
+ bonusDiscountLineItems: PropTypes.array,
+ product: PropTypes.object,
+ itemsAdded: PropTypes.array,
+ onOpenBonusModal: PropTypes.func,
+ onClose: PropTypes.func
+}
+
+export default SelectBonusProductsButton
+
diff --git a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js
index 7f7cbb68e9..0ba8278dc8 100644
--- a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js
+++ b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js
@@ -36,6 +36,13 @@ import {
} from '@salesforce/retail-react-app/app/utils/product-utils'
import {EINSTEIN_RECOMMENDERS} from '@salesforce/retail-react-app/app/constants'
import DisplayPrice from '@salesforce/retail-react-app/app/components/display-price'
+import SelectBonusProductsCard from '@salesforce/retail-react-app/app/pages/cart/partials/select-bonus-products-card'
+
+import {
+ getRemainingAvailableBonusProductsForProduct,
+ useBasketProductsWithPromotions,
+ getPromotionCalloutText
+} from '@salesforce/retail-react-app/app/utils/bonus-product-utils'
/**
* This is the context for managing the AddToCartModal.
@@ -48,7 +55,6 @@ export const AddToCartModalProvider = ({children}) => {
return (
{children}
-
)
}
@@ -73,7 +79,14 @@ export const AddToCartModal = () => {
const {currency, productSubTotal} = basket
const numberOfItemsAdded = isProductABundle
? selectedQuantity
- : itemsAdded.reduce((acc, {quantity}) => acc + quantity, 0)
+ : Array.isArray(itemsAdded)
+ ? itemsAdded.reduce((acc, {quantity}) => acc + quantity, 0)
+ : 0
+
+ // Bonus product logic
+ const {data: productsWithPromotions} = useBasketProductsWithPromotions(basket)
+ // Port v4 logic: Check for bonus discount line items and calculate remaining capacity
+ const {bonusDiscountLineItems = []} = basket || {}
if (!isOpen) {
return null
@@ -298,6 +311,54 @@ export const AddToCartModal = () => {
)
})}
+
+ {/* V4 Logic: Render SelectBonusProductsCard right after the product items */}
+ {bonusDiscountLineItems &&
+ bonusDiscountLineItems.length > 0 &&
+ (() => {
+ // Compute aggregated remaining capacity based on the latest basket data
+ const remainingBonusProductsData =
+ getRemainingAvailableBonusProductsForProduct(
+ basket,
+ product?.id,
+ productsWithPromotions
+ )
+
+ // Only render if there is remaining capacity across the collection
+ const hasCapacity =
+ remainingBonusProductsData?.aggregatedMaxBonusItems > 0 &&
+ remainingBonusProductsData?.aggregatedSelectedItems <
+ remainingBonusProductsData?.aggregatedMaxBonusItems
+
+ if (!hasCapacity) {
+ return null
+ }
+
+ // Get the first remaining available bonus product which contains the complete bonus discount line item data
+ const firstRemainingBonusProduct =
+ remainingBonusProductsData.bonusItems[0]
+ return (
+ {
+ // Close AddToCart modal first - the SelectBonusProductsCard will handle opening the bonus modal
+ if (onClose) onClose()
+ }}
+ bonusDiscountLineItem={{
+ id: firstRemainingBonusProduct?.bonusDiscountLineItemId,
+ promotionId: firstRemainingBonusProduct?.promotionId,
+ maxBonusItems:
+ remainingBonusProductsData.aggregatedMaxBonusItems,
+ bonusProducts: remainingBonusProductsData.bonusItems
+ }}
+ />
+ )
+ })()}
{
+
+ useContext(BonusProductSelectionModalContext)
+
+export const BonusProductSelectionModalProvider = ({children}) => {
+ const bonusProductSelectionModal = useBonusProductSelectionModal()
+ return (
+
+ {children}
+
+
+
+ )
+}
+
+BonusProductSelectionModalProvider.propTypes = {
+ children: PropTypes.node.isRequired
+}
+
+// Component to display individual bonus product with checkbox for selection
+const BonusProductItem = ({product, productData, foundProductData, onSelect, isLoading}) => {
+ const intl = useIntl()
+ const productName = product?.productName || product?.title
+
+ // Get the appropriate image group from the passed product data
+ const imageGroup = useMemo(() => {
+ if (!productData?.imageGroups) {
+ return null
+ }
+
+ const variantImages = filterImageGroups(productData.imageGroups, product)
+
+ if (variantImages?.length > 0) {
+ const largeImage = findImageGroupBy(variantImages, {
+ viewType: 'large'
+ })
+ return largeImage
+ }
+
+ // Fall back to default small images
+ const defaultSmallImage = findImageGroupBy(productData.imageGroups, {
+ viewType: 'small'
+ })
+ return defaultSmallImage
+ }, [productData, product])
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {imageGroup && imageGroup.images && imageGroup.images[0] ? (
+
+ ) : (
+
+
+ {intl.formatMessage({
+ id: 'bonus_product_modal.no_image',
+ defaultMessage: 'No Image'
+ })}
+
+
+ )}
+
+
+ {productName}
+
+
+
+ {foundProductData?.price ? `$${foundProductData.price}` : ''}
+
+
+ Free
+
+
+
+
+
+ )
+}
+
+BonusProductItem.propTypes = {
+ product: PropTypes.object.isRequired,
+ productData: PropTypes.object,
+ foundProductData: PropTypes.object,
+ onSelect: PropTypes.func.isRequired,
+ isLoading: PropTypes.bool
+}
+
+/**
+ * Modal for selecting from available bonus products.
+ */
+export const BonusProductSelectionModal = () => {
+ const {isOpen, onClose: originalOnClose, data} = useBonusProductSelectionModalContext()
+ // Independent state for the product view modal
+ const [isViewOpen, setIsViewOpen] = useState(false)
+ const [selectedProduct, setSelectedProduct] = useState(null)
+ const [selectedBonusMeta, setSelectedBonusMeta] = useState({
+ bonusDiscountLineItemId: null,
+ promotionId: null
+ })
+ const intl = useIntl()
+
+ // Extract bonus products and basket for selection counts
+ const bonusProducts = data?.bonusDiscountLineItems || []
+ const {data: basket} = useCurrentBasket()
+ const bonusLineItemIds = useMemo(
+ () => bonusProducts.map((bli) => bli.id).filter(Boolean),
+ [bonusProducts]
+ )
+ const maxBonusItems = useMemo(
+ () => bonusProducts.reduce((sum, bli) => sum + (bli.maxBonusItems || 0), 0),
+ [bonusProducts]
+ )
+ const selectedBonusItems = useMemo(() => {
+ const items = basket?.productItems || []
+ return items
+ .filter(
+ (it) =>
+ it?.bonusProductLineItem &&
+ bonusLineItemIds.includes(it?.bonusDiscountLineItemId)
+ )
+ .reduce((acc, it) => acc + (it?.quantity || 0), 0)
+ }, [basket, bonusLineItemIds])
+
+ // Get product IDs for fetching product data, deduplicating by productId
+ const uniqueBonusProducts = bonusProducts
+ .flatMap((item) => item.bonusProducts || [])
+ .filter(
+ (product, index, self) =>
+ index === self.findIndex((p) => p.productId === product.productId)
+ )
+
+ const productIds = uniqueBonusProducts
+ .map((product) => product.productId)
+ .filter(Boolean)
+ .join(',')
+
+ // Fetch product data
+ const {data: productData, isLoading} = useProducts(
+ {
+ parameters: {
+ ids: productIds,
+ allImages: true
+ }
+ },
+ {
+ enabled: Boolean(productIds),
+ placeholderData: null
+ }
+ )
+
+ // Build a mapping for quick lookup of fetched product by id
+ const productById = useMemo(() => {
+ const map = new Map()
+ productData?.data?.forEach((p) => map.set(p.id, p))
+ return map
+ }, [productData])
+
+ // When selecting a product, compute metadata, close the selection modal, and open product view modal
+ const switchToProductView = useCallback(
+ (bonusProduct, foundProductData) => {
+ const initial = foundProductData || productById.get(bonusProduct?.productId)
+ const normalizedInitial = initial
+ ? {
+ productId: initial.id,
+ ...initial,
+ // Ensure imageGroups are preserved
+ imageGroups: initial.imageGroups || [],
+ // Ensure other required fields
+ variants: initial.variants || [],
+ variationAttributes: initial.variationAttributes || [],
+ type: initial.type || {set: false, bundle: false}
+ }
+ : {
+ productId: bonusProduct?.productId,
+ imageGroups: [],
+ variants: [],
+ variationAttributes: [],
+ type: {set: false, bundle: false}
+ }
+
+ // Determine the promotion and available bonus discount line item id for this product
+ let computedPromotionId = null
+ let computedBonusDiscountLineItemId = null
+
+ const candidates = bonusProducts.filter((bli) =>
+ (bli.bonusProducts || []).some((p) => p.productId === normalizedInitial.productId)
+ )
+
+ if (candidates.length > 0) {
+ for (const candidate of candidates) {
+ const availableId = findAvailableBonusDiscountLineItemId(
+ basket,
+ candidate.promotionId,
+ 1,
+ candidate.id
+ )
+ if (availableId) {
+ computedPromotionId = candidate.promotionId
+ computedBonusDiscountLineItemId = availableId
+ break
+ }
+ }
+
+ // Fallback to the first candidate if none computed
+ if (!computedBonusDiscountLineItemId) {
+ computedPromotionId = candidates[0].promotionId || null
+ computedBonusDiscountLineItemId = candidates[0].id || null
+ }
+ }
+
+ setSelectedProduct(normalizedInitial)
+ setSelectedBonusMeta({
+ bonusDiscountLineItemId: computedBonusDiscountLineItemId,
+ promotionId: computedPromotionId
+ })
+
+ // Close selection modal first, then open view modal after a brief delay
+ originalOnClose()
+ setTimeout(() => {
+ setIsViewOpen(true)
+ }, 150)
+ },
+ [productById, bonusProducts, basket, originalOnClose]
+ )
+
+ const handleClose = useCallback(() => {
+ // Auto-reset on close; also ensure view modal is closed
+ setSelectedProduct(null)
+ setSelectedBonusMeta({bonusDiscountLineItemId: null, promotionId: null})
+ setIsViewOpen(false)
+ originalOnClose()
+ }, [originalOnClose])
+
+ // Render selection modal (if open) and product view modal (controlled independently)
+ // Only render selection modal if view modal is not open to prevent layering issues
+ return (
+ <>
+ {/* Selection Modal - only show if view modal is not open */}
+ {!isViewOpen && (
+
+
+
+
+
+ {intl.formatMessage(
+ {
+ id: 'bonus_product_modal.title',
+ defaultMessage:
+ 'Select Bonus Product ({selected} of {max} selected)'
+ },
+ {selected: selectedBonusItems, max: maxBonusItems}
+ )}
+
+
+
+
+ {bonusProducts.length === 0 ? (
+
+ {intl.formatMessage({
+ id: 'bonus_product_modal.no_bonus_products',
+ defaultMessage: 'No bonus products available'
+ })}
+
+ ) : (
+
+
+ {uniqueBonusProducts.map((product) => {
+ const foundProductData = productData?.data?.find(
+ (p) => p.id === product.productId
+ )
+ return (
+
+ )
+ })}
+
+
+ )}
+
+
+
+
+ )}
+
+ {/* Product View Modal */}
+ {isViewOpen && selectedProduct && (
+
+ )}
+ >
+ )
+}
+
+export const useBonusProductSelectionModal = () => {
+ const {isOpen, data, onOpen, onClose} = useModalState({
+ // Keep the modal open when query params change (product view cleans variant params)
+ closeOnRouteChange: false,
+ resetDataOnClose: true
+ })
+ return {isOpen, data, onOpen, onClose}
+}
diff --git a/packages/template-retail-react-app/app/hooks/use-bonus-product-view-modal.js b/packages/template-retail-react-app/app/hooks/use-bonus-product-view-modal.js
new file mode 100644
index 0000000000..9d5813bb72
--- /dev/null
+++ b/packages/template-retail-react-app/app/hooks/use-bonus-product-view-modal.js
@@ -0,0 +1,20 @@
+/*
+ * 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 {useModalState} from './use-modal-state'
+
+/**
+ * Hook for managing the bonus product view modal state
+ */
+export const useBonusProductViewModal = () => {
+ const {isOpen, data, onOpen, onClose} = useModalState({
+ closeOnRouteChange: true,
+ resetDataOnClose: true
+ })
+ return {isOpen, data, onOpen, onClose}
+}
+
diff --git a/packages/template-retail-react-app/app/hooks/use-derived-product.js b/packages/template-retail-react-app/app/hooks/use-derived-product.js
index 1fad725d47..c8b2ec6ccc 100644
--- a/packages/template-retail-react-app/app/hooks/use-derived-product.js
+++ b/packages/template-retail-react-app/app/hooks/use-derived-product.js
@@ -33,8 +33,8 @@ export const useDerivedProduct = (
const stockLevel = product?.inventory?.stockLevel || 0
const stepQuantity = product?.stepQuantity || 1
const minOrderQuantity = stockLevel > 0 ? product?.minOrderQuantity || 1 : 0
- const initialQuantity = product?.quantity || product?.minOrderQuantity || 1
-
+ const initialQuantity = product?.quantity || product?.minOrderQuantity || 1
+
// used for product bundles when there are multiple products
const lowestStockLevelProductName = product?.inventory?.lowestStockLevelProductName
const intl = useIntl()
diff --git a/packages/template-retail-react-app/app/hooks/use-modal-state.js b/packages/template-retail-react-app/app/hooks/use-modal-state.js
new file mode 100644
index 0000000000..80ac7334c5
--- /dev/null
+++ b/packages/template-retail-react-app/app/hooks/use-modal-state.js
@@ -0,0 +1,51 @@
+/*
+ * 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'
+import {useLocation} from 'react-router-dom'
+
+/**
+ * Reusable modal state hook
+ * - Manages isOpen and optional data payload
+ * - Provides onOpen(data) and onClose() handlers
+ * - Optionally auto-closes on route changes
+ */
+export const useModalState = ({closeOnRouteChange = true, resetDataOnClose = true} = {}) => {
+ const [state, setState] = useState({
+ isOpen: false,
+ data: null
+ })
+
+ const {pathname} = useLocation()
+
+ useEffect(() => {
+ if (closeOnRouteChange && state.isOpen) {
+ setState({
+ ...state,
+ isOpen: false
+ })
+ }
+ }, [pathname])
+
+ return {
+ isOpen: state.isOpen,
+ data: state.data,
+ onOpen: (data) => {
+ setState({
+ isOpen: true,
+ data
+ })
+ },
+ onClose: () => {
+ setState({
+ isOpen: false,
+ data: resetDataOnClose ? null : state.data
+ })
+ }
+ }
+}
+
diff --git a/packages/template-retail-react-app/app/pages/cart/index.jsx b/packages/template-retail-react-app/app/pages/cart/index.jsx
index 6346d1b1a9..e4d6c2582b 100644
--- a/packages/template-retail-react-app/app/pages/cart/index.jsx
+++ b/packages/template-retail-react-app/app/pages/cart/index.jsx
@@ -32,12 +32,23 @@ import ProductItemList from '@salesforce/retail-react-app/app/components/product
import ProductViewModal from '@salesforce/retail-react-app/app/components/product-view-modal'
import BundleProductViewModal from '@salesforce/retail-react-app/app/components/product-view-modal/bundle'
import RecommendedProducts from '@salesforce/retail-react-app/app/components/recommended-products'
+import CartProductListWithGroupedBonusProducts from '@salesforce/retail-react-app/app/pages/cart/partials/cart-product-list-with-grouped-bonus-products'
+import SelectBonusProductsCard from '@salesforce/retail-react-app/app/pages/cart/partials/select-bonus-products-card'
// Hooks
import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
import {useWishList} from '@salesforce/retail-react-app/app/hooks/use-wish-list'
+// Bonus Product Utilities
+import {
+ useBasketProductsWithPromotions,
+ getPromotionCalloutText
+} from '@salesforce/retail-react-app/app/utils/bonus-product-utils'
+import {useBonusProductViewModal} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-view-modal'
+import {useBonusProductSelectionModalContext} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-selection-modal'
+import BonusProductViewModal from '@salesforce/retail-react-app/app/components/bonus-product-view-modal'
+
// Constants
import {
API_ERROR_MESSAGE,
@@ -52,6 +63,7 @@ import {REMOVE_CART_ITEM_CONFIRMATION_DIALOG_CONFIG} from '@salesforce/retail-re
// Utilities
import debounce from 'lodash/debounce'
+import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
import {
useShopperBasketsMutation,
@@ -70,6 +82,11 @@ const DEBOUNCE_WAIT = 750
const Cart = () => {
const {data: basket, isLoading} = useCurrentBasket()
+ // Get configuration for bonus product grouping
+ const config = getConfig()
+ const groupBonusProductsWithQualifyingProduct =
+ config.app?.pages?.cart?.groupBonusProductsWithQualifyingProduct ?? true
+
// Pickup in Store - only enabled if feature toggle is on
const isPickupOrder = STORE_LOCATOR_IS_ENABLED
? basket?.shipments[0]?.shippingMethod?.c_storePickupEnabled === true
@@ -90,6 +107,21 @@ const Cart = () => {
const {selectedStore} = useSelectedStore()
const selectedInventoryId = selectedStore?.inventoryId || null
const productIds = basket?.productItems?.map(({productId}) => productId).join(',') ?? ''
+
+ // Bonus Product Logic
+ const {data: productsWithPromotions, isLoading: isPromotionDataLoading} = useBasketProductsWithPromotions(basket)
+ const bonusProductViewModal = useBonusProductViewModal()
+ const {onOpen: openBonusSelectionModal} = useBonusProductSelectionModalContext()
+
+ // Handle opening bonus product selection modal (not the view modal directly)
+ const handleSelectBonusProducts = () => {
+ const bonusDiscountLineItems = basket?.bonusDiscountLineItems || []
+ if (bonusDiscountLineItems.length > 0) {
+ openBonusSelectionModal({
+ bonusDiscountLineItems: bonusDiscountLineItems
+ })
+ }
+ }
const {data: products, isLoading: isProductsLoading} = useProducts(
{
parameters: {
@@ -663,22 +695,87 @@ const Cart = () => {
)}
)}
- {/* Regular Products */}
-
+ {/* Conditional Bonus Product Rendering */}
+ {groupBonusProductsWithQualifyingProduct ? (
+ /* Grouped layout: Groups bonus products with their qualifying products */
+ (
+
+ )}
+ getPromotionCalloutText={getPromotionCalloutText}
+ onSelectBonusProducts={handleSelectBonusProducts}
+ />
+ ) : (
+ /* Simple layout: Renders all cart items individually with separate bonus product cards */
+
+ {/* Render all cart items in simple layout */}
+ {basket.productItems?.map((productItem, idx) => (
+
+ ))}
+
+ {/* Render SelectBonusProductsCard for each bonusDiscountLineItem */}
+ {basket.bonusDiscountLineItems?.map((bonusDiscountLineItem) => {
+ // Find a qualifying product that triggered this bonus opportunity
+ const qualifyingProduct = basket.productItems?.find(
+ (item) =>
+ !item.bonusProductLineItem &&
+ item.priceAdjustments?.some(
+ (adj) => adj.promotionId === bonusDiscountLineItem.promotionId
+ )
+ ) || {productId: bonusDiscountLineItem.promotionId} // Fallback
+
+ return (
+
+ )
+ })}
+
+ )}
- {/* Bonus Products */}
- {categorizedProducts.bonusProducts.length > 0 && (
+ {/* Fallback: Orphan Bonus Products (only when grouping is disabled) */}
+ {!groupBonusProductsWithQualifyingProduct && categorizedProducts.bonusProducts.length > 0 && (
<>
@@ -794,6 +891,17 @@ const Cart = () => {
productItems={basket?.productItems}
handleUnavailableProducts={handleUnavailableProducts}
/>
+
+ {/* Bonus Product View Modal */}
+ {bonusProductViewModal.isOpen && bonusProductViewModal.data && (
+
+ )}
)
}
diff --git a/packages/template-retail-react-app/app/pages/cart/partials/cart-product-list-with-grouped-bonus-products.jsx b/packages/template-retail-react-app/app/pages/cart/partials/cart-product-list-with-grouped-bonus-products.jsx
new file mode 100644
index 0000000000..46141dabfa
--- /dev/null
+++ b/packages/template-retail-react-app/app/pages/cart/partials/cart-product-list-with-grouped-bonus-products.jsx
@@ -0,0 +1,209 @@
+/*
+ * Copyright (c) 2023, 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 PropTypes from 'prop-types'
+import {Stack, Box, Heading} from '@salesforce/retail-react-app/app/components/shared/ui'
+import SelectBonusProductsCard from '@salesforce/retail-react-app/app/pages/cart/partials/select-bonus-products-card'
+import {
+ getBonusProductsInCartForProduct,
+ getRemainingAvailableBonusProductsForProduct,
+ isProductEligibleForBonusProducts
+} from '@salesforce/retail-react-app/app/utils/bonus-product-utils'
+
+/**
+ * Fragment component that renders cart items with bonus products grouped with their qualifying products
+ * @param {Object} props - Component props
+ * @param {Array} props.nonBonusProducts - Array of non-bonus products
+ * @param {Object} props.basket - The current basket data
+ * @param {Object} props.productsWithPromotions - Products with promotion data
+ * @param {boolean} props.isPromotionDataLoading - Whether promotion data is loading
+ * @param {Function} props.renderProductItem - Function to render individual product items
+ * @param {Function} props.getPromotionCalloutText - Function to get promotion text
+ * @param {Function} props.onSelectBonusProducts - Callback when select bonus products button is clicked
+ * @returns {JSX.Element} The grouped cart product list
+ */
+const CartProductListWithGroupedBonusProducts = ({
+ nonBonusProducts,
+ basket,
+ productsWithPromotions,
+ isPromotionDataLoading,
+ renderProductItem,
+ getPromotionCalloutText,
+ onSelectBonusProducts
+}) => {
+ // Fallback: if no non-bonus products, render all products in simple layout
+ if (!nonBonusProducts || nonBonusProducts.length === 0) {
+ return (
+
+ {basket.productItems?.map((productItem, idx) =>
+ renderProductItem(productItem, idx)
+ )}
+
+ )
+ }
+
+ return (
+
+ {nonBonusProducts.map((qualifyingProduct, qualifyingIdx) => {
+ // Skip bonus product logic if promotion data is not loaded
+ if (!productsWithPromotions || isPromotionDataLoading) {
+ return (
+
+ {renderProductItem(qualifyingProduct, qualifyingIdx)}
+
+ )
+ }
+
+ // Check if product is eligible for bonus products
+ const isEligible = isProductEligibleForBonusProducts(
+ qualifyingProduct.productId,
+ productsWithPromotions
+ )
+
+ // If not eligible, render as simple card
+ if (!isEligible) {
+ return (
+
+ {renderProductItem(qualifyingProduct, qualifyingIdx)}
+
+ )
+ }
+
+ // Enhanced rendering for eligible products
+ try {
+ // Get bonus product data for this qualifying product
+ const bonusProductsForThisProduct = getBonusProductsInCartForProduct(
+ basket,
+ qualifyingProduct.productId,
+ productsWithPromotions
+ )
+ const remainingBonusProductsData = getRemainingAvailableBonusProductsForProduct(
+ basket,
+ qualifyingProduct.productId,
+ productsWithPromotions
+ )
+
+ const hasBonusProductsInCart = bonusProductsForThisProduct.length > 0
+ const hasRemainingCapacity =
+ remainingBonusProductsData.hasRemainingCapacity ||
+ (isEligible && remainingBonusProductsData.aggregatedMaxBonusItems === 0)
+
+ return (
+
+ {/* Main product */}
+
+ {renderProductItem(qualifyingProduct, qualifyingIdx, {
+ hideBorder: true
+ })}
+
+
+ {/* Bonus products already in cart */}
+ {hasBonusProductsInCart && (
+
+
+ Bonus Products
+
+
+ {bonusProductsForThisProduct.map(
+ (bonusProduct, bonusIdx) => (
+
+ {renderProductItem(bonusProduct, bonusIdx, {
+ showQuantitySelector: false,
+ hideBorder: true
+ })}
+
+ )
+ )}
+
+
+ )}
+
+ {/* Separator between bonus products and select card */}
+ {hasBonusProductsInCart && hasRemainingCapacity && (
+
+ )}
+
+ {/* Select Bonus Products card */}
+ {hasRemainingCapacity && (
+
+ )}
+
+ {/* Add divider between product groups if not the last item */}
+ {qualifyingIdx < nonBonusProducts.length - 1 && (
+
+ )}
+
+ )
+ } catch (error) {
+ console.error('Error in enhanced rendering:', error)
+ // Fallback to simple rendering if enhanced fails
+ return (
+
+ {renderProductItem(qualifyingProduct, qualifyingIdx)}
+
+ )
+ }
+ })}
+
+ {/* Temporarily disabled orphan bonus products for debugging */}
+
+ )
+}
+
+CartProductListWithGroupedBonusProducts.propTypes = {
+ nonBonusProducts: PropTypes.arrayOf(
+ PropTypes.shape({
+ itemId: PropTypes.string,
+ productId: PropTypes.string
+ })
+ ).isRequired,
+ basket: PropTypes.shape({
+ productItems: PropTypes.arrayOf(
+ PropTypes.shape({
+ itemId: PropTypes.string,
+ productId: PropTypes.string,
+ bonusProductLineItem: PropTypes.bool
+ })
+ )
+ }).isRequired,
+ productsWithPromotions: PropTypes.object,
+ isPromotionDataLoading: PropTypes.bool.isRequired,
+ renderProductItem: PropTypes.func.isRequired,
+ getPromotionCalloutText: PropTypes.func.isRequired,
+ onSelectBonusProducts: PropTypes.func.isRequired
+}
+
+export default CartProductListWithGroupedBonusProducts
\ No newline at end of file
diff --git a/packages/template-retail-react-app/app/pages/cart/partials/cart-secondary-button-group.jsx b/packages/template-retail-react-app/app/pages/cart/partials/cart-secondary-button-group.jsx
index fd4cd1b18c..c3b6bfa0e6 100644
--- a/packages/template-retail-react-app/app/pages/cart/partials/cart-secondary-button-group.jsx
+++ b/packages/template-retail-react-app/app/pages/cart/partials/cart-secondary-button-group.jsx
@@ -84,14 +84,12 @@ const CartSecondaryButtonGroup = ({
divider={}
>
- {!isBonusProduct && (
-
- )}
+
{customer.isRegistered && !isBonusProduct && (