Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
3 changes: 3 additions & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## v8.3.0
- Add support for Rule Based Promotions for Choice of Bonus Products.

## v8.2.0-dev (Sep 26, 2025)
- [Bugfix] Use `serverSafeEncode` util for address mutations. [#3380](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3380)
## v8.1.0 (Sep 25, 2025)
Expand Down
1 change: 1 addition & 0 deletions packages/template-retail-react-app/app/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export {useVariationAttributes} from '@salesforce/retail-react-app/app/hooks/use
export {useVariationParams} from '@salesforce/retail-react-app/app/hooks/use-variation-params'
export {useDerivedProduct} from '@salesforce/retail-react-app/app/hooks/use-derived-product'
export {useCurrency} from '@salesforce/retail-react-app/app/hooks/use-currency'
export {useRuleBasedBonusProducts} from '@salesforce/retail-react-app/app/hooks/use-rule-based-bonus-products'
Original file line number Diff line number Diff line change
Expand Up @@ -515,4 +515,133 @@ describe('useBonusProductData', () => {
expect(normalizedSet.selectedVariant.variationValues).toEqual({color: 'multi'})
expect(normalizedSet.type.set).toBe(true)
})

describe('Integration with Rule-Based Promotions', () => {
test('handles rule-based promotion with empty bonusProducts array', () => {
const ruleBasedModalData = {
bonusDiscountLineItems: [
{
id: 'rule-based-bonus-1',
promotionId: 'rule-based-promo',
maxBonusItems: 3,
bonusProducts: [] // Empty for rule-based
}
]
}

const {result} = renderHook(() => useBonusProductData(ruleBasedModalData))

// Should handle empty bonusProducts gracefully
expect(result.current.bonusProducts).toEqual(ruleBasedModalData.bonusDiscountLineItems)
expect(result.current.bonusLineItemIds).toEqual(['rule-based-bonus-1'])
expect(result.current.maxBonusItems).toBe(3)
expect(result.current.uniqueBonusProducts).toEqual([])
expect(result.current.productIds).toBe('')
})

test('handles mixed list-based and rule-based promotions', () => {
const mixedModalData = {
bonusDiscountLineItems: [
{
id: 'list-based-1',
promotionId: 'list-promo',
maxBonusItems: 2,
bonusProducts: [
{productId: 'list-product-1'},
{productId: 'list-product-2'}
]
},
{
id: 'rule-based-1',
promotionId: 'rule-promo',
maxBonusItems: 3,
bonusProducts: [] // Rule-based
}
]
}

const {result} = renderHook(() => useBonusProductData(mixedModalData))

// Should combine maxBonusItems from both
expect(result.current.maxBonusItems).toBe(5)
expect(result.current.bonusLineItemIds).toEqual(['list-based-1', 'rule-based-1'])

// Should only include products from list-based in uniqueBonusProducts
expect(result.current.uniqueBonusProducts).toEqual([
{productId: 'list-product-1'},
{productId: 'list-product-2'}
])

// Product IDs should only include list-based products
expect(result.current.productIds).toBe('list-product-1,list-product-2')
})

test('computes selected items correctly with rule-based bonus products in basket', () => {
const mixedBasket = {
productItems: [
{
productId: 'list-bonus-1',
bonusProductLineItem: true,
bonusDiscountLineItemId: 'list-based-1',
quantity: 1
},
{
productId: 'rule-bonus-1',
bonusProductLineItem: true,
bonusDiscountLineItemId: 'rule-based-1',
quantity: 2
}
]
}

useCurrentBasket.mockReturnValue({data: mixedBasket})

const mixedModalData = {
bonusDiscountLineItems: [
{
id: 'list-based-1',
promotionId: 'list-promo',
maxBonusItems: 2,
bonusProducts: [{productId: 'list-bonus-1'}]
},
{
id: 'rule-based-1',
promotionId: 'rule-promo',
maxBonusItems: 3,
bonusProducts: []
}
]
}

const {result} = renderHook(() => useBonusProductData(mixedModalData))

// Should count both list-based and rule-based selected items
expect(result.current.selectedBonusItems).toBe(3) // 1 + 2
})

test('computeBonusMeta handles rule-based promotion with no bonusProducts', () => {
const ruleBasedModalData = {
bonusDiscountLineItems: [
{
id: 'rule-based-1',
promotionId: 'rule-promo',
maxBonusItems: 3,
bonusProducts: []
}
]
}

findAvailableBonusDiscountLineItemIds.mockReturnValue([['rule-based-1', 3]])

const {result} = renderHook(() => useBonusProductData(ruleBasedModalData))

// Should return null for products not in any bonusProducts list
const meta = result.current.computeBonusMeta({productId: 'rule-fetched-product'})

expect(meta).toEqual({
promotionId: null,
bonusDiscountLineItemId: null
})
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025, 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 {useProductSearch} from '@salesforce/commerce-sdk-react'

/**
* Hook to fetch rule-based bonus products using ShopperSearch productSearch endpoint.
*
* For rule-based bonus promotions, bonusProducts array is empty in the basket response.
* This hook uses the productSearch endpoint with promotionId to fetch eligible bonus products.
*
* @param {string} promotionId - The promotion ID to fetch bonus products for
* @param {Object} options - Additional options
* @param {boolean} [options.enabled=true] - Whether to fetch products
* @param {number} [options.limit=25] - Maximum number of products to return
* @param {number} [options.offset=0] - Offset for pagination
* @returns {Object} React Query result with products data
*
* @example
* const {products, isLoading, error} = useRuleBasedBonusProducts(
* 'my-promotion-id',
* {enabled: isModalOpen}
* )
*/
export const useRuleBasedBonusProducts = (promotionId, {enabled = true, limit, offset} = {}) => {
const {data, isLoading, error, ...rest} = useProductSearch(
{
parameters: {
promotionId,
limit: limit || 25,
offset: offset || 0
}
},
{
enabled: enabled && Boolean(promotionId)
}
)

return {
products: data?.hits || [],
total: data?.total || 0,
isLoading,
error,
...rest
}
}
Loading
Loading