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} Array of processed bonus products with metadata + */ +const processBonusProducts = (discountItem, ruleBasedProductsMap, additionalFields = {}) => { + const products = [] + + if (isRuleBasedPromotion(discountItem)) { + // Use products from ruleBasedProductsMap + const ruleProducts = ruleBasedProductsMap[discountItem.promotionId] || [] + ruleProducts.forEach((product) => { + products.push({ + ...product, + promotionId: discountItem.promotionId, + ...additionalFields + }) + }) + } else { + // List-based: use existing bonusProducts array + discountItem.bonusProducts?.forEach((bonusProduct) => { + products.push({ + ...bonusProduct, + promotionId: discountItem.promotionId, + ...additionalFields + }) + }) + } + + return products +} + /** * Gets all available bonus discount line items that are triggered by a specific product. * + * Supports both list-based and rule-based promotions: + * - List-based: Uses bonusProducts array from bonusDiscountLineItems + * - Rule-based: Uses ruleBasedProductsMap to get dynamically fetched products + * * @param {Object} basket - The current basket data * @param {string} productId - The product ID to find available bonus items for * @param {Object} productsWithPromotions - Products data with promotion info + * @param {Object} [ruleBasedProductsMap={}] - Map of promotionId to products array for rule-based promotions * @returns {Array} Array of available bonus discount line items */ -export const getAvailableBonusItemsForProduct = (basket, productId, productsWithPromotions) => { +export const getAvailableBonusItemsForProduct = ( + basket, + productId, + productsWithPromotions, + ruleBasedProductsMap = {} +) => { if (!basket || !productId || !productsWithPromotions) { return [] } @@ -52,13 +103,10 @@ export const getAvailableBonusItemsForProduct = (basket, productId, productsWith // Flatten the bonus products from all matching discount line items const availableBonusItems = [] matchingDiscountItems.forEach((discountItem) => { - discountItem.bonusProducts?.forEach((bonusProduct) => { - availableBonusItems.push({ - ...bonusProduct, - promotionId: discountItem.promotionId, - discountLineItemId: discountItem.id - }) + const products = processBonusProducts(discountItem, ruleBasedProductsMap, { + discountLineItemId: discountItem.id }) + availableBonusItems.push(...products) }) return availableBonusItems @@ -69,6 +117,10 @@ export const getAvailableBonusItemsForProduct = (basket, productId, productsWith * and the maxBonusItems limits. Only returns bonus items with remainingBonusItemsCount > 0. * Also includes aggregated statistics for promotion tracking. * + * Supports both list-based and rule-based promotions: + * - List-based: Uses bonusProducts array from bonusDiscountLineItems + * - Rule-based: Uses ruleBasedProductsMap to get dynamically fetched products + * * Uses correct logic: * - Available items: aggregated maxBonusItems from bonusDiscountLineItems with same promotionId * - Selected items: sum of quantities of bonus products in cart matched by bonusDiscountLineItemId @@ -76,12 +128,14 @@ export const getAvailableBonusItemsForProduct = (basket, productId, productsWith * @param {Object} basket - The current basket data * @param {string} productId - The product ID to find remaining bonus products for * @param {Object} productsWithPromotions - Products data with promotion info + * @param {Object} [ruleBasedProductsMap={}] - Map of promotionId to products array for rule-based promotions * @returns {Object} Object containing bonusItems array and aggregated statistics */ export const getRemainingAvailableBonusProductsForProduct = ( basket, productId, - productsWithPromotions + productsWithPromotions, + ruleBasedProductsMap = {} ) => { if (!basket || !productId || !productsWithPromotions) { return { @@ -188,14 +242,11 @@ export const getRemainingAvailableBonusProductsForProduct = ( // If there are remaining slots, add all bonus products from this discount item if (remainingBonusItemsCount > 0) { - discountItem.bonusProducts?.forEach((bonusProduct) => { - remainingBonusItems.push({ - ...bonusProduct, - promotionId: discountItem.promotionId, - bonusDiscountLineItemId: discountItem.id, - remainingBonusItemsCount: remainingBonusItemsCount // All products share the same remaining count for this discount item - }) + const products = processBonusProducts(discountItem, ruleBasedProductsMap, { + bonusDiscountLineItemId: discountItem.id, + remainingBonusItemsCount: remainingBonusItemsCount }) + remainingBonusItems.push(...products) } }) diff --git a/packages/template-retail-react-app/app/utils/bonus-product/discovery.test.js b/packages/template-retail-react-app/app/utils/bonus-product/discovery.test.js index 6a6933e151..5f4c001ef0 100644 --- a/packages/template-retail-react-app/app/utils/bonus-product/discovery.test.js +++ b/packages/template-retail-react-app/app/utils/bonus-product/discovery.test.js @@ -159,4 +159,696 @@ describe('Bonus Product Discovery', () => { expect(result).toEqual([]) }) }) + + describe('Rule-Based Promotions Support', () => { + const mockRuleBasedBasket = { + bonusDiscountLineItems: [ + { + id: 'rule-based-123', + promotionId: 'rule-based-promo', + maxBonusItems: 3, + bonusProducts: [] // Empty array indicates rule-based + } + ], + productItems: [ + { + productId: 'prod-456', + priceAdjustments: [{promotionId: 'rule-based-promo', price: -15}] + } + ] + } + + const mockProductsForRuleBased = { + 'prod-456': { + id: 'prod-456', + productPromotions: [ + { + promotionId: 'rule-based-promo', + calloutMsg: 'Get choice of bonus from Electronics category!' + } + ] + } + } + + const mockRuleBasedProductsMap = { + 'rule-based-promo': [ + {productId: 'rule-product-1', productName: 'Rule Product 1'}, + {productId: 'rule-product-2', productName: 'Rule Product 2'}, + {productId: 'rule-product-3', productName: 'Rule Product 3'} + ] + } + + describe('getAvailableBonusItemsForProduct with rule-based products', () => { + test('returns rule-based products from ruleBasedProductsMap', () => { + const result = discoveryUtils.getAvailableBonusItemsForProduct( + mockRuleBasedBasket, + 'prod-456', + mockProductsForRuleBased, + mockRuleBasedProductsMap + ) + + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ + productId: 'rule-product-1', + productName: 'Rule Product 1', + promotionId: 'rule-based-promo', + discountLineItemId: 'rule-based-123' + }) + expect(result[1].productId).toBe('rule-product-2') + expect(result[2].productId).toBe('rule-product-3') + }) + + test('returns empty array when ruleBasedProductsMap is not provided', () => { + const result = discoveryUtils.getAvailableBonusItemsForProduct( + mockRuleBasedBasket, + 'prod-456', + mockProductsForRuleBased + // No ruleBasedProductsMap provided + ) + + expect(result).toEqual([]) + }) + + test('handles mixed list-based and rule-based promotions', () => { + const mixedBasket = { + bonusDiscountLineItems: [ + { + id: 'list-based-123', + promotionId: 'list-based-promo', + maxBonusItems: 1, + bonusProducts: [{productId: 'list-product-1'}] + }, + { + id: 'rule-based-456', + promotionId: 'rule-based-promo', + maxBonusItems: 2, + bonusProducts: [] // Rule-based + } + ], + productItems: [] + } + + const mixedProducts = { + 'prod-789': { + id: 'prod-789', + productPromotions: [ + {promotionId: 'list-based-promo'}, + {promotionId: 'rule-based-promo'} + ] + } + } + + const result = discoveryUtils.getAvailableBonusItemsForProduct( + mixedBasket, + 'prod-789', + mixedProducts, + mockRuleBasedProductsMap + ) + + expect(result).toHaveLength(4) // 1 list-based + 3 rule-based + expect(result[0].productId).toBe('list-product-1') + expect(result[1].productId).toBe('rule-product-1') + expect(result[2].productId).toBe('rule-product-2') + expect(result[3].productId).toBe('rule-product-3') + }) + }) + + describe('getRemainingAvailableBonusProductsForProduct with rule-based products', () => { + test('calculates remaining count for rule-based products', () => { + const result = discoveryUtils.getRemainingAvailableBonusProductsForProduct( + mockRuleBasedBasket, + 'prod-456', + mockProductsForRuleBased, + mockRuleBasedProductsMap + ) + + expect(result.bonusItems).toHaveLength(3) + expect(result.aggregatedMaxBonusItems).toBe(3) + expect(result.aggregatedSelectedItems).toBe(0) + expect(result.hasRemainingCapacity).toBe(true) + expect(result.bonusItems[0].remainingBonusItemsCount).toBe(3) + }) + + test('filters out rule-based products when capacity is full', () => { + const basketWithBonusItems = { + ...mockRuleBasedBasket, + productItems: [ + ...mockRuleBasedBasket.productItems, + { + productId: 'rule-product-1', + bonusProductLineItem: true, + bonusDiscountLineItemId: 'rule-based-123', + quantity: 3 // Fill the capacity + } + ] + } + + const result = discoveryUtils.getRemainingAvailableBonusProductsForProduct( + basketWithBonusItems, + 'prod-456', + mockProductsForRuleBased, + mockRuleBasedProductsMap + ) + + expect(result.bonusItems).toEqual([]) + expect(result.aggregatedMaxBonusItems).toBe(3) + expect(result.aggregatedSelectedItems).toBe(3) + expect(result.hasRemainingCapacity).toBe(false) + }) + + test('handles mixed promotions with different remaining counts', () => { + const mixedBasket = { + bonusDiscountLineItems: [ + { + id: 'list-based-123', + promotionId: 'list-based-promo', + maxBonusItems: 2, + bonusProducts: [{productId: 'list-product-1'}] + }, + { + id: 'rule-based-456', + promotionId: 'rule-based-promo', + maxBonusItems: 3, + bonusProducts: [] + } + ], + productItems: [ + { + productId: 'list-product-1', + bonusProductLineItem: true, + bonusDiscountLineItemId: 'list-based-123', + quantity: 1 // 1 of 2 used + } + ] + } + + const mixedProducts = { + 'prod-789': { + id: 'prod-789', + productPromotions: [ + {promotionId: 'list-based-promo'}, + {promotionId: 'rule-based-promo'} + ] + } + } + + const result = discoveryUtils.getRemainingAvailableBonusProductsForProduct( + mixedBasket, + 'prod-789', + mixedProducts, + mockRuleBasedProductsMap + ) + + // List-based has 1 remaining, rule-based has 3 remaining + expect(result.bonusItems).toHaveLength(4) + expect(result.aggregatedMaxBonusItems).toBe(5) + expect(result.aggregatedSelectedItems).toBe(1) + expect(result.hasRemainingCapacity).toBe(true) + + // Check that list-based product has remainingCount of 1 + const listBasedItem = result.bonusItems.find( + (item) => item.productId === 'list-product-1' + ) + expect(listBasedItem.remainingBonusItemsCount).toBe(1) + + // Check that rule-based products have remainingCount of 3 + const ruleBasedItem = result.bonusItems.find( + (item) => item.productId === 'rule-product-1' + ) + expect(ruleBasedItem.remainingBonusItemsCount).toBe(3) + }) + }) + }) + + describe('Backward Compatibility with List-Based Promotions', () => { + test('getAvailableBonusItemsForProduct works without ruleBasedProductsMap parameter', () => { + const listBasedBasket = { + bonusDiscountLineItems: [ + { + id: 'list-123', + promotionId: 'list-promo', + maxBonusItems: 2, + bonusProducts: [ + {productId: 'list-product-1', productName: 'List Product 1'}, + {productId: 'list-product-2', productName: 'List Product 2'} + ] + } + ], + productItems: [] + } + + const listBasedProducts = { + 'prod-100': { + id: 'prod-100', + productPromotions: [{promotionId: 'list-promo'}] + } + } + + // Call without ruleBasedProductsMap - should still work for list-based + const result = discoveryUtils.getAvailableBonusItemsForProduct( + listBasedBasket, + 'prod-100', + listBasedProducts + ) + + expect(result).toHaveLength(2) + expect(result[0].productId).toBe('list-product-1') + expect(result[1].productId).toBe('list-product-2') + }) + + test('getRemainingAvailableBonusProductsForProduct works without ruleBasedProductsMap parameter', () => { + const listBasedBasket = { + bonusDiscountLineItems: [ + { + id: 'list-456', + promotionId: 'list-promo-2', + maxBonusItems: 3, + bonusProducts: [ + {productId: 'bonus-a', productName: 'Bonus A'}, + {productId: 'bonus-b', productName: 'Bonus B'}, + {productId: 'bonus-c', productName: 'Bonus C'} + ] + } + ], + productItems: [ + { + productId: 'bonus-a', + bonusProductLineItem: true, + bonusDiscountLineItemId: 'list-456', + quantity: 1 + } + ] + } + + const listBasedProducts = { + 'prod-200': { + id: 'prod-200', + productPromotions: [{promotionId: 'list-promo-2'}] + } + } + + // Call without ruleBasedProductsMap - should calculate remaining correctly + const result = discoveryUtils.getRemainingAvailableBonusProductsForProduct( + listBasedBasket, + 'prod-200', + listBasedProducts + ) + + expect(result.bonusItems).toHaveLength(3) + expect(result.aggregatedMaxBonusItems).toBe(3) + expect(result.aggregatedSelectedItems).toBe(1) + expect(result.hasRemainingCapacity).toBe(true) + expect(result.bonusItems[0].remainingBonusItemsCount).toBe(2) + }) + + test('existing list-based promotions return same results with empty ruleBasedProductsMap', () => { + const listBasedBasket = { + bonusDiscountLineItems: [ + { + id: 'legacy-promo', + promotionId: 'legacy-promotion', + maxBonusItems: 1, + bonusProducts: [ + {productId: 'legacy-product', productName: 'Legacy Product'} + ] + } + ], + productItems: [] + } + + const listBasedProducts = { + 'prod-300': { + id: 'prod-300', + productPromotions: [{promotionId: 'legacy-promotion'}] + } + } + + // Results should be identical with or without empty map + const resultWithoutMap = discoveryUtils.getAvailableBonusItemsForProduct( + listBasedBasket, + 'prod-300', + listBasedProducts + ) + + const resultWithEmptyMap = discoveryUtils.getAvailableBonusItemsForProduct( + listBasedBasket, + 'prod-300', + listBasedProducts, + {} + ) + + expect(resultWithoutMap).toEqual(resultWithEmptyMap) + expect(resultWithoutMap).toHaveLength(1) + expect(resultWithoutMap[0].productId).toBe('legacy-product') + }) + }) + + describe('Integration with Real Basket Data Structure', () => { + test('handles complete SCAPI basket response structure for list-based promotion', () => { + const realBasket = { + basketId: 'test-basket-123', + currency: 'USD', + customerInfo: {customerId: 'test-customer'}, + bonusDiscountLineItems: [ + { + couponCode: 'BONUS10', + id: '8a7b9c0d1e2f3456', + maxBonusItems: 2, + promotionId: 'BuyXGetYBonusChoice', + promotionLink: 'https://example.com/promotions/BuyXGetYBonusChoice', + bonusProducts: [ + { + productId: 'bonus-product-A', + productName: 'Bonus Product A', + title: 'Bonus Product A' + }, + { + productId: 'bonus-product-B', + productName: 'Bonus Product B', + title: 'Bonus Product B' + } + ] + } + ], + productItems: [ + { + itemId: 'item-001', + productId: 'qualifying-product-X', + productName: 'Qualifying Product X', + quantity: 1, + price: 99.99, + priceAfterItemDiscount: 99.99, + bonusProductLineItem: false, + priceAdjustments: [ + { + promotionId: 'BuyXGetYBonusChoice', + appliedDiscount: {type: 'bonus_choice'}, + itemText: 'Bonus product promotion', + price: 0 + } + ] + } + ] + } + + const realProducts = { + 'qualifying-product-X': { + id: 'qualifying-product-X', + name: 'Qualifying Product X', + productPromotions: [ + { + promotionId: 'BuyXGetYBonusChoice', + calloutMsg: 'Buy 1, Get 2 Bonus Products Free!' + } + ] + } + } + + const result = discoveryUtils.getAvailableBonusItemsForProduct( + realBasket, + 'qualifying-product-X', + realProducts + ) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + productId: 'bonus-product-A', + productName: 'Bonus Product A', + title: 'Bonus Product A', + promotionId: 'BuyXGetYBonusChoice', + discountLineItemId: '8a7b9c0d1e2f3456' + }) + expect(result[1]).toEqual({ + productId: 'bonus-product-B', + productName: 'Bonus Product B', + title: 'Bonus Product B', + promotionId: 'BuyXGetYBonusChoice', + discountLineItemId: '8a7b9c0d1e2f3456' + }) + }) + + test('handles complete SCAPI basket response structure for rule-based promotion', () => { + const realRuleBasedBasket = { + basketId: 'test-basket-456', + currency: 'USD', + bonusDiscountLineItems: [ + { + couponCode: 'RULEBASED20', + id: '9b8c7d6e5f4a3210', + maxBonusItems: 3, + promotionId: 'CategoryBonusRule', + promotionLink: 'https://example.com/promotions/CategoryBonusRule', + bonusProducts: [] // Rule-based: empty array + } + ], + productItems: [ + { + itemId: 'item-002', + productId: 'qualifying-product-Y', + productName: 'Qualifying Product Y', + quantity: 2, + price: 149.99, + priceAfterItemDiscount: 149.99, + bonusProductLineItem: false, + priceAdjustments: [ + { + promotionId: 'CategoryBonusRule', + appliedDiscount: {type: 'bonus_choice'}, + itemText: 'Choose bonus from Electronics category', + price: 0 + } + ] + } + ] + } + + const realProducts = { + 'qualifying-product-Y': { + id: 'qualifying-product-Y', + name: 'Qualifying Product Y', + productPromotions: [ + { + promotionId: 'CategoryBonusRule', + calloutMsg: 'Buy 2, Get Choice from Electronics!' + } + ] + } + } + + const ruleBasedProductsMap = { + CategoryBonusRule: [ + { + productId: 'electronics-1', + productName: 'Headphones', + price: 79.99, + currency: 'USD', + image: { + alt: 'Headphones', + link: '/images/headphones.jpg' + } + }, + { + productId: 'electronics-2', + productName: 'Smart Watch', + price: 199.99, + currency: 'USD', + image: { + alt: 'Smart Watch', + link: '/images/watch.jpg' + } + }, + { + productId: 'electronics-3', + productName: 'Wireless Mouse', + price: 29.99, + currency: 'USD', + image: { + alt: 'Mouse', + link: '/images/mouse.jpg' + } + } + ] + } + + const result = discoveryUtils.getAvailableBonusItemsForProduct( + realRuleBasedBasket, + 'qualifying-product-Y', + realProducts, + ruleBasedProductsMap + ) + + expect(result).toHaveLength(3) + expect(result[0].productId).toBe('electronics-1') + expect(result[1].productId).toBe('electronics-2') + expect(result[2].productId).toBe('electronics-3') + expect(result[0].promotionId).toBe('CategoryBonusRule') + expect(result[0].discountLineItemId).toBe('9b8c7d6e5f4a3210') + }) + + test('handles basket with both list-based and rule-based promotions and partial selection', () => { + const complexBasket = { + basketId: 'complex-basket-789', + currency: 'USD', + bonusDiscountLineItems: [ + { + id: 'list-discount-1', + promotionId: 'ListBasedPromo', + maxBonusItems: 2, + bonusProducts: [ + {productId: 'list-bonus-1', productName: 'List Bonus 1'}, + {productId: 'list-bonus-2', productName: 'List Bonus 2'} + ] + }, + { + id: 'rule-discount-1', + promotionId: 'RuleBasedPromo', + maxBonusItems: 3, + bonusProducts: [] + } + ], + productItems: [ + { + itemId: 'qual-item-1', + productId: 'qualifying-prod-1', + quantity: 1, + bonusProductLineItem: false, + priceAdjustments: [ + {promotionId: 'ListBasedPromo'}, + {promotionId: 'RuleBasedPromo'} + ] + }, + { + itemId: 'bonus-item-1', + productId: 'list-bonus-1', + quantity: 1, + bonusProductLineItem: true, + bonusDiscountLineItemId: 'list-discount-1' + }, + { + itemId: 'bonus-item-2', + productId: 'rule-fetched-1', + quantity: 2, + bonusProductLineItem: true, + bonusDiscountLineItemId: 'rule-discount-1' + } + ] + } + + const complexProducts = { + 'qualifying-prod-1': { + id: 'qualifying-prod-1', + productPromotions: [ + {promotionId: 'ListBasedPromo'}, + {promotionId: 'RuleBasedPromo'} + ] + } + } + + const ruleBasedMap = { + RuleBasedPromo: [ + {productId: 'rule-fetched-1', productName: 'Rule Product 1'}, + {productId: 'rule-fetched-2', productName: 'Rule Product 2'} + ] + } + + const result = discoveryUtils.getRemainingAvailableBonusProductsForProduct( + complexBasket, + 'qualifying-prod-1', + complexProducts, + ruleBasedMap + ) + + // List-based: 2 max, 1 selected = 1 remaining + // Rule-based: 3 max, 2 selected = 1 remaining + // Total: 2 products available (1 from each) + expect(result.aggregatedMaxBonusItems).toBe(5) + expect(result.aggregatedSelectedItems).toBe(3) + expect(result.hasRemainingCapacity).toBe(true) + + // Should include remaining products from both promotions + const listProducts = result.bonusItems.filter((item) => + item.productId.startsWith('list-') + ) + const ruleProducts = result.bonusItems.filter((item) => + item.productId.startsWith('rule-') + ) + + expect(listProducts).toHaveLength(2) // Both list products should be available + expect(ruleProducts).toHaveLength(2) // Both rule products should be available + + // Verify remaining counts + const listProduct = result.bonusItems.find((item) => item.productId === 'list-bonus-1') + expect(listProduct.remainingBonusItemsCount).toBe(1) + + const ruleProduct = result.bonusItems.find( + (item) => item.productId === 'rule-fetched-1' + ) + expect(ruleProduct.remainingBonusItemsCount).toBe(1) + }) + + test('handles basket with all bonus slots filled across mixed promotions', () => { + const fullBasket = { + basketId: 'full-basket-101', + bonusDiscountLineItems: [ + { + id: 'list-full', + promotionId: 'ListPromo', + maxBonusItems: 1, + bonusProducts: [{productId: 'list-prod'}] + }, + { + id: 'rule-full', + promotionId: 'RulePromo', + maxBonusItems: 2, + bonusProducts: [] + } + ], + productItems: [ + { + itemId: 'qual', + productId: 'qualifying', + bonusProductLineItem: false, + priceAdjustments: [{promotionId: 'ListPromo'}, {promotionId: 'RulePromo'}] + }, + { + itemId: 'b1', + productId: 'list-prod', + quantity: 1, + bonusProductLineItem: true, + bonusDiscountLineItemId: 'list-full' + }, + { + itemId: 'b2', + productId: 'rule-prod-1', + quantity: 2, + bonusProductLineItem: true, + bonusDiscountLineItemId: 'rule-full' + } + ] + } + + const fullProducts = { + qualifying: { + id: 'qualifying', + productPromotions: [{promotionId: 'ListPromo'}, {promotionId: 'RulePromo'}] + } + } + + const ruleMap = { + RulePromo: [{productId: 'rule-prod-1'}, {productId: 'rule-prod-2'}] + } + + const result = discoveryUtils.getRemainingAvailableBonusProductsForProduct( + fullBasket, + 'qualifying', + fullProducts, + ruleMap + ) + + expect(result.bonusItems).toEqual([]) + expect(result.aggregatedMaxBonusItems).toBe(3) + expect(result.aggregatedSelectedItems).toBe(3) + expect(result.hasRemainingCapacity).toBe(false) + }) + }) }) diff --git a/packages/template-retail-react-app/app/utils/bonus-product/utils.js b/packages/template-retail-react-app/app/utils/bonus-product/utils.js index 05aa746c8d..3014c8615c 100644 --- a/packages/template-retail-react-app/app/utils/bonus-product/utils.js +++ b/packages/template-retail-react-app/app/utils/bonus-product/utils.js @@ -43,7 +43,8 @@ export {getBonusProductCountsForPromotion} from '@salesforce/retail-react-app/ap // Re-export business logic utilities export { shouldShowBonusProductSelection, - isAutomaticPromotion + isAutomaticPromotion, + isRuleBasedPromotion } from '@salesforce/retail-react-app/app/utils/bonus-product/business-logic' // Re-export React hooks diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index b1c508e572..e30bf35a18 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -100,7 +100,7 @@ "bundlesize": [ { "path": "build/main.js", - "maxSize": "82 kB" + "maxSize": "83 kB" }, { "path": "build/vendor.js",