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 @@ -471,6 +477,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 @@ -20,6 +20,7 @@ import {
} from '@chakra-ui/react'
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 {useProducts} from '@salesforce/commerce-sdk-react'
import ImageGallery, {
Skeleton as ImageGallerySkeleton
Expand All @@ -38,8 +39,17 @@ const BundleProductViewModal = ({
showDeliveryOptions,
...props
}) => {
const productViewModalData = useProductViewModal(bundle)
const {variationParams} = useDerivedProduct(bundle)
// Use custom hook for controlled variation management
const {controlledVariationValues, handleVariationChange} = useControlledVariations(bundle)

const productViewModalData = useProductViewModal(bundle, controlledVariationValues)
const {variationParams} = useDerivedProduct(
bundle,
false,
false,
false,
controlledVariationValues
)
const childProductRefs = useRef({})
const [childProductOrderability, setChildProductOrderability] = useState({})
const [selectedChildProducts, setSelectedChildProducts] = useState([])
Expand Down Expand Up @@ -120,6 +130,8 @@ const BundleProductViewModal = ({
}}
childProductOrderability={childProductOrderability}
setSelectedBundleQuantity={setSelectedBundleQuantity}
controlledVariationValues={controlledVariationValues}
onVariationChange={handleVariationChange}
{...props}
/>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ 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'

/**
* A Modal that contains Product View
*/
const ProductViewModal = ({product, isOpen, onClose, ...props}) => {
const productViewModalData = useProductViewModal(product)
// Use custom hook for controlled variation management
const {controlledVariationValues, handleVariationChange} = useControlledVariations(product)

const productViewModalData = useProductViewModal(product, controlledVariationValues)

Copy link
Contributor

Choose a reason for hiding this comment

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

I see all 3 are using same code for auto selection of variant product.
Whats the different between three modals ? whats the use case for each ?

Also its being used at 3 places, can we extract it out in some util/helper ?

Copy link
Contributor

Choose a reason for hiding this comment

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

Also do we need this auto selection now ? Does it help with the 400 bug ? How ?

Copy link
Contributor

Choose a reason for hiding this comment

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

We need to refactor the duplicated code from:

  1. app/components/product-view-modal/index.jsx
  2. app/components/bonus-product-view-modal/index.jsx
  3. app/components/product-view-modal/bundle.jsx

Copy link
Contributor Author

@sf-shikhar-prasoon sf-shikhar-prasoon Oct 30, 2025

Choose a reason for hiding this comment

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

  • BonusProductViewModal: Shows bonus products that customers can select when they qualify for promotions (e.g., "Buy 2, get 1 free - choose your free item")
  • ProductViewModal: Quick view modal for regular products from product listing pages
  • BundleProductViewModal: Shows products that are part of a bundle on the cart page when editing bundle items

Since we are changing architecture for one modal, we should not let other modals use the URL architecture. All three needed the same refactoring because they all display products with variations that were previously using URL state.


The auto-selection logic was already there before in the old code - we just had to re-implement it in the new React state approach. Previously, auto-selection happened via URL parameters. Now, we're doing it in react state.
If I recall correctly, without it, the modal doesn't fetch image etc on the product-view modal. You had to click the size for it to start displaying the image


Yes, I'll refactor duplicate code

Copy link
Contributor Author

@sf-shikhar-prasoon sf-shikhar-prasoon Oct 30, 2025

Choose a reason for hiding this comment

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

@sf-deepali-bharmal @sf-cboscenco I changed it
Instead of all 3 modals, now only product-view modal will use react state.

A test was failing where clicking the update button on a product view modal was not sending the error notification if the update failed. I couldn't find how fix it and I was suspecting the changes to modals caused this. So I reverted making changes to other modals.

Copy link
Contributor

Choose a reason for hiding this comment

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

@sf-shikhar-prasoon Please test all three modals.

const intl = useIntl()
const label = intl.formatMessage(
Expand All @@ -44,6 +48,8 @@ const ProductViewModal = ({product, isOpen, onClose, ...props}) => {
imageSize="sm"
product={productViewModalData.product}
isLoading={productViewModalData.isFetching}
controlledVariationValues={controlledVariationValues}
onVariationChange={handleVariationChange}
{...props}
/>
</ModalBody>
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 @@ -404,11 +404,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 @@ -470,11 +473,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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 {useState, useEffect, useCallback} from 'react'

/**
* Custom hook for managing controlled variation state in modals.
* Provides React state-based variation management (instead of URL parameters).
*
* Features:
* - Manages variation selection state
* - Auto-selects single-value variation attributes
* - Provides callback for handling variation changes
*
* @param {Object} product - The product object with variationAttributes
* @returns {Object} - { controlledVariationValues, handleVariationChange }
*/
export const useControlledVariations = (product) => {
const [controlledVariationValues, setControlledVariationValues] = useState({})

// Auto-select variation attributes with only one value
useEffect(() => {
if (!product?.variationAttributes) return

const autoSelections = {}
product.variationAttributes.forEach((attr) => {
// Only auto-select if there's exactly one value and it's not already selected
if (attr.values?.length === 1 && !controlledVariationValues[attr.id]) {
autoSelections[attr.id] = attr.values[0].value
}
})

if (Object.keys(autoSelections).length > 0) {
setControlledVariationValues((prev) => ({
...prev,
...autoSelections
}))
}
}, [product?.variationAttributes, controlledVariationValues])

// Handle variation changes in controlled mode
const handleVariationChange = useCallback((attributeId, value) => {
setControlledVariationValues((prev) => ({
...prev,
[attributeId]: value
}))
}, [])

return {
controlledVariationValues,
handleVariationChange
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export const useDerivedProduct = (
product,
isProductPartOfSet = false,
isProductPartOfBundle = false,
pickupInStore = false
pickupInStore = false,
controlledVariationValues = null,
onVariationChange = null
) => {
const showLoading = !product
const isProductABundle = product?.type?.bundle
Expand All @@ -40,13 +42,25 @@ export const useDerivedProduct = (
// used for product bundles when there are multiple products
const lowestStockLevelProductName = product?.inventory?.lowestStockLevelProductName
const intl = useIntl()
const variant = useVariant(product, isProductPartOfSet, isProductPartOfBundle)
const variant = useVariant(
product,
isProductPartOfSet,
isProductPartOfBundle,
controlledVariationValues
)
const isStandardProduct = product?.type?.item
const variationParams = useVariationParams(product, isProductPartOfSet, isProductPartOfBundle)
const variationParams = useVariationParams(
product,
isProductPartOfSet,
isProductPartOfBundle,
controlledVariationValues
)
const variationAttributes = useVariationAttributes(
product,
isProductPartOfSet,
isProductPartOfBundle
isProductPartOfBundle,
controlledVariationValues,
onVariationChange
)
const [quantity, setQuantity] = useState(initialQuantity)
const {selectedStore} = useSelectedStore()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,33 @@
*/

import {useEffect, useState} from 'react'
import {removeQueryParamsFromPath} from '@salesforce/retail-react-app/app/utils/url'
import {useHistory, useLocation} from 'react-router-dom'
import {useVariant} from '@salesforce/retail-react-app/app/hooks/use-variant'
import {useToast} from '@salesforce/retail-react-app/app/hooks/use-toast'
import {useIntl} from 'react-intl'
import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants'
import {useProduct} from '@salesforce/commerce-sdk-react'
import {useVariant} from '@salesforce/retail-react-app/app/hooks/use-variant'

/**
* This hook is responsible for fetching a product detail based on the variation selection
* and managing the variation params on the url when the modal is open/close
* This hook is responsible for fetching a product detail based on the current product/variant.
* Note: This hook does NOT manage URL params. It expects the modal to manage variation selection
* via React state passed through controlledVariationValues in the hooks chain.
*
* @param initialProduct - the initial product when the modal is first open
* @param controlledVariationValues - optional controlled variation values from modal state
* @param queryOptions - optional React Query options to pass to useProduct
* @returns object
* @returns object containing product data and loading state
*/
export const useProductViewModal = (initialProduct, queryOptions = {}) => {
const location = useLocation()
const history = useHistory()
export const useProductViewModal = (
initialProduct,
controlledVariationValues = null,
queryOptions = {}
) => {
const intl = useIntl()
const toast = useToast()
const [product, setProduct] = useState(initialProduct)
const variant = useVariant(product)

// Compute the variant based on controlled variation values
const variant = useVariant(product, false, false, controlledVariationValues)

const {data: currentProduct, isFetching} = useProduct(
{parameters: {id: (variant || product)?.productId}},
Expand Down Expand Up @@ -58,25 +63,8 @@ export const useProductViewModal = (initialProduct, queryOptions = {}) => {
if (currentProduct) setProduct(currentProduct)
}, [currentProduct])

const cleanUpVariantParams = () => {
const paramToRemove = [...(product?.variationAttributes?.map(({id}) => id) ?? []), 'pid']
const updatedParams = removeQueryParamsFromPath(`${location.search}`, paramToRemove)

history.replace({search: updatedParams})
}

useEffect(() => {
// when the modal is first mounted,
// clean up the params in case there are variant params not related to current product
cleanUpVariantParams()
return () => {
cleanUpVariantParams()
}
}, [])

return {
product,
variant,
isFetching
}
}
Loading
Loading