diff --git a/packages/template-retail-react-app/app/components/swatch-group/index.jsx b/packages/template-retail-react-app/app/components/swatch-group/index.jsx index d005367321..e05e3dbc42 100644 --- a/packages/template-retail-react-app/app/components/swatch-group/index.jsx +++ b/packages/template-retail-react-app/app/components/swatch-group/index.jsx @@ -15,7 +15,14 @@ import {noop} from '../../utils/utils' * Each Swatch is a link with will direct to a href passed to them */ const SwatchGroup = (props) => { - const {displayName, children, value, label = '', variant = 'square', onChange = noop} = props + const { + displayName, + children, + value: selectedValue, + label = '', + variant = 'square', + onChange = noop + } = props const styles = useStyleConfig('SwatchGroup') return ( @@ -28,9 +35,7 @@ const SwatchGroup = (props) => { const childValue = child.props.value return React.cloneElement(child, { - selected: childValue === value, - key: childValue, - value, + selected: childValue === selectedValue, variant, onChange }) diff --git a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js index 13889d059e..756b3de2f0 100644 --- a/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-add-to-cart-modal.js @@ -29,6 +29,7 @@ import RecommendedProducts from '../components/recommended-products' import {LockIcon} from '../components/icons' import {useVariationAttributes} from './' import {findImageGroupBy} from '../utils/image-groups-utils' +import {useVariant} from './use-variant' /** * This is the context for managing the AddToCartModal. @@ -54,20 +55,21 @@ AddToCartModalProvider.propTypes = { */ export const AddToCartModal = () => { const {isOpen, onClose, data} = useAddToCartModalContext() - const {product, quantity} = data || {} - const intl = useIntl() - const basket = useBasket() - const size = useBreakpointValue({base: 'full', lg: '2xl', xl: '4xl'}) - const variationAttributes = useVariationAttributes(product) if (!isOpen) { return null } + const {product, isProductPartOfSet, quantity} = data || {} + const intl = useIntl() + const basket = useBasket() + const size = useBreakpointValue({base: 'full', lg: '2xl', xl: '4xl'}) + const variationAttributes = useVariationAttributes(product, isProductPartOfSet) + const variant = useVariant(product, isProductPartOfSet) const {currency, productItems, productSubTotal, itemAccumulatedCount} = basket const {id, variationValues} = product const lineItemPrice = productItems?.find((item) => item.productId === id)?.basePrice * quantity const image = findImageGroupBy(product.imageGroups, { viewType: 'small', - selectedVariationAttributes: variationValues + selectedVariationAttributes: variationValues || variant?.variationValues })?.images?.[0] return ( diff --git a/packages/template-retail-react-app/app/hooks/use-pdp-search-params.js b/packages/template-retail-react-app/app/hooks/use-pdp-search-params.js new file mode 100644 index 0000000000..5c71c907a4 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-pdp-search-params.js @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2023, 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 {useLocation} from 'react-router-dom' + +export const usePDPSearchParams = (productId) => { + const {search} = useLocation() + + const allParams = new URLSearchParams(search) + const productParams = new URLSearchParams(allParams.get(productId) || '') + + return [allParams, productParams] +} diff --git a/packages/template-retail-react-app/app/hooks/use-product.js b/packages/template-retail-react-app/app/hooks/use-product.js index 5c1e238779..bf350b6c21 100644 --- a/packages/template-retail-react-app/app/hooks/use-product.js +++ b/packages/template-retail-react-app/app/hooks/use-product.js @@ -15,7 +15,7 @@ const OUT_OF_STOCK = 'OUT_OF_STOCK' const UNFULFILLABLE = 'UNFULFILLABLE' // TODO: This needs to be refactored. -export const useProduct = (product) => { +export const useProduct = (product, isProductPartOfSet = false) => { const showLoading = !product const stockLevel = product?.inventory?.stockLevel || 0 const stepQuantity = product?.stepQuantity || 1 @@ -23,9 +23,9 @@ export const useProduct = (product) => { const initialQuantity = product?.quantity || product?.minOrderQuantity || 1 const intl = useIntl() - const variant = useVariant(product) - const variationParams = useVariationParams(product) - const variationAttributes = useVariationAttributes(product) + const variant = useVariant(product, isProductPartOfSet) + const variationParams = useVariationParams(product, isProductPartOfSet) + const variationAttributes = useVariationAttributes(product, isProductPartOfSet) const [quantity, setQuantity] = useState(initialQuantity) // A product is considered out of stock if the stock level is 0 or if we have all our diff --git a/packages/template-retail-react-app/app/hooks/use-variant.js b/packages/template-retail-react-app/app/hooks/use-variant.js index f99afb7f80..ab4024eab5 100644 --- a/packages/template-retail-react-app/app/hooks/use-variant.js +++ b/packages/template-retail-react-app/app/hooks/use-variant.js @@ -16,9 +16,9 @@ import {useVariationParams} from './use-variation-params' * @param {Object} product * @returns {Object} the currently selected `Variant` object. */ -export const useVariant = (product = {}) => { +export const useVariant = (product = {}, isProductPartOfSet = false) => { const {variants = []} = product - const variationParams = useVariationParams(product) + const variationParams = useVariationParams(product, isProductPartOfSet) // Get a filtered array of variants. The resulting array will only have variants // which have all the current variation params values set. diff --git a/packages/template-retail-react-app/app/hooks/use-variation-attributes.js b/packages/template-retail-react-app/app/hooks/use-variation-attributes.js index 7a62d32e5d..6370ab04cb 100644 --- a/packages/template-retail-react-app/app/hooks/use-variation-attributes.js +++ b/packages/template-retail-react-app/app/hooks/use-variation-attributes.js @@ -12,7 +12,8 @@ import {useLocation} from 'react-router-dom' import {useVariationParams} from './use-variation-params' // Utils -import {rebuildPathWithParams} from '../utils/url' +import {updateSearchParams} from '../utils/url' +import {usePDPSearchParams} from './use-pdp-search-params' /** * Return the first image in the `swatch` type image group for a given @@ -47,8 +48,23 @@ const getVariantValueSwatch = (product, variationValue) => { * @param {Object} location * @returns {String} a product url for the current variation value. */ -const buildVariantValueHref = (product, params, location) => { - return rebuildPathWithParams(`${location.pathname}${location.search}`, params) +const buildVariantValueHref = ({ + pathname, + existingParams, + newParams, + productId, + isProductPartOfSet +}) => { + const [allParams, productParams] = existingParams + + if (isProductPartOfSet) { + updateSearchParams(productParams, newParams) + allParams.set(productId, productParams.toString()) + } else { + updateSearchParams(allParams, newParams) + } + + return `${pathname}?${allParams.toString()}` } /** @@ -80,10 +96,12 @@ const isVariantValueOrderable = (product, variationParams) => { * @returns {Array} a decorated variation attributes list. * */ -export const useVariationAttributes = (product = {}) => { +export const useVariationAttributes = (product = {}, isProductPartOfSet = false) => { const {variationAttributes = []} = product const location = useLocation() - const variationParams = useVariationParams(product) + const variationParams = useVariationParams(product, isProductPartOfSet) + + const existingParams = usePDPSearchParams(product.id) return useMemo( () => @@ -104,7 +122,13 @@ export const useVariationAttributes = (product = {}) => { return { ...value, image: getVariantValueSwatch(product, value), - href: buildVariantValueHref(product, params, location), + href: buildVariantValueHref({ + pathname: location.pathname, + existingParams, + newParams: params, + productId: product.id, + isProductPartOfSet + }), orderable: isVariantValueOrderable(product, params) } }) diff --git a/packages/template-retail-react-app/app/hooks/use-variation-params.js b/packages/template-retail-react-app/app/hooks/use-variation-params.js index d210b39a45..630c6dd06b 100644 --- a/packages/template-retail-react-app/app/hooks/use-variation-params.js +++ b/packages/template-retail-react-app/app/hooks/use-variation-params.js @@ -5,25 +5,27 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import {useLocation} from 'react-router-dom' +import {usePDPSearchParams} from './use-pdp-search-params' /* * This hook will return only the params that are also product attributes for the * passed in product object. */ -export const useVariationParams = (product = {}) => { +export const useVariationParams = (product = {}, isProductPartOfSet = false) => { const {variationAttributes = [], variationValues = {}} = product - const variationParams = variationAttributes.map(({id}) => id) - const {search} = useLocation() - const params = new URLSearchParams(search) - // Using all the variation attribute id from the array generated above, get + const [allParams, productParams] = usePDPSearchParams(product.id) + const params = isProductPartOfSet ? productParams : allParams + + // Using all the variation attribute id from the array generated below, get // the value if there is one from the location search params and add it to the // accumulator. - const filteredVariationParams = variationParams.reduce((acc, key) => { - let value = params.get(`${key}`) || variationValues?.[key] - return value ? {...acc, [key]: value} : acc - }, {}) + const variationParams = variationAttributes + .map(({id}) => id) + .reduce((acc, key) => { + let value = params.get(`${key}`) || variationValues?.[key] + return value ? {...acc, [key]: value} : acc + }, {}) - return filteredVariationParams + return variationParams } diff --git a/packages/template-retail-react-app/app/pages/product-detail/index.jsx b/packages/template-retail-react-app/app/pages/product-detail/index.jsx index 2e36f8bd57..8ef6f7f4a4 100644 --- a/packages/template-retail-react-app/app/pages/product-detail/index.jsx +++ b/packages/template-retail-react-app/app/pages/product-detail/index.jsx @@ -5,22 +5,13 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {useEffect, useState} from 'react' +import React, {Fragment, useEffect, useState} from 'react' import PropTypes from 'prop-types' import {Helmet} from 'react-helmet' import {FormattedMessage, useIntl} from 'react-intl' // Components -import { - Accordion, - AccordionItem, - AccordionButton, - AccordionPanel, - AccordionIcon, - Box, - Button, - Stack -} from '@chakra-ui/react' +import {Box, Button, Stack} from '@chakra-ui/react' // Hooks import useBasket from '../../commerce-api/hooks/useBasket' @@ -32,6 +23,7 @@ import useEinstein from '../../commerce-api/hooks/useEinstein' // Project Components import RecommendedProducts from '../../components/recommended-products' import ProductView from '../../partials/product-view' +import InformationAccordion from './partials/information-accordion' // Others/Utils import {HTTPNotFound} from 'pwa-kit-react-sdk/ssr/universal/errors' @@ -57,6 +49,8 @@ const ProductDetail = ({category, product, isLoading}) => { const navigate = useNavigation() const [primaryCategory, setPrimaryCategory] = useState(category) + const isProductASet = product?.type.set + // 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 @@ -155,105 +149,55 @@ const ProductDetail = ({category, product, isLoading}) => { - handleAddToCart(variant, quantity)} - addToWishlist={(_, quantity) => handleAddToWishlist(quantity)} - isProductLoading={isLoading} - isCustomerProductListLoading={!wishlist.isInitialized} - /> - - {/* Information Accordion */} - - - {/* Details */} - -

- - - {formatMessage({ - defaultMessage: 'Product Detail', - id: 'product_detail.accordion.button.product_detail' - })} - - - -

- -
+ {/* Product Set: parent product */} + handleAddToCart(variant, quantity)} + addToWishlist={(_, quantity) => handleAddToWishlist(quantity)} + isProductLoading={isLoading} + isCustomerProductListLoading={!wishlist.isInitialized} + /> + +
+ + {/* TODO: consider `childProduct.belongsToSet` */} + {// Product Set: render the child products + product.setProducts.map((childProduct) => ( + + + handleAddToCart(variant, quantity) + } + addToWishlist={(_, quantity) => handleAddToWishlist(quantity)} + isProductLoading={isLoading} + isCustomerProductListLoading={!wishlist.isInitialized} /> - - - - {/* Size & Fit */} - -

- - - {formatMessage({ - defaultMessage: 'Size & Fit', - id: 'product_detail.accordion.button.size_fit' - })} - - - -

- - {formatMessage({ - defaultMessage: 'Coming Soon', - id: 'product_detail.accordion.message.coming_soon' - })} - -
- - {/* Reviews */} - -

- - - {formatMessage({ - defaultMessage: 'Reviews', - id: 'product_detail.accordion.button.reviews' - })} - - - -

- - {formatMessage({ - defaultMessage: 'Coming Soon', - id: 'product_detail.accordion.message.coming_soon' - })} - -
- - {/* Questions */} - -

- - - {formatMessage({ - defaultMessage: 'Questions', - id: 'product_detail.accordion.button.questions' - })} - - - -

- - {formatMessage({ - defaultMessage: 'Coming Soon', - id: 'product_detail.accordion.message.coming_soon' - })} - -
- - - + + + +
+
+
+ ))} + + ) : ( + + handleAddToCart(variant, quantity)} + addToWishlist={(_, quantity) => handleAddToWishlist(quantity)} + isProductLoading={isLoading} + isCustomerProductListLoading={!wishlist.isInitialized} + /> + + + )} {/* Product Recommendations */} diff --git a/packages/template-retail-react-app/app/pages/product-detail/partials/information-accordion.jsx b/packages/template-retail-react-app/app/pages/product-detail/partials/information-accordion.jsx new file mode 100644 index 0000000000..7d1316362b --- /dev/null +++ b/packages/template-retail-react-app/app/pages/product-detail/partials/information-accordion.jsx @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2023, 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 { + Accordion, + AccordionItem, + AccordionButton, + AccordionPanel, + AccordionIcon, + Box, + Stack +} from '@chakra-ui/react' +import {useIntl} from 'react-intl' + +const InformationAccordion = ({product}) => { + const {formatMessage} = useIntl() + + return ( + + + {/* Details */} + +

+ + + {formatMessage({ + defaultMessage: 'Product Detail', + id: 'product_detail.accordion.button.product_detail' + })} + + + +

+ +
+ + + + {/* Size & Fit */} + +

+ + + {formatMessage({ + defaultMessage: 'Size & Fit', + id: 'product_detail.accordion.button.size_fit' + })} + + + +

+ + {formatMessage({ + defaultMessage: 'Coming Soon', + id: 'product_detail.accordion.message.coming_soon' + })} + +
+ + {/* Reviews */} + +

+ + + {formatMessage({ + defaultMessage: 'Reviews', + id: 'product_detail.accordion.button.reviews' + })} + + + +

+ + {formatMessage({ + defaultMessage: 'Coming Soon', + id: 'product_detail.accordion.message.coming_soon' + })} + +
+ + {/* Questions */} + +

+ + + {formatMessage({ + defaultMessage: 'Questions', + id: 'product_detail.accordion.button.questions' + })} + + + +

+ + {formatMessage({ + defaultMessage: 'Coming Soon', + id: 'product_detail.accordion.message.coming_soon' + })} + +
+ + + + ) +} + +InformationAccordion.propTypes = { + product: PropTypes.object +} + +export default InformationAccordion diff --git a/packages/template-retail-react-app/app/partials/product-view/index.jsx b/packages/template-retail-react-app/app/partials/product-view/index.jsx index 067493c27a..701a74ae4d 100644 --- a/packages/template-retail-react-app/app/partials/product-view/index.jsx +++ b/packages/template-retail-react-app/app/partials/product-view/index.jsx @@ -26,13 +26,15 @@ import {Skeleton as ImageGallerySkeleton} from '../../components/image-gallery' import {HideOnDesktop, HideOnMobile} from '../../components/responsive' import QuantityPicker from '../../components/quantity-picker' -const ProductViewHeader = ({name, price, currency, category}) => { +const ProductViewHeader = ({name, price, currency, category, productType}) => { const intl = useIntl() const {currency: activeCurrency} = useCurrency() + const isProductASet = productType?.set + return ( {category && ( - + )} @@ -43,8 +45,13 @@ const ProductViewHeader = ({name, price, currency, category}) => { {/* Price */} - + + {isProductASet && + `${intl.formatMessage({ + id: 'product_view.label.starting_at_price', + defaultMessage: 'Starting at' + })} `} {intl.formatNumber(price, { style: 'currency', currency: currency || activeCurrency @@ -59,7 +66,8 @@ ProductViewHeader.propTypes = { name: PropTypes.string, price: PropTypes.number, currency: PropTypes.string, - category: PropTypes.array + category: PropTypes.array, + productType: PropTypes.object } const ButtonWithRegistration = withRegistration(Button) @@ -78,7 +86,8 @@ const ProductView = ({ updateCart, addToWishlist, updateWishlist, - isProductLoading + isProductLoading, + isProductPartOfSet = false }) => { const intl = useIntl() const history = useHistory() @@ -102,7 +111,7 @@ const ProductView = ({ variationAttributes, stockLevel, stepQuantity - } = useProduct(product) + } = useProduct(product, isProductPartOfSet) const canAddToWishlist = !isProductLoading const canOrder = !isProductLoading && @@ -110,6 +119,8 @@ const ProductView = ({ parseInt(quantity) > 0 && parseInt(quantity) <= stockLevel + const isProductASet = product?.type.set + const renderActionButtons = () => { const buttons = [] @@ -124,7 +135,7 @@ const ProductView = ({ return } await addToCart(variant, quantity) - onAddToCartModalOpen({product, quantity}) + onAddToCartModalOpen({product, isProductPartOfSet, quantity}) } const handleWishlistItem = async () => { @@ -205,6 +216,7 @@ const ProductView = ({ @@ -236,12 +248,13 @@ const ProductView = ({ )} - {/* Variations & Quantity Selector */} - + {/* Variations & Quantity Selector & CTA buttons */} + @@ -319,47 +332,49 @@ const ProductView = ({ )} {/* Quantity Selector */} - - - - + {!isProductASet && ( + + + + - { - // Set the Quantity of product to value of input if value number - if (numberValue >= 0) { - setQuantity(numberValue) - } else if (stringValue === '') { - // We want to allow the use to clear the input to start a new input so here we set the quantity to '' so NAN is not displayed - // User will not be able to add '' qauntity to the cart due to the add to cart button enablement rules - setQuantity(stringValue) - } - }} - onBlur={(e) => { - // Default to 1the `minOrderQuantity` if a user leaves the box with an invalid value - const value = e.target.value - if (parseInt(value) < 0 || value === '') { - setQuantity(minOrderQuantity) - } - }} - onFocus={(e) => { - // This is useful for mobile devices, this allows the user to pop open the keyboard and set the - // new quantity with one click. NOTE: This is something that can be refactored into the parent - // component, potentially as a prop called `selectInputOnFocus`. - e.target.select() - }} - /> - + { + // Set the Quantity of product to value of input if value number + if (numberValue >= 0) { + setQuantity(numberValue) + } else if (stringValue === '') { + // We want to allow the use to clear the input to start a new input so here we set the quantity to '' so NAN is not displayed + // User will not be able to add '' qauntity to the cart due to the add to cart button enablement rules + setQuantity(stringValue) + } + }} + onBlur={(e) => { + // Default to 1the `minOrderQuantity` if a user leaves the box with an invalid value + const value = e.target.value + if (parseInt(value) < 0 || value === '') { + setQuantity(minOrderQuantity) + } + }} + onFocus={(e) => { + // This is useful for mobile devices, this allows the user to pop open the keyboard and set the + // new quantity with one click. NOTE: This is something that can be refactored into the parent + // component, potentially as a prop called `selectInputOnFocus`. + e.target.select() + }} + /> + + )} {!showLoading && showOptionsMessage && ( @@ -383,6 +398,7 @@ const ProductView = ({ )} + {isProductASet &&

{product?.shortDescription}

}
@@ -393,9 +409,15 @@ const ProductView = ({
)} - - {renderActionButtons()} - + {!isProductASet && ( + + {renderActionButtons()} + + )}
@@ -404,7 +426,11 @@ const ProductView = ({ position="fixed" bg="white" width="100%" - display={['block', 'block', 'block', 'none']} + display={ + isProductPartOfSet || isProductASet + ? 'none' + : ['block', 'block', 'block', 'none'] + } p={[4, 4, 6]} left={0} bottom={0} @@ -419,6 +445,7 @@ const ProductView = ({ ProductView.propTypes = { product: PropTypes.object, + isProductPartOfSet: PropTypes.bool, category: PropTypes.array, isProductLoading: PropTypes.bool, isWishlistLoading: PropTypes.bool, diff --git a/packages/template-retail-react-app/app/translations/compiled/en-US.json b/packages/template-retail-react-app/app/translations/compiled/en-US.json index a07f957b83..f526fbad7d 100644 --- a/packages/template-retail-react-app/app/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/translations/compiled/en-US.json @@ -2335,6 +2335,12 @@ "value": "Quantity" } ], + "product_view.label.starting_at_price": [ + { + "type": 0, + "value": "Starting at" + } + ], "product_view.link.full_details": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/translations/en-US.json b/packages/template-retail-react-app/app/translations/en-US.json index 4ebd6e071b..2e0b1d9d99 100644 --- a/packages/template-retail-react-app/app/translations/en-US.json +++ b/packages/template-retail-react-app/app/translations/en-US.json @@ -1023,6 +1023,9 @@ "product_view.label.quantity": { "defaultMessage": "Quantity" }, + "product_view.label.starting_at_price": { + "defaultMessage": "Starting at" + }, "product_view.link.full_details": { "defaultMessage": "See full details" }, diff --git a/packages/template-retail-react-app/app/utils/url.js b/packages/template-retail-react-app/app/utils/url.js index d7747aced8..3a9a272cf9 100644 --- a/packages/template-retail-react-app/app/utils/url.js +++ b/packages/template-retail-react-app/app/utils/url.js @@ -46,17 +46,7 @@ export const rebuildPathWithParams = (url, extraParams) => { const [pathname, search] = url.split('?') const params = new URLSearchParams(search) - // Apply any extra params. - Object.keys(extraParams).forEach((key) => { - const value = extraParams[key] - - // 0 is a valid value as for a param - if (!value && value !== 0) { - params.delete(key) - } else { - params.set(key, value) - } - }) + updateSearchParams(params, extraParams) // Clean up any trailing `=` for params without values. const paramStr = params @@ -68,6 +58,17 @@ export const rebuildPathWithParams = (url, extraParams) => { return `${pathname}${Array.from(paramStr).length > 0 ? `?${paramStr}` : ''}` } +export const updateSearchParams = (searchParams, newParams) => { + Object.entries(newParams).forEach(([key, value]) => { + // 0 is a valid value as for a param + if (!value && value !== 0) { + searchParams.delete(key) + } else { + searchParams.set(key, value) + } + }) +} + /** * Builds a list of modified Urls with the provided params key and values, * preserving any search params provided in the original url.Optionally