diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/pages/home/index.jsx.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/pages/home/index.jsx.hbs index a269d332e0..6682d1678c 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/pages/home/index.jsx.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/pages/home/index.jsx.hbs @@ -57,6 +57,9 @@ const Home = () => { const {data: productSearchResult, isLoading} = useProductSearch({ parameters: { refine: [`cgid=${HOME_SHOP_PRODUCTS_CATEGORY_ID}`, 'htype=master'], + expand: ['promotions', 'variations', 'prices', 'images', 'availability'], + perPricebook: true, + allVariationProperties: true, limit: HOME_SHOP_PRODUCTS_LIMIT } }) diff --git a/packages/template-retail-react-app/app/components/_app/index.jsx b/packages/template-retail-react-app/app/components/_app/index.jsx index 07d5c14efb..708e707057 100644 --- a/packages/template-retail-react-app/app/components/_app/index.jsx +++ b/packages/template-retail-react-app/app/components/_app/index.jsx @@ -16,7 +16,6 @@ import {useQuery} from '@tanstack/react-query' import { useAccessToken, useCategory, - useCustomerBaskets, useShopperBasketsMutation } from '@salesforce/commerce-sdk-react' diff --git a/packages/template-retail-react-app/app/components/display-price/current-price.jsx b/packages/template-retail-react-app/app/components/display-price/current-price.jsx new file mode 100644 index 0000000000..2db3db73d3 --- /dev/null +++ b/packages/template-retail-react-app/app/components/display-price/current-price.jsx @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024, 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 {Text} from '@salesforce/retail-react-app/app/components/shared/ui' +import {useIntl} from 'react-intl' +import msg from '@salesforce/retail-react-app/app/components/display-price/messages' + +/** + * Component that displays current price of a product with a11y + * @param price - price of the product + * @param as - an HTML tag or component to be rendered as + * @param isRange - show price as range or not + * @param currency - currency to show the price in + * @param extraProps - extra props to be passed into Text Component + * @returns {JSX.Element} + */ +const CurrentPrice = ({price, as, isRange = false, currency, ...extraProps}) => { + const intl = useIntl() + const currentPriceText = intl.formatNumber(price, { + style: 'currency', + currency + }) + return isRange ? ( + + {intl.formatMessage(msg.currentPriceWithRange, { + currentPrice: currentPriceText + })} + + ) : ( + + {currentPriceText} + + ) +} +CurrentPrice.propTypes = { + price: PropTypes.number.isRequired, + currency: PropTypes.string.isRequired, + as: PropTypes.string, + isRange: PropTypes.bool, + extraProps: PropTypes.object +} + +export default CurrentPrice diff --git a/packages/template-retail-react-app/app/components/display-price/index.jsx b/packages/template-retail-react-app/app/components/display-price/index.jsx index d3eef28aad..39fe65b3c4 100644 --- a/packages/template-retail-react-app/app/components/display-price/index.jsx +++ b/packages/template-retail-react-app/app/components/display-price/index.jsx @@ -7,61 +7,61 @@ import React from 'react' import PropTypes from 'prop-types' -import {Skeleton, Text} from '@salesforce/retail-react-app/app/components/shared/ui' -import {useIntl} from 'react-intl' -import {useCurrency} from '@salesforce/retail-react-app/app/hooks' +import {Box} from '@salesforce/retail-react-app/app/components/shared/ui' +import CurrentPrice from '@salesforce/retail-react-app/app/components/display-price/current-price' +import ListPrice from '@salesforce/retail-react-app/app/components/display-price/list-price' -const DisplayPrice = ({ - basePrice, - discountPrice, - isProductASet = false, - currency, - discountPriceProps, - basePriceProps, - skeletonProps -}) => { - const intl = useIntl() - const {currency: activeCurrency} = useCurrency() - return ( - - - {isProductASet && - `${intl.formatMessage({ - id: 'product_view.label.starting_at_price', - defaultMessage: 'Starting at' - })} `} - - {typeof discountPrice === 'number' && ( - - {intl.formatNumber(discountPrice, { - style: 'currency', - currency: currency || activeCurrency - })} - - )} - - {intl.formatNumber(basePrice, { - style: 'currency', - currency: currency || activeCurrency - })} - - +/** + * @param priceData - price info extracted from a product + * // If a product is a set, + * on PLP, Show From X where X is the lowest of its children + * on PDP, Show From X where X is the lowest of its children and + * the set children will have it own price as From X (cross) Y + * // if a product is a master + * on PLP and PDP, show From X (cross) Y , the X and Y are + * current and list price of variant that has the lowest price (including promotionalPrice) + * // if a standard/bundle + * show exact price on PLP and PDP as X (cross) Y + * @param currency - currency + */ +const DisplayPrice = ({priceData, currency}) => { + const {listPrice, currentPrice, isASet, isMaster, isOnSale, isRange} = priceData + const renderCurrentPrice = (isRange) => ( + + ) + + const renderListPrice = (isRange) => + listPrice && + + const renderPriceSet = (isRange) => ( + <> + {renderCurrentPrice(isRange)} {isOnSale && renderListPrice(isRange)} + ) + + if (isASet) { + return renderCurrentPrice(true) + } + + if (isMaster) { + return renderPriceSet(isRange) + } + + return {renderPriceSet(false)} } DisplayPrice.propTypes = { - basePrice: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - discountPrice: PropTypes.number, - currency: PropTypes.string, - isProductASet: PropTypes.bool, - discountPriceProps: PropTypes.object, - basePriceProps: PropTypes.object, - skeletonProps: PropTypes.object + priceData: PropTypes.shape({ + currentPrice: PropTypes.number.isRequired, + isOnSale: PropTypes.bool.isRequired, + listPrice: PropTypes.number, + isASet: PropTypes.bool, + isMaster: PropTypes.bool, + isRange: PropTypes.bool, + maxPrice: PropTypes.number, + tieredPrice: PropTypes.number + }), + currency: PropTypes.string.isRequired } export default DisplayPrice diff --git a/packages/template-retail-react-app/app/components/display-price/index.test.js b/packages/template-retail-react-app/app/components/display-price/index.test.js index f492cbbf94..d0a6fe057f 100644 --- a/packages/template-retail-react-app/app/components/display-price/index.test.js +++ b/packages/template-retail-react-app/app/components/display-price/index.test.js @@ -8,29 +8,75 @@ import React from 'react' import {screen, within} from '@testing-library/react' import DisplayPrice from '@salesforce/retail-react-app/app/components/display-price/index' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import CurrentPrice from '@salesforce/retail-react-app/app/components/display-price/current-price' +import ListPrice from '@salesforce/retail-react-app/app/components/display-price/list-price' describe('DisplayPrice', function () { + const data = { + currentPrice: 90, + listPrice: 100, + isASet: false, + isOnSale: true, + isMaster: true, + isRange: true + } test('should render without error', () => { - renderWithProviders() + renderWithProviders() expect(screen.getByText(/£90\.00/i)).toBeInTheDocument() expect(screen.getByText(/£100\.00/i)).toBeInTheDocument() }) test('should render according html tag for prices', () => { - const {container} = renderWithProviders( - + const {container} = renderWithProviders() + const currentPriceTag = container.querySelectorAll('b') + const strikethroughPriceTag = container.querySelectorAll('s') + expect(within(currentPriceTag[0]).getByText(/£90\.00/i)).toBeDefined() + expect(within(strikethroughPriceTag[0]).getByText(/£100\.00/i)).toBeDefined() + expect(currentPriceTag).toHaveLength(1) + expect(strikethroughPriceTag).toHaveLength(1) + }) + + test('should not render list price when price is not on sale', () => { + renderWithProviders( + ) - const discountPriceTag = container.querySelectorAll('b') - const basePriceTag = container.querySelectorAll('s') - expect(within(discountPriceTag[0]).getByText(/£90\.00/i)).toBeDefined() - expect(within(basePriceTag[0]).getByText(/£100\.00/i)).toBeDefined() - expect(discountPriceTag).toHaveLength(1) - expect(basePriceTag).toHaveLength(1) + expect(screen.queryByText(/£90\.`00/i)).not.toBeInTheDocument() + expect(screen.getByText(/£100\.00/i)).toBeInTheDocument() + }) +}) + +describe('CurrentPrice', function () { + test('should render exact price with correct aria-label', () => { + renderWithProviders() + expect(screen.getByText(/£100\.00/i)).toBeInTheDocument() + expect(screen.getByLabelText(/current price £100\.00/i)).toBeInTheDocument() }) - test('should not render discount price if not available', () => { - renderWithProviders() - expect(screen.queryByText(/£90\.00/i)).not.toBeInTheDocument() + test('should render range price', () => { + renderWithProviders() expect(screen.getByText(/£100\.00/i)).toBeInTheDocument() + expect(screen.getByText(/from/i)).toBeInTheDocument() + expect(screen.getByLabelText(/from current price £100\.00/i)).toBeInTheDocument() + }) +}) + +describe('ListPrice', function () { + test('should render strikethrough price with exact price in aria-label', () => { + const {container} = renderWithProviders() + const strikethroughPriceTag = container.querySelectorAll('s') + expect(screen.getByLabelText(/original price £100\.00/i)).toBeInTheDocument() + expect(within(strikethroughPriceTag[0]).getByText(/£100\.00/i)).toBeDefined() + }) + + test('should render strikethrough price with range price in aria-label', () => { + const {container} = renderWithProviders( + + ) + const strikethroughPriceTag = container.querySelectorAll('s') + expect(screen.getByLabelText(/from original price £100\.00/i)).toBeInTheDocument() + expect(within(strikethroughPriceTag[0]).getByText(/£100\.00/i)).toBeDefined() }) }) diff --git a/packages/template-retail-react-app/app/components/display-price/list-price.jsx b/packages/template-retail-react-app/app/components/display-price/list-price.jsx new file mode 100644 index 0000000000..8aa443d69b --- /dev/null +++ b/packages/template-retail-react-app/app/components/display-price/list-price.jsx @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024, 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 {Text} from '@salesforce/retail-react-app/app/components/shared/ui' +import {useIntl} from 'react-intl' +import msg from '@salesforce/retail-react-app/app/components/display-price/messages' +/** + * Component that displays list price of a product with a11y + * @param price - price of the product + * @param as - an HTML tag or component to be rendered as + * @param isRange - show price as range or not + * @param props - extra props to be passed into Text Component + * @param extraProps - extra props to be passed into Text Component + * @returns {JSX.Element} + */ +const ListPrice = ({price, isRange = false, as = 's', currency, ...extraProps}) => { + const intl = useIntl() + const listPriceText = intl.formatNumber(price, { + style: 'currency', + currency + }) + + return isRange ? ( + + {listPriceText} + + ) : ( + + {listPriceText} + + ) +} + +ListPrice.propTypes = { + price: PropTypes.number.isRequired, + currency: PropTypes.string.isRequired, + as: PropTypes.string, + isRange: PropTypes.bool, + extraProps: PropTypes.object +} + +export default ListPrice diff --git a/packages/template-retail-react-app/app/components/display-price/messages.js b/packages/template-retail-react-app/app/components/display-price/messages.js new file mode 100644 index 0000000000..8b9ae9e038 --- /dev/null +++ b/packages/template-retail-react-app/app/components/display-price/messages.js @@ -0,0 +1,35 @@ +/* + * 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 {defineMessages} from 'react-intl' + +const messages = defineMessages({ + // price display + currentPriceWithRange: { + id: 'display_price.label.current_price_with_range', + defaultMessage: 'From {currentPrice}' + }, + // aria-label + ariaLabelCurrentPrice: { + id: 'display_price.assistive_msg.current_price', + defaultMessage: `current price {currentPrice}` + }, + ariaLabelCurrentPriceWithRange: { + id: 'display_price.assistive_msg.current_price_with_range', + defaultMessage: `From current price {currentPrice}` + }, + ariaLabelListPrice: { + id: 'display_price.assistive_msg.strikethrough_price', + defaultMessage: `original price {listPrice}` + }, + ariaLabelListPriceWithRange: { + id: 'display_price.assistive_msg.strikethrough_price_with_range', + defaultMessage: `From original price {listPrice}` + } +}) + +export default messages diff --git a/packages/template-retail-react-app/app/components/item-variant/item-price.jsx b/packages/template-retail-react-app/app/components/item-variant/item-price.jsx index cc3c422705..7cbc621f75 100644 --- a/packages/template-retail-react-app/app/components/item-variant/item-price.jsx +++ b/packages/template-retail-react-app/app/components/item-variant/item-price.jsx @@ -73,8 +73,8 @@ const ItemPrice = ({currency, align = 'right', baseDirection = 'column', ...prop {isProductASet && `${intl.formatMessage({ - defaultMessage: 'Starting at', - id: 'item_price.label.starting_at' + defaultMessage: 'From', + id: 'item_price.label.from' })} `} { dynamicImageProps, ...rest } = props + const {currency} = useCurrency() - const {currency, image, price, productId, hitType} = product - + const {image, productId} = product // ProductTile is used by two components, RecommendedProducts and ProductList. // RecommendedProducts provides a localized product name as `name` and non-localized product // name as `productName`. ProductList provides a localized name as `productName` and does not // use the `name` property. const localizedProductName = product.name ?? product.productName - const {currency: activeCurrency} = useCurrency() const isFavouriteLoading = useRef(false) const styles = useMultiStyleConfig('ProductTile') + //TODO variants needs to be filter according to selectedAttribute value + const variants = product?.variants + + const priceData = getPriceData({...product, variants}) + return ( { {localizedProductName} {/* Price */} - - {hitType === 'set' - ? intl.formatMessage( - { - id: 'product_tile.label.starting_at_price', - defaultMessage: 'Starting at {price}' - }, - { - price: intl.formatNumber(price, { - style: 'currency', - currency: currency || activeCurrency - }) - } - ) - : intl.formatNumber(price, { - style: 'currency', - currency: currency || activeCurrency - })} - + {enableFavourite && ( { const {getAllByRole} = renderWithProviders() @@ -89,18 +34,15 @@ test('Renders Skeleton', () => { expect(skeleton).toBeDefined() }) -test('Product set - renders the appropriate price label', async () => { - const {getByTestId} = renderWithProviders() - - const container = getByTestId('product-tile-price') - expect(container).toHaveTextContent(/starting at/i) -}) - test('Remove from wishlist cannot be muti-clicked', () => { const onClick = jest.fn() const {getByTestId} = renderWithProviders( - + ) const wishlistButton = getByTestId('wishlist-button') @@ -108,3 +50,60 @@ test('Remove from wishlist cannot be muti-clicked', () => { fireEvent.click(wishlistButton) expect(onClick).toHaveBeenCalledTimes(1) }) + +test('renders exact price with strikethrough price for master product with multiple variants', () => { + const {queryByText, getByText, container} = renderWithProviders( + + ) + expect(getByText(/Black Single Pleat Athletic Fit Wool Suit - Edit/i)).toBeInTheDocument() + expect(queryByText(/from/i)).toBeInTheDocument() + + const currentPriceTag = container.querySelectorAll('b') + const strikethroughPriceTag = container.querySelectorAll('s') + expect(within(currentPriceTag[0]).getByText(/£191\.99/i)).toBeDefined() + expect(within(strikethroughPriceTag[0]).getByText(/£223\.99/i)).toBeDefined() + // From and price are in separate b tag + expect(currentPriceTag).toHaveLength(1) + expect(strikethroughPriceTag).toHaveLength(1) +}) + +test('renders exact price with strikethrough price for master product with one variant', () => { + const {getByText, queryByText, container} = renderWithProviders( + + ) + expect(getByText(/black flat front wool suit/i)).toBeInTheDocument() + expect(getByText(/£191\.99/i)).toBeInTheDocument() + expect(getByText(/£320\.00/i)).toBeInTheDocument() + expect(queryByText(/from/i)).not.toBeInTheDocument() + + const currentPriceTag = container.querySelectorAll('b') + const strikethroughPriceTag = container.querySelectorAll('s') + expect(within(currentPriceTag[0]).getByText(/£191\.99/i)).toBeDefined() + expect(within(strikethroughPriceTag[0]).getByText(/£320\.00/i)).toBeDefined() + expect(currentPriceTag).toHaveLength(1) + expect(strikethroughPriceTag).toHaveLength(1) +}) + +test('Product set - shows range From X where X is the lowest price child', () => { + const {getByText, queryByText} = renderWithProviders( + + ) + expect(getByText(/Winter Look/i)).toBeInTheDocument() + expect(queryByText(/from/i)).toBeInTheDocument() + expect(queryByText(/£40\.16/i)).toBeInTheDocument() + expect(queryByText(/£44\.16/i)).not.toBeInTheDocument() +}) + +test('renders strike through price with standard product', () => { + const {getByText, container} = renderWithProviders( + + ) + expect(getByText(/Laptop Briefcase with wheels \(37L\)/i)).toBeInTheDocument() + expect(getByText(/£63\.99/i)).toBeInTheDocument() + const currentPriceTag = container.querySelectorAll('b') + const strikethroughPriceTag = container.querySelectorAll('s') + expect(within(currentPriceTag[0]).getByText(/£63\.99/i)).toBeDefined() + expect(within(strikethroughPriceTag[0]).getByText(/£67\.99/i)).toBeDefined() + expect(currentPriceTag).toHaveLength(1) + expect(strikethroughPriceTag).toHaveLength(1) +}) diff --git a/packages/template-retail-react-app/app/components/product-view/index.jsx b/packages/template-retail-react-app/app/components/product-view/index.jsx index 3a68dd2e97..11abe6ba8c 100644 --- a/packages/template-retail-react-app/app/components/product-view/index.jsx +++ b/packages/template-retail-react-app/app/components/product-view/index.jsx @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React, {forwardRef, useEffect, useRef, useState} from 'react' +import React, {forwardRef, useEffect, useMemo, useRef, useState} from 'react' import PropTypes from 'prop-types' import {useLocation} from 'react-router-dom' import {useIntl, FormattedMessage} from 'react-intl' @@ -21,7 +21,7 @@ import { Fade, useTheme } from '@salesforce/retail-react-app/app/components/shared/ui' -import {useDerivedProduct} from '@salesforce/retail-react-app/app/hooks' +import {useCurrency, useDerivedProduct} from '@salesforce/retail-react-app/app/hooks' import {useAddToCartModalContext} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal' // project components @@ -37,11 +37,9 @@ import {API_ERROR_MESSAGE} from '@salesforce/retail-react-app/app/constants' import DisplayPrice from '@salesforce/retail-react-app/app/components/display-price' import Swatch from '@salesforce/retail-react-app/app/components/swatch-group/swatch' import SwatchGroup from '@salesforce/retail-react-app/app/components/swatch-group' -import {getDisplayPrice} from '@salesforce/retail-react-app/app/utils/product-utils' - -const ProductViewHeader = ({name, basePrice, discountPrice, currency, category, productType}) => { - const isProductASet = productType?.set +import {getPriceData} from '@salesforce/retail-react-app/app/utils/product-utils' +const ProductViewHeader = ({name, currency, priceData, category}) => { return ( {category && ( @@ -55,23 +53,27 @@ const ProductViewHeader = ({name, basePrice, discountPrice, currency, category, {`${name}`} - + + + ) } ProductViewHeader.propTypes = { name: PropTypes.string, - basePrice: PropTypes.number, - discountPrice: PropTypes.number, currency: PropTypes.string, category: PropTypes.array, - productType: PropTypes.object + priceData: PropTypes.shape({ + currentPrice: PropTypes.number.isRequired, + isOnSale: PropTypes.bool.isRequired, + listPrice: PropTypes.number, + isASet: PropTypes.bool, + isMaster: PropTypes.bool, + isRange: PropTypes.bool, + maxPrice: PropTypes.number, + tieredPrice: PropTypes.number + }) } const ButtonWithRegistration = withRegistration(Button) @@ -102,6 +104,7 @@ const ProductView = forwardRef( }, ref ) => { + const {currency: activeCurrency} = useCurrency() const showToast = useToast() const intl = useIntl() const location = useLocation() @@ -125,7 +128,9 @@ const ProductView = forwardRef( stockLevel, stepQuantity } = useDerivedProduct(product, isProductPartOfSet) - const {basePrice, discountPrice} = getDisplayPrice(product) + const priceData = useMemo(() => { + return getPriceData(product, {quantity}) + }, [product, quantity]) const canAddToWishlist = !isProductLoading const isProductASet = product?.type.set const errorContainerRef = useRef(null) @@ -291,10 +296,8 @@ const ProductView = forwardRef( @@ -332,10 +335,8 @@ const ProductView = forwardRef( diff --git a/packages/template-retail-react-app/app/components/product-view/index.test.js b/packages/template-retail-react-app/app/components/product-view/index.test.js index 532f46dac7..e13c583c94 100644 --- a/packages/template-retail-react-app/app/components/product-view/index.test.js +++ b/packages/template-retail-react-app/app/components/product-view/index.test.js @@ -137,14 +137,14 @@ test('renders a product set properly - parent item', () => { // NOTE: there can be duplicates of the same element, due to mobile and desktop views // (they're hidden with display:none style) - const startingAtLabel = screen.getAllByText(/starting at/i)[0] + const fromAtLabel = screen.getAllByText(/from/i)[0] const addSetToCartButton = screen.getAllByRole('button', {name: /add set to cart/i})[0] const addSetToWishlistButton = screen.getAllByRole('button', {name: /add set to wishlist/i})[0] const variationAttributes = screen.queryAllByRole('radiogroup') // e.g. sizes, colors const quantityPicker = screen.queryByRole('spinbutton', {name: /quantity/i}) // What should exist: - expect(startingAtLabel).toBeInTheDocument() + expect(fromAtLabel).toBeInTheDocument() expect(addSetToCartButton).toBeInTheDocument() expect(addSetToWishlistButton).toBeInTheDocument() @@ -166,7 +166,7 @@ test('renders a product set properly - child item', () => { const addToWishlistButton = screen.getAllByRole('button', {name: /add to wishlist/i})[0] const variationAttributes = screen.getAllByRole('radiogroup') // e.g. sizes, colors const quantityPicker = screen.getByRole('spinbutton', {name: /quantity/i}) - const startingAtLabels = screen.queryAllByText(/starting at/i) + const fromLabels = screen.queryAllByText(/from/i) // What should exist: expect(addToCartButton).toBeInTheDocument() @@ -174,8 +174,9 @@ test('renders a product set properly - child item', () => { expect(variationAttributes).toHaveLength(2) expect(quantityPicker).toBeInTheDocument() - // What should _not_ exist: - expect(startingAtLabels).toHaveLength(0) + // since setProducts are master products, as pricing now display From X (cross) Y where X Y are sale and lis price respectively + // of the variant that has lowest price (including promotional price) + expect(fromLabels).toHaveLength(2) }) test('validateOrderability callback is called when adding a set to cart', async () => { diff --git a/packages/template-retail-react-app/app/components/unavailable-product-confirmation-modal/index.test.js b/packages/template-retail-react-app/app/components/unavailable-product-confirmation-modal/index.test.js index 3488211698..206073056a 100644 --- a/packages/template-retail-react-app/app/components/unavailable-product-confirmation-modal/index.test.js +++ b/packages/template-retail-react-app/app/components/unavailable-product-confirmation-modal/index.test.js @@ -344,7 +344,7 @@ describe('UnavailableProductConfirmationModal', () => { test('opens confirmation modal when unavailable products are found', async () => { const mockProductIds = ['701642889899M', '701642889830M'] const mockFunc = jest.fn() - const {getByRole, user} = renderWithProviders( + const {getByText, queryByText, queryByRole, user} = renderWithProviders( { ) await waitFor(async () => { - const removeBtn = getByRole('button') - expect(removeBtn).toBeInTheDocument() - await user.click(removeBtn) - expect(mockFunc).toHaveBeenCalled() - expect(removeBtn).not.toBeInTheDocument() + expect(getByText(/Items Unavailable/i)).toBeInTheDocument() }) + const removeBtn = queryByRole('button') + + expect(removeBtn).toBeInTheDocument() + await user.click(removeBtn) + expect(mockFunc).toHaveBeenCalled() + await waitFor(async () => { + expect(queryByText(/Items Unavailable/i)).not.toBeInTheDocument() + }) + expect(removeBtn).not.toBeInTheDocument() }) }) 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 a559679ac6..882e79c820 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 @@ -30,7 +30,7 @@ import RecommendedProducts from '@salesforce/retail-react-app/app/components/rec import {LockIcon} from '@salesforce/retail-react-app/app/components/icons' import {findImageGroupBy} from '@salesforce/retail-react-app/app/utils/image-groups-utils' import { - getDisplayPrice, + getPriceData, getDisplayVariationValues } from '@salesforce/retail-react-app/app/utils/product-utils' import {EINSTEIN_RECOMMENDERS} from '@salesforce/retail-react-app/app/constants' @@ -114,10 +114,7 @@ export const AddToCartModal = () => { viewType: 'small', selectedVariationAttributes: variant.variationValues })?.images?.[0] - const { - basePrice: lineItemBasePrice, - discountPrice: lineItemDiscountPrice - } = getDisplayPrice(product) + const priceData = getPriceData(product, {quantity}) const variationAttributeValues = getDisplayVariationValues( product.variationAttributes, variant.variationValues @@ -170,13 +167,7 @@ export const AddToCartModal = () => { diff --git a/packages/template-retail-react-app/app/hooks/use-current-basket.test.js b/packages/template-retail-react-app/app/hooks/use-current-basket.test.js index 88795854e0..faa7bd23ca 100644 --- a/packages/template-retail-react-app/app/hooks/use-current-basket.test.js +++ b/packages/template-retail-react-app/app/hooks/use-current-basket.test.js @@ -6,14 +6,11 @@ */ import React from 'react' -import {screen, waitFor} from '@testing-library/react' +import {screen} from '@testing-library/react' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' import {useCustomerBaskets} from '@salesforce/commerce-sdk-react' -import { - mockCustomerBaskets, - mockEmptyBasket -} from '@salesforce/retail-react-app/app/mocks/mock-data' +import {mockCustomerBaskets} from '@salesforce/retail-react-app/app/mocks/mock-data' const MOCK_USE_QUERY_RESULT = { data: undefined, diff --git a/packages/template-retail-react-app/app/hooks/use-derived-product.js b/packages/template-retail-react-app/app/hooks/use-derived-product.js index 647cd8e17d..205f824689 100644 --- a/packages/template-retail-react-app/app/hooks/use-derived-product.js +++ b/packages/template-retail-react-app/app/hooks/use-derived-product.js @@ -10,7 +10,6 @@ import {useVariant} from '@salesforce/retail-react-app/app/hooks/use-variant' import {useIntl} from 'react-intl' import {useVariationParams} from '@salesforce/retail-react-app/app/hooks/use-variation-params' import {useVariationAttributes} from '@salesforce/retail-react-app/app/hooks/use-variation-attributes' -import {getDisplayPrice} from '@salesforce/retail-react-app/app/utils/product-utils' const OUT_OF_STOCK = 'OUT_OF_STOCK' const UNFULFILLABLE = 'UNFULFILLABLE' @@ -55,8 +54,6 @@ export const useDerivedProduct = (product, isProductPartOfSet = false) => { (isOutOfStock && inventoryMessages[OUT_OF_STOCK]) || (unfulfillable && inventoryMessages[UNFULFILLABLE]) - const {basePrice, discountPrice} = getDisplayPrice(product) - // If the `initialQuantity` changes, update the state. This typically happens // when either the master product changes, or the inventory of the product changes // from out-of-stock to in-stock or vice versa. @@ -75,8 +72,6 @@ export const useDerivedProduct = (product, isProductPartOfSet = false) => { variationParams, setQuantity, variant, - stockLevel, - basePrice, - discountPrice + stockLevel } } diff --git a/packages/template-retail-react-app/app/hooks/use-einstein.js b/packages/template-retail-react-app/app/hooks/use-einstein.js index fca064ea46..0e60b5abef 100644 --- a/packages/template-retail-react-app/app/hooks/use-einstein.js +++ b/packages/template-retail-react-app/app/hooks/use-einstein.js @@ -405,7 +405,19 @@ const useEinstein = () => { const token = await getTokenWhenReady() // Fetch the product details for the recommendations const products = await api.shopperProducts.getProducts({ - parameters: {ids: ids.join(',')}, + parameters: { + ids: ids.join(','), + perPricebook: true, + expand: [ + 'availability', + 'links', + 'promotions', + 'options', + 'images', + 'prices', + 'variations' + ] + }, headers: { Authorization: `Bearer ${token}` } diff --git a/packages/template-retail-react-app/app/mocks/product-search-hit-data.js b/packages/template-retail-react-app/app/mocks/product-search-hit-data.js new file mode 100644 index 0000000000..9970e08998 --- /dev/null +++ b/packages/template-retail-react-app/app/mocks/product-search-hit-data.js @@ -0,0 +1,751 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +const mockProductSearchItem = { + currency: 'USD', + image: { + alt: 'Charcoal Single Pleat Wool Suit, , large', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4de8166b/images/large/PG.33698RUBN4Q.CHARCWL.PZ.jpg' + }, + price: 299.99, + productName: 'Charcoal Single Pleat Wool Suit' +} +const mockProductSetHit = { + currency: 'GBP', + hitType: 'set', + image: { + alt: 'Winter Look, , large', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dwe1c4cd52/images/large/PG.10205921.JJ5FUXX.PZ.jpg', + link: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dwe1c4cd52/images/large/PG.10205921.JJ5FUXX.PZ.jpg', + title: 'Winter Look, ' + }, + orderable: true, + price: 40.16, + priceMax: 71.03, + pricePerUnit: 44.16, + pricePerUnitMax: 71.03, + priceRanges: [ + { + maxPrice: 101.76, + minPrice: 44.16, + pricebook: 'gbp-m-list-prices' + }, + { + maxPrice: 71.03, + minPrice: 40.16, + pricebook: 'gbp-m-sale-prices' + } + ], + productId: 'winter-lookM', + productName: 'Winter Look', + productType: { + set: true + }, + representedProduct: { + id: '740357357531M', + c_color: 'BLACKLE', + c_refinementColor: 'black', + c_size: '065', + c_width: 'M' + } +} +const mockStandardProductHit = { + currency: 'GBP', + hitType: 'product', + image: { + alt: 'Laptop Briefcase with wheels (37L), , large', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw7cb2d401/images/large/P0048_001.jpg', + link: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw7cb2d401/images/large/P0048_001.jpg', + title: 'Laptop Briefcase with wheels (37L), ' + }, + orderable: true, + price: 63.99, + pricePerUnit: 63.99, + productId: 'P0048M', + productName: 'Laptop Briefcase with wheels (37L)', + productType: { + item: true + }, + representedProduct: { + id: 'P0048M', + c_styleNumber: 'P0048', + c_tabDescription: + 'Perfect for business travel, this briefcase is ultra practical with plenty of space for your laptop and all its extras, as well as storage for documents, paperwork and all your essential items. The wheeled system allows you to travel comfortably with your work and when you reach your destination, you can remove the laptop compartment and carry over your shoulder to meetings. It’s the business.', + c_tabDetails: + '1682 ballistic nylon and genuine leather inserts| Spacious main storage compartment for documents and binders|Removable, padded laptop sleeve with D-rings for carrying with shoulder strap|Change handle system and cantilever wheels|Zip pull in gunmetal with black rubber insert Leather “comfort” insert detailed handle|Internal storage pockets for CD-Rom and peripherals|Real leather inserts' + }, + representedProducts: [ + { + id: 'P0048M' + } + ], + tieredPrices: [ + { + price: 67.99, + pricebook: 'gbp-m-list-prices', + quantity: 1 + } + ] +} +const mockMasterProductHitWithOneVariant = { + currency: 'GBP', + hitType: 'master', + image: { + alt: 'Black Flat Front Wool Suit, , large', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw3d8972fe/images/large/PG.52001DAN84Q.BLACKWL.PZ.jpg', + link: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw3d8972fe/images/large/PG.52001DAN84Q.BLACKWL.PZ.jpg', + title: 'Black Flat Front Wool Suit, ' + }, + orderable: true, + price: 191.99, + pricePerUnit: 191.99, + priceRanges: [ + { + maxPrice: 320, + minPrice: 320, + pricebook: 'gbp-m-list-prices' + }, + { + maxPrice: 191.99, + minPrice: 191.99, + pricebook: 'gbp-m-sale-prices' + } + ], + productId: '25686544M', + productName: 'Black Flat Front Wool Suit', + productType: { + master: true + }, + representedProduct: { + id: '750518703077M', + c_color: 'BLACKWL', + c_refinementColor: 'black', + c_size: '048', + c_width: 'V' + }, + representedProducts: [ + { + id: '750518703077M' + }, + { + id: '750518703060M' + }, + { + id: '750518703039M' + }, + { + id: '750518703046M' + } + ], + variants: [ + { + orderable: true, + price: 191.99, + productId: '750518703077M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKWL', + size: '048', + width: 'V' + } + } + ], + variationAttributes: [ + { + id: 'color', + name: 'Colour', + values: [ + { + name: 'Black', + orderable: true, + value: 'BLACKWL' + } + ] + }, + { + id: 'size', + name: 'Size', + values: [ + { + name: '42', + orderable: true, + value: '042' + }, + { + name: '43', + orderable: true, + value: '043' + }, + { + name: '46', + orderable: true, + value: '046' + }, + { + name: '48', + orderable: true, + value: '048' + } + ] + }, + { + id: 'width', + name: 'Width', + values: [ + { + name: 'Regular', + orderable: true, + value: 'V' + } + ] + } + ] +} +const mockMasterProductHitWithMultipleVariants = { + currency: 'GBP', + hitType: 'master', + image: { + alt: 'Black Single Pleat Athletic Fit Wool Suit - Edit, , large', + disBaseLink: + 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw5d64302b/images/large/PG.52001RUBN4Q.BLACKFB.PZ.jpg', + link: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw5d64302b/images/large/PG.52001RUBN4Q.BLACKFB.PZ.jpg', + title: 'Black Single Pleat Athletic Fit Wool Suit - Edit, ' + }, + orderable: true, + price: 191.99, + pricePerUnit: 191.99, + priceRanges: [ + { + maxPrice: 320, + minPrice: 191.99, + pricebook: 'gbp-m-list-prices' + }, + { + maxPrice: 191.99, + minPrice: 191.99, + pricebook: 'gbp-m-sale-prices' + } + ], + productId: '25604524M', + productName: 'Black Single Pleat Athletic Fit Wool Suit - Edit', + productType: { + master: true + }, + representedProduct: { + id: '750518699660M' + }, + variants: [ + { + orderable: true, + price: 191.99, + productId: '750518699660M', + tieredPrices: [ + { + price: 223.99, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '050', + width: 'V' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699585M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '039', + width: 'V' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699653M', + tieredPrices: [ + { + price: 191.99, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '048', + width: 'V' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699615M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '042', + width: 'V' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699608M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '041', + width: 'V' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699646M', + tieredPrices: [ + { + price: 191.99, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '046', + width: 'V' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699592M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '040', + width: 'V' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699622M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '043', + width: 'V' + } + }, + { + orderable: false, + price: 191.99, + productId: '750518699578M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '038', + width: 'V' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699875M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '046', + width: 'L' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699868M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '044', + width: 'L' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699820M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '040', + width: 'L' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699882M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '048', + width: 'L' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699851M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '043', + width: 'L' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699844M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '042', + width: 'L' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699769M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '044', + width: 'S' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699721M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '040', + width: 'S' + } + }, + { + orderable: true, + price: 191.99, + productId: '750518699745M', + tieredPrices: [ + { + price: 320, + pricebook: 'gbp-m-list-prices', + quantity: 1 + }, + { + price: 191.99, + pricebook: 'gbp-m-sale-prices', + quantity: 1 + } + ], + variationValues: { + color: 'BLACKFB', + size: '042', + width: 'S' + } + } + ], + variationAttributes: [ + { + id: 'color', + name: 'Colour', + values: [ + { + name: 'Black', + orderable: true, + value: 'BLACKFB' + } + ] + }, + { + id: 'size', + name: 'Size', + values: [ + { + name: '38', + orderable: false, + value: '038' + }, + { + name: '39', + orderable: true, + value: '039' + }, + { + name: '40', + orderable: true, + value: '040' + }, + { + name: '41', + orderable: true, + value: '041' + }, + { + name: '42', + orderable: true, + value: '042' + }, + { + name: '43', + orderable: true, + value: '043' + }, + { + name: '44', + orderable: true, + value: '044' + }, + { + name: '46', + orderable: true, + value: '046' + }, + { + name: '48', + orderable: true, + value: '048' + }, + { + name: '50', + orderable: true, + value: '050' + } + ] + }, + { + id: 'width', + name: 'Width', + values: [ + { + name: 'Short', + orderable: true, + value: 'S' + }, + { + name: 'Regular', + orderable: true, + value: 'V' + }, + { + name: 'Long', + orderable: true, + value: 'L' + } + ] + } + ] +} +export { + mockProductSetHit, + mockStandardProductHit, + mockMasterProductHitWithOneVariant, + mockProductSearchItem, + mockMasterProductHitWithMultipleVariants +} diff --git a/packages/template-retail-react-app/app/pages/checkout/index.jsx b/packages/template-retail-react-app/app/pages/checkout/index.jsx index da384db3c7..db9d610c6d 100644 --- a/packages/template-retail-react-app/app/pages/checkout/index.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/index.jsx @@ -29,11 +29,7 @@ import OrderSummary from '@salesforce/retail-react-app/app/components/order-summ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer' import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import CheckoutSkeleton from '@salesforce/retail-react-app/app/pages/checkout/partials/checkout-skeleton' -import { - useUsid, - useShopperOrdersMutation, - useShopperBasketsMutation -} from '@salesforce/commerce-sdk-react' +import {useShopperOrdersMutation, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' import UnavailableProductConfirmationModal from '@salesforce/retail-react-app/app/components/unavailable-product-confirmation-modal' import { API_ERROR_MESSAGE, @@ -45,7 +41,6 @@ import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading- const Checkout = () => { const {formatMessage} = useIntl() const navigate = useNavigation() - const {usid} = useUsid() const {step} = useCheckout() const [error, setError] = useState() const {data: basket} = useCurrentBasket() diff --git a/packages/template-retail-react-app/app/pages/home/index.jsx b/packages/template-retail-react-app/app/pages/home/index.jsx index 34f0ddbccf..7179504e8d 100644 --- a/packages/template-retail-react-app/app/pages/home/index.jsx +++ b/packages/template-retail-react-app/app/pages/home/index.jsx @@ -64,6 +64,9 @@ const Home = () => { const {data: productSearchResult, isLoading} = useProductSearch({ parameters: { refine: [`cgid=${HOME_SHOP_PRODUCTS_CATEGORY_ID}`, 'htype=master'], + expand: ['promotions', 'variations', 'prices', 'images', 'availability'], + perPricebook: true, + allVariationProperties: true, limit: HOME_SHOP_PRODUCTS_LIMIT } }) 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 f8cddb61c7..164582f6f0 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 @@ -79,6 +79,16 @@ const ProductDetail = () => { { parameters: { id: urlParams.get('pid') || productId, + perPricebook: true, + expand: [ + 'availability', + 'promotions', + 'options', + 'images', + 'prices', + 'variations', + 'set_products' + ], allImages: true } }, diff --git a/packages/template-retail-react-app/app/pages/product-list/index.jsx b/packages/template-retail-react-app/app/pages/product-list/index.jsx index 86fdadfb42..a7ee0a2ac2 100644 --- a/packages/template-retail-react-app/app/pages/product-list/index.jsx +++ b/packages/template-retail-react-app/app/pages/product-list/index.jsx @@ -149,6 +149,9 @@ const ProductList = (props) => { { parameters: { ...restOfParams, + perPricebook: true, + allVariationProperties: true, + expand: ['promotions', 'variations', 'prices', 'images', 'availability'], refine: _refine } }, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index 20ebb0d5d5..ba89cd9507 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -1005,6 +1005,56 @@ "value": "Security code info" } ], + "display_price.assistive_msg.current_price": [ + { + "type": 0, + "value": "current price " + }, + { + "type": 1, + "value": "currentPrice" + } + ], + "display_price.assistive_msg.current_price_with_range": [ + { + "type": 0, + "value": "From current price " + }, + { + "type": 1, + "value": "currentPrice" + } + ], + "display_price.assistive_msg.strikethrough_price": [ + { + "type": 0, + "value": "original price " + }, + { + "type": 1, + "value": "listPrice" + } + ], + "display_price.assistive_msg.strikethrough_price_with_range": [ + { + "type": 0, + "value": "From original price " + }, + { + "type": 1, + "value": "listPrice" + } + ], + "display_price.label.current_price_with_range": [ + { + "type": 0, + "value": "From " + }, + { + "type": 1, + "value": "currentPrice" + } + ], "drawer_menu.button.account_details": [ { "type": 0, @@ -1667,10 +1717,10 @@ "value": "Unavailable" } ], - "item_price.label.starting_at": [ + "item_price.label.from": [ { "type": 0, - "value": "Starting at" + "value": "From" } ], "lCPCxk": [ @@ -2495,16 +2545,6 @@ "value": " from wishlist" } ], - "product_tile.label.starting_at_price": [ - { - "type": 0, - "value": "Starting at " - }, - { - "type": 1, - "value": "price" - } - ], "product_view.button.add_set_to_cart": [ { "type": 0, @@ -2565,12 +2605,6 @@ "value": "+" } ], - "product_view.label.starting_at_price": [ - { - "type": 0, - "value": "Starting at" - } - ], "product_view.label.variant_type": [ { "type": 1, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index 20ebb0d5d5..ba89cd9507 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -1005,6 +1005,56 @@ "value": "Security code info" } ], + "display_price.assistive_msg.current_price": [ + { + "type": 0, + "value": "current price " + }, + { + "type": 1, + "value": "currentPrice" + } + ], + "display_price.assistive_msg.current_price_with_range": [ + { + "type": 0, + "value": "From current price " + }, + { + "type": 1, + "value": "currentPrice" + } + ], + "display_price.assistive_msg.strikethrough_price": [ + { + "type": 0, + "value": "original price " + }, + { + "type": 1, + "value": "listPrice" + } + ], + "display_price.assistive_msg.strikethrough_price_with_range": [ + { + "type": 0, + "value": "From original price " + }, + { + "type": 1, + "value": "listPrice" + } + ], + "display_price.label.current_price_with_range": [ + { + "type": 0, + "value": "From " + }, + { + "type": 1, + "value": "currentPrice" + } + ], "drawer_menu.button.account_details": [ { "type": 0, @@ -1667,10 +1717,10 @@ "value": "Unavailable" } ], - "item_price.label.starting_at": [ + "item_price.label.from": [ { "type": 0, - "value": "Starting at" + "value": "From" } ], "lCPCxk": [ @@ -2495,16 +2545,6 @@ "value": " from wishlist" } ], - "product_tile.label.starting_at_price": [ - { - "type": 0, - "value": "Starting at " - }, - { - "type": 1, - "value": "price" - } - ], "product_view.button.add_set_to_cart": [ { "type": 0, @@ -2565,12 +2605,6 @@ "value": "+" } ], - "product_view.label.starting_at_price": [ - { - "type": 0, - "value": "Starting at" - } - ], "product_view.label.variant_type": [ { "type": 1, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index 8e15e0cea6..0ec844414d 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -2085,6 +2085,96 @@ "value": "]" } ], + "display_price.assistive_msg.current_price": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "ƈŭŭřřḗḗƞŧ ƥřīƈḗḗ " + }, + { + "type": 1, + "value": "currentPrice" + }, + { + "type": 0, + "value": "]" + } + ], + "display_price.assistive_msg.current_price_with_range": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƒřǿǿḿ ƈŭŭřřḗḗƞŧ ƥřīƈḗḗ " + }, + { + "type": 1, + "value": "currentPrice" + }, + { + "type": 0, + "value": "]" + } + ], + "display_price.assistive_msg.strikethrough_price": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "ǿǿřīɠīƞȧȧŀ ƥřīƈḗḗ " + }, + { + "type": 1, + "value": "listPrice" + }, + { + "type": 0, + "value": "]" + } + ], + "display_price.assistive_msg.strikethrough_price_with_range": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƒřǿǿḿ ǿǿřīɠīƞȧȧŀ ƥřīƈḗḗ " + }, + { + "type": 1, + "value": "listPrice" + }, + { + "type": 0, + "value": "]" + } + ], + "display_price.label.current_price_with_range": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƒřǿǿḿ " + }, + { + "type": 1, + "value": "currentPrice" + }, + { + "type": 0, + "value": "]" + } + ], "drawer_menu.button.account_details": [ { "type": 0, @@ -3523,14 +3613,14 @@ "value": "]" } ], - "item_price.label.starting_at": [ + "item_price.label.from": [ { "type": 0, "value": "[" }, { "type": 0, - "value": "Şŧȧȧřŧīƞɠ ȧȧŧ" + "value": "Ƒřǿǿḿ" }, { "type": 0, @@ -5303,24 +5393,6 @@ "value": "]" } ], - "product_tile.label.starting_at_price": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Şŧȧȧřŧīƞɠ ȧȧŧ " - }, - { - "type": 1, - "value": "price" - }, - { - "type": 0, - "value": "]" - } - ], "product_view.button.add_set_to_cart": [ { "type": 0, @@ -5461,20 +5533,6 @@ "value": "]" } ], - "product_view.label.starting_at_price": [ - { - "type": 0, - "value": "[" - }, - { - "type": 0, - "value": "Şŧȧȧřŧīƞɠ ȧȧŧ" - }, - { - "type": 0, - "value": "]" - } - ], "product_view.label.variant_type": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/utils/product-utils.js b/packages/template-retail-react-app/app/utils/product-utils.js index d999ce3cdb..44a456601c 100644 --- a/packages/template-retail-react-app/app/utils/product-utils.js +++ b/packages/template-retail-react-app/app/utils/product-utils.js @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023, Salesforce, Inc. + * Copyright (c) 2024, 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 @@ -35,19 +35,101 @@ export const getDisplayVariationValues = (variationAttributes, values = {}) => { } /** - * This function extract the promotional price from a product. If there are more than one price, the smallest price will be picked + * This function extract the price information of a given product + * If a product is a master, + * currentPrice: get the lowest price (including promotional prices) among variants + * listPrice: get the list price of the variant that has lowest price (including promotional price) + * maxPrice: the max price in tieredPrices of variant that has lowest price * @param {object} product - product detail object - * @returns {{discountPrice: number, basePrice: number | string}} + * @param {object} opts - options to pass into the function like intl, quantity, and currency */ -export const getDisplayPrice = (product) => { - const basePrice = product?.pricePerUnit || product?.price - const promotionalPriceList = product?.productPromotions - ?.map((promo) => promo.promotionalPrice) - .filter((i) => i !== null && i !== undefined) - // choose the smallest price among the promotionalPrice - const discountPrice = promotionalPriceList?.length ? Math.min(...promotionalPriceList) : null +export const getPriceData = (product, opts = {}) => { + const {quantity = 1} = opts + const isASet = product?.hitType === 'set' || !!product?.type?.set + const isMaster = product?.hitType === 'master' || !!product?.type?.master + let currentPrice + let variantWithLowestPrice + // grab the variant that has the lowest price (including promotional price) + if (isMaster) { + const variants = product?.variants || [] + variantWithLowestPrice = variants.reduce( + (minVariant, variant) => { + const promotions = variant.productPromotions || [] + const smallestPromotionalPrice = getSmallestValByProperty( + promotions, + 'promotionalPrice' + ) + const variantSalePrice = + smallestPromotionalPrice && smallestPromotionalPrice < variant.price + ? smallestPromotionalPrice + : variant.price + return variantSalePrice < minVariant.minPrice + ? {minPrice: variantSalePrice, variant} + : minVariant + }, + {minPrice: Infinity, variant: null} + ) + currentPrice = variantWithLowestPrice?.minPrice + } else { + const promotionalPrice = getSmallestValByProperty( + product?.productPromotions, + 'promotionalPrice' + ) + currentPrice = + promotionalPrice && promotionalPrice < product?.price + ? promotionalPrice + : product?.price + } + + // since the price is the lowest value among price books, each product will have at lease a single item tiered price at quantity 1 + // the highest value of tieredPrices is presumptively the list price + const tieredPrices = + variantWithLowestPrice?.variant?.tieredPrices || product?.tieredPrices || [] + const maxTieredPrice = tieredPrices?.length + ? Math.max(...tieredPrices.map((item) => item.price)) + : undefined + const highestTieredPrice = tieredPrices.find((tier) => tier.price === maxTieredPrice) + const listPrice = highestTieredPrice?.price + + // if a product has tieredPrices, get the tiered that has the higher closest quantity to current quantity + const filteredTiered = tieredPrices.filter((tiered) => tiered.quantity <= quantity) + const closestTieredPrice = + filteredTiered.length && + filteredTiered.reduce((prev, curr) => { + return Math.abs(curr.quantity - quantity) < Math.abs(prev.quantity - quantity) + ? curr + : prev + }) return { - basePrice, - discountPrice + currentPrice, + listPrice, + isOnSale: currentPrice < listPrice, + isASet, + isMaster, + // For a product, set price is the lowest price of its children, so the price should be considered a range + // For a master product, when it has more than 2 variants, we use the lowest priced variant, so it is considered a range price + // but for master that has one variant, it is not considered range + isRange: (isMaster && product?.variants?.length > 1) || isASet || false, + // priceMax is for product set + tieredPrice: closestTieredPrice?.price, + maxPrice: product?.priceMax || maxTieredPrice + } +} + +/** + * @private + * Find the smallest value by key from a given array + * @param arr + * @param key + */ +const getSmallestValByProperty = (arr, key) => { + if (!arr || !arr.length) return undefined + if (!key) { + throw new Error('Please specify a key.') } + const vals = arr + .map((item) => item[key]) + .filter(Boolean) + .filter(Number) + return vals.length ? Math.min(...vals) : undefined } diff --git a/packages/template-retail-react-app/app/utils/product-utils.test.js b/packages/template-retail-react-app/app/utils/product-utils.test.js index 772bc65aa0..077af750a4 100644 --- a/packages/template-retail-react-app/app/utils/product-utils.test.js +++ b/packages/template-retail-react-app/app/utils/product-utils.test.js @@ -6,72 +6,115 @@ */ import { - getDisplayPrice, + getPriceData, getDisplayVariationValues } from '@salesforce/retail-react-app/app/utils/product-utils' -import {mockedCustomerProductListsDetails} from '@salesforce/retail-react-app/app/mocks/mock-data' +import { + mockMasterProductHitWithMultipleVariants, + mockMasterProductHitWithOneVariant, + mockProductSetHit, + mockStandardProductHit +} from '@salesforce/retail-react-app/app/mocks/product-search-hit-data' -const variationAttributes = [ - { - id: 'color', - name: 'Colour', - values: [ - {name: 'Black', orderable: true, value: 'BLACKLE'}, - {name: 'Taupe', orderable: true, value: 'TAUPETX'} - ] - }, - { - id: 'size', - name: 'Size', - values: [ - {name: '6', orderable: true, value: '060'}, - {name: '6.5', orderable: true, value: '065'}, - {name: '7', orderable: true, value: '070'}, - {name: '7.5', orderable: true, value: '075'}, - {name: '8', orderable: true, value: '080'}, - {name: '8.5', orderable: true, value: '085'}, - {name: '9', orderable: true, value: '090'}, - {name: '9.5', orderable: true, value: '095'}, - {name: '10', orderable: true, value: '100'}, - {name: '11', orderable: true, value: '110'} - ] - }, - {id: 'width', name: 'Width', values: [{name: 'M', orderable: true, value: 'M'}]} -] +describe('getDisplayVariationValues', function () { + const variationAttributes = [ + { + id: 'color', + name: 'Colour', + values: [ + {name: 'Black', orderable: true, value: 'BLACKLE'}, + {name: 'Taupe', orderable: true, value: 'TAUPETX'} + ] + }, + { + id: 'size', + name: 'Size', + values: [ + {name: '6', orderable: true, value: '060'}, + {name: '6.5', orderable: true, value: '065'}, + {name: '7', orderable: true, value: '070'}, + {name: '7.5', orderable: true, value: '075'}, + {name: '8', orderable: true, value: '080'}, + {name: '8.5', orderable: true, value: '085'}, + {name: '9', orderable: true, value: '090'}, + {name: '9.5', orderable: true, value: '095'}, + {name: '10', orderable: true, value: '100'}, + {name: '11', orderable: true, value: '110'} + ] + }, + {id: 'width', name: 'Width', values: [{name: 'M', orderable: true, value: 'M'}]} + ] -test('getDisplayVariationValues', () => { - const selectedValues = { - color: 'TAUPETX', - size: '065', - width: 'M' - } - const result = getDisplayVariationValues(variationAttributes, selectedValues) + test('returned selected values', () => { + const selectedValues = { + color: 'TAUPETX', + size: '065', + width: 'M' + } + const result = getDisplayVariationValues(variationAttributes, selectedValues) - expect(result).toEqual({ - Colour: 'Taupe', - Size: '6.5', - Width: 'M' + expect(result).toEqual({ + Colour: 'Taupe', + Size: '6.5', + Width: 'M' + }) }) }) -describe('getDisplayPrice', function () { - test('returns basePrice and discountPrice', () => { - const {basePrice, discountPrice} = getDisplayPrice( - mockedCustomerProductListsDetails.data[0] - ) +describe('getPriceData', function () { + test('returns price data for master product that has more than one variant', () => { + const priceData = getPriceData(mockMasterProductHitWithMultipleVariants) + expect(priceData).toEqual({ + currentPrice: 191.99, + listPrice: 223.99, + isOnSale: true, + isASet: false, + isMaster: true, + isRange: true, + tieredPrice: 223.99, + maxPrice: 223.99 + }) + }) - expect(basePrice).toBe(199.0) - expect(discountPrice).toBe(189.0) + test('returns price data for master product that has ONLY one variant', () => { + const priceData = getPriceData(mockMasterProductHitWithOneVariant) + expect(priceData).toEqual({ + currentPrice: 191.99, + listPrice: 320, + isOnSale: true, + isASet: false, + isMaster: true, + isRange: false, + tieredPrice: 320, + maxPrice: 320 + }) }) - test('returns null if there is not discount promotion', () => { - const data = { - ...mockedCustomerProductListsDetails.data[0], - productPromotions: [] - } - const {basePrice, discountPrice} = getDisplayPrice(data) + test('returns correct priceData for product set', () => { + const priceData = getPriceData(mockProductSetHit) + expect(priceData).toEqual({ + currentPrice: 40.16, + listPrice: undefined, + isOnSale: false, + isASet: true, + isMaster: false, + isRange: true, + tieredPrice: undefined, + maxPrice: 71.03 + }) + }) - expect(basePrice).toBe(199.0) - expect(discountPrice).toBeNull() + test('returns correct priceData for standard product', () => { + const priceData = getPriceData(mockStandardProductHit) + expect(priceData).toEqual({ + currentPrice: 63.99, + listPrice: 67.99, + isOnSale: true, + isASet: false, + isMaster: false, + isRange: false, + tieredPrice: 67.99, + maxPrice: 67.99 + }) }) }) diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index 27b24c3f38..ab9ed15ad3 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -93,7 +93,7 @@ "bundlesize": [ { "path": "build/main.js", - "maxSize": "43 kB" + "maxSize": "44 kB" }, { "path": "build/vendor.js", diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index ab3fc69b9b..ce74a9ce01 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -408,6 +408,21 @@ "credit_card_fields.tool_tip.security_code_aria_label": { "defaultMessage": "Security code info" }, + "display_price.assistive_msg.current_price": { + "defaultMessage": "current price {currentPrice}" + }, + "display_price.assistive_msg.current_price_with_range": { + "defaultMessage": "From current price {currentPrice}" + }, + "display_price.assistive_msg.strikethrough_price": { + "defaultMessage": "original price {listPrice}" + }, + "display_price.assistive_msg.strikethrough_price_with_range": { + "defaultMessage": "From original price {listPrice}" + }, + "display_price.label.current_price_with_range": { + "defaultMessage": "From {currentPrice}" + }, "drawer_menu.button.account_details": { "defaultMessage": "Account Details" }, @@ -702,8 +717,8 @@ "defaultMessage": "Unavailable", "description": "A unavailable badge placed on top of a product image" }, - "item_price.label.starting_at": { - "defaultMessage": "Starting at" + "item_price.label.from": { + "defaultMessage": "From" }, "lCPCxk": { "defaultMessage": "Please select all your options above" @@ -1066,9 +1081,6 @@ "product_tile.assistive_msg.remove_from_wishlist": { "defaultMessage": "Remove {product} from wishlist" }, - "product_tile.label.starting_at_price": { - "defaultMessage": "Starting at {price}" - }, "product_view.button.add_set_to_cart": { "defaultMessage": "Add Set to Cart" }, @@ -1099,9 +1111,6 @@ "product_view.label.quantity_increment": { "defaultMessage": "+" }, - "product_view.label.starting_at_price": { - "defaultMessage": "Starting at" - }, "product_view.label.variant_type": { "defaultMessage": "{variantType}" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index ab3fc69b9b..ce74a9ce01 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -408,6 +408,21 @@ "credit_card_fields.tool_tip.security_code_aria_label": { "defaultMessage": "Security code info" }, + "display_price.assistive_msg.current_price": { + "defaultMessage": "current price {currentPrice}" + }, + "display_price.assistive_msg.current_price_with_range": { + "defaultMessage": "From current price {currentPrice}" + }, + "display_price.assistive_msg.strikethrough_price": { + "defaultMessage": "original price {listPrice}" + }, + "display_price.assistive_msg.strikethrough_price_with_range": { + "defaultMessage": "From original price {listPrice}" + }, + "display_price.label.current_price_with_range": { + "defaultMessage": "From {currentPrice}" + }, "drawer_menu.button.account_details": { "defaultMessage": "Account Details" }, @@ -702,8 +717,8 @@ "defaultMessage": "Unavailable", "description": "A unavailable badge placed on top of a product image" }, - "item_price.label.starting_at": { - "defaultMessage": "Starting at" + "item_price.label.from": { + "defaultMessage": "From" }, "lCPCxk": { "defaultMessage": "Please select all your options above" @@ -1066,9 +1081,6 @@ "product_tile.assistive_msg.remove_from_wishlist": { "defaultMessage": "Remove {product} from wishlist" }, - "product_tile.label.starting_at_price": { - "defaultMessage": "Starting at {price}" - }, "product_view.button.add_set_to_cart": { "defaultMessage": "Add Set to Cart" }, @@ -1099,9 +1111,6 @@ "product_view.label.quantity_increment": { "defaultMessage": "+" }, - "product_view.label.starting_at_price": { - "defaultMessage": "Starting at" - }, "product_view.label.variant_type": { "defaultMessage": "{variantType}" },