diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 7ce5b524fd..6871d3b025 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -12,6 +12,7 @@ - Support saving default shipping address on user registration from order confirmation [#2706](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2706) - Minor updates to support BOPIS E2E tests [#2716](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2716) - Provide support for partial hydration [#2696](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2696) +- Show Automatic Bonus Products on Cart Page [#2704](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2704) ## v6.1.0 (May 22, 2025) diff --git a/packages/template-retail-react-app/app/components/item-variant/item-attributes.jsx b/packages/template-retail-react-app/app/components/item-variant/item-attributes.jsx index 15ee4ec9c6..0dc7b897fe 100644 --- a/packages/template-retail-react-app/app/components/item-variant/item-attributes.jsx +++ b/packages/template-retail-react-app/app/components/item-variant/item-attributes.jsx @@ -20,13 +20,15 @@ import {getDisplayVariationValues} from '@salesforce/retail-react-app/app/utils/ * In the context of a cart product item variant, this component renders a styled * list of the selected variation values as well as any promos (w/ info popover). */ -const ItemAttributes = ({includeQuantity, currency, ...props}) => { +const ItemAttributes = ({includeQuantity, currency, excludeBonusLabel, ...props}) => { const variant = useItemVariant() const {data: basket} = useCurrentBasket() const {currency: activeCurrency} = useCurrency() const promotionIds = variant.priceAdjustments?.map((adj) => adj.promotionId) ?? [] const intl = useIntl() + const displayBonusProductLabel = !excludeBonusLabel && variant?.bonusProductLineItem + // Fetch all the promotions given by price adjustments. We display this info in // the promotion info popover when applicable. const {data: res} = usePromotions( @@ -91,6 +93,15 @@ const ItemAttributes = ({includeQuantity, currency, ...props}) => { return ( + {displayBonusProductLabel && ( + + + + )} + {variationValues && Object.keys(variationValues).map((key) => ( { ItemAttributes.propTypes = { includeQuantity: PropTypes.bool, - currency: PropTypes.string + currency: PropTypes.string, + excludeBonusLabel: PropTypes.bool } export default ItemAttributes diff --git a/packages/template-retail-react-app/app/components/item-variant/item-attributes.test.js b/packages/template-retail-react-app/app/components/item-variant/item-attributes.test.js index ec751b1d30..1574f2684f 100644 --- a/packages/template-retail-react-app/app/components/item-variant/item-attributes.test.js +++ b/packages/template-retail-react-app/app/components/item-variant/item-attributes.test.js @@ -73,3 +73,75 @@ test('component renders product bundles without variant data', async () => { }) }) }) + +const renderComponent = (variant, props = {}) => { + renderWithProviders( + + + + ) +} + +describe('bonus product', () => { + test('renders Bonus Product when bonusProductLineItem is true', async () => { + const mockVariantWithBonusProduct = { + ...mockBundledProductItemsVariant, + bonusProductLineItem: true + } + + renderComponent(mockVariantWithBonusProduct, {excludeBonusLabel: true}) + + await waitFor(() => { + expect(screen.queryByText(/Bonus Product/i)).not.toBeInTheDocument() + }) + + renderComponent(mockVariantWithBonusProduct, {excludeBonusLabel: false}) + + await waitFor(() => { + expect(screen.getByText(/Bonus Product/i)).toBeInTheDocument() + }) + }) + + test('renders Bonus Product when excludeBonusLabel is not set', async () => { + const mockVariantWithOutBonusProduct = { + ...mockBundledProductItemsVariant, + bonusProductLineItem: false + } + + renderComponent(mockVariantWithOutBonusProduct) + + await waitFor(() => { + expect(screen.queryByText(/Bonus Product/i)).not.toBeInTheDocument() + }) + + const mockVariantWithBonusProduct = { + ...mockBundledProductItemsVariant, + bonusProductLineItem: true + } + + renderComponent(mockVariantWithBonusProduct) + + await waitFor(() => { + expect(screen.queryByText(/Bonus Product/i)).toBeInTheDocument() + }) + }) + + test('does not render Bonus Product when bonusProductLineItem is false', async () => { + const mockVariantWithoutBonusProduct = { + ...mockBundledProductItemsVariant, + bonusProductLineItem: false + } + + renderComponent(mockVariantWithoutBonusProduct, {excludeBonusLabel: true}) + + await waitFor(() => { + expect(screen.queryByText(/Bonus Product/i)).not.toBeInTheDocument() + }) + + renderComponent(mockVariantWithoutBonusProduct, {excludeBonusLabel: false}) + + await waitFor(() => { + expect(screen.queryByText(/Bonus Product/i)).not.toBeInTheDocument() + }) + }) +}) diff --git a/packages/template-retail-react-app/app/components/product-item/bonus-product-quantity.jsx b/packages/template-retail-react-app/app/components/product-item/bonus-product-quantity.jsx new file mode 100644 index 0000000000..ee0c39c4cf --- /dev/null +++ b/packages/template-retail-react-app/app/components/product-item/bonus-product-quantity.jsx @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react' +import PropTypes from 'prop-types' +import {FormattedMessage, useIntl} from 'react-intl' +import {Text, Skeleton} from '@salesforce/retail-react-app/app/components/shared/ui' + +const BonusProductQuantity = ({product}) => { + const intl = useIntl() + return ( + + + + + + ) +} + +BonusProductQuantity.propTypes = { + product: PropTypes.object +} + +export default BonusProductQuantity diff --git a/packages/template-retail-react-app/app/components/product-item/bonus-product-quantity.test.jsx b/packages/template-retail-react-app/app/components/product-item/bonus-product-quantity.test.jsx new file mode 100644 index 0000000000..fa1b78405f --- /dev/null +++ b/packages/template-retail-react-app/app/components/product-item/bonus-product-quantity.test.jsx @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react' +import {render, screen} from '@testing-library/react' +import {IntlProvider} from 'react-intl' +import BonusProductQuantity from '@salesforce/retail-react-app/app/components/product-item/bonus-product-quantity' + +const mockProduct = {quantity: 1} + +const renderWithIntl = (component) => + render( + + {component} + + ) + +describe('BonusProductQuantity', () => { + test('renders the quantity text', () => { + renderWithIntl() + expect(screen.getByText(/Quantity: 1/)).toBeInTheDocument() + }) + + test('applies correct aria-label', () => { + renderWithIntl() + const quantityElement = screen.getByText(/Quantity: 1/) + expect(quantityElement).toHaveAttribute('aria-label') + }) + + test('renders skeleton when product is undefined', () => { + renderWithIntl() + // The Skeleton component from Chakra UI renders a div with class "chakra-skeleton" + expect(document.querySelector('.chakra-skeleton')).toBeInTheDocument() + }) +}) diff --git a/packages/template-retail-react-app/app/components/product-item/index.jsx b/packages/template-retail-react-app/app/components/product-item/index.jsx index 4314d476a0..ec92669f03 100644 --- a/packages/template-retail-react-app/app/components/product-item/index.jsx +++ b/packages/template-retail-react-app/app/components/product-item/index.jsx @@ -6,17 +6,9 @@ */ import React from 'react' import PropTypes from 'prop-types' -import {FormattedMessage, useIntl} from 'react-intl' // Chakra Components -import { - Box, - Fade, - Flex, - Stack, - Text, - VisuallyHidden -} from '@salesforce/retail-react-app/app/components/shared/ui' +import {Box, Fade, Flex, Stack, Text} from '@salesforce/retail-react-app/app/components/shared/ui' // Project Components import {HideOnDesktop, HideOnMobile} from '@salesforce/retail-react-app/app/components/responsive' @@ -26,7 +18,8 @@ import CartItemVariantName from '@salesforce/retail-react-app/app/components/ite import CartItemVariantAttributes from '@salesforce/retail-react-app/app/components/item-variant/item-attributes' import CartItemVariantPrice from '@salesforce/retail-react-app/app/components/item-variant/item-price' import LoadingSpinner from '@salesforce/retail-react-app/app/components/loading-spinner' -import QuantityPicker from '@salesforce/retail-react-app/app/components/quantity-picker' +import BonusProductQuantity from '@salesforce/retail-react-app/app/components/product-item/bonus-product-quantity' +import ProductQuantityPicker from '@salesforce/retail-react-app/app/components/product-item/product-quantity-picker' // Utilities import {noop} from '@salesforce/retail-react-app/app/utils/utils' @@ -53,7 +46,6 @@ const ProductItem = ({ const {stepQuantity, showInventoryMessage, inventoryMessage, quantity, setQuantity} = useDerivedProduct(product) const {currency: activeCurrency} = useCurrency() - const intl = useIntl() return ( - + - - + ) : ( + - - { - // Default to last known quantity if a user leaves the box with an invalid value - const {value} = e.target - - if (!value) { - setQuantity(product.quantity) - } - }} - onChange={(stringValue, numberValue) => { - // Set the Quantity of product to value of input if value number - if (numberValue >= 0) { - // Call handler - onItemQuantityChange(numberValue).then( - (isValidChange) => - isValidChange && 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 '' quantity to the cart due to the add to cart button enablement rules - setQuantity(stringValue) - } - }} - productName={product?.name} - /> - - {product?.name} - {intl.formatMessage( - { - id: 'item_variant.assistive_msg.quantity', - defaultMessage: 'Quantity {quantity}' - }, - { - quantity: product?.quantity - } - )} - + )} diff --git a/packages/template-retail-react-app/app/components/product-item/index.test.js b/packages/template-retail-react-app/app/components/product-item/index.test.js index b67a3fe2e2..e63ceb05b5 100644 --- a/packages/template-retail-react-app/app/components/product-item/index.test.js +++ b/packages/template-retail-react-app/app/components/product-item/index.test.js @@ -9,6 +9,7 @@ import ProductItem from '@salesforce/retail-react-app/app/components/product-ite import {mockedCustomerProductListsDetails} from '@salesforce/retail-react-app/app/mocks/mock-data' import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' import {screen} from '@testing-library/react' +import PropTypes from 'prop-types' jest.mock('@salesforce/commerce-sdk-react', () => { const originalModule = jest.requireActual('@salesforce/commerce-sdk-react') @@ -24,16 +25,55 @@ beforeEach(() => { }) jest.setTimeout(60000) -const MockedComponent = () => { - const product = mockedCustomerProductListsDetails.data[0] - return + +const mockProduct = { + ...mockedCustomerProductListsDetails.data[0], + productName: mockedCustomerProductListsDetails.data[0].name, + bonusProductLineItem: false, + quantity: 1 +} + +const mockBonusProduct = { + ...mockProduct, + bonusProductLineItem: true +} + +const MockedComponent = ({ + product = mockProduct, + onItemQuantityChange = async () => {}, + showLoading = false +}) => { + return ( + Primary Action} + secondaryActions={} + /> + ) } -test('renders product item name, attributes and price', async () => { - renderWithProviders() +MockedComponent.propTypes = { + product: PropTypes.object, + onItemQuantityChange: PropTypes.func, + showLoading: PropTypes.bool +} + +describe('ProductItem Component', () => { + test('renders product item name, attributes, price and quantity picker', async () => { + renderWithProviders() + + expect(await screen.getByText(/apple ipod nano$/i)).toBeInTheDocument() + expect(await screen.getByText(/color: green/i)).toBeInTheDocument() + expect(await screen.getByText(/memory size: 16 GB$/i)).toBeInTheDocument() + expect(screen.queryByRole('spinbutton')).toBeInTheDocument() + }) + + test('renders bonus product without quantity picker', () => { + renderWithProviders() - // look for the element that has sole product name - expect(await screen.getByText(/apple ipod nano$/i)).toBeInTheDocument() - expect(await screen.getByText(/color: green/i)).toBeInTheDocument() - expect(await screen.getByText(/memory size: 16 GB$/i)).toBeInTheDocument() + expect(screen.getByText(/Quantity:/i)).toBeInTheDocument() + expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument() + }) }) diff --git a/packages/template-retail-react-app/app/components/product-item/product-quantity-picker.jsx b/packages/template-retail-react-app/app/components/product-item/product-quantity-picker.jsx new file mode 100644 index 0000000000..f597150a21 --- /dev/null +++ b/packages/template-retail-react-app/app/components/product-item/product-quantity-picker.jsx @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react' +import PropTypes from 'prop-types' +import {FormattedMessage, useIntl} from 'react-intl' +import {Text, VisuallyHidden} from '@salesforce/retail-react-app/app/components/shared/ui' +import QuantityPicker from '@salesforce/retail-react-app/app/components/quantity-picker' + +const ProductQuantityPicker = ({ + product, + onItemQuantityChange, + stepQuantity, + quantity, + setQuantity +}) => { + const intl = useIntl() + + const handleQuantityChange = (stringValue, numberValue) => { + // Set the Quantity of product to value of input if value number + if (numberValue >= 0) { + // Call handler + onItemQuantityChange(numberValue).then( + (isValidChange) => isValidChange && 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 '' quantity to the cart due to the add to cart button enablement rules + setQuantity(stringValue) + } + } + + const handleQuantityBlur = (e) => { + // Default to last known quantity if a user leaves the box with an invalid value + const {value} = e.target + + if (!value) { + setQuantity(product.quantity) + } + } + + return ( + <> + + + + + + {product?.name} + {intl.formatMessage( + { + id: 'item_variant.assistive_msg.quantity', + defaultMessage: 'Quantity {quantity}' + }, + { + quantity: product?.quantity + } + )} + + + ) +} + +ProductQuantityPicker.propTypes = { + product: PropTypes.object.isRequired, + onItemQuantityChange: PropTypes.func.isRequired, + stepQuantity: PropTypes.number.isRequired, + quantity: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + setQuantity: PropTypes.func.isRequired +} + +export default ProductQuantityPicker diff --git a/packages/template-retail-react-app/app/components/product-item/product-quantity-picker.test.jsx b/packages/template-retail-react-app/app/components/product-item/product-quantity-picker.test.jsx new file mode 100644 index 0000000000..e9b9b7b8c4 --- /dev/null +++ b/packages/template-retail-react-app/app/components/product-item/product-quantity-picker.test.jsx @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react' +import {render, screen, fireEvent, waitFor} from '@testing-library/react' +import {IntlProvider} from 'react-intl' +import ProductQuantityPicker from '@salesforce/retail-react-app/app/components/product-item/product-quantity-picker' + +const mockProduct = {name: 'Test Product', quantity: 1} +const mockOnItemQuantityChange = jest.fn(() => Promise.resolve(true)) +const mockSetQuantity = jest.fn() + +const renderWithIntl = (component) => + render( + + {component} + + ) + +describe('ProductQuantityPicker', () => { + test('renders the quantity label', () => { + renderWithIntl( + + ) + expect(screen.getByText(/Quantity:/i)).toBeInTheDocument() + }) + + test('calls onItemQuantityChange when quantity is changed', async () => { + renderWithIntl( + + ) + const input = screen.getByRole('spinbutton') + fireEvent.change(input, {target: {value: '2'}}) + await waitFor(() => { + expect(mockOnItemQuantityChange).toHaveBeenCalledWith(2) + }) + }) + + test('sets quantity to empty string when input is cleared', () => { + renderWithIntl( + + ) + const input = screen.getByRole('spinbutton') + fireEvent.change(input, {target: {value: ''}}) + expect(mockSetQuantity).toHaveBeenCalledWith('') + }) + + test('restores previous quantity on blur with empty value', async () => { + const {getByRole} = renderWithIntl( + + ) + const input = getByRole('spinbutton') + fireEvent.change(input, {target: {value: ''}}) + fireEvent.blur(input) + await waitFor(() => { + expect(mockSetQuantity).toHaveBeenCalledWith(1) + }) + }) +}) diff --git a/packages/template-retail-react-app/app/mocks/mock-data.js b/packages/template-retail-react-app/app/mocks/mock-data.js index 0539350d5a..016419d6c4 100644 --- a/packages/template-retail-react-app/app/mocks/mock-data.js +++ b/packages/template-retail-react-app/app/mocks/mock-data.js @@ -5711,3 +5711,52 @@ export const mockPasswordUpdateFalure = { detail: 'The update password request is invalid. Customer\u0027s current password is not valid', errorMessage: 'Customer\u0027s current password is not valid' } + +export const mockBasketWithBonusProducts = { + baskets: [ + { + ...mockCustomerBaskets.baskets[0], + productItems: [ + { + adjustedTax: 2.93, + basePrice: 61.43, + bonusProductLineItem: false, + gift: false, + itemId: '4a9af0a24fe46c3f6d8721b371', + itemText: 'Belted Cardigan With Studs', + price: 61.43, + priceAfterItemDiscount: 61.43, + priceAfterOrderDiscount: 61.43, + productId: '701642889830M', + productName: 'Belted Cardigan With Studs', + quantity: 2, + shipmentId: 'me', + tax: 2.93, + taxBasis: 61.43, + taxClassId: 'standard', + taxRate: 0.05 + }, + { + adjustedTax: 0, + basePrice: 0, + bonusProductLineItem: true, + gift: false, + itemId: '5b1a03848f0807f99f37ea93e4', + itemText: 'Free Gift with Purchase', + price: 0, + priceAfterItemDiscount: 0, + priceAfterOrderDiscount: 0, + productId: '013742335262M', + productName: 'Free Gift with Purchase', + quantity: 1, + shipmentId: 'me', + tax: 0, + taxBasis: 0, + taxClassId: 'standard', + taxRate: 0.05 + } + ] + } + ], + total: 1 +} diff --git a/packages/template-retail-react-app/app/pages/cart/index.jsx b/packages/template-retail-react-app/app/pages/cart/index.jsx index 59904ba2d9..b83d21634f 100644 --- a/packages/template-retail-react-app/app/pages/cart/index.jsx +++ b/packages/template-retail-react-app/app/pages/cart/index.jsx @@ -24,6 +24,7 @@ import CartCta from '@salesforce/retail-react-app/app/pages/cart/partials/cart-c import CartSecondaryButtonGroup from '@salesforce/retail-react-app/app/pages/cart/partials/cart-secondary-button-group' import CartSkeleton from '@salesforce/retail-react-app/app/pages/cart/partials/cart-skeleton' import CartTitle from '@salesforce/retail-react-app/app/pages/cart/partials/cart-title' +import BonusProductsTitle from '@salesforce/retail-react-app/app/pages/cart/partials/bonus-products-title' import ConfirmationModal from '@salesforce/retail-react-app/app/components/confirmation-modal' import EmptyCart from '@salesforce/retail-react-app/app/pages/cart/partials/empty-cart' import OrderSummary from '@salesforce/retail-react-app/app/components/order-summary' @@ -584,6 +585,56 @@ const Cart = () => { ) } + // Categorize products into regular and bonus + const categorizedProducts = useMemo(() => { + return basket?.productItems?.reduce( + (acc, productItem) => { + if (productItem.bonusProductLineItem) { + acc.bonusProducts.push(productItem) + } else { + acc.regularProducts.push(productItem) + } + return acc + }, + {regularProducts: [], bonusProducts: []} + ) + }, [basket?.productItems]) + + // Function to create product items + const createProductItemProps = (productItem, isBonusProduct = false) => ({ + isBonusProduct, + secondaryActions: ( + { + setSelectedItem(product) + onOpen() + }} + onRemoveItemClick={handleRemoveItem} + /> + ), + product: { + ...productItem, + ...(productsByItemId && productsByItemId[productItem.itemId]), + isProductUnavailable: !isProductsLoading + ? !productsByItemId?.[productItem.itemId] + : undefined, + price: productItem.price, + quantity: localQuantity[productItem.itemId] + ? localQuantity[productItem.itemId] + : productItem.quantity + }, + onItemQuantityChange: handleChangeItemQuantity.bind(this, productItem), + showLoading: isCartItemLoading && selectedItem?.itemId === productItem.itemId, + handleRemoveItem + }) + /********* Rendering UI **********/ if (isLoading) { return @@ -592,6 +643,7 @@ const Cart = () => { if (!isLoading && !basket?.productItems?.length) { return } + return ( { )} )} - {basket.productItems?.map((productItem, idx) => { - return ( - { - setSelectedItem(product) - onOpen() - }} - onRemoveItemClick={handleRemoveItem} + {/* Regular Products */} + {categorizedProducts.regularProducts.map((productItem) => ( + + ))} + + {/* Bonus Products */} + {categorizedProducts.bonusProducts.length > 0 && ( + <> + + + + {categorizedProducts.bonusProducts.map( + (productItem) => ( + - } - product={{ - ...productItem, - ...(productsByItemId && - productsByItemId[productItem.itemId]), - isProductUnavailable: !isProductsLoading - ? !productsByItemId?.[productItem.itemId] - : undefined, - price: productItem.price, - quantity: localQuantity[productItem.itemId] - ? localQuantity[productItem.itemId] - : productItem.quantity - }} - onItemQuantityChange={handleChangeItemQuantity.bind( - this, - productItem - )} - showLoading={ - isCartItemLoading && - selectedItem?.itemId === productItem.itemId - } - handleRemoveItem={handleRemoveItem} - /> - ) - })} + ) + )} + + )} {isOpen && !selectedItem.bundledProductItems && ( diff --git a/packages/template-retail-react-app/app/pages/cart/index.test.js b/packages/template-retail-react-app/app/pages/cart/index.test.js index e3be510251..dc68bef23b 100644 --- a/packages/template-retail-react-app/app/pages/cart/index.test.js +++ b/packages/template-retail-react-app/app/pages/cart/index.test.js @@ -14,7 +14,8 @@ import { mockCustomerBaskets, mockEmptyBasket, mockCartVariant, - mockedCustomerProductLists + mockedCustomerProductLists, + mockBasketWithBonusProducts } from '@salesforce/retail-react-app/app/mocks/mock-data' import mockVariant from '@salesforce/retail-react-app/app/mocks/variant-750518699578M' import {rest} from 'msw' @@ -1229,6 +1230,35 @@ describe('Product bundles', () => { }) }) +describe('Bonus products', () => { + beforeEach(() => { + prependHandlersToServer([ + { + path: '*/customers/:customerId/baskets', + method: 'get', + res: () => mockBasketWithBonusProducts + } + ]) + }) + + test('renders bonus products in cart with correct styling and no quantity picker', async () => { + renderWithProviders() + + // Wait for the cart to load + await waitFor(() => { + expect(screen.queryByTestId('sf-cart-skeleton')).not.toBeInTheDocument() + }) + + // Find products by their names + const regularProduct = screen.getByText('Belted Cardigan With Studs') + const bonusProduct = screen.getByText('Free Gift with Purchase') + + expect(regularProduct).toBeInTheDocument() + expect(bonusProduct).toBeInTheDocument() + expect(within(bonusProduct).queryByTestId('quantity-picker')).not.toBeInTheDocument() + }) +}) + describe('Unavailable products tests', function () { test('Remove unavailable/out of stock/low stock products from cart', async () => { prependHandlersToServer([ diff --git a/packages/template-retail-react-app/app/pages/cart/partials/bonus-products-title.jsx b/packages/template-retail-react-app/app/pages/cart/partials/bonus-products-title.jsx new file mode 100644 index 0000000000..02133184f9 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/cart/partials/bonus-products-title.jsx @@ -0,0 +1,28 @@ +/* + * 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 {FormattedMessage} from 'react-intl' +import {Heading} from '@salesforce/retail-react-app/app/components/shared/ui' +import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' + +const BonusProductsTitle = () => { + const {data: basket} = useCurrentBasket() + const bonusItemsCount = + basket?.productItems?.filter((item) => item.bonusProductLineItem)?.length || 0 + + return ( + + + + ) +} + +export default BonusProductsTitle diff --git a/packages/template-retail-react-app/app/pages/cart/partials/bonus-products-title.test.js b/packages/template-retail-react-app/app/pages/cart/partials/bonus-products-title.test.js new file mode 100644 index 0000000000..d9edcbbbe4 --- /dev/null +++ b/packages/template-retail-react-app/app/pages/cart/partials/bonus-products-title.test.js @@ -0,0 +1,58 @@ +/* + * 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 {screen} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import BonusProductsTitle from '@salesforce/retail-react-app/app/pages/cart/partials/bonus-products-title' +import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' + +// Mock the useCurrentBasket hook +jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket') + +describe('BonusProductsTitle', () => { + beforeEach(() => { + jest.clearAllMocks() + // Provide a default mock that includes derivedData to prevent AddToCartModal errors + useCurrentBasket.mockReturnValue({ + data: {}, + derivedData: {totalItems: 0} + }) + }) + + it('renders title with 1 item when one bonus product', () => { + const basketData = { + productItems: [ + {id: '1', bonusProductLineItem: true}, + {id: '2', bonusProductLineItem: false} + ] + } + useCurrentBasket.mockReturnValue({ + data: basketData, + derivedData: {totalItems: 2} + }) + + renderWithProviders() + expect(screen.getByText('Bonus Products (1 item)')).toBeInTheDocument() + }) + + it('renders title with multiple items when multiple bonus products', () => { + const basketData = { + productItems: [ + {id: '1', bonusProductLineItem: true}, + {id: '2', bonusProductLineItem: true}, + {id: '3', bonusProductLineItem: false} + ] + } + useCurrentBasket.mockReturnValue({ + data: basketData, + derivedData: {totalItems: 3} + }) + + renderWithProviders() + expect(screen.getByText('Bonus Products (2 items)')).toBeInTheDocument() + }) +}) diff --git a/packages/template-retail-react-app/app/pages/cart/partials/cart-secondary-button-group.jsx b/packages/template-retail-react-app/app/pages/cart/partials/cart-secondary-button-group.jsx index 22e85b6a23..6c517b1cec 100644 --- a/packages/template-retail-react-app/app/pages/cart/partials/cart-secondary-button-group.jsx +++ b/packages/template-retail-react-app/app/pages/cart/partials/cart-secondary-button-group.jsx @@ -62,6 +62,7 @@ const CartSecondaryButtonGroup = ({ isAGift = false }) => { const variant = useItemVariant() + const isBonusProduct = variant?.bonusProductLineItem const {data: customer} = useCurrentCustomer() const modalProps = useDisclosure() @@ -83,13 +84,15 @@ const CartSecondaryButtonGroup = ({ divider={} > - - {customer.isRegistered && ( + {!isBonusProduct && ( + + )} + {customer.isRegistered && !isBonusProduct && ( - - { - const checked = e.target.checked - onIsAGiftChange(variant, checked) - }} - > - - - {/* if you want to provide a link to your gift site, uncomment this section and re-build your translation*/} - {/**/} - {/* */} - {/**/} - + {!isBonusProduct && ( + + { + const checked = e.target.checked + onIsAGiftChange(variant, checked) + }} + > + + + {/* if you want to provide a link to your gift site, uncomment this section and re-build your translation*/} + {/**/} + {/* */} + {/**/} + + )} { - const product = mockedCustomerProductListsDetails.data[0] + const product = { + ...mockedCustomerProductListsDetails.data[0], + productName: mockedCustomerProductListsDetails.data[0].name, + bonusProductLineItem: isBonusProduct + } return ( - + { @@ -101,3 +107,13 @@ test('renders secondary with event handlers', async () => { expect(onRemoveItemClick).toHaveBeenCalledTimes(1) }) + +test('hides remove, wishlist and gift checkbox for bonus product', async () => { + const {user} = renderWithProviders() + + expect(screen.getByRole('button', {name: /edit/i})).toBeInTheDocument() + + expect(screen.queryByRole('button', {name: /remove/i})).not.toBeInTheDocument() + expect(screen.queryByRole('button', {name: /add to wishlist/i})).not.toBeInTheDocument() + expect(screen.queryByRole('checkbox', {name: /this is a gift/i})).not.toBeInTheDocument() +}) 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 22468342cf..b820c25b82 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 @@ -453,6 +453,64 @@ "value": "Password Reset" } ], + "bonus_product_item.label.quantity": [ + { + "type": 0, + "value": "Quantity: " + }, + { + "type": 1, + "value": "quantity" + } + ], + "bonus_products_title.title.num_of_items": [ + { + "type": 0, + "value": "Bonus Products (" + }, + { + "offset": 0, + "options": { + "=0": { + "value": [ + { + "type": 0, + "value": "0 items" + } + ] + }, + "one": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " item" + } + ] + }, + "other": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " items" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "itemCount" + }, + { + "type": 0, + "value": ")" + } + ], "carousel.button.scroll_left.assistive_msg": [ { "type": 0, @@ -1939,6 +1997,12 @@ "value": "Secure" } ], + "item_attributes.label.bonus_product": [ + { + "type": 0, + "value": "Bonus Product" + } + ], "item_attributes.label.promotions": [ { "type": 0, 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 22468342cf..b820c25b82 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 @@ -453,6 +453,64 @@ "value": "Password Reset" } ], + "bonus_product_item.label.quantity": [ + { + "type": 0, + "value": "Quantity: " + }, + { + "type": 1, + "value": "quantity" + } + ], + "bonus_products_title.title.num_of_items": [ + { + "type": 0, + "value": "Bonus Products (" + }, + { + "offset": 0, + "options": { + "=0": { + "value": [ + { + "type": 0, + "value": "0 items" + } + ] + }, + "one": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " item" + } + ] + }, + "other": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " items" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "itemCount" + }, + { + "type": 0, + "value": ")" + } + ], "carousel.button.scroll_left.assistive_msg": [ { "type": 0, @@ -1939,6 +1997,12 @@ "value": "Secure" } ], + "item_attributes.label.bonus_product": [ + { + "type": 0, + "value": "Bonus Product" + } + ], "item_attributes.label.promotions": [ { "type": 0, 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 1f6b153083..3ea173bdf4 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 @@ -925,6 +925,80 @@ "value": "]" } ], + "bonus_product_item.label.quantity": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ɋŭŭȧȧƞŧīŧẏ: " + }, + { + "type": 1, + "value": "quantity" + }, + { + "type": 0, + "value": "]" + } + ], + "bonus_products_title.title.num_of_items": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ɓǿǿƞŭŭş Ƥřǿǿḓŭŭƈŧş (" + }, + { + "offset": 0, + "options": { + "=0": { + "value": [ + { + "type": 0, + "value": "0 īŧḗḗḿş" + } + ] + }, + "one": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " īŧḗḗḿ" + } + ] + }, + "other": { + "value": [ + { + "type": 7 + }, + { + "type": 0, + "value": " īŧḗḗḿş" + } + ] + } + }, + "pluralType": "cardinal", + "type": 6, + "value": "itemCount" + }, + { + "type": 0, + "value": ")" + }, + { + "type": 0, + "value": "]" + } + ], "carousel.button.scroll_left.assistive_msg": [ { "type": 0, @@ -4091,6 +4165,20 @@ "value": "]" } ], + "item_attributes.label.bonus_product": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ɓǿǿƞŭŭş Ƥřǿǿḓŭŭƈŧ" + }, + { + "type": 0, + "value": "]" + } + ], "item_attributes.label.promotions": [ { "type": 0, diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index 63dbb202c0..1fbc9980e8 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -177,6 +177,12 @@ "auth_modal.password_reset_success.title.password_reset": { "defaultMessage": "Password Reset" }, + "bonus_product_item.label.quantity": { + "defaultMessage": "Quantity: {quantity}" + }, + "bonus_products_title.title.num_of_items": { + "defaultMessage": "Bonus Products ({itemCount, plural, =0 {0 items} one {# item} other {# items}})" + }, "carousel.button.scroll_left.assistive_msg": { "defaultMessage": "Scroll carousel left" }, @@ -811,6 +817,9 @@ "icons.assistive_msg.lock": { "defaultMessage": "Secure" }, + "item_attributes.label.bonus_product": { + "defaultMessage": "Bonus Product" + }, "item_attributes.label.promotions": { "defaultMessage": "Promotions" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 63dbb202c0..1fbc9980e8 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -177,6 +177,12 @@ "auth_modal.password_reset_success.title.password_reset": { "defaultMessage": "Password Reset" }, + "bonus_product_item.label.quantity": { + "defaultMessage": "Quantity: {quantity}" + }, + "bonus_products_title.title.num_of_items": { + "defaultMessage": "Bonus Products ({itemCount, plural, =0 {0 items} one {# item} other {# items}})" + }, "carousel.button.scroll_left.assistive_msg": { "defaultMessage": "Scroll carousel left" }, @@ -811,6 +817,9 @@ "icons.assistive_msg.lock": { "defaultMessage": "Secure" }, + "item_attributes.label.bonus_product": { + "defaultMessage": "Bonus Product" + }, "item_attributes.label.promotions": { "defaultMessage": "Promotions" },