Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
afb6ea9
Manual bonus products port from v4
sf-cboscenco Aug 25, 2025
a3625f0
@W-19265242@ Configurable base paths for /mobify routes (PWA Kit 3.12…
sf-shikhar-prasoon Aug 25, 2025
ce7398e
Merge branch 'feature/manual-bonus-products-v3' into feature/manual-b…
sf-cboscenco Aug 25, 2025
0a9c248
Merge pull request #3174 from SalesforceCommerceCloud/feature/manual-…
sf-cboscenco Aug 25, 2025
37cbd7d
fix quality issue
sf-xingquan-jin Aug 28, 2025
8c858c0
remove accidental bug.patch file
sf-xingquan-jin Aug 28, 2025
d4b204b
Merge branch 'develop' into t/cc-sharks/sprasoon/merge-develop-aug29
sf-shikhar-prasoon Aug 29, 2025
3caf368
address review comments and add tests
sf-xingquan-jin Sep 2, 2025
de91d59
lint fixes
sf-xingquan-jin Sep 2, 2025
8e9268f
Merge pull request #3212 from SalesforceCommerceCloud/t/cc-sharks/W-1…
sf-xingquan-jin Sep 2, 2025
04c37a2
Merge pull request #3236 from SalesforceCommerceCloud/t/cc-sharks/spr…
sf-shikhar-prasoon Sep 3, 2025
2dcfa7d
Merge pull request #3234 from SalesforceCommerceCloud/develop
sf-shikhar-prasoon Sep 3, 2025
bf555f3
Merge pull request #3249 from SalesforceCommerceCloud/develop
sf-shikhar-prasoon Sep 5, 2025
a4acedd
@W-19398239 Bonus Product Selection grid changes (#3255)
sf-shikhar-prasoon Sep 8, 2025
9c6a07c
Return to the bonus selection modal if there are more available bonus…
sf-cboscenco Sep 8, 2025
78ddacd
Added tests, fixed linting errors
sf-cboscenco Sep 9, 2025
fd70853
More fixes for linting errors
sf-cboscenco Sep 9, 2025
275635e
Merge pull request #3265 from SalesforceCommerceCloud/t/cc-sharks/W-1…
sf-cboscenco Sep 9, 2025
38c46da
add scroll and tests
sf-shikhar-prasoon Sep 8, 2025
40f8cf0
debug lines. mock items avail for selection
sf-shikhar-prasoon Sep 9, 2025
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ e2e/mrt-target/
# Runners do not run `npm install` for actions; `runs.main`/`runs.post` must point to files in `dist/`.
# We generally ignore all dist directories, but allow the action’s dist so the action can execute.
# We need this specifically to engage post run hook for the e2e_release_mrt_target action to release the MRT target back to the pool.
!.github/actions/**/dist/
!.github/actions/**/dist/
57 changes: 30 additions & 27 deletions packages/template-retail-react-app/app/components/_app/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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'
Expand Down Expand Up @@ -433,34 +434,36 @@ const App = (props) => {
</Island>
{!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"
<BonusProductSelectionModalProvider>
<SkipNavContent
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
outline: 0
}}
>
<OfflineBoundary isOnline={false}>
{children}
</OfflineBoundary>
</Box>
</SkipNavContent>

<Island hydrateOn={'visible'}>
{!isCheckout ? <Footer /> : <CheckoutFooter />}
</Island>

<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>

<Island hydrateOn={'visible'}>
{!isCheckout ? <Footer /> : <CheckoutFooter />}
</Island>

<AuthModal {...authModal} />
<DntNotification {...dntNotification} />
</BonusProductSelectionModalProvider>
</AddToCartModalProvider>
</Box>
</CurrencyProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
/*
* 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,
getRemainingAvailableBonusProductsForProduct
} from '@salesforce/retail-react-app/app/utils/bonus-product-utils'
import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
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,
onReturnToSelection,
...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 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]
)

// Helper function to calculate remaining bonus quantity
const getRemainingBonusQuantity = () => {
if (basket && product) {
const bonusData = getRemainingAvailableBonusProductsForProduct(basket, product.id, {
[product.id]: product
})
// Return remaining capacity: total allowed - already in cart
return bonusData.aggregatedMaxBonusItems - bonusData.aggregatedSelectedItems
}
return null
}

// Helper function to check if there are remaining bonus products available
const checkForRemainingBonusProducts = (updatedBasket) => {
if (!updatedBasket?.bonusDiscountLineItems) {
return false
}

// Check if any bonus discount line items still have available capacity
return updatedBasket.bonusDiscountLineItems.some((discountItem) => {
const maxBonusItems = discountItem.maxBonusItems || 0

// Calculate how many bonus products are already in cart for this specific discount item
const selectedQuantity =
updatedBasket.productItems
?.filter(
(cartItem) =>
cartItem.bonusProductLineItem &&
cartItem.bonusDiscountLineItemId === discountItem.id
)
.reduce((total, cartItem) => total + (cartItem.quantity || 0), 0) || 0

// Return true if there's still capacity available
return selectedQuantity < maxBonusItems
})
}

// Custom addToCart handler for bonus products that includes bonusDiscountLineItemId
const handleAddToCart = useCallback(
async (products) => {
try {
const productItems = []

// Process each item in the selection
for (const {variant, quantity} of products) {
// Default quantity to 1 if not provided or invalid, ensure positive
let finalQuantity = Math.max(quantity || 1, 1)

// Cap quantity to remaining capacity (defensive programming)
const maxAllowed = getRemainingBonusQuantity()
if (maxAllowed && finalQuantity > maxAllowed) {
finalQuantity = maxAllowed
}

// 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')
continue // Skip this item but process others
}

productItems.push({
productId: variant?.productId || product?.productId || product?.id,
price: variant?.price || product?.price,
quantity: parseInt(finalQuantity, 10),
bonusDiscountLineItemId: availableBonusDiscountLineItemId
})
}

if (productItems.length === 0) {
return null
}

const result = await addItemToNewOrExistingBasket(productItems)

// Check for remaining bonus products after successful add to cart
if (result) {
// Get updated basket data to check for remaining bonus products
// addItemToNewOrExistingBasket returns the basket directly
const updatedBasket = result

// Check if there are still remaining bonus products available
const hasRemainingBonusProducts = checkForRemainingBonusProducts(updatedBasket)

if (hasRemainingBonusProducts && onReturnToSelection) {
// Return to SelectBonusProductModal if there are remaining bonus products
onReturnToSelection()
// Return null to prevent AddToCartModal from opening
return null
} else {
// Navigate to cart page if no remaining bonus products or no callback provided
onClose()
// Always use a delay to ensure modal closes cleanly
setTimeout(() => {
navigate('/cart', 'push')
}, 200)
// Return null to prevent AddToCartModal from opening
return null
}
}

// Return the expected format for AddToCartModal if needed
return products
} catch (error) {
console.error('Error adding bonus product to cart:', error)
return null
}
},
[
addItemToNewOrExistingBasket,
product,
bonusDiscountLineItemId,
promotionId,
basket,
onClose,
navigate,
onReturnToSelection
]
)

// 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(
() => [
<Button key="view-cart" variant="outline" onClick={handleViewCart}>
{messages.viewCart}
</Button>
],
[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])

// Calculate max order quantity for UI
const maxOrderQuantity = getRemainingBonusQuantity()

return (
<Modal
isOpen={isOpen}
onClose={onClose}
size={productViewModalTheme.modal.size}
closeOnOverlayClick={true}
closeOnEsc={true}
motionPreset="slideInBottom"
preserveScrollBarGap={true}
>
<ModalOverlay />
<ModalContent
data-testid="bonus-product-view-modal"
aria-label={messages.modalLabel}
margin={productViewModalTheme.layout.content.margin}
borderRadius={productViewModalTheme.layout.content.borderRadius}
bg={productViewModalTheme.layout.content.background}
maxHeight={productViewModalTheme.layout.content.maxHeight}
overflowY={productViewModalTheme.layout.content.overflowY}
>
<ModalBody
bg={productViewModalTheme.layout.body.background}
p={productViewModalTheme.layout.body.padding}
pb={productViewModalTheme.layout.body.paddingBottom}
mt={productViewModalTheme.layout.body.marginTop}
>
{productViewModalData.isFetching && !productViewModalData.product ? (
<Box p={8} textAlign="center">
<Text>Loading product details...</Text>
</Box>
) : (
<ProductView
showFullLink={false}
imageSize="sm"
showImageGallery={true}
product={productToRender}
isLoading={false}
addToCart={handleAddToCart}
isProductLoading={false}
customButtons={customButtons}
promotionId={promotionId}
maxOrderQuantity={maxOrderQuantity}
{...props}
/>
)}
</ModalBody>
<ModalCloseButton size="sm" />
</ModalContent>
</Modal>
)
}

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
onReturnToSelection: PropTypes.func // Callback to return to SelectBonusProductModal
}

export default BonusProductViewModal
Loading
Loading