Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '@salesforce/retail-react-app/app/components/shared/ui'
import ProductView from '@salesforce/retail-react-app/app/components/product-view'
import {useProductViewModal} from '@salesforce/retail-react-app/app/hooks/use-product-view-modal'
import {useControlledVariations} from '@salesforce/retail-react-app/app/hooks/use-controlled-variations'
import {useIntl} from 'react-intl'
import {useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react'
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
Expand Down Expand Up @@ -60,7 +61,12 @@ const BonusProductViewModal = ({
}
}, [product])

const productViewModalData = useProductViewModal(safeProduct, {keepPreviousData: true})
// Use custom hook for controlled variation management
const {controlledVariationValues, handleVariationChange} = useControlledVariations(safeProduct)

const productViewModalData = useProductViewModal(safeProduct, controlledVariationValues, {
keepPreviousData: true
})

// Keep a stable reference to the last successfully loaded product
// This prevents constant re-renders while fetching
Expand Down Expand Up @@ -474,6 +480,8 @@ const BonusProductViewModal = ({
<HideOnMobile>{BackToSelectionButton}</HideOnMobile>
) : null
}
controlledVariationValues={controlledVariationValues}
onVariationChange={handleVariationChange}
{...props}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,250 @@ describe('BonusProductViewModal - Quantity Distribution Across Multiple BonusDis
})
})

describe('BonusProductViewModal - URL Pollution Prevention', () => {
/*
DO NOT REMOVE THIS COMMENT! This test was generated by Cursor

This test verifies that bonus product variant selection does not modify PDP URL parameters.
This is the core fix for the 400 error - bonus modals use React state instead of URL params.
This test leveraged the following Cursor rules: pwa-kit/testing/unit-tests-generic, pwa-kit/testing/unit-tests-template-retail-react-app
This test was generated with the following model: Claude Sonnet 4.5
*/
test('bonus product variant selection does not modify PDP URL', () => {
const mockProductWithVariations = {
...mockProductDetail,
id: '793775370033',
productId: '793775370033',
variationAttributes: [
{
id: 'color',
values: [
{value: 'red', name: 'Red'},
{value: 'blue', name: 'Blue'}
]
},
{
id: 'size',
values: [
{value: 'M', name: 'Medium'},
{value: 'L', name: 'Large'}
]
}
]
}

const mockBasket = {
basketId: 'test-basket',
bonusDiscountLineItems: [{promotionId: 'test-promo', bonusProducts: []}]
}

useCurrentBasket.mockReturnValue({data: mockBasket, derivedData: {totalItems: 0}})
useProductViewModal.mockReturnValue({
product: mockProductWithVariations,
isFetching: false
})

// Store initial URL
const initialUrl = window.location.href

// Open bonus modal
renderWithProviders(
<BonusProductViewModal
product={mockProductWithVariations}
isOpen={true}
onClose={mockOnClose}
promotionId="test-promo"
/>
)

// Verify modal renders
expect(screen.getByTestId('bonus-product-view-modal')).toBeInTheDocument()

// Assert URL unchanged after opening modal
expect(window.location.href).toBe(initialUrl)

// The bonus modal uses React state (controlledVariationValues) instead of URL params
// This prevents URL pollution and fixes the 400 error issue
})

test('opening bonus modal from PDP with URL params does not change PDP URL', () => {
const mockProductWithVariations = {
...mockProductDetail,
id: '793775370033',
productId: '793775370033',
variationAttributes: [
{
id: 'color',
values: [
{value: 'red', name: 'Red'},
{value: 'blue', name: 'Blue'}
]
}
]
}

const mockBasket = {
basketId: 'test-basket',
bonusDiscountLineItems: [{promotionId: 'test-promo', bonusProducts: []}]
}

// Simulate PDP with existing URL params (color=red&size=M)
const initialSearch = window.location.search

useCurrentBasket.mockReturnValue({data: mockBasket, derivedData: {totalItems: 0}})
useProductViewModal.mockReturnValue({
product: mockProductWithVariations,
isFetching: false
})

// Open bonus modal while on PDP with URL params
renderWithProviders(
<BonusProductViewModal
product={mockProductWithVariations}
isOpen={true}
onClose={mockOnClose}
promotionId="test-promo"
/>
)

// Verify modal renders
expect(screen.getByTestId('bonus-product-view-modal')).toBeInTheDocument()

// Assert URL params unchanged
expect(window.location.search).toBe(initialSearch)

// The bonus modal's controlled variation state is isolated from URL
// This ensures PDP URL params remain untouched
})
})

describe('BonusProductViewModal - Variation State Reset', () => {
/*
DO NOT REMOVE THIS COMMENT! This test was generated by Cursor

This test verifies that variation state (controlledVariationValues) is reset when the modal closes and reopens.
This test leveraged the following Cursor rules: pwa-kit/testing/unit-tests-generic, pwa-kit/testing/unit-tests-template-retail-react-app
This test was generated with the following model: Claude Sonnet 4.5
*/
test('resets variation state when modal closes and reopens', () => {
const mockProductWithVariations = {
...mockProductDetail,
id: '793775370033',
productId: '793775370033',
variationAttributes: [
{
id: 'color',
values: [
{value: 'red', name: 'Red'},
{value: 'blue', name: 'Blue'}
]
},
{
id: 'size',
values: [{value: 'M', name: 'Medium'}] // Single value - should be auto-selected
}
]
}

const mockBasket = {
basketId: 'test-basket',
bonusDiscountLineItems: [{promotionId: 'test-promo', bonusProducts: []}]
}

useCurrentBasket.mockReturnValue({data: mockBasket, derivedData: {totalItems: 0}})
useProductViewModal.mockReturnValue({
product: mockProductWithVariations,
isFetching: false
})

// First render - modal is open
const {unmount} = renderWithProviders(
<BonusProductViewModal
product={mockProductWithVariations}
isOpen={true}
onClose={mockOnClose}
promotionId="test-promo"
/>
)

// Verify modal renders
expect(screen.getByTestId('bonus-product-view-modal')).toBeInTheDocument()

// Unmount the modal (simulating close)
unmount()

// Re-render with modal open again
renderWithProviders(
<BonusProductViewModal
product={mockProductWithVariations}
isOpen={true}
onClose={mockOnClose}
promotionId="test-promo"
/>
)

// Verify modal renders again
expect(screen.getByTestId('bonus-product-view-modal')).toBeInTheDocument()

// The modal should render successfully without any state pollution from the previous render
// The useControlledVariations hook creates fresh state on mount with useState({})
// This test verifies that the component lifecycle correctly resets the state
})

test('auto-selection works correctly on modal reopen', () => {
const mockProductWithSingleVariation = {
...mockProductDetail,
id: '793775370033',
productId: '793775370033',
variationAttributes: [
{
id: 'size',
values: [{value: 'M', name: 'Medium'}] // Single value - should be auto-selected
}
]
}

const mockBasket = {
basketId: 'test-basket',
bonusDiscountLineItems: [{promotionId: 'test-promo', bonusProducts: []}]
}

useCurrentBasket.mockReturnValue({data: mockBasket, derivedData: {totalItems: 0}})
useProductViewModal.mockReturnValue({
product: mockProductWithSingleVariation,
isFetching: false
})

// First render
const {unmount} = renderWithProviders(
<BonusProductViewModal
product={mockProductWithSingleVariation}
isOpen={true}
onClose={mockOnClose}
promotionId="test-promo"
/>
)

expect(screen.getByTestId('bonus-product-view-modal')).toBeInTheDocument()

// Close modal
unmount()

// Reopen modal
renderWithProviders(
<BonusProductViewModal
product={mockProductWithSingleVariation}
isOpen={true}
onClose={mockOnClose}
promotionId="test-promo"
/>
)

// Modal should render successfully with auto-selection working again
expect(screen.getByTestId('bonus-product-view-modal')).toBeInTheDocument()
})
})

describe('BonusProductViewModal - Variant Filtering Integration Tests', () => {
const mockOnClose = jest.fn()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const BundleProductViewModal = ({
...props
}) => {
const productViewModalData = useProductViewModal(bundle)
const {variationParams} = useDerivedProduct(bundle)
const {variationParams} = useDerivedProduct(bundle, false, false, false)
const childProductRefs = useRef({})
const [childProductOrderability, setChildProductOrderability] = useState({})
const [selectedChildProducts, setSelectedChildProducts] = useState([])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ const ProductView = forwardRef(
showDeliveryOptions = true,
customButtons = [],
maxOrderQuantity = null,
imageGalleryFooter = null
imageGalleryFooter = null,
controlledVariationValues = null,
onVariationChange = null
},
ref
) => {
Expand Down Expand Up @@ -183,7 +185,14 @@ const ProductView = forwardRef(
unfulfillable,
isSelectedStoreOutOfStock,
selectedStore
} = useDerivedProduct(product, isProductPartOfSet, isProductPartOfBundle, pickupInStore)
} = useDerivedProduct(
product,
isProductPartOfSet,
isProductPartOfBundle,
pickupInStore,
controlledVariationValues,
onVariationChange
)
const priceData = useMemo(() => {
return getPriceData(product, {quantity})
}, [product, quantity])
Expand Down Expand Up @@ -630,6 +639,11 @@ const ProductView = forwardRef(
},
{variantType: name}
)}
handleChange={
onVariationChange
? (value) => onVariationChange(id, value)
: undefined
}
>
{swatches}
</SwatchGroup>
Expand Down Expand Up @@ -930,7 +944,9 @@ ProductView.propTypes = {
promotionId: PropTypes.string,
maxOrderQuantity: PropTypes.number,
imageGalleryFooter: PropTypes.node,
alignItems: PropTypes.string
alignItems: PropTypes.string,
controlledVariationValues: PropTypes.object,
onVariationChange: PropTypes.func
}

export default ProductView
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 @@ -16,3 +16,4 @@ export {useVariationParams} from '@salesforce/retail-react-app/app/hooks/use-var
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'
export {useControlledVariations} from '@salesforce/retail-react-app/app/hooks/use-controlled-variations'
Original file line number Diff line number Diff line change
Expand Up @@ -422,11 +422,14 @@ export const AddToCartModal = () => {
'Cart Subtotal ({itemAccumulatedCount} item)',
id: 'add_to_cart_modal.label.cart_subtotal'
},
{itemAccumulatedCount: totalItems}
{
itemAccumulatedCount: totalItems
}
)}
</Text>
<Text alignSelf="flex-end" fontWeight="600">
{productSubTotal &&
currency &&
intl.formatNumber(productSubTotal, {
style: 'currency',
currency: currency
Expand Down Expand Up @@ -488,11 +491,14 @@ export const AddToCartModal = () => {
defaultMessage: 'Cart Subtotal ({itemAccumulatedCount} item)',
id: 'add_to_cart_modal.label.cart_subtotal'
},
{itemAccumulatedCount: totalItems}
{
itemAccumulatedCount: totalItems
}
)}
</Text>
<Text alignSelf="flex-end" fontWeight="600">
{productSubTotal &&
currency &&
intl.formatNumber(productSubTotal, {
style: 'currency',
currency: currency
Expand Down
Loading
Loading