diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md
index fc73cc1517..7860fd90bd 100644
--- a/packages/template-retail-react-app/CHANGELOG.md
+++ b/packages/template-retail-react-app/CHANGELOG.md
@@ -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)
diff --git a/packages/template-retail-react-app/app/hooks/index.js b/packages/template-retail-react-app/app/hooks/index.js
index 2750633c01..4dd17ebd22 100644
--- a/packages/template-retail-react-app/app/hooks/index.js
+++ b/packages/template-retail-react-app/app/hooks/index.js
@@ -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'
diff --git a/packages/template-retail-react-app/app/hooks/use-bonus-product-selection-modal/use-bonus-product-data.test.js b/packages/template-retail-react-app/app/hooks/use-bonus-product-selection-modal/use-bonus-product-data.test.js
index 6ddbb4b950..dfe88a1f0d 100644
--- a/packages/template-retail-react-app/app/hooks/use-bonus-product-selection-modal/use-bonus-product-data.test.js
+++ b/packages/template-retail-react-app/app/hooks/use-bonus-product-selection-modal/use-bonus-product-data.test.js
@@ -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
+ })
+ })
+ })
})
diff --git a/packages/template-retail-react-app/app/hooks/use-rule-based-bonus-products.js b/packages/template-retail-react-app/app/hooks/use-rule-based-bonus-products.js
new file mode 100644
index 0000000000..7858bb5ec9
--- /dev/null
+++ b/packages/template-retail-react-app/app/hooks/use-rule-based-bonus-products.js
@@ -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
+ }
+}
diff --git a/packages/template-retail-react-app/app/hooks/use-rule-based-bonus-products.test.js b/packages/template-retail-react-app/app/hooks/use-rule-based-bonus-products.test.js
new file mode 100644
index 0000000000..3c19bf50e4
--- /dev/null
+++ b/packages/template-retail-react-app/app/hooks/use-rule-based-bonus-products.test.js
@@ -0,0 +1,291 @@
+/*
+ * 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 React from 'react'
+import {screen, waitFor} from '@testing-library/react'
+import PropTypes from 'prop-types'
+import {useRuleBasedBonusProducts} from '@salesforce/retail-react-app/app/hooks/use-rule-based-bonus-products'
+import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
+import {useProductSearch} from '@salesforce/commerce-sdk-react'
+
+jest.mock('@salesforce/commerce-sdk-react', () => {
+ const originalModule = jest.requireActual('@salesforce/commerce-sdk-react')
+ return {
+ ...originalModule,
+ useProductSearch: jest.fn()
+ }
+})
+
+const MockComponent = ({promotionId, enabled = true, limit, offset}) => {
+ const {products, total, isLoading, error} = useRuleBasedBonusProducts(promotionId, {
+ enabled,
+ limit,
+ offset
+ })
+
+ if (isLoading) return
Loading...
+ if (error) return {error.message}
+
+ return (
+
+
{products.length}
+
{total}
+ {products.map((product) => (
+
+ {product.productName}
+
+ ))}
+
+ )
+}
+
+MockComponent.propTypes = {
+ promotionId: PropTypes.string,
+ enabled: PropTypes.bool,
+ limit: PropTypes.number,
+ offset: PropTypes.number
+}
+
+describe('useRuleBasedBonusProducts', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('fetches products successfully using useProductSearch', async () => {
+ const mockData = {
+ hits: [
+ {productId: 'product-1', productName: 'Bonus Product 1'},
+ {productId: 'product-2', productName: 'Bonus Product 2'}
+ ],
+ total: 2
+ }
+
+ useProductSearch.mockReturnValue({
+ data: mockData,
+ isLoading: false,
+ error: null
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('products-count')).toHaveTextContent('2')
+ expect(screen.getByTestId('products-total')).toHaveTextContent('2')
+ expect(screen.getByTestId('product-product-1')).toHaveTextContent('Bonus Product 1')
+ expect(screen.getByTestId('product-product-2')).toHaveTextContent('Bonus Product 2')
+ })
+
+ expect(useProductSearch).toHaveBeenCalledWith(
+ {
+ parameters: {
+ promotionId: 'test-promotion-id',
+ limit: 25,
+ offset: 0
+ }
+ },
+ {
+ enabled: true
+ }
+ )
+ })
+
+ test('does not fetch when enabled is false', async () => {
+ useProductSearch.mockReturnValue({
+ data: null,
+ isLoading: false,
+ error: null
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('products-count')).toHaveTextContent('0')
+ })
+
+ expect(useProductSearch).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ enabled: false
+ })
+ )
+ })
+
+ test('does not fetch when promotionId is missing', async () => {
+ useProductSearch.mockReturnValue({
+ data: null,
+ isLoading: false,
+ error: null
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('products-count')).toHaveTextContent('0')
+ })
+
+ expect(useProductSearch).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ enabled: false
+ })
+ )
+ })
+
+ test('includes pagination parameters when provided', async () => {
+ const mockData = {
+ hits: [{productId: 'product-1', productName: 'Product 1'}],
+ total: 100
+ }
+
+ useProductSearch.mockReturnValue({
+ data: mockData,
+ isLoading: false,
+ error: null
+ })
+
+ renderWithProviders(
+
+ )
+
+ await waitFor(() => {
+ expect(screen.getByTestId('products-total')).toHaveTextContent('100')
+ })
+
+ expect(useProductSearch).toHaveBeenCalledWith(
+ {
+ parameters: {
+ promotionId: 'test-promotion-id',
+ limit: 50,
+ offset: 25
+ }
+ },
+ {
+ enabled: true
+ }
+ )
+ })
+
+ test('handles API errors gracefully', async () => {
+ const mockError = new Error('API Error: 500')
+
+ useProductSearch.mockReturnValue({
+ data: null,
+ isLoading: false,
+ error: mockError
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ const errorElement = screen.getByTestId('error')
+ expect(errorElement).toBeInTheDocument()
+ expect(errorElement.textContent).toContain('API Error: 500')
+ })
+ })
+
+ test('shows loading state', async () => {
+ useProductSearch.mockReturnValue({
+ data: null,
+ isLoading: true,
+ error: null
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('loading')).toBeInTheDocument()
+ expect(screen.getByTestId('loading')).toHaveTextContent('Loading...')
+ })
+ })
+
+ test('returns empty array when no products found', async () => {
+ useProductSearch.mockReturnValue({
+ data: {hits: [], total: 0},
+ isLoading: false,
+ error: null
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('products-count')).toHaveTextContent('0')
+ expect(screen.getByTestId('products-total')).toHaveTextContent('0')
+ })
+ })
+
+ test('uses default pagination values when not provided', async () => {
+ const mockData = {
+ hits: [{productId: 'product-1'}],
+ total: 1
+ }
+
+ useProductSearch.mockReturnValue({
+ data: mockData,
+ isLoading: false,
+ error: null
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('products-count')).toHaveTextContent('1')
+ })
+
+ expect(useProductSearch).toHaveBeenCalledWith(
+ {
+ parameters: {
+ promotionId: 'test-promotion-id',
+ limit: 25, // Default value
+ offset: 0 // Default value
+ }
+ },
+ {
+ enabled: true
+ }
+ )
+ })
+
+ test('handles different promotionIds correctly', async () => {
+ const mockDataPromo1 = {
+ hits: [
+ {productId: 'promo1-product-1', productName: 'Promo 1 Product 1'},
+ {productId: 'promo1-product-2', productName: 'Promo 1 Product 2'}
+ ],
+ total: 2
+ }
+
+ // Test promotion-1
+ useProductSearch.mockReturnValue({
+ data: mockDataPromo1,
+ isLoading: false,
+ error: null
+ })
+
+ renderWithProviders()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('products-count')).toHaveTextContent('2')
+ expect(screen.getByTestId('products-total')).toHaveTextContent('2')
+ expect(screen.getByTestId('product-promo1-product-1')).toBeInTheDocument()
+ expect(screen.getByTestId('product-promo1-product-2')).toBeInTheDocument()
+ })
+
+ // Verify hook was called with correct promotionId
+ expect(useProductSearch).toHaveBeenCalledWith(
+ {
+ parameters: {
+ promotionId: 'promotion-1',
+ limit: 25,
+ offset: 0
+ }
+ },
+ {
+ enabled: true
+ }
+ )
+ })
+})
diff --git a/packages/template-retail-react-app/app/utils/bonus-product/business-logic.js b/packages/template-retail-react-app/app/utils/bonus-product/business-logic.js
index a38f61f086..4968e5a76d 100644
--- a/packages/template-retail-react-app/app/utils/bonus-product/business-logic.js
+++ b/packages/template-retail-react-app/app/utils/bonus-product/business-logic.js
@@ -25,6 +25,48 @@ import {
* - UI behavior determination
*/
+/**
+ * Detects if a bonus discount line item is rule-based.
+ * Rule-based promotions have empty bonusProducts arrays - products are
+ * determined by dynamic rules and must be fetched via SCAPI product search.
+ *
+ * Examples of rule-based promotions:
+ * - "Get choice of bonus where brand = 'Sony'"
+ * - "Get choice of bonus where price < $50"
+ * - "Get choice of bonus from 'Electronics' category"
+ *
+ * @param {Object} bonusDiscountLineItem - A single bonus discount line item from basket
+ * @returns {boolean} True if rule-based (empty bonusProducts array), false if list-based
+ *
+ * @example
+ * // Rule-based promotion (dynamic rules)
+ * const ruleBasedItem = {
+ * promotionId: 'promo-123',
+ * bonusProducts: [] // Empty array indicates rule-based
+ * }
+ * isRuleBasedPromotion(ruleBasedItem) // true
+ *
+ * @example
+ * // List-based promotion (static product list)
+ * const listBasedItem = {
+ * promotionId: 'promo-456',
+ * bonusProducts: [
+ * { productId: 'p1', productName: 'Product 1' },
+ * { productId: 'p2', productName: 'Product 2' }
+ * ]
+ * }
+ * isRuleBasedPromotion(listBasedItem) // false
+ */
+export const isRuleBasedPromotion = (bonusDiscountLineItem) => {
+ if (!bonusDiscountLineItem) {
+ return false
+ }
+
+ // Rule-based indicator: bonusProducts array is empty or doesn't exist
+ const bonusProducts = bonusDiscountLineItem.bonusProducts || []
+ return bonusProducts.length === 0
+}
+
/**
* Determines if a product's promotions are automatic (no choice) or manual (choice of bonus products).
* Automatic promotions add bonus products directly to cart without user selection.
diff --git a/packages/template-retail-react-app/app/utils/bonus-product/business-logic.test.js b/packages/template-retail-react-app/app/utils/bonus-product/business-logic.test.js
index f7db8f5fd3..ec94e62661 100644
--- a/packages/template-retail-react-app/app/utils/bonus-product/business-logic.test.js
+++ b/packages/template-retail-react-app/app/utils/bonus-product/business-logic.test.js
@@ -8,6 +8,81 @@
import * as businessLogicUtils from '@salesforce/retail-react-app/app/utils/bonus-product/business-logic'
describe('Bonus Product Business Logic', () => {
+ describe('isRuleBasedPromotion', () => {
+ test('returns true for rule-based promotion (empty bonusProducts array)', () => {
+ const bonusDiscountLineItem = {
+ promotionId: 'promo-123',
+ bonusProducts: [] // Empty array = rule-based
+ }
+
+ const result = businessLogicUtils.isRuleBasedPromotion(bonusDiscountLineItem)
+ expect(result).toBe(true)
+ })
+
+ test('returns true when bonusProducts does not exist', () => {
+ const bonusDiscountLineItem = {
+ promotionId: 'promo-123'
+ // No bonusProducts property = rule-based
+ }
+
+ const result = businessLogicUtils.isRuleBasedPromotion(bonusDiscountLineItem)
+ expect(result).toBe(true)
+ })
+
+ test('returns false for list-based promotion (populated bonusProducts array)', () => {
+ const bonusDiscountLineItem = {
+ promotionId: 'promo-456',
+ bonusProducts: [
+ {productId: 'p1', productName: 'Product 1'},
+ {productId: 'p2', productName: 'Product 2'}
+ ]
+ }
+
+ const result = businessLogicUtils.isRuleBasedPromotion(bonusDiscountLineItem)
+ expect(result).toBe(false)
+ })
+
+ test('returns false when bonusDiscountLineItem is null', () => {
+ const result = businessLogicUtils.isRuleBasedPromotion(null)
+ expect(result).toBe(false)
+ })
+
+ test('returns false when bonusDiscountLineItem is undefined', () => {
+ const result = businessLogicUtils.isRuleBasedPromotion(undefined)
+ expect(result).toBe(false)
+ })
+
+ test('returns true when bonusProducts is null', () => {
+ const bonusDiscountLineItem = {
+ promotionId: 'promo-789',
+ bonusProducts: null // null = rule-based
+ }
+
+ const result = businessLogicUtils.isRuleBasedPromotion(bonusDiscountLineItem)
+ expect(result).toBe(true)
+ })
+
+ test('returns true when bonusProducts is undefined', () => {
+ const bonusDiscountLineItem = {
+ promotionId: 'promo-789',
+ bonusProducts: undefined // undefined = rule-based
+ }
+
+ const result = businessLogicUtils.isRuleBasedPromotion(bonusDiscountLineItem)
+ expect(result).toBe(true)
+ })
+
+ test('returns false for single bonus product in array', () => {
+ const bonusDiscountLineItem = {
+ promotionId: 'promo-single',
+ bonusProducts: [{productId: 'p1', productName: 'Product 1'}]
+ }
+
+ const result = businessLogicUtils.isRuleBasedPromotion(bonusDiscountLineItem)
+ expect(result).toBe(false)
+ })
+ })
+
describe('shouldShowBonusProductSelection', () => {
test('returns true when product is eligible and not available as bonus', () => {
const basket = {
diff --git a/packages/template-retail-react-app/app/utils/bonus-product/discovery.js b/packages/template-retail-react-app/app/utils/bonus-product/discovery.js
index 38f455722b..917743a250 100644
--- a/packages/template-retail-react-app/app/utils/bonus-product/discovery.js
+++ b/packages/template-retail-react-app/app/utils/bonus-product/discovery.js
@@ -6,6 +6,7 @@
*/
import {getPromotionIdsForProduct} from '@salesforce/retail-react-app/app/utils/bonus-product/common'
+import {isRuleBasedPromotion} from '@salesforce/retail-react-app/app/utils/bonus-product/business-logic'
/**
* Discovery utilities for finding available bonus products.
@@ -14,6 +15,10 @@ import {getPromotionIdsForProduct} from '@salesforce/retail-react-app/app/utils/
* for selection and addition to the cart. It focuses on finding NEW items that can
* be added, calculating remaining capacity, and determining available discount line items.
*
+ * Supports both list-based and rule-based bonus promotions:
+ * - List-based: Products defined in bonusProducts array (static list)
+ * - Rule-based: Products fetched dynamically via ruleBasedProductsMap parameter
+ *
* Functions in this file:
* - Discovery of available bonus items to add
* - Calculation of remaining capacity/availability
@@ -23,15 +28,61 @@ import {getPromotionIdsForProduct} from '@salesforce/retail-react-app/app/utils/
* Note: This is different from cart.js which deals with existing cart state.
*/
+/**
+ * Processes bonus products from a discount item based on promotion type.
+ * Handles both rule-based and list-based promotions.
+ *
+ * @param {Object} discountItem - The bonus discount line item
+ * @param {Object} ruleBasedProductsMap - Map of promotionId to products array for rule-based promotions
+ * @param {Object} additionalFields - Additional fields to merge into each product
+ * @returns {Array