Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ test('bundle product view modal disables update button when quantity exceeds chi

// Set product bundle quantity selection to 4
fireEvent.change(quantityInput, {target: {value: '4'}})
fireEvent.blur(quantityInput) // Trigger validation
fireEvent.keyDown(quantityInput, {key: 'Enter', code: 'Enter', charCode: 13})

fireEvent.click(sizeSelectBtn)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
* Copyright (c) 2021, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import React from 'react'
import {fireEvent, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ProductView from '@salesforce/retail-react-app/app/components/product-view'
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
import {useBonusProductSearch} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-search'
import {useBonusProductModalContext} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-modal'
import * as useDerivedProductModule from '@salesforce/retail-react-app/app/hooks/use-derived-product'

// Mock scrollIntoView for jsdom
// eslint-disable-next-line @typescript-eslint/no-empty-function
global.HTMLElement.prototype.scrollIntoView = function () {}

// Shared mocks for modal context
const mockOnBonusProductModalOpen = jest.fn()
const mockAddBonusProducts = jest.fn()
const mockOnAddToCartModalOpen = jest.fn()

// Mocks must be at the very top before any imports
jest.mock('@salesforce/retail-react-app/app/hooks/use-bonus-product-search', () => ({
__esModule: true,
useBonusProductSearch: jest.fn()
}))
jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({
useCurrentCustomer: () => ({
data: {
authType: 'registered',
isRegistered: true
}
})
}))
jest.mock('@salesforce/retail-react-app/app/hooks/use-currency', () => ({
useCurrency: () => ({
currency: 'USD'
})
}))
jest.mock('@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal', () => ({
useAddToCartModalContext: () => ({
isOpen: false,
onOpen: mockOnAddToCartModalOpen,
onClose: jest.fn()
}),
AddToCartModalProvider: ({children}) => children
}))
jest.mock('@salesforce/retail-react-app/app/hooks/use-bonus-product-modal', () => ({
useBonusProductModalContext: () => ({
isOpen: false,
onOpen: mockOnBonusProductModalOpen,
onClose: jest.fn(),
bonusProducts: [],
addBonusProducts: mockAddBonusProducts
}),
BonusProductModalProvider: ({children}) => children
}))
jest.mock('@salesforce/retail-react-app/app/hooks/use-toast', () => ({
useToast: () => jest.fn()
}))

describe('ProductView Bonus Product Integration', () => {
beforeEach(() => {
mockOnBonusProductModalOpen.mockReset()
mockAddBonusProducts.mockReset()
mockOnAddToCartModalOpen.mockReset()

// Patch useDerivedProduct for these tests
jest.spyOn(useDerivedProductModule, 'useDerivedProduct').mockImplementation(() => ({
showLoading: false,
showInventoryMessage: false,
inventoryMessage: '',
quantity: 1,
minOrderQuantity: 1,
setQuantity: jest.fn(),
variant: {
productId: 'p1-38',
orderable: true,
variationValues: {size: '38'}
},
variationParams: {size: '38'},
variationAttributes: [
{
id: 'size',
name: 'Size',
selectedValue: {name: '38', value: '38'},
values: [
{name: '38', value: '38', orderable: true},
{name: '39', value: '39', orderable: true}
]
}
],
stockLevel: 10,
stepQuantity: 1,
isOutOfStock: false,
unfulfillable: false
}))
})

const product = {
id: 'p1',
name: 'Test Product',
type: {variant: true},
variationAttributes: [
{
id: 'size',
name: 'Size',
values: [
{name: '38', value: '38', orderable: true},
{name: '39', value: '39', orderable: true}
]
}
],
variants: [
{
productId: 'p1-38',
orderable: true,
variationValues: {size: '38'}
},
{
productId: 'p1-39',
orderable: true,
variationValues: {size: '39'}
}
],
variationValues: {size: '38'}
}

test('calls bonus product modal open when addToCart returns rule-based bonusDiscountLineItems', async () => {
const user = userEvent.setup()

// Mock addToCart to return a rule-based bonus
const mockAddToCart = jest.fn().mockResolvedValue({
productSelectionValues: [{id: 'item1'}],
bonusDiscountLineItems: [{id: 'bonus1', promotionId: 'promo123'}]
})

// Mock useBonusProductSearch to return a hit for promo123
useBonusProductSearch.mockImplementation((promotionId) => {
if (promotionId === 'promo123') {
return {
data: {
hits: [
{
productId: 'prod1',
productName: 'Bonus Product 1',
c_productUrl: '/product/prod1'
}
]
}
}
}
return {data: null}
})

renderWithProviders(<ProductView product={product} addToCart={mockAddToCart} />)

// Select the first size swatch
const sizeSwatch = screen.getByRole('radio', {name: /38/i})
await user.click(sizeSwatch)

// Click Add to Cart
const addToCartButton = screen.getAllByText(/add to cart/i)[0]
await user.click(addToCartButton)

// Wait for modal open
await waitFor(() => {
expect(mockOnBonusProductModalOpen).toHaveBeenCalled()
const call = mockOnBonusProductModalOpen.mock.calls[0][0]
expect(call.newBonusItems).toEqual(
expect.arrayContaining([
expect.objectContaining({
bonusProducts: expect.arrayContaining([
expect.objectContaining({
productId: 'prod1',
productName: 'Bonus Product 1'
})
])
})
])
)
})
})

test('calls add to cart modal open when no bonusDiscountLineItems', async () => {
const user = userEvent.setup()

const mockAddToCart = jest.fn().mockResolvedValue({productSelectionValues: [{id: 'item1'}]})
useBonusProductSearch.mockImplementation(() => ({data: null}))

renderWithProviders(<ProductView product={product} addToCart={mockAddToCart} />)

// Select the first size swatch
const sizeSwatch = screen.getByRole('radio', {name: /38/i})
await user.click(sizeSwatch)

const addToCartButton = screen.getAllByText(/add to cart/i)[0]
await user.click(addToCartButton)

await waitFor(() => {
expect(mockOnAddToCartModalOpen).toHaveBeenCalled()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import {useCurrency, useDerivedProduct} from '@salesforce/retail-react-app/app/hooks'
import {useAddToCartModalContext} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal'
import {useBonusProductModalContext} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-modal'
import {useBonusProductSearch} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-search'

// project components
import ImageGallery from '@salesforce/retail-react-app/app/components/image-gallery'
Expand Down Expand Up @@ -141,6 +142,64 @@ const ProductView = forwardRef(
} = useBonusProductModalContext()
const theme = useTheme()
const [showOptionsMessage, toggleShowOptionsMessage] = useState(false)
const [promotionIdToSearch, setPromotionIdToSearch] = useState(null)
const {data: bonusProductSearchResult} = useBonusProductSearch(promotionIdToSearch)

// State to track all promotion IDs and their results
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The business logic here seems to be isolated to bonus products only. Is it possibel to extract it out into a hook to reduce code in this component? E.g useBonusProduct()

const [pendingPromotionIds, setPendingPromotionIds] = useState([])
const [bonusItemsForModal, setBonusItemsForModal] = useState(null)
const ruleBasedPromotionsRef = useRef([])

// Effect to handle multiple promotions sequentially
useEffect(() => {
if (bonusProductSearchResult?.hits?.length > 0 && promotionIdToSearch) {
// Format the bonus products for current promotion
let formattedBonusProducts = bonusProductSearchResult.hits.map((bonusProduct) => {
return {
productId: bonusProduct.productId,
productName: bonusProduct.productName,
c_productUrl: bonusProduct.c_productUrl
}
Copy link
Contributor

@alexvuong alexvuong Jun 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the data structure we are dealing for bonus product. You can avoid doing this here by taking advantage of react-query select data transformation during useBonusProductSearch hook. then the data returns will always have this shape.

https://tanstack.com/query/v4/docs/framework/react/reference/useQuery
See useProductViewModal for an example

P/s: You can definitely create promotion id map from here as well if that is more convenient and reduce the amount of data transformation logic in the handleAddToCart

})

// Add current promotion result to ref
const currentPromotionResult = {
bonusProducts: formattedBonusProducts,
id: bonusItemsForModal?.promotionIdToIdMap?.[promotionIdToSearch],
maxBonusItems:
bonusItemsForModal?.promotionIdToMaxBonusItemsMap?.[promotionIdToSearch]
}
ruleBasedPromotionsRef.current.push(currentPromotionResult)
Copy link
Contributor

@alexvuong alexvuong Jun 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For React ref, we are re-assigning current

ruleBasedPromotionsRef.current = currentPromotionResult


// Move to next promotion if any pending
if (pendingPromotionIds.length > 0) {
const nextPromotionId = pendingPromotionIds[0]
setPendingPromotionIds((prev) => prev.slice(1))
setPromotionIdToSearch(nextPromotionId)
Copy link
Contributor

@alexvuong alexvuong Jun 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we filter out any pending promotion from useBonusProducts hook select transformation step so all promotions returned are always valid promotion.
//pseu-do code

const {data: bonusProd} = useBonusProductSearch(args, {
   enabdle: !! promotionId,
   select: (res) => {
       const validPromo = res.hits.map(promo => !promo.isPending)
       return res.hits.map (promo => ({
           id: ...,
           name: ...
        })
   
   }
 } )

} else {
// All promotions processed, now open the modal
const bonusProductsToShow = [
...ruleBasedPromotionsRef.current,
...bonusItemsForModal.listBasedBonusProducts
]

addBonusProducts(bonusItemsForModal.newBonusItems)
onBonusProductModalOpen({
newBonusItems: bonusProductsToShow,
openAddToCartModalIfNeeded: true,
product: bonusItemsForModal.product,
itemsAdded: bonusItemsForModal.itemsAdded,
selectedQuantity: bonusItemsForModal.selectedQuantity
})

// Clear state
setBonusItemsForModal(null)
setPromotionIdToSearch(null)
ruleBasedPromotionsRef.current = []
}
}
}, [bonusProductSearchResult])

const {
showLoading,
showInventoryMessage,
Expand Down Expand Up @@ -305,16 +364,59 @@ const ProductView = forwardRef(
if (isValidResponse) {
// Show bonus product modal first if there are bonus items
if (newBonusItems?.length > 0) {
// Update bonusProducts list with the new bonus items
addBonusProducts(newBonusItems)
onBonusProductModalOpen({
newBonusItems,
allBonusItems: addToCartResponse.bonusDiscountLineItems,
openAddToCartModalIfNeeded: true,
product,
itemsAdded,
selectedQuantity: quantity
})
let listBasedBonusProducts = newBonusItems.filter(
(item) => item.bonusProducts
)
// Collect all promotion IDs for rule-based promotions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's clean up comment here unless it is necessary. Too much comments accumulated over time can affect bundle size if every line of code has one comment explaining what it does

const ruleBasedPromotionIds = newBonusItems
.filter((item) => !item.bonusProducts) // Rule-based promotions don't have bonusProducts
.map((item) => item.promotionId)
.filter(Boolean)

//create maps of promotionId to id and maxBonusItems for rule based promotions
const {promotionIdToIdMap, promotionIdToMaxBonusItemsMap} =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we reduce this logic here if we take advantage of useProductSearch hook select for data transformation?

newBonusItems
.filter((item) => !item.bonusProducts)
.reduce(
(acc, item) => {
acc.promotionIdToIdMap[item.promotionId] = item.id
acc.promotionIdToMaxBonusItemsMap[item.promotionId] =
item.maxBonusItems
return acc
},
{
promotionIdToIdMap: {},
promotionIdToMaxBonusItemsMap: {}
}
)

// Start sequential processing if we have promotion IDs
if (ruleBasedPromotionIds.length > 0) {
// Store bonus items for processing in useEffect
setBonusItemsForModal({
newBonusItems,
product,
itemsAdded,
selectedQuantity: quantity,
promotionIdToIdMap,
promotionIdToMaxBonusItemsMap,
listBasedBonusProducts
})

// Set first promotion ID and queue the rest
setPromotionIdToSearch(ruleBasedPromotionIds[0])
setPendingPromotionIds(ruleBasedPromotionIds.slice(1))
} else {
// No rule-based promotions, just show list-based ones immediately
addBonusProducts(newBonusItems)
onBonusProductModalOpen({
newBonusItems: listBasedBonusProducts,
openAddToCartModalIfNeeded: true,
product,
itemsAdded,
selectedQuantity: quantity
})
}
} else {
// If no bonus items, just show add to cart modal
onAddToCartModalOpen({
Expand Down
Loading