diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/index.jsx b/packages/extension-chakra-storefront/src/pages/product-detail/index.jsx
index c24992ecbd..f9af8b3411 100644
--- a/packages/extension-chakra-storefront/src/pages/product-detail/index.jsx
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/index.jsx
@@ -1,622 +1,86 @@
/*
- * Copyright (c) 2022, Salesforce, Inc.
+ * 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, {Fragment, useCallback, useEffect, useState} from 'react'
-import PropTypes from 'prop-types'
-import {FormattedMessage, useIntl} from 'react-intl'
-import {keepPreviousData} from '@tanstack/react-query'
-import {normalizeSetBundleProduct, getUpdateBundleChildArray} from '../../utils/product-utils'
-
-// Components
-import {Box, Button, Stack} from '@chakra-ui/react'
-import {
- useProduct,
- useProducts,
- useCategory,
- useShopperCustomersMutation,
- useShopperBasketsMutation,
- useCustomerId,
- useShopperBasketsMutationHelper
-} from '@salesforce/commerce-sdk-react'
-
-// Hooks
-import {useCurrentBasket, useExtensionConfig, useVariant} from '../../hooks'
-import useNavigation from '../../hooks/use-navigation'
-import useEinstein from '../../hooks/use-einstein'
-import useDataCloud from '../../hooks/use-datacloud'
-import useActiveData from '../../hooks/use-active-data'
-import {useServerContext} from '@salesforce/pwa-kit-react-sdk/ssr/universal/hooks'
-// Project Components
-import RecommendedProducts from '../../components/recommended-products'
-import ProductView from '../../components/product-view'
-import InformationAccordion from '../../pages/product-detail/partials/information-accordion'
-
-import {HTTPNotFound, HTTPError} from '@salesforce/pwa-kit-react-sdk/ssr/universal/errors'
-import logger from '../../utils/logger-instance'
-
-// constant
-import {
- API_ERROR_MESSAGE,
- EINSTEIN_RECOMMENDERS,
- TOAST_ACTION_VIEW_WISHLIST,
- TOAST_MESSAGE_ADDED_TO_WISHLIST,
- TOAST_MESSAGE_ALREADY_IN_WISHLIST
-} from '../../constants'
-import {rebuildPathWithParams} from '../../utils/url'
-import {useHistory, useLocation, useParams} from 'react-router-dom'
-import useToast from '../../hooks/use-toast'
-import {useWishList} from '../../hooks/use-wish-list'
-import Metadata from './metadata'
+import React from 'react'
+import {Box, Stack} from '@chakra-ui/react'
+import ProductDetails from './partials/product-details'
+import RecommendedProductsSection from './partials/recommended-products-section'
+import PageMetadata from './page-metadata'
+import PageCache from './page-cache'
+import PageAnalytics from './page-analytics'
+import {useProductDetailData} from './use-product-detail-data'
const ProductDetail = () => {
- const {formatMessage} = useIntl()
- const history = useHistory()
- const location = useLocation()
- const einstein = useEinstein()
- const dataCloud = useDataCloud()
- const activeData = useActiveData()
- const toast = useToast()
- const navigate = useNavigation()
- const customerId = useCustomerId()
- const {maxCacheAge: MAX_CACHE_AGE, staleWhileRevalidate: STALE_WHILE_REVALIDATE} =
- useExtensionConfig()
-
- /****************************** Basket *********************************/
- const {isLoading: isBasketLoading} = useCurrentBasket()
- const {addItemToNewOrExistingBasket} = useShopperBasketsMutationHelper()
- const updateItemsInBasketMutation = useShopperBasketsMutation('updateItemsInBasket')
- const {res} = useServerContext()
- if (res) {
- res.set(
- 'Cache-Control',
- `s-maxage=${MAX_CACHE_AGE}, stale-while-revalidate=${STALE_WHILE_REVALIDATE}`
- )
- }
-
- /*************************** Product Detail and Category ********************/
- const {productId} = useParams()
- const urlParams = new URLSearchParams(location.search)
- const {
- data: product,
- isLoading: isProductLoading,
- isError: isProductError,
- error: productError
- } = useProduct(
- {
- parameters: {
- id: urlParams.get('pid') || productId,
- perPricebook: true,
- expand: [
- 'availability',
- 'promotions',
- 'options',
- 'images',
- 'prices',
- 'variations',
- 'set_products',
- 'bundled_products',
- 'page_meta_tags'
- ],
- allImages: true
- }
- },
- {
- // When shoppers select a different variant (and the app fetches the new data),
- // the old data is still rendered (and not the skeletons).
- placeholderData: keepPreviousData
- }
- )
-
- // Note: Since category needs id from product detail, it can't be server side rendered atm
- // until we can do dependent query on server
const {
- data: category,
- isError: isCategoryError,
- error: categoryError
- } = useCategory({
- parameters: {
- id: product?.primaryCategoryId,
- levels: 1
- }
- })
-
- /****************************** Sets and Bundles *********************************/
- const [childProductSelection, setChildProductSelection] = useState({})
- const [childProductOrderability, setChildProductOrderability] = useState({})
- const [selectedBundleQuantity, setSelectedBundleQuantity] = useState(1)
- const childProductRefs = React.useRef({})
- const isProductASet = product?.type.set
- const isProductABundle = product?.type.bundle
-
- let bundleChildVariantIds = ''
- if (isProductABundle)
- bundleChildVariantIds = Object.keys(childProductSelection)
- ?.map((key) => childProductSelection[key].variant.productId)
- .join(',')
-
- const {data: bundleChildrenData} = useProducts(
- {
- parameters: {
- ids: bundleChildVariantIds,
- allImages: false,
- expand: ['availability', 'variations'],
- select: '(data.(id,inventory,master))'
- }
- },
- {
- enabled: bundleChildVariantIds?.length > 0,
- placeholderData: keepPreviousData
- }
- )
-
- if (isProductABundle && bundleChildrenData) {
- // Loop through the bundle children and update the inventory for variant selection
- product.bundledProducts.forEach(({product: childProduct}, index) => {
- const matchingChildProduct = bundleChildrenData.data.find(
- (bundleChild) => bundleChild.master.masterId === childProduct.id
- )
- if (matchingChildProduct) {
- product.bundledProducts[index].product = {
- ...childProduct,
- inventory: matchingChildProduct.inventory
- }
- }
- })
- }
-
- const comboProduct = isProductASet || isProductABundle ? normalizeSetBundleProduct(product) : {}
-
- /**************** Error Handling ****************/
-
- if (isProductError) {
- const errorStatus = productError?.response?.status
- switch (errorStatus) {
- case 404:
- throw new HTTPNotFound('Product Not Found.')
- default:
- throw new HTTPError(errorStatus, `HTTP Error ${errorStatus} occurred.`)
- }
- }
- if (isCategoryError) {
- const errorStatus = categoryError?.response?.status
- switch (errorStatus) {
- case 404:
- throw new HTTPNotFound('Category Not Found.')
- default:
- throw new HTTPError(errorStatus, `HTTP Error ${errorStatus} occurred.`)
- }
- }
-
- const [primaryCategory, setPrimaryCategory] = useState(category)
- const variant = useVariant(product)
- // This page uses the `primaryCategoryId` to retrieve the category data. This attribute
- // is only available on `master` products. Since a variation will be loaded once all the
- // attributes are selected (to get the correct inventory values), the category information
- // is overridden. This will allow us to keep the initial category around until a different
- // master product is loaded.
- useEffect(() => {
- if (category) {
- setPrimaryCategory(category)
- }
- }, [category])
-
- /**************** Product Variant ****************/
- useEffect(() => {
- if (!variant) {
- return
- }
- // update the variation attributes parameter on
- // the url accordingly as the variant changes
- const updatedUrl = rebuildPathWithParams(`${location.pathname}${location.search}`, {
- pid: variant?.productId
- })
- history.replace(updatedUrl)
- }, [variant])
-
- /**************** Wishlist ****************/
- const {data: wishlist, isLoading: isWishlistLoading} = useWishList()
- const createCustomerProductListItem = useShopperCustomersMutation(
- 'createCustomerProductListItem'
- )
-
- const handleAddToWishlist = (product, variant, quantity) => {
- const isItemInWishlist = wishlist?.customerProductListItems?.find(
- (i) => i.productId === variant?.productId || i.productId === product?.id
- )
-
- if (!isItemInWishlist) {
- createCustomerProductListItem.mutate(
- {
- parameters: {
- listId: wishlist.id,
- customerId
- },
- body: {
- // NOTE: API does not respect quantity, it always adds 1
- quantity,
- productId: variant?.productId || product?.id,
- public: false,
- priority: 1,
- type: 'product'
- }
- },
- {
- onSuccess: () => {
- toast({
- title: formatMessage(TOAST_MESSAGE_ADDED_TO_WISHLIST, {quantity: 1}),
- type: 'success',
- action: (
-
- )
- })
- },
- onError: () => {
- showError()
- }
- }
- )
- } else {
- toast({
- title: formatMessage(TOAST_MESSAGE_ALREADY_IN_WISHLIST),
- type: 'info',
- action: (
-
- )
- })
- }
- }
-
- /**************** Add To Cart ****************/
- const showError = () => {
- toast({
- title: formatMessage(API_ERROR_MESSAGE),
- type: 'error'
- })
- }
-
- const handleAddToCart = async (productSelectionValues) => {
- try {
- const productItems = productSelectionValues.map(({variant, quantity}) => ({
- productId: variant.productId,
- price: variant.price,
- quantity
- }))
-
- await addItemToNewOrExistingBasket(productItems)
-
- einstein.sendAddToCart(productItems)
-
- // If the items were successfully added, set the return value to be used
- // by the add to cart modal.
- return productSelectionValues
- } catch (error) {
- console.log('error', error)
- showError(error)
- }
- }
-
- /**************** Product Set/Bundles Handlers ****************/
- const handleChildProductValidation = useCallback(() => {
- // Run validation for all child products. This will ensure the error
- // messages are shown.
- Object.values(childProductRefs.current).forEach(({validateOrderability}) => {
- validateOrderability({scrollErrorIntoView: false})
- })
-
- // Using ot state for which child products are selected, scroll to the first
- // one that isn't selected.
- const selectedProductIds = Object.keys(childProductSelection)
- const firstUnselectedProduct = comboProduct.childProducts.find(
- ({product: childProduct}) => !selectedProductIds.includes(childProduct.id)
- )?.product
-
- if (firstUnselectedProduct) {
- // Get the reference to the product view and scroll to it.
- const {ref} = childProductRefs.current[firstUnselectedProduct.id]
-
- if (ref.scrollIntoView) {
- ref.scrollIntoView({
- behavior: 'smooth',
- block: 'end'
- })
- }
-
- return false
- }
-
- return true
- }, [product, childProductSelection])
-
- /**************** Product Set Handlers ****************/
- const handleProductSetAddToCart = () => {
- // Get all the selected products, and pass them to the addToCart handler which
- // accepts an array.
- const productSelectionValues = Object.values(childProductSelection)
- return handleAddToCart(productSelectionValues)
- }
-
- /**************** Product Bundle Handlers ****************/
- // Top level bundle does not have variants
- const handleProductBundleAddToCart = async (variant, selectedQuantity) => {
- try {
- const childProductSelections = Object.values(childProductSelection)
-
- const productItems = [
- {
- productId: product.id,
- price: product.price,
- quantity: selectedQuantity,
- // The add item endpoint in the shopper baskets API does not respect variant selections
- // for bundle children, so we have to make a follow up call to update the basket
- // with the chosen variant selections
- bundledProductItems: childProductSelections.map((child) => {
- return {
- productId: child.variant.productId,
- quantity: child.quantity
- }
- })
- }
- ]
-
- const res = await addItemToNewOrExistingBasket(productItems)
-
- const bundleChildMasterIds = childProductSelections.map((child) => {
- return child.product.id
- })
-
- // since the returned data includes all products in basket
- // here we compare list of productIds in bundleProductItems of each productItem to filter out the
- // current bundle that was last added into cart
- const currentBundle = res.productItems.find((productItem) => {
- if (!productItem.bundledProductItems?.length) return
- const bundleChildIds = productItem.bundledProductItems?.map((item) => {
- // seek out the bundle child that still uses masterId as product id
- return item.productId
- })
- return bundleChildIds.every((id) => bundleChildMasterIds.includes(id))
- })
-
- const itemsToBeUpdated = getUpdateBundleChildArray(
- currentBundle,
- childProductSelections
- )
-
- if (itemsToBeUpdated.length) {
- // make a follow up call to update child variant selection for product bundle
- // since add item endpoint doesn't currently consider product bundle child variants
- await updateItemsInBasketMutation.mutateAsync({
- method: 'PATCH',
- parameters: {
- basketId: res.basketId
- },
- body: itemsToBeUpdated
- })
- }
-
- einstein.sendAddToCart(productItems)
-
- return childProductSelections
- } catch (error) {
- showError(error)
- }
- }
-
- /**************** Einstein ****************/
- useEffect(() => {
- if (product && product.type.set) {
- einstein.sendViewProduct(product)
- dataCloud.sendViewProduct(product)
- const childrenProducts = product.setProducts
- childrenProducts.map((child) => {
- try {
- einstein.sendViewProduct(child)
- } catch (err) {
- logger.error('Einstein sendViewProduct error', {
- namespace: 'ProductDetail.useEffect',
- additionalProperties: {error: err, child}
- })
- }
- activeData.sendViewProduct(category, child, 'detail')
- dataCloud.sendViewProduct(child)
- })
- } else if (product) {
- try {
- einstein.sendViewProduct(product)
- } catch (err) {
- logger.error('Einstein sendViewProduct error', {
- namespace: 'ProductDetail.useEffect',
- additionalProperties: {error: err, product}
- })
- }
- activeData.sendViewProduct(category, product, 'detail')
- dataCloud.sendViewProduct(product)
- }
- }, [product])
+ product,
+ isProductLoading,
+ primaryCategory,
+ isProductASet,
+ isProductABundle,
+ comboProduct,
+ childProductRefs,
+ childProductSelection,
+ setChildProductSelection,
+ childProductOrderability,
+ setChildProductOrderability,
+ selectedBundleQuantity,
+ setSelectedBundleQuantity,
+ handleAddToCart,
+ handleAddToWishlist,
+ handleProductSetAddToCart,
+ handleProductBundleAddToCart,
+ handleChildProductValidation,
+ isBasketLoading,
+ isWishlistLoading
+ } = useProductDetailData()
return (
-
-
-
-
- {isProductASet || isProductABundle ? (
-
-
-
-
-
- {/* TODO: consider `childProduct.belongsToSet` */}
- {
- // Render the child products
- comboProduct.childProducts.map(
- ({product: childProduct, quantity: childQuantity}) => (
-
-
- handleAddToCart([
- {
- product: childProduct,
- variant,
- quantity
- }
- ])
- : null
- }
- addToWishlist={
- isProductASet ? handleAddToWishlist : null
- }
- onVariantSelected={(product, variant, quantity) => {
- if (quantity) {
- setChildProductSelection((previousState) => ({
- ...previousState,
- [product.id]: {
- product,
- variant,
- quantity: isProductABundle
- ? childQuantity
- : quantity
- }
- }))
- } else {
- const selections = {...childProductSelection}
- delete selections[product.id]
- setChildProductSelection(selections)
- }
- }}
- isProductLoading={isProductLoading}
- isBasketLoading={isBasketLoading}
- isWishlistLoading={isWishlistLoading}
- setChildProductOrderability={
- setChildProductOrderability
- }
- />
-
-
-
-
-
-
- )
- )
- }
-
- ) : (
-
-
- handleAddToCart([{product, variant, quantity}])
- }
- addToWishlist={handleAddToWishlist}
- isProductLoading={isProductLoading}
- isBasketLoading={isBasketLoading}
- isWishlistLoading={isWishlistLoading}
- />
-
-
- )}
-
- {/* Product Recommendations */}
+ <>
+
+
+
+
- {!isProductASet && (
-
- }
- recommender={EINSTEIN_RECOMMENDERS.PDP_COMPLETE_SET}
- products={[product]}
- mx={{base: -4, md: -8, lg: 0}}
- shouldFetch={() => product?.id}
- />
- )}
-
- }
- recommender={EINSTEIN_RECOMMENDERS.PDP_MIGHT_ALSO_LIKE}
- products={[product]}
- mx={{base: -4, md: -8, lg: 0}}
- shouldFetch={() => product?.id}
+
-
- }
- recommender={EINSTEIN_RECOMMENDERS.PDP_RECENTLY_VIEWED}
- mx={{base: -4, md: -8, lg: 0}}
- />
+
-
-
+
+ >
)
}
ProductDetail.getTemplateName = () => 'product-detail'
-ProductDetail.propTypes = {
- /**
- * The current react router match object. (Provided internally)
- */
- match: PropTypes.object
-}
-
export default ProductDetail
diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/index.test.js b/packages/extension-chakra-storefront/src/pages/product-detail/index.test.js
index ce380dd96b..e959d66eb7 100644
--- a/packages/extension-chakra-storefront/src/pages/product-detail/index.test.js
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/index.test.js
@@ -1,9 +1,10 @@
/*
- * Copyright (c) 2023, Salesforce, Inc.
+ * 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 {fireEvent, screen, waitFor, within} from '@testing-library/react'
import {mockCustomerBaskets, mockedCustomerProductLists} from '../../mocks/mock-data'
diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/page-analytics.jsx b/packages/extension-chakra-storefront/src/pages/product-detail/page-analytics.jsx
new file mode 100644
index 0000000000..8b5c90ab61
--- /dev/null
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/page-analytics.jsx
@@ -0,0 +1,62 @@
+/*
+ * 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 {useEffect} from 'react'
+import PropTypes from 'prop-types'
+import useEinstein from '../../hooks/use-einstein'
+import useDataCloud from '../../hooks/use-datacloud'
+import useActiveData from '../../hooks/use-active-data'
+import logger from '../../utils/logger-instance'
+
+const PageAnalytics = ({product, category}) => {
+ const einstein = useEinstein()
+ const dataCloud = useDataCloud()
+ const activeData = useActiveData()
+
+ useEffect(() => {
+ if (!product || !category) {
+ return
+ }
+ if (product && product.type.set) {
+ einstein.sendViewProduct(product)
+ dataCloud.sendViewProduct(product)
+ const childrenProducts = product.setProducts
+ childrenProducts.map((child) => {
+ try {
+ einstein.sendViewProduct(child)
+ } catch (err) {
+ logger.error('Einstein sendViewProduct error', {
+ namespace: 'useProductAnalytics.useEffect',
+ additionalProperties: {error: err, child}
+ })
+ }
+ activeData.sendViewProduct(category, child, 'detail')
+ dataCloud.sendViewProduct(child)
+ })
+ } else if (product) {
+ try {
+ einstein.sendViewProduct(product)
+ } catch (err) {
+ logger.error('Einstein sendViewProduct error', {
+ namespace: 'useProductAnalytics.useEffect',
+ additionalProperties: {error: err, product}
+ })
+ }
+ activeData.sendViewProduct(category, product, 'detail')
+ dataCloud.sendViewProduct(product)
+ }
+ }, [product?.id, category?.id])
+
+ return null
+}
+
+PageAnalytics.propTypes = {
+ product: PropTypes.object,
+ category: PropTypes.object
+}
+
+export default PageAnalytics
diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/page-cache.jsx b/packages/extension-chakra-storefront/src/pages/product-detail/page-cache.jsx
new file mode 100644
index 0000000000..46fdeda00e
--- /dev/null
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/page-cache.jsx
@@ -0,0 +1,22 @@
+/*
+ * 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 {useServerContext} from '@salesforce/pwa-kit-react-sdk/ssr/universal/hooks'
+import {useExtensionConfig} from '../../hooks'
+/*
+ * This component is used to set the cache headers for the page.
+ */
+export default function PageCache() {
+ const {res} = useServerContext()
+ const {maxCacheAge: MAX_CACHE_AGE, staleWhileRevalidate: STALE_WHILE_REVALIDATE} =
+ useExtensionConfig()
+ if (res) {
+ res.set(
+ 'Cache-Control',
+ `s-maxage=${MAX_CACHE_AGE}, stale-while-revalidate=${STALE_WHILE_REVALIDATE}`
+ )
+ }
+}
diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/metadata.jsx b/packages/extension-chakra-storefront/src/pages/product-detail/page-metadata.jsx
similarity index 94%
rename from packages/extension-chakra-storefront/src/pages/product-detail/metadata.jsx
rename to packages/extension-chakra-storefront/src/pages/product-detail/page-metadata.jsx
index 1e23f45d2e..7e9d606fa7 100644
--- a/packages/extension-chakra-storefront/src/pages/product-detail/metadata.jsx
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/page-metadata.jsx
@@ -19,7 +19,7 @@ import Seo from '../../components/seo'
* @param {string} product.pageMetaTags.id - The id of the meta tag
* @param {string} product.pageMetaTags.value - The value of the meta tag
*/
-export default function Metadata({product}) {
+export default function PageMetadata({product}) {
if (!product) {
return null
}
@@ -33,7 +33,7 @@ export default function Metadata({product}) {
return
}
-Metadata.propTypes = {
+PageMetadata.propTypes = {
product: PropTypes.shape({
pageTitle: PropTypes.string,
pageDescription: PropTypes.string,
@@ -44,5 +44,5 @@ Metadata.propTypes = {
value: PropTypes.string.isRequired
})
)
- }).isRequired
+ })
}
diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/metadata.test.js b/packages/extension-chakra-storefront/src/pages/product-detail/page-metadata.test.js
similarity index 99%
rename from packages/extension-chakra-storefront/src/pages/product-detail/metadata.test.js
rename to packages/extension-chakra-storefront/src/pages/product-detail/page-metadata.test.js
index e91b4a7aad..8c0281ef3c 100644
--- a/packages/extension-chakra-storefront/src/pages/product-detail/metadata.test.js
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/page-metadata.test.js
@@ -7,7 +7,7 @@
import React from 'react'
import {render} from '@testing-library/react'
-import Metadata from './metadata'
+import Metadata from './page-metadata'
jest.mock('../../components/seo', () => {
return function MockSeo(props) {
diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/partials/product-details-composite.jsx b/packages/extension-chakra-storefront/src/pages/product-detail/partials/product-details-composite.jsx
new file mode 100644
index 0000000000..cd7830d057
--- /dev/null
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/partials/product-details-composite.jsx
@@ -0,0 +1,152 @@
+/*
+ * 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, {Fragment} from 'react'
+import PropTypes from 'prop-types'
+import {Box} from '@chakra-ui/react'
+import ProductView from '../../../components/product-view'
+import InformationAccordion from './information-accordion'
+
+/**
+ * This component is used to render the product details for a composite product.
+ *
+ * A composite product is a product that is made up of multiple products.
+ *
+ * It can be a set or a bundle.
+ */
+const CompositeProductDetails = ({
+ product,
+ primaryCategory,
+ isProductASet,
+ isProductABundle,
+ isProductLoading,
+ isBasketLoading,
+ isWishlistLoading,
+ handleAddToWishlist,
+ handleAddToCart,
+ handleProductSetAddToCart,
+ handleProductBundleAddToCart,
+ handleChildProductValidation,
+ childProductOrderability,
+ setSelectedBundleQuantity,
+ comboProduct,
+ childProductRefs,
+ selectedBundleQuantity,
+ setChildProductSelection,
+ childProductSelection,
+ setChildProductOrderability
+}) => {
+ return (
+
+
+
+
+
+ {/* TODO: consider `childProduct.belongsToSet` */}
+ {
+ // Render the child products
+ comboProduct.childProducts.map(
+ ({product: childProduct, quantity: childQuantity}) => (
+
+
+ handleAddToCart([
+ {
+ product: childProduct,
+ variant,
+ quantity
+ }
+ ])
+ : null
+ }
+ addToWishlist={isProductASet ? handleAddToWishlist : null}
+ onVariantSelected={(product, variant, quantity) => {
+ if (quantity) {
+ setChildProductSelection((previousState) => ({
+ ...previousState,
+ [product.id]: {
+ product,
+ variant,
+ quantity: isProductABundle
+ ? childQuantity
+ : quantity
+ }
+ }))
+ } else {
+ const selections = {...childProductSelection}
+ delete selections[product.id]
+ setChildProductSelection(selections)
+ }
+ }}
+ isProductLoading={isProductLoading}
+ isBasketLoading={isBasketLoading}
+ isWishlistLoading={isWishlistLoading}
+ setChildProductOrderability={setChildProductOrderability}
+ />
+
+
+
+
+
+
+ )
+ )
+ }
+
+ )
+}
+
+CompositeProductDetails.propTypes = {
+ product: PropTypes.object,
+ primaryCategory: PropTypes.object,
+ isProductASet: PropTypes.bool,
+ isProductABundle: PropTypes.bool,
+ isProductLoading: PropTypes.bool,
+ isBasketLoading: PropTypes.bool,
+ isWishlistLoading: PropTypes.bool,
+ handleAddToWishlist: PropTypes.func,
+ handleAddToCart: PropTypes.func,
+ handleProductSetAddToCart: PropTypes.func,
+ handleProductBundleAddToCart: PropTypes.func,
+ handleChildProductValidation: PropTypes.func,
+ childProductOrderability: PropTypes.object,
+ setSelectedBundleQuantity: PropTypes.func,
+ comboProduct: PropTypes.object,
+ childProductRefs: PropTypes.object,
+ selectedBundleQuantity: PropTypes.number,
+ setChildProductSelection: PropTypes.func,
+ childProductSelection: PropTypes.object,
+ setChildProductOrderability: PropTypes.func
+}
+
+export default CompositeProductDetails
diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/partials/product-details-simple.jsx b/packages/extension-chakra-storefront/src/pages/product-detail/partials/product-details-simple.jsx
new file mode 100644
index 0000000000..60c6bb7ab1
--- /dev/null
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/partials/product-details-simple.jsx
@@ -0,0 +1,47 @@
+/*
+ * 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, {Fragment} from 'react'
+import PropTypes from 'prop-types'
+import ProductView from '../../../components/product-view'
+import InformationAccordion from './information-accordion'
+
+const SimpleProductDetails = ({
+ product,
+ primaryCategory,
+ isProductLoading,
+ isBasketLoading,
+ isWishlistLoading,
+ handleAddToWishlist,
+ handleAddToCart
+}) => {
+ return (
+
+ handleAddToCart([{product, variant, quantity}])}
+ addToWishlist={handleAddToWishlist}
+ isProductLoading={isProductLoading}
+ isBasketLoading={isBasketLoading}
+ isWishlistLoading={isWishlistLoading}
+ />
+
+
+ )
+}
+
+SimpleProductDetails.propTypes = {
+ product: PropTypes.object,
+ primaryCategory: PropTypes.object,
+ isProductLoading: PropTypes.bool,
+ isBasketLoading: PropTypes.bool,
+ isWishlistLoading: PropTypes.bool,
+ handleAddToWishlist: PropTypes.func,
+ handleAddToCart: PropTypes.func
+}
+
+export default SimpleProductDetails
diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/partials/product-details.jsx b/packages/extension-chakra-storefront/src/pages/product-detail/partials/product-details.jsx
new file mode 100644
index 0000000000..e88a309a0b
--- /dev/null
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/partials/product-details.jsx
@@ -0,0 +1,45 @@
+/*
+ * 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 PropTypes from 'prop-types'
+import SimpleProductDetails from './product-details-simple'
+import CompositeProductDetails from './product-details-composite'
+
+const ProductDetails = (props) => {
+ const {isProductASet, isProductABundle} = props
+
+ if (isProductASet || isProductABundle) {
+ return
+ }
+
+ return
+}
+
+ProductDetails.propTypes = {
+ product: PropTypes.object,
+ primaryCategory: PropTypes.object,
+ isProductASet: PropTypes.bool,
+ isProductABundle: PropTypes.bool,
+ isProductLoading: PropTypes.bool,
+ isBasketLoading: PropTypes.bool,
+ isWishlistLoading: PropTypes.bool,
+ handleAddToWishlist: PropTypes.func,
+ handleAddToCart: PropTypes.func,
+ handleProductSetAddToCart: PropTypes.func,
+ handleProductBundleAddToCart: PropTypes.func,
+ handleChildProductValidation: PropTypes.func,
+ childProductOrderability: PropTypes.object,
+ setSelectedBundleQuantity: PropTypes.func,
+ comboProduct: PropTypes.object,
+ childProductRefs: PropTypes.object,
+ selectedBundleQuantity: PropTypes.number,
+ setChildProductSelection: PropTypes.func,
+ childProductSelection: PropTypes.object,
+ setChildProductOrderability: PropTypes.func
+}
+
+export default ProductDetails
diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/partials/recommended-products-section.jsx b/packages/extension-chakra-storefront/src/pages/product-detail/partials/recommended-products-section.jsx
new file mode 100644
index 0000000000..78f0861d51
--- /dev/null
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/partials/recommended-products-section.jsx
@@ -0,0 +1,69 @@
+/*
+ * 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 PropTypes from 'prop-types'
+import {FormattedMessage} from 'react-intl'
+import {Stack} from '@chakra-ui/react'
+import RecommendedProducts from '../../../components/recommended-products'
+import {EINSTEIN_RECOMMENDERS} from '../../../constants'
+import {useLocation} from 'react-router-dom'
+
+const RecommendedProductsSection = ({product, isProductASet}) => {
+ const location = useLocation()
+
+ return (
+
+ {!isProductASet && (
+
+ }
+ recommender={EINSTEIN_RECOMMENDERS.PDP_COMPLETE_SET}
+ products={[product]}
+ mx={{base: -4, md: -8, lg: 0}}
+ shouldFetch={() => product?.id}
+ />
+ )}
+
+ }
+ recommender={EINSTEIN_RECOMMENDERS.PDP_MIGHT_ALSO_LIKE}
+ products={[product]}
+ mx={{base: -4, md: -8, lg: 0}}
+ shouldFetch={() => product?.id}
+ />
+
+
+ }
+ recommender={EINSTEIN_RECOMMENDERS.PDP_RECENTLY_VIEWED}
+ mx={{base: -4, md: -8, lg: 0}}
+ />
+
+ )
+}
+
+RecommendedProductsSection.propTypes = {
+ product: PropTypes.object,
+ isProductASet: PropTypes.bool
+}
+
+export default RecommendedProductsSection
diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/use-product-detail-data.js b/packages/extension-chakra-storefront/src/pages/product-detail/use-product-detail-data.js
new file mode 100644
index 0000000000..b540bcf20b
--- /dev/null
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/use-product-detail-data.js
@@ -0,0 +1,342 @@
+/*
+ * 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, {useCallback, useEffect, useState} from 'react'
+import {useIntl} from 'react-intl'
+import {keepPreviousData} from '@tanstack/react-query'
+import {HTTPNotFound, HTTPError} from '@salesforce/pwa-kit-react-sdk/ssr/universal/errors'
+
+import {
+ useProduct,
+ useProducts,
+ useCategory,
+ useShopperBasketsMutation,
+ useShopperBasketsMutationHelper
+} from '@salesforce/commerce-sdk-react'
+import {useHistory, useLocation, useParams} from 'react-router-dom'
+
+import {useCurrentBasket, useVariant} from '../../hooks'
+import useEinstein from '../../hooks/use-einstein'
+import useToast from '../../hooks/use-toast'
+import {useProductDetailWishlist} from './use-product-detail-wishlist'
+
+import {normalizeSetBundleProduct, getUpdateBundleChildArray} from '../../utils/product-utils'
+
+import {API_ERROR_MESSAGE} from '../../constants'
+import {rebuildPathWithParams} from '../../utils/url'
+
+export const useProductDetailData = () => {
+ const {formatMessage} = useIntl()
+ const history = useHistory()
+ const location = useLocation()
+ const einstein = useEinstein()
+ const toast = useToast()
+ const {handleAddToWishlist, isWishlistLoading} = useProductDetailWishlist()
+
+ /****************************** Basket *********************************/
+ const {isLoading: isBasketLoading} = useCurrentBasket()
+ const {addItemToNewOrExistingBasket} = useShopperBasketsMutationHelper()
+ const updateItemsInBasketMutation = useShopperBasketsMutation('updateItemsInBasket')
+
+ /*************************** Product Detail and Category ********************/
+ const {productId} = useParams()
+ const urlParams = new URLSearchParams(location.search)
+ const {
+ data: product,
+ isLoading: isProductLoading,
+ isError: isProductError,
+ error: productError
+ } = useProduct(
+ {
+ parameters: {
+ id: urlParams.get('pid') || productId,
+ perPricebook: true,
+ expand: [
+ 'availability',
+ 'promotions',
+ 'options',
+ 'images',
+ 'prices',
+ 'variations',
+ 'set_products',
+ 'bundled_products',
+ 'page_meta_tags'
+ ],
+ allImages: true
+ }
+ },
+ {
+ // When shoppers select a different variant (and the app fetches the new data),
+ // the old data is still rendered (and not the skeletons).
+ placeholderData: keepPreviousData
+ }
+ )
+
+ // Note: Since category needs id from product detail, it can't be server side rendered atm
+ // until we can do dependent query on server
+ const {
+ data: category,
+ isError: isCategoryError,
+ error: categoryError
+ } = useCategory({
+ parameters: {
+ id: product?.primaryCategoryId,
+ levels: 1
+ }
+ })
+
+ /****************************** Sets and Bundles *********************************/
+ const [childProductSelection, setChildProductSelection] = useState({})
+ const [childProductOrderability, setChildProductOrderability] = useState({})
+ const [selectedBundleQuantity, setSelectedBundleQuantity] = useState(1)
+ const childProductRefs = React.useRef({})
+ const isProductASet = product?.type.set
+ const isProductABundle = product?.type.bundle
+
+ let bundleChildVariantIds = ''
+ if (isProductABundle)
+ bundleChildVariantIds = Object.keys(childProductSelection)
+ ?.map((key) => childProductSelection[key].variant.productId)
+ .join(',')
+
+ const {data: bundleChildrenData} = useProducts(
+ {
+ parameters: {
+ ids: bundleChildVariantIds,
+ allImages: false,
+ expand: ['availability', 'variations'],
+ select: '(data.(id,inventory,master))'
+ }
+ },
+ {
+ enabled: bundleChildVariantIds?.length > 0,
+ placeholderData: keepPreviousData
+ }
+ )
+
+ if (isProductABundle && bundleChildrenData) {
+ // Loop through the bundle children and update the inventory for variant selection
+ product.bundledProducts.forEach(({product: childProduct}, index) => {
+ const matchingChildProduct = bundleChildrenData.data.find(
+ (bundleChild) => bundleChild.master.masterId === childProduct.id
+ )
+ if (matchingChildProduct) {
+ product.bundledProducts[index].product = {
+ ...childProduct,
+ inventory: matchingChildProduct.inventory
+ }
+ }
+ })
+ }
+
+ const comboProduct = isProductASet || isProductABundle ? normalizeSetBundleProduct(product) : {}
+
+ /**************** Error Handling ****************/
+
+ if (isProductError) {
+ const errorStatus = productError?.response?.status
+ switch (errorStatus) {
+ case 404:
+ throw new HTTPNotFound('Product Not Found.')
+ default:
+ throw new HTTPError(errorStatus, `HTTP Error ${errorStatus} occurred.`)
+ }
+ }
+ if (isCategoryError) {
+ const errorStatus = categoryError?.response?.status
+ switch (errorStatus) {
+ case 404:
+ throw new HTTPNotFound('Category Not Found.')
+ default:
+ throw new HTTPError(errorStatus, `HTTP Error ${errorStatus} occurred.`)
+ }
+ }
+
+ const [primaryCategory, setPrimaryCategory] = useState(category)
+ const variant = useVariant(product)
+ // This page uses the `primaryCategoryId` to retrieve the category data. This attribute
+ // is only available on `master` products. Since a variation will be loaded once all the
+ // attributes are selected (to get the correct inventory values), the category information
+ // is overridden. This will allow us to keep the initial category around until a different
+ // master product is loaded.
+ useEffect(() => {
+ if (category) {
+ setPrimaryCategory(category)
+ }
+ }, [category])
+
+ /**************** Product Variant ****************/
+ useEffect(() => {
+ if (!variant) {
+ return
+ }
+ // update the variation attributes parameter on
+ // the url accordingly as the variant changes
+ const updatedUrl = rebuildPathWithParams(`${location.pathname}${location.search}`, {
+ pid: variant?.productId
+ })
+ history.replace(updatedUrl)
+ }, [variant])
+
+ /**************** Add To Cart ****************/
+ const showError = () => {
+ toast({
+ title: formatMessage(API_ERROR_MESSAGE),
+ type: 'error'
+ })
+ }
+
+ const handleAddToCart = async (productSelectionValues) => {
+ try {
+ const productItems = productSelectionValues.map(({variant, quantity}) => ({
+ productId: variant.productId,
+ price: variant.price,
+ quantity
+ }))
+
+ await addItemToNewOrExistingBasket(productItems)
+
+ einstein.sendAddToCart(productItems)
+
+ // If the items were successfully added, set the return value to be used
+ // by the add to cart modal.
+ return productSelectionValues
+ } catch (error) {
+ console.log('error', error)
+ showError(error)
+ }
+ }
+
+ /**************** Product Set/Bundles Handlers ****************/
+ const handleChildProductValidation = useCallback(() => {
+ // Run validation for all child products. This will ensure the error
+ // messages are shown.
+ Object.values(childProductRefs.current).forEach(({validateOrderability}) => {
+ validateOrderability({scrollErrorIntoView: false})
+ })
+
+ // Using ot state for which child products are selected, scroll to the first
+ // one that isn't selected.
+ const selectedProductIds = Object.keys(childProductSelection)
+ const firstUnselectedProduct = comboProduct.childProducts.find(
+ ({product: childProduct}) => !selectedProductIds.includes(childProduct.id)
+ )?.product
+
+ if (firstUnselectedProduct) {
+ // Get the reference to the product view and scroll to it.
+ const {ref} = childProductRefs.current[firstUnselectedProduct.id]
+
+ if (ref.scrollIntoView) {
+ ref.scrollIntoView({
+ behavior: 'smooth',
+ block: 'end'
+ })
+ }
+
+ return false
+ }
+
+ return true
+ }, [product, childProductSelection])
+
+ /**************** Product Set Handlers ****************/
+ const handleProductSetAddToCart = () => {
+ // Get all the selected products, and pass them to the addToCart handler which
+ // accepts an array.
+ const productSelectionValues = Object.values(childProductSelection)
+ return handleAddToCart(productSelectionValues)
+ }
+
+ /**************** Product Bundle Handlers ****************/
+ // Top level bundle does not have variants
+ const handleProductBundleAddToCart = async (variant, selectedQuantity) => {
+ try {
+ const childProductSelections = Object.values(childProductSelection)
+
+ const productItems = [
+ {
+ productId: product.id,
+ price: product.price,
+ quantity: selectedQuantity,
+ // The add item endpoint in the shopper baskets API does not respect variant selections
+ // for bundle children, so we have to make a follow up call to update the basket
+ // with the chosen variant selections
+ bundledProductItems: childProductSelections.map((child) => {
+ return {
+ productId: child.variant.productId,
+ quantity: child.quantity
+ }
+ })
+ }
+ ]
+
+ const res = await addItemToNewOrExistingBasket(productItems)
+
+ const bundleChildMasterIds = childProductSelections.map((child) => {
+ return child.product.id
+ })
+
+ // since the returned data includes all products in basket
+ // here we compare list of productIds in bundleProductItems of each productItem to filter out the
+ // current bundle that was last added into cart
+ const currentBundle = res.productItems.find((productItem) => {
+ if (!productItem.bundledProductItems?.length) return
+ const bundleChildIds = productItem.bundledProductItems?.map((item) => {
+ // seek out the bundle child that still uses masterId as product id
+ return item.productId
+ })
+ return bundleChildIds.every((id) => bundleChildMasterIds.includes(id))
+ })
+
+ const itemsToBeUpdated = getUpdateBundleChildArray(
+ currentBundle,
+ childProductSelections
+ )
+
+ if (itemsToBeUpdated.length) {
+ // make a follow up call to update child variant selection for product bundle
+ // since add item endpoint doesn't currently consider product bundle child variants
+ await updateItemsInBasketMutation.mutateAsync({
+ method: 'PATCH',
+ parameters: {
+ basketId: res.basketId
+ },
+ body: itemsToBeUpdated
+ })
+ }
+
+ einstein.sendAddToCart(productItems)
+
+ return childProductSelections
+ } catch (error) {
+ showError(error)
+ }
+ }
+
+ return {
+ product,
+ isProductLoading,
+ primaryCategory,
+ isProductASet,
+ isProductABundle,
+ comboProduct,
+ childProductRefs,
+ childProductSelection,
+ setChildProductSelection,
+ childProductOrderability,
+ setChildProductOrderability,
+ selectedBundleQuantity,
+ setSelectedBundleQuantity,
+ handleAddToCart,
+ handleAddToWishlist,
+ handleProductSetAddToCart,
+ handleProductBundleAddToCart,
+ handleChildProductValidation,
+ isBasketLoading,
+ isWishlistLoading
+ }
+}
diff --git a/packages/extension-chakra-storefront/src/pages/product-detail/use-product-detail-wishlist.js b/packages/extension-chakra-storefront/src/pages/product-detail/use-product-detail-wishlist.js
new file mode 100644
index 0000000000..7008f348f1
--- /dev/null
+++ b/packages/extension-chakra-storefront/src/pages/product-detail/use-product-detail-wishlist.js
@@ -0,0 +1,99 @@
+/*
+ * 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 {useIntl} from 'react-intl'
+import {Button} from '@chakra-ui/react'
+import {useShopperCustomersMutation, useCustomerId} from '@salesforce/commerce-sdk-react'
+import useNavigation from '../../hooks/use-navigation'
+import useToast from '../../hooks/use-toast'
+import {useWishList} from '../../hooks/use-wish-list'
+import {
+ API_ERROR_MESSAGE,
+ TOAST_ACTION_VIEW_WISHLIST,
+ TOAST_MESSAGE_ADDED_TO_WISHLIST,
+ TOAST_MESSAGE_ALREADY_IN_WISHLIST
+} from '../../constants'
+
+// TODO: we are in the process of de-duplicating the wishlist related logic
+// across multiple pages. We first need to extract these logic into individual files
+// and then we will dedupe them and remove the unnecessary files.
+// This file should be removed and the logic should be moved to use-wishlist.js
+export const useProductDetailWishlist = () => {
+ const {formatMessage} = useIntl()
+ const navigate = useNavigation()
+ const customerId = useCustomerId()
+ const toast = useToast()
+
+ const {data: wishlist, isLoading: isWishlistLoading} = useWishList()
+ const createCustomerProductListItem = useShopperCustomersMutation(
+ 'createCustomerProductListItem'
+ )
+
+ const showError = () => {
+ toast({
+ title: formatMessage(API_ERROR_MESSAGE),
+ type: 'error'
+ })
+ }
+
+ const handleAddToWishlist = (product, variant, quantity) => {
+ const isItemInWishlist = wishlist?.customerProductListItems?.find(
+ (i) => i.productId === variant?.productId || i.productId === product?.id
+ )
+
+ if (!isItemInWishlist) {
+ createCustomerProductListItem.mutate(
+ {
+ parameters: {
+ listId: wishlist.id,
+ customerId
+ },
+ body: {
+ // NOTE: API does not respect quantity, it always adds 1
+ quantity,
+ productId: variant?.productId || product?.id,
+ public: false,
+ priority: 1,
+ type: 'product'
+ }
+ },
+ {
+ onSuccess: () => {
+ toast({
+ title: formatMessage(TOAST_MESSAGE_ADDED_TO_WISHLIST, {quantity: 1}),
+ type: 'success',
+ action: (
+
+ )
+ })
+ },
+ onError: () => {
+ showError()
+ }
+ }
+ )
+ } else {
+ toast({
+ title: formatMessage(TOAST_MESSAGE_ALREADY_IN_WISHLIST),
+ type: 'info',
+ action: (
+
+ )
+ })
+ }
+ }
+
+ return {handleAddToWishlist, isWishlistLoading}
+}