Skip to content

Commit 31d17a2

Browse files
sf-shikhar-prasoonsf-xingquan-jinsf-cboscencoddiazccrz
authored
Add support for Rule Based Promotions for Choice of Bonus Products (#3418)
* @W-18957985- Product Search Integration & Rule-Based Detection (#3396) * 1. use search endpoint * 2 add rule based detection * 3. update discovery logic * add tests, update bundle size limit, update changelog * @W-19980361 Rule based bonus products- Work Item 2- update display logic (#3408) * 1. use search endpoint * 2 add rule based detection * 3. update discovery logic * cleanup. remove unused code * add tests, update bundle size limit, update changelog * fix header counts * fix flasing, re-rendering * pr comments + lint * @W-19980361 fix 2 bugs- selection count & flashing due to re-renders (#3421) * fix header counts * fix flasing, re-rendering * pr comments + lint * add param &refine=pmpt=bonus for promotion type * pr comments * use commerce sdk * add const- limit=50 | replace harded Search Param values * remove function breakdown change * update changelog * refactor. re-use * fix: prevent promotion message flashing when changing product variant… (#3428) * fix tests * Rule-Based Bonus Product Promotions Enhancement This PR enhances the existing rule-based bonus product promotions feature by adding proper variant eligibility checking. Previously, rule-based promotions could not accurately determine which specific product variants qualified for a promotion. This enhancement introduces a qualifying products lookup system that uses the product search API to fetch and validate eligible variants, ensuring that only products that truly qualify can trigger bonus product selection UI and functionality. Key Changes: 1. New Hook: useRuleBasedQualifyingProducts - Added to app/hooks/use-rule-based-bonus-products.js - Fetches qualifying products for rule-based promotions using productSearch API - Uses refinement parameters: pmid=${promotionId} and pmpt=qualifying - Returns a Set of qualifying product IDs for efficient lookup - Supports variant eligibility checking by examining both variant and master product IDs 2. Enhanced Promotion Eligibility Logic - Updated getPromotionIdsForProduct() in app/utils/bonus-product/common.js - Distinguishes between list-based and rule-based promotions - Added fallback logic to check master product IDs when variants don't directly qualify - Filters promotion IDs to only return those the product actually qualifies for 3. Qualifying Products Map Integration - Added ruleBasedQualifyingProductsMap parameter throughout the bonus product utility chain - Updated useBasketProductsWithPromotions() hook to fetch and return qualifying products map - Propagated the map through all relevant functions and components 4. Updated Function Signatures - isAutomaticPromotion() - shouldShowBonusProductSelection() - getBonusProductsForSpecificCartItem() - getBonusProductsInCartForProduct() - getAvailableBonusItemsForProduct() - getRemainingAvailableBonusProductsForProduct() 5. Component Updates - Cart Page: Destructures and passes ruleBasedQualifyingProductsMap - Add to Cart Modal: Uses qualifying products map for eligibility checks - Cart Product List: Accepts and forwards ruleBasedQualifyingProductsMap prop 6. Comprehensive Test Updates - Updated all test files to distinguish list-based vs rule-based promotions - Added new test cases for rule-based promotion eligibility scenarios - Enhanced test coverage across all bonus product utility test files Backward Compatibility: - All changes are backward compatible with existing list-based promotions - Default parameter values ensure functions work without the new map - Existing tests updated to explicitly mark promotions as list-based * fix: prevent promotion message flashing when changing product variant (#3431) * fix: prevent promotion flash by keeping previous data during variant changes - Add keepPreviousData option parameter to useProductViewModal hook - Pass keepPreviousData: true in BonusProductViewModal to maintain UI stability - Prevents promotion message from clearing while fetching new variant data * chore: trigger CI * remove those stable references * revert last changes * bundle size limit update * @W-20037720: Not allow duplicate items to appear in the cart (#3433) * Fixed duplicating bonus products in the UI * Fixed issue with list based not rendering properly * Update cart-product-list-with-grouped-bonus-products.jsx Signed-off-by: Daniel Diaz <[email protected]> * Fixed lint errors --------- Signed-off-by: Daniel Diaz <[email protected]> --------- Signed-off-by: Daniel Diaz <[email protected]> Co-authored-by: sf-xingquan-jin <[email protected]> Co-authored-by: cboscenco <[email protected]> Co-authored-by: Daniel Diaz <[email protected]>
1 parent 9ecf901 commit 31d17a2

31 files changed

+2457
-198
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## 8.2.0-dev
2+
- Add support for Rule Based Promotions for Choice of Bonus Products. We are currently supporting only one product level rule based promotion per product [#3418](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3418)
3+
14
## v8.2.0-dev (Sep 26, 2025)
25
- Added Einstein suggestions support for popular and recent searches in search functionality. Users can now see personalized search suggestions based on Einstein AI recommendations. [#3422](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3422)
36
- [Bugfix] Fix footer heading semantic consistency and alignment. Fix accessibility compliance by adding proper h1 headings to checkout pages to resolve the page-has-heading-one accessibility rule violation. [#3398](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3398)

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

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,9 @@ import {useProductViewModal} from '@salesforce/retail-react-app/app/hooks/use-pr
2424
import {useIntl} from 'react-intl'
2525
import {useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react'
2626
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
27-
import {getRemainingAvailableBonusProductsForProduct} from '@salesforce/retail-react-app/app/utils/bonus-product'
2827
import {processProductsForBonusCart} from '@salesforce/retail-react-app/app/utils/bonus-product/cart'
2928
import {useBonusProductCounts} from '@salesforce/retail-react-app/app/utils/bonus-product/hooks'
30-
import {
31-
createGetRemainingBonusQuantity,
32-
checkForRemainingBonusProducts
33-
} from '@salesforce/retail-react-app/app/components/bonus-product-view-modal/utils'
29+
import {checkForRemainingBonusProducts} from '@salesforce/retail-react-app/app/components/bonus-product-view-modal/utils'
3430
import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
3531
import {productViewModalTheme} from '@salesforce/retail-react-app/app/theme/components/project/product-view-modal'
3632
import {bonusProductViewModalTheme} from '@salesforce/retail-react-app/app/theme/components/project/bonus-product-view-modal'
@@ -64,7 +60,30 @@ const BonusProductViewModal = ({
6460
}
6561
}, [product])
6662

67-
const productViewModalData = useProductViewModal(safeProduct)
63+
const productViewModalData = useProductViewModal(safeProduct, {keepPreviousData: true})
64+
65+
// Keep a stable reference to the last successfully loaded product
66+
// This prevents constant re-renders while fetching
67+
const lastLoadedProductRef = React.useRef(productViewModalData.product)
68+
69+
React.useLayoutEffect(() => {
70+
if (productViewModalData.product && !productViewModalData.isFetching) {
71+
lastLoadedProductRef.current = productViewModalData.product
72+
}
73+
}, [productViewModalData.product, productViewModalData.isFetching])
74+
75+
// Use the stable product reference to prevent flashing during fetches
76+
const stableProductViewModalData = React.useMemo(
77+
() => ({
78+
...productViewModalData,
79+
product:
80+
productViewModalData.isFetching && lastLoadedProductRef.current
81+
? lastLoadedProductRef.current
82+
: productViewModalData.product
83+
}),
84+
[productViewModalData.product, productViewModalData.isFetching]
85+
)
86+
6887
const {addItemToNewOrExistingBasket} = useShopperBasketsMutationHelper()
6988
const {data: basket} = useCurrentBasket()
7089
const navigate = useNavigation()
@@ -99,7 +118,7 @@ const BonusProductViewModal = ({
99118
id: 'bonus_product_view_modal.modal_label',
100119
defaultMessage: 'Bonus product selection modal for {productName}'
101120
},
102-
{productName: productViewModalData?.product?.name}
121+
{productName: stableProductViewModalData?.product?.name}
103122
),
104123
viewCart: formatMessage({
105124
id: 'bonus_product_view_modal.button.view_cart',
@@ -110,31 +129,24 @@ const BonusProductViewModal = ({
110129
defaultMessage: '← Back to Selection'
111130
})
112131
}),
113-
[intl]
114-
)
115-
116-
// Create getRemainingBonusQuantity function using the factory
117-
const getRemainingBonusQuantity = useMemo(
118-
() =>
119-
createGetRemainingBonusQuantity(
120-
basket,
121-
product,
122-
getRemainingAvailableBonusProductsForProduct
123-
),
124-
[basket, product]
132+
[intl, stableProductViewModalData?.product?.name, formatMessage]
125133
)
126134

127135
// Custom addToCart handler for bonus products that includes bonusDiscountLineItemId
128136
const handleAddToCart = useCallback(
129137
async (products) => {
130138
try {
131139
// Process products using the extracted helper function
140+
// Use a function that returns the remaining capacity based on the bonus counts
141+
const getRemainingQuantity = () =>
142+
Math.max(0, finalMaxBonusItems - finalSelectedBonusItems)
143+
132144
const productItems = processProductsForBonusCart(
133145
products,
134146
basket,
135147
promotionId,
136148
product,
137-
getRemainingBonusQuantity
149+
getRemainingQuantity
138150
)
139151

140152
if (productItems.length === 0) {
@@ -190,7 +202,8 @@ const BonusProductViewModal = ({
190202
basket,
191203
promotionId,
192204
product,
193-
getRemainingBonusQuantity,
205+
finalMaxBonusItems,
206+
finalSelectedBonusItems,
194207
onClose,
195208
navigate,
196209
onReturnToSelection,
@@ -239,7 +252,7 @@ const BonusProductViewModal = ({
239252

240253
// Clean product data and pre-filter variants based on available bonus products
241254
const productToRender = useMemo(() => {
242-
const baseProduct = productViewModalData.product || safeProduct
255+
const baseProduct = stableProductViewModalData.product || safeProduct
243256

244257
// Always provide a fallback product for testing scenarios
245258
if (!baseProduct) {
@@ -365,10 +378,15 @@ const BonusProductViewModal = ({
365378
}
366379

367380
return finalProduct
368-
}, [productViewModalData.product, safeProduct, hasPromotionData, availableBonusProductIds])
381+
}, [
382+
stableProductViewModalData.product,
383+
safeProduct,
384+
hasPromotionData,
385+
availableBonusProductIds
386+
])
369387

370-
// Calculate max order quantity for UI
371-
const maxOrderQuantity = getRemainingBonusQuantity()
388+
// Calculate max order quantity for UI - reuse the same calculation from the header
389+
const maxOrderQuantity = Math.max(0, finalMaxBonusItems - finalSelectedBonusItems)
372390

373391
return (
374392
<Modal
@@ -427,7 +445,8 @@ const BonusProductViewModal = ({
427445
}
428446
pb={productViewModalTheme.layout.body.paddingBottom}
429447
>
430-
{(productViewModalData.isFetching && !productViewModalData.product) ||
448+
{(stableProductViewModalData.isFetching &&
449+
!stableProductViewModalData.product) ||
431450
!productToRender ? (
432451
<Box p={8} textAlign="center">
433452
<Text>Loading product details...</Text>

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import {
1616
getRemainingAvailableBonusProductsForProduct,
1717
findAvailableBonusDiscountLineItemIds
1818
} from '@salesforce/retail-react-app/app/utils/bonus-product'
19-
import {useBonusProductCounts} from '@salesforce/retail-react-app/app/utils/bonus-product/hooks'
19+
import {
20+
useBonusProductCounts,
21+
useRuleBasedPromotionIds
22+
} from '@salesforce/retail-react-app/app/utils/bonus-product/hooks'
2023
import {processProductsForBonusCart} from '@salesforce/retail-react-app/app/utils/bonus-product/cart'
2124
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
2225
import {useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react'
@@ -42,6 +45,19 @@ jest.mock('@salesforce/commerce-sdk-react', () => ({
4245
useShopperCustomersMutation: jest.fn(() => ({
4346
mutateAsync: jest.fn()
4447
})),
48+
useProductSearch: jest.fn(() => ({
49+
data: null,
50+
isLoading: false,
51+
error: null
52+
})),
53+
useCommerceApi: jest.fn(() => ({
54+
shopperSearch: {
55+
productSearch: jest.fn()
56+
}
57+
})),
58+
useAccessToken: jest.fn(() => ({
59+
getTokenWhenReady: jest.fn().mockResolvedValue('mock-token')
60+
})),
4561
CommerceApiProvider: ({children}) => children
4662
}))
4763

@@ -117,7 +133,8 @@ jest.mock('@salesforce/retail-react-app/app/utils/bonus-product', () => ({
117133

118134
// Mock bonus product hooks
119135
jest.mock('@salesforce/retail-react-app/app/utils/bonus-product/hooks', () => ({
120-
useBonusProductCounts: jest.fn()
136+
useBonusProductCounts: jest.fn(),
137+
useRuleBasedPromotionIds: jest.fn(() => [])
121138
}))
122139

123140
// Mock bonus product cart helpers
@@ -171,6 +188,9 @@ beforeEach(() => {
171188
finalMaxBonusItems: 5
172189
})
173190

191+
// Mock useRuleBasedPromotionIds to return empty array by default
192+
useRuleBasedPromotionIds.mockReturnValue([])
193+
174194
// Mock findAvailableBonusDiscountLineItemIds to return array of pairs
175195
findAvailableBonusDiscountLineItemIds.mockReturnValue([['bonus-1', 1]])
176196

packages/template-retail-react-app/app/components/bonus-product-view-modal/utils.js

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,67 @@
1414
* Creates a function to get the remaining bonus quantity for a specific product.
1515
* This is a factory function that creates the getRemainingBonusQuantity function with the necessary dependencies.
1616
*
17+
* For rule-based promotions, we use the promotionId directly from the basket to calculate remaining capacity.
18+
* This avoids issues with productSearch data that doesn't have productPromotions.
19+
*
1720
* @param {Object} basket - The current basket object
1821
* @param {Object} product - The product object
1922
* @param {Function} getRemainingAvailableBonusProductsForProduct - The utility function to get remaining bonus data
23+
* @param {string} promotionId - The promotion ID for this bonus product
2024
* @returns {Function} - Function that returns remaining bonus quantity or null
2125
*/
2226
export const createGetRemainingBonusQuantity = (
2327
basket,
2428
product,
25-
getRemainingAvailableBonusProductsForProduct
29+
getRemainingAvailableBonusProductsForProduct,
30+
promotionId
2631
) => {
2732
return () => {
28-
if (basket && product) {
29-
const bonusData = getRemainingAvailableBonusProductsForProduct(basket, product.id, {
30-
[product.id]: product
31-
})
32-
// Return remaining capacity: total allowed - already in cart
33-
return bonusData.aggregatedMaxBonusItems - bonusData.aggregatedSelectedItems
33+
if (!basket || !product) {
34+
return null
35+
}
36+
37+
// If we have a promotionId, use it directly to calculate remaining capacity
38+
// This works for both list-based and rule-based promotions
39+
// IMPORTANT: Sum up ALL bonusDiscountLineItems with this promotionId (one per qualifying product)
40+
if (promotionId && basket.bonusDiscountLineItems) {
41+
// Find all bonus discount line items for this promotion
42+
const promotionBonusItems = basket.bonusDiscountLineItems.filter(
43+
(item) => item.promotionId === promotionId
44+
)
45+
46+
if (promotionBonusItems.length > 0) {
47+
// Sum up max items for this promotion (e.g., 3 qualifying products × 2 bonus each = 6 total)
48+
const maxBonusItems = promotionBonusItems.reduce(
49+
(sum, item) => sum + (item.maxBonusItems || 0),
50+
0
51+
)
52+
53+
// Get IDs of all bonus discount line items for this promotion
54+
const promotionBonusLineItemIds = promotionBonusItems
55+
.map((item) => item.id)
56+
.filter(Boolean)
57+
58+
// Count selected items for this promotion (all bonus items with this promotion's bonusDiscountLineItemIds)
59+
const selectedQuantity =
60+
basket.productItems
61+
?.filter(
62+
(cartItem) =>
63+
cartItem.bonusProductLineItem &&
64+
promotionBonusLineItemIds.includes(cartItem.bonusDiscountLineItemId)
65+
)
66+
.reduce((total, cartItem) => total + (cartItem.quantity || 0), 0) || 0
67+
68+
return Math.max(0, maxBonusItems - selectedQuantity)
69+
}
3470
}
35-
return null
71+
72+
// Fallback: use the discovery function (for legacy/list-based without promotionId)
73+
const bonusData = getRemainingAvailableBonusProductsForProduct(basket, product.id, {
74+
[product.id]: product
75+
})
76+
77+
return Math.max(0, bonusData.aggregatedMaxBonusItems - bonusData.aggregatedSelectedItems)
3678
}
3779
}
3880

packages/template-retail-react-app/app/constants.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export const DEFAULT_LIMIT_VALUES = [25, 50, 100] // Page sizes
2121
// Constants for customer orders searching.
2222
export const DEFAULT_ORDERS_SEARCH_PARAMS = {limit: 10, offset: 0, sort: 'best-matches', refine: []}
2323

24+
// Constants for bonus product searching.
25+
export const DEFAULT_BONUS_PRODUCT_SEARCH_PARAMS = {limit: 25, offset: 0}
26+
2427
// Constants for Search Component
2528
export const RECENT_SEARCH_LIMIT = 5
2629
export const RECENT_SEARCH_KEY = 'recent-search-key'

packages/template-retail-react-app/app/hooks/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ export {useVariationAttributes} from '@salesforce/retail-react-app/app/hooks/use
1515
export {useVariationParams} from '@salesforce/retail-react-app/app/hooks/use-variation-params'
1616
export {useDerivedProduct} from '@salesforce/retail-react-app/app/hooks/use-derived-product'
1717
export {useCurrency} from '@salesforce/retail-react-app/app/hooks/use-currency'
18+
export {useRuleBasedBonusProducts} from '@salesforce/retail-react-app/app/hooks/use-rule-based-bonus-products'

packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ import {
4242
getRemainingAvailableBonusProductsForProduct,
4343
useBasketProductsWithPromotions,
4444
getPromotionCalloutText,
45-
shouldShowBonusProductSelection
45+
shouldShowBonusProductSelection,
46+
getPromotionIdsForProduct
4647
} from '@salesforce/retail-react-app/app/utils/bonus-product'
4748

4849
/**
@@ -85,7 +86,8 @@ export const AddToCartModal = () => {
8586
: 0
8687

8788
// Bonus product logic
88-
const {data: productsWithPromotions} = useBasketProductsWithPromotions(basket)
89+
const {data: productsWithPromotions, ruleBasedQualifyingProductsMap} =
90+
useBasketProductsWithPromotions(basket)
8991
// Port v4 logic: Check for bonus discount line items and calculate remaining capacity
9092
const {bonusDiscountLineItems = []} = basket || {}
9193

@@ -323,7 +325,8 @@ export const AddToCartModal = () => {
323325
shouldShowBonusProductSelection(
324326
basket,
325327
product?.id,
326-
productsWithPromotions
328+
productsWithPromotions,
329+
ruleBasedQualifyingProductsMap
327330
)
328331

329332
if (!shouldShowBonusSelection) {
@@ -335,7 +338,9 @@ export const AddToCartModal = () => {
335338
getRemainingAvailableBonusProductsForProduct(
336339
basket,
337340
product?.id,
338-
productsWithPromotions
341+
productsWithPromotions,
342+
{},
343+
ruleBasedQualifyingProductsMap
339344
)
340345

341346
// Only render if there is remaining capacity across the collection
@@ -348,9 +353,25 @@ export const AddToCartModal = () => {
348353
return null
349354
}
350355

351-
// Get the first remaining available bonus product which contains the complete bonus discount line item data
352-
const firstRemainingBonusProduct =
353-
remainingBonusProductsData.bonusItems[0]
356+
// Get promotionIds for this product to find matching bonusDiscountLineItems
357+
const promotionIds = getPromotionIdsForProduct(
358+
basket,
359+
product?.id,
360+
productsWithPromotions,
361+
ruleBasedQualifyingProductsMap
362+
)
363+
364+
// Find the first bonusDiscountLineItem that matches any of the promotionIds
365+
const matchingBonusDiscountLineItem =
366+
basket?.bonusDiscountLineItems?.find((bli) =>
367+
promotionIds.includes(bli.promotionId)
368+
)
369+
370+
// If no matching bonusDiscountLineItem found, don't render
371+
if (!matchingBonusDiscountLineItem) {
372+
return null
373+
}
374+
354375
return (
355376
<SelectBonusProductsCard
356377
qualifyingProduct={{productId: product?.id}}
@@ -363,14 +384,7 @@ export const AddToCartModal = () => {
363384
// Close AddToCart modal first - the SelectBonusProductsCard will handle opening the bonus modal
364385
if (onClose) onClose()
365386
}}
366-
bonusDiscountLineItem={{
367-
id: firstRemainingBonusProduct?.bonusDiscountLineItemId,
368-
promotionId:
369-
firstRemainingBonusProduct?.promotionId,
370-
maxBonusItems:
371-
remainingBonusProductsData.aggregatedMaxBonusItems,
372-
bonusProducts: remainingBonusProductsData.bonusItems
373-
}}
387+
bonusDiscountLineItem={matchingBonusDiscountLineItem}
374388
hideSelectionCounter={true} // Hide "(0 of 2 selected)" from promotion text
375389
/>
376390
)

packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.test.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,6 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => ({
2727
useCurrentBasket: jest.fn()
2828
}))
2929

30-
jest.mock('@salesforce/retail-react-app/app/utils/bonus-product', () => ({
31-
findAvailableBonusDiscountLineItemIds: jest.fn(() => [])
32-
}))
33-
3430
jest.mock('@salesforce/commerce-sdk-react', () => ({
3531
...jest.requireActual('@salesforce/commerce-sdk-react'),
3632
useCustomerId: jest.fn(() => 'test-customer-id'),
@@ -106,7 +102,9 @@ jest.mock('@salesforce/retail-react-app/app/utils/bonus-product', () => ({
106102
product.productPromotions?.find((p) => p.promotionId === promotionId)?.calloutMsg || ''
107103
)
108104
}),
109-
shouldShowBonusProductSelection: jest.fn(() => true)
105+
shouldShowBonusProductSelection: jest.fn(() => true),
106+
getPromotionIdsForProduct: jest.fn(() => ['promo-123']),
107+
findAvailableBonusDiscountLineItemIds: jest.fn(() => [])
110108
}))
111109

112110
const MOCK_PRODUCT = {

0 commit comments

Comments
 (0)