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} +}