diff --git a/packages/template-chakra-storefront/mocks/standard-product.js b/packages/template-chakra-storefront/mocks/standard-product.js
new file mode 100644
index 0000000000..96afde2aaf
--- /dev/null
+++ b/packages/template-chakra-storefront/mocks/standard-product.js
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2025, Salesforce, Inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+
+export const mockStandardProductOrderable = {
+ currency: 'GBP',
+ id: 'a-standard-dress',
+ imageGroups: [
+ {
+ images: [
+ {
+ alt: 'White and Black Tone, , large',
+ disBaseLink:
+ 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw9178fd89/images/large/PG.W20766.IVORYXX.PZ.jpg',
+ link: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw9178fd89/images/large/PG.W20766.IVORYXX.PZ.jpg',
+ title: 'White and Black Tone, '
+ }
+ ],
+ viewType: 'large'
+ },
+ {
+ images: [
+ {
+ alt: 'White and Black Tone, , medium',
+ disBaseLink:
+ 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw9af8c50e/images/medium/PG.W20766.IVORYXX.PZ.jpg',
+ link: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw9af8c50e/images/medium/PG.W20766.IVORYXX.PZ.jpg',
+ title: 'White and Black Tone, '
+ }
+ ],
+ viewType: 'medium'
+ },
+ {
+ images: [
+ {
+ alt: 'White and Black Tone, , small',
+ disBaseLink:
+ 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw58be3274/images/small/PG.W20766.IVORYXX.PZ.jpg',
+ link: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.static/-/Sites-apparel-m-catalog/default/dw58be3274/images/small/PG.W20766.IVORYXX.PZ.jpg',
+ title: 'White and Black Tone, '
+ }
+ ],
+ viewType: 'small'
+ }
+ ],
+ inventory: {
+ ats: 999999,
+ backorderable: false,
+ id: 'inventory_m',
+ orderable: true,
+ preorderable: false,
+ stockLevel: 999999
+ },
+ longDescription: 'A Standard Dress',
+ minOrderQuantity: 1,
+ name: 'White and Black Tone',
+ pageMetaTags: [
+ {
+ id: 'description',
+ value: 'Buy White and Black Tone at RefArchGlobal.'
+ },
+ {
+ id: 'robots',
+ value: 'index, follow'
+ },
+ {
+ id: 'og:url',
+ value: 'https://zzrf-001.dx.commercecloud.salesforce.com/on/demandware.store/s/RefArchGlobal/dw/shop/v99_9/products/a-standard-dress?currency=GBP&locale=en-GB&expand=availability,promotions,options,images,prices,variations,set_products,bundled_products,page_meta_tags&all_images=true'
+ },
+ {
+ id: 'title',
+ value: 'Buy White and Black Tone for GBP 4.00 | RefArchGlobal'
+ }
+ ],
+ price: 4,
+ pricePerUnit: 4,
+ primaryCategoryId: 'womens-outfits',
+ shortDescription: 'A Standard Dress',
+ slugUrl:
+ 'https://zzrf-001.dx.commercecloud.salesforce.com/s/RefArchGlobal/en_GB/product/a-standard-dress/a-standard-dress.html',
+ stepQuantity: 1,
+ type: {
+ item: true
+ },
+ tieredPrices: [
+ {
+ price: 70,
+ pricebook: 'gbp-m-list-prices',
+ quantity: 1
+ },
+ {
+ price: 4,
+ pricebook: 'gbp-m-sale-prices',
+ quantity: 1
+ }
+ ]
+}
+
+export const mockStandardProductNotOrderable = {
+ ...mockStandardProductOrderable,
+ id: 'a-standard-dress-not-orderable',
+ inventory: {
+ ats: 0,
+ backorderable: false,
+ id: 'inventory_m',
+ orderable: false,
+ preorderable: false,
+ stockLevel: 0
+ }
+}
diff --git a/packages/template-chakra-storefront/src/components/product-view/index.jsx b/packages/template-chakra-storefront/src/components/product-view/index.jsx
index 94080cf1c3..7571f811b8 100644
--- a/packages/template-chakra-storefront/src/components/product-view/index.jsx
+++ b/packages/template-chakra-storefront/src/components/product-view/index.jsx
@@ -162,8 +162,21 @@ const ProductView = forwardRef(
setChildProductOrderability,
isBasketLoading = false,
onVariantSelected = () => {},
- validateOrderability = (variant, quantity, stockLevel) =>
- !isProductLoading && variant?.orderable && quantity > 0 && quantity <= stockLevel,
+ validateOrderability = (product, variant, quantity, stockLevel) => {
+ if (isProductLoading) return false
+
+ // If product has variations, a variant must be selected
+ if (product?.variationAttributes?.length > 0 && !variant) {
+ return false
+ }
+
+ // Check if product (either variant or standard) is orderable and if quantity is valid
+ return (
+ (variant?.orderable || product?.inventory?.orderable) &&
+ quantity > 0 &&
+ quantity <= stockLevel
+ )
+ },
showImageGallery = true,
setSelectedBundleQuantity = () => {},
selectedBundleParentQuantity = 1
@@ -199,8 +212,8 @@ const ProductView = forwardRef(
return getPriceData(product, {quantity})
}, [product, quantity])
const canAddToWishlist = !isProductLoading
- const isProductASet = product?.type.set
- const isProductABundle = product?.type.bundle
+ const isProductASet = product?.type?.set
+ const isProductABundle = product?.type?.bundle
const errorContainerRef = useRef(null)
const {disableButton, customInventoryMessage} = useMemo(() => {
@@ -240,7 +253,7 @@ const ProductView = forwardRef(
const validateAndShowError = (opts = {}) => {
const {scrollErrorIntoView = true} = opts
// Validate that all attributes are selected before proceeding.
- const hasValidSelection = validateOrderability(variant, quantity, stockLevel)
+ const hasValidSelection = validateOrderability(product, variant, quantity, stockLevel)
const hasError = !isProductASet && !isProductABundle && !hasValidSelection
const scrollToError = hasError && scrollErrorIntoView
@@ -379,7 +392,7 @@ const ProductView = forwardRef(
if (
!isProductASet &&
!isProductABundle &&
- validateOrderability(variant, quantity, stockLevel)
+ validateOrderability(product, variant, quantity, stockLevel)
) {
toggleShowOptionsMessage(false)
}
diff --git a/packages/template-chakra-storefront/src/components/product-view/index.test.js b/packages/template-chakra-storefront/src/components/product-view/index.test.js
index 773cba5bd1..f254a4c739 100644
--- a/packages/template-chakra-storefront/src/components/product-view/index.test.js
+++ b/packages/template-chakra-storefront/src/components/product-view/index.test.js
@@ -7,14 +7,19 @@
import React from 'react'
import PropTypes from 'prop-types'
-import {act, fireEvent, screen, waitFor} from '@testing-library/react'
+import {act, screen, waitFor} from '@testing-library/react'
import mockProductDetail from '../../../mocks/variant-750518699578M'
import mockProductSet from '../../../mocks/product-set-winter-lookM'
import {mockProductBundle} from '../../../mocks/product-bundle'
+import {
+ mockStandardProductOrderable,
+ mockStandardProductNotOrderable
+} from '../../../mocks/standard-product'
import ProductView from '../../components/product-view'
import {renderWithProviders} from '../../utils/test-utils'
-import userEvent from '@testing-library/user-event'
+
import {useCurrentCustomer} from '../../hooks'
+import frMessages from '../../static/translations/compiled/fr-FR.json'
const MockComponent = (props) => {
const {data: customer} = useCurrentCustomer()
@@ -46,261 +51,458 @@ afterEach(() => {
sessionStorage.clear()
})
-test('ProductView Component renders properly', async () => {
- const addToCart = jest.fn()
- renderWithProviders()
- expect(screen.getAllByText(/Black Single Pleat Athletic Fit Wool Suit/i)).toHaveLength(2)
- expect(screen.getAllByText(/299\.99/)).toHaveLength(4)
- expect(screen.getAllByText(/Add to cart/i)).toHaveLength(2)
- expect(screen.getAllByRole('radiogroup')).toHaveLength(3)
- expect(screen.getAllByText(/add to cart/i)).toHaveLength(2)
-})
-
-test('ProductView Component renders with addToCart event handler', async () => {
- const addToCart = jest.fn()
- await renderWithProviders()
-
- const addToCartButton = screen.getAllByText(/add to cart/i)[0]
- await act(async () => {
- fireEvent.click(addToCartButton)
- })
- await waitFor(() => {
- expect(addToCart).toHaveBeenCalledTimes(1)
- })
-})
-
-test('ProductView Component renders with addToWishList event handler', async () => {
- const addToWishlist = jest.fn()
-
- await renderWithProviders(
-
- )
-
- await waitFor(() => {
- expect(screen.getByText(/customer: registered/)).toBeInTheDocument()
- })
-
- const addToWishListButton = screen.getAllByText(/Add to wishlist/i)[0]
-
- await act(async () => {
- fireEvent.click(addToWishListButton)
+describe('ProductView Component', () => {
+ describe('Basic Rendering', () => {
+ test('renders properly with all expected elements', async () => {
+ const addToCart = jest.fn()
+ renderWithProviders()
+ expect(screen.getAllByText(/Black Single Pleat Athletic Fit Wool Suit/i)).toHaveLength(
+ 2
+ )
+ expect(screen.getAllByText(/299\.99/)).toHaveLength(4)
+ expect(screen.getAllByText(/Add to cart/i)).toHaveLength(2)
+ expect(screen.getAllByRole('radiogroup')).toHaveLength(3)
+ expect(screen.getAllByText(/add to cart/i)).toHaveLength(2)
+ })
})
- expect(addToWishlist).toHaveBeenCalledTimes(1)
-})
-
-test('ProductView Component renders with updateWishlist event handler', async () => {
- const updateWishlist = jest.fn()
- await renderWithProviders(
-
- )
-
- await waitFor(() => {
- expect(screen.getByText(/customer: registered/)).toBeInTheDocument()
+ describe('Event Handlers', () => {
+ test('calls addToCart when add to cart button is clicked', async () => {
+ const addToCart = jest.fn()
+ const {user} = renderWithProviders(
+
+ )
+
+ const addToCartButton = screen.getAllByText(/add to cart/i)[0]
+ await act(async () => {
+ await user.click(addToCartButton)
+ })
+
+ await waitFor(() => {
+ expect(addToCart).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ test('calls addToWishlist when add to wishlist button is clicked', async () => {
+ const addToWishlist = jest.fn()
+
+ const {user} = await renderWithProviders(
+
+ )
+
+ await waitFor(() => {
+ expect(screen.getByText(/customer: registered/)).toBeInTheDocument()
+ })
+
+ const addToWishListButton = screen.getAllByText(/Add to wishlist/i)[0]
+ await act(async () => {
+ await user.click(addToWishListButton)
+ })
+ await waitFor(() => {
+ expect(addToWishlist).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ test('calls updateWishlist when update button is clicked', async () => {
+ const updateWishlist = jest.fn()
+
+ const {user} = renderWithProviders(
+
+ )
+
+ await waitFor(() => {
+ expect(screen.getByText(/customer: registered/)).toBeInTheDocument()
+ })
+
+ const updateWishlistButton = screen.getAllByText(/Update/i)[0]
+ await act(async () => {
+ await user.click(updateWishlistButton)
+ })
+
+ await waitFor(() => {
+ expect(updateWishlist).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ test('calls onVariantSelected after variant selection', async () => {
+ const onVariantSelected = jest.fn()
+ const child = mockProductSet.setProducts[0]
+
+ const {user} = renderWithProviders(
+ {}}
+ addToWishlist={() => {}}
+ />
+ )
+
+ const size = screen.getByRole('radio', {name: /xl/i})
+ await act(async () => {
+ await user.click(size)
+ })
+
+ await waitFor(() => {
+ expect(onVariantSelected).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ test('calls validateOrderability when adding a set to cart', async () => {
+ const parent = mockProductSet
+ const validateOrderability = jest.fn()
+
+ const {user} = renderWithProviders(
+ {}}
+ addToWishlist={() => {}}
+ />
+ )
+
+ const button = screen.getByRole('button', {name: /add set to cart/i})
+ await act(async () => {
+ await user.click(button)
+ })
+
+ await waitFor(() => {
+ expect(validateOrderability).toHaveBeenCalledTimes(1)
+ })
+ })
})
- const updateWishlistButton = screen.getAllByText(/Update/i)[0]
- await act(async () => {
- fireEvent.click(updateWishlistButton)
+ describe('Quantity Management', () => {
+ test('can update quantity through input field', async () => {
+ const addToCart = jest.fn()
+ const {user} = await renderWithProviders(
+
+ )
+
+ let quantityBox
+ await waitFor(() => {
+ quantityBox = screen.getByRole('spinbutton')
+ })
+
+ await waitFor(() => {
+ expect(quantityBox).toHaveValue('1')
+ })
+
+ // update item quantity
+ await act(async () => {
+ await user.type(quantityBox, '{backspace}3')
+ })
+
+ await waitFor(() => {
+ expect(quantityBox).toHaveValue('3')
+ })
+ })
+
+ test('handles invalid quantity inputs by resetting to minimum', async () => {
+ // Any invalid input should be reset to minOrderQuantity
+ const {user} = renderWithProviders()
+
+ const quantityInput = screen.getByRole('spinbutton', {name: /quantity/i})
+ const minQuantity = mockProductDetail.minOrderQuantity.toString()
+
+ // Quantity is empty
+ await act(async () => {
+ await user.clear(quantityInput)
+ })
+ await act(async () => {
+ await user.tab()
+ })
+ await waitFor(() => {
+ expect(quantityInput).toHaveValue(minQuantity)
+ })
+
+ // Quantity is zero
+ await act(async () => {
+ await user.clear(quantityInput)
+ })
+ await act(async () => {
+ await user.type(quantityInput, '0')
+ })
+ await act(async () => {
+ await user.tab()
+ })
+ await waitFor(() => {
+ expect(quantityInput).toHaveValue(minQuantity)
+ })
+ })
+
+ test('increases and decreases quantity with increment/decrement buttons', async () => {
+ const {user} = await renderWithProviders()
+
+ const quantityInput = await screen.findByRole('spinbutton')
+ const incrementButton = screen.getByTestId('quantity-increment')
+ const decrementButton = screen.getByTestId('quantity-decrement')
+
+ // Click increment
+ await act(async () => {
+ await user.click(incrementButton)
+ })
+ await waitFor(() => {
+ expect(quantityInput).toHaveValue('2')
+ })
+
+ // Click decrement
+ await act(async () => {
+ await user.click(decrementButton)
+ })
+ await waitFor(() => {
+ expect(quantityInput).toHaveValue('1')
+ })
+ })
})
- expect(updateWishlist).toHaveBeenCalledTimes(1)
-})
-test('Product View can update quantity', async () => {
- const user = userEvent.setup()
- const addToCart = jest.fn()
- await renderWithProviders()
-
- let quantityBox
- await waitFor(() => {
- quantityBox = screen.getByRole('spinbutton')
+ describe('Loading States', () => {
+ test('disables add to cart button when basket is loading', async () => {
+ renderWithProviders(
+ {}}
+ isBasketLoading={true}
+ />
+ )
+
+ // In Chakra UI v3, when a Button has loading={true}, the button text is hidden with a loading spinner.
+ // A data-loading attribute is automatically added to the DOM element
+ const addToCartButton = document.querySelector('[data-loading]')
+ expect(addToCartButton).toBeDisabled()
+ })
+
+ test('enables add to cart button when basket is not loading', async () => {
+ renderWithProviders(
+ {}}
+ isBasketLoading={false}
+ />
+ )
+ expect(screen.getByRole('button', {name: /add to cart/i})).toBeEnabled()
+ })
})
- await waitFor(() => {
- expect(quantityBox).toHaveValue('1')
+ describe('Product Sets', () => {
+ test('renders parent item correctly', () => {
+ const parent = mockProductSet
+ renderWithProviders(
+ {}} addToWishlist={() => {}} />
+ )
+
+ // NOTE: there can be duplicates of the same element, due to mobile and desktop views
+ // (they're hidden with display:none style)
+
+ 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(fromAtLabel).toBeInTheDocument()
+ expect(addSetToCartButton).toBeInTheDocument()
+ expect(addSetToWishlistButton).toBeInTheDocument()
+
+ // What should _not_ exist:
+ expect(variationAttributes).toHaveLength(0)
+ expect(quantityPicker).toBeNull()
+ })
+
+ test('renders child item correctly', () => {
+ const child = mockProductSet.setProducts[0]
+ renderWithProviders(
+ {}} addToWishlist={() => {}} />
+ )
+
+ // NOTE: there can be duplicates of the same element, due to mobile and desktop views
+ // (they're hidden with display:none style)
+
+ const addToCartButton = screen.getAllByRole('button', {name: /add to cart/i})[0]
+ 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 fromLabels = screen.queryAllByText(/from/i)
+
+ // What should exist:
+ expect(addToCartButton).toBeInTheDocument()
+ expect(addToWishlistButton).toBeInTheDocument()
+ expect(variationAttributes).toHaveLength(2)
+ expect(quantityPicker).toBeInTheDocument()
+
+ // 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(4)
+ })
})
- await act(async () => {
- // update item quantity
- await user.type(quantityBox, '{backspace}3')
- })
- await waitFor(() => {
- expect(quantityBox).toHaveValue('3')
+ describe('Product Bundles', () => {
+ test('renders parent item correctly', () => {
+ const parent = mockProductBundle
+ renderWithProviders(
+ {}} addToWishlist={() => {}} />
+ )
+
+ // NOTE: there can be duplicates of the same element, due to mobile and desktop views
+ // (they're hidden with display:none style)
+ const addBundleToCartButton = screen.getAllByRole('button', {
+ name: /add bundle to cart/i
+ })[0]
+ const addBundleToWishlistButton = screen.getAllByRole('button', {
+ name: /add bundle to wishlist/i
+ })[0]
+ const quantityPicker = screen.getByRole('spinbutton', {name: /quantity/i})
+ const variationAttributes = screen.queryAllByRole('radiogroup') // e.g. sizes, colors
+
+ // What should exist:
+ expect(addBundleToCartButton).toBeInTheDocument()
+ expect(addBundleToWishlistButton).toBeInTheDocument()
+ expect(quantityPicker).toBeInTheDocument()
+
+ // What should _not_ exist:
+ expect(variationAttributes).toHaveLength(0)
+ })
+
+ test('renders child item correctly', () => {
+ const child = mockProductBundle.bundledProducts[0].product
+ renderWithProviders(
+ {}}
+ addToWishlist={() => {}}
+ isProductPartOfBundle={true}
+ setChildProductOrderability={() => {}}
+ />
+ )
+
+ const addToCartButton = screen.queryByRole('button', {name: /add to cart/i})
+ const addToWishlistButton = screen.queryByRole('button', {name: /add to wishlist/i})
+ const variationAttributes = screen.getAllByRole('radiogroup') // e.g. sizes, colors
+ const quantityPicker = screen.queryByRole('spinbutton', {name: /quantity:/i})
+
+ // What should exist:
+ expect(variationAttributes).toHaveLength(2)
+
+ // What should _not_ exist:
+ expect(addToCartButton).toBeNull()
+ expect(addToWishlistButton).toBeNull()
+ expect(quantityPicker).toBeNull()
+ })
})
})
-test('renders a product set properly - parent item', () => {
- const parent = mockProductSet
+test('renders "Add to Cart" and "Add to Wishlist" buttons in French', async () => {
+ const addToCart = jest.fn()
+ const addToWishlist = jest.fn()
renderWithProviders(
- {}} addToWishlist={() => {}} />
+ ,
+ {
+ wrapperProps: {locale: {id: 'fr-FR'}, messages: frMessages}
+ }
)
- // NOTE: there can be duplicates of the same element, due to mobile and desktop views
- // (they're hidden with display:none style)
-
- 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(fromAtLabel).toBeInTheDocument()
- expect(addSetToCartButton).toBeInTheDocument()
- expect(addSetToWishlistButton).toBeInTheDocument()
-
- // What should _not_ exist:
- expect(variationAttributes).toHaveLength(0)
- expect(quantityPicker).toBeNull()
+ const titles = await screen.findAllByText(/Black Single Pleat Athletic Fit Wool Suit/i)
+ expect(titles.length).toBeGreaterThan(0)
+ expect(screen.getByRole('button', {name: /ajouter au panier/i})).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', {name: /ajouter à la liste de souhaits/i})
+ ).toBeInTheDocument()
})
-test('renders a product set properly - child item', () => {
- const child = mockProductSet.setProducts[0]
- renderWithProviders(
- {}} addToWishlist={() => {}} />
- )
-
- // NOTE: there can be duplicates of the same element, due to mobile and desktop views
- // (they're hidden with display:none style)
+describe('validateOrderability', () => {
+ test('returns true when variant is undefined but product is orderable', () => {
+ const validateOrderability = (variant, product, quantity, stockLevel) =>
+ (variant?.orderable || product?.inventory?.orderable) &&
+ quantity > 0 &&
+ quantity <= stockLevel
- const addToCartButton = screen.getAllByRole('button', {name: /add to cart/i})[0]
- 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 fromLabels = screen.queryAllByText(/from/i)
+ const variant = undefined
+ const product = mockStandardProductOrderable
+ const quantity = 1
+ const stockLevel = 999999
- // What should exist:
- expect(addToCartButton).toBeInTheDocument()
- expect(addToWishlistButton).toBeInTheDocument()
- expect(variationAttributes).toHaveLength(2)
- expect(quantityPicker).toBeInTheDocument()
-
- // 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(4)
-})
+ const result = validateOrderability(variant, product, quantity, stockLevel)
+ expect(result).toBe(true)
+ })
-test('validateOrderability callback is called when adding a set to cart', async () => {
- const user = userEvent.setup()
+ test('returns false when variant is undefined and product is not orderable', () => {
+ const validateOrderability = (variant, product, quantity, stockLevel) =>
+ (variant?.orderable || product?.inventory?.orderable) &&
+ quantity > 0 &&
+ quantity <= stockLevel
- const parent = mockProductSet
- const validateOrderability = jest.fn()
+ const variant = undefined
+ const product = mockStandardProductNotOrderable
+ const quantity = 1
+ const stockLevel = 0
- renderWithProviders(
- {}}
- addToWishlist={() => {}}
- />
- )
-
- const button = screen.getByRole('button', {name: /add set to cart/i})
- await act(async () => {
- await user.click(button)
- })
- await waitFor(() => {
- expect(validateOrderability).toHaveBeenCalledTimes(1)
+ const result = validateOrderability(variant, product, quantity, stockLevel)
+ expect(result).toBe(false)
})
-})
-
-test('onVariantSelected callback is called after successfully selected a variant', async () => {
- const user = userEvent.setup()
- const onVariantSelected = jest.fn()
- const child = mockProductSet.setProducts[0]
+ test('returns true when variant is orderable regardless of product orderability', () => {
+ const validateOrderability = (variant, product, quantity, stockLevel) =>
+ (variant?.orderable || product?.inventory?.orderable) &&
+ quantity > 0 &&
+ quantity <= stockLevel
- renderWithProviders(
- {}}
- addToWishlist={() => {}}
- />
- )
+ const variant = {orderable: true}
+ const product = mockStandardProductNotOrderable // product is not orderable
+ const quantity = 1
+ const stockLevel = 999999
- const size = screen.getByRole('radio', {name: /xl/i})
- await act(async () => {
- await user.click(size)
+ const result = validateOrderability(variant, product, quantity, stockLevel)
+ expect(result).toBe(true)
})
- await waitFor(() => {
- expect(onVariantSelected).toHaveBeenCalledTimes(1)
- })
-})
-describe('add to cart button loading tests', () => {
- test('add to cart button is disabled if isBasketLoading is true', async () => {
- renderWithProviders(
- {}}
- isBasketLoading={true}
- />
- )
- // In Chakra UI v3, when a Button has loading={true}, the button text is hidden with a loading spinner.
- // A data-loading attribute is automatically added to the DOM element
- const addToCartButton = document.querySelector('[data-loading]')
- expect(addToCartButton).toBeDisabled()
- })
+ test('returns false when both variant and product are not orderable', () => {
+ const validateOrderability = (variant, product, quantity, stockLevel) =>
+ (variant?.orderable || product?.inventory?.orderable) &&
+ quantity > 0 &&
+ quantity <= stockLevel
+
+ const variant = {orderable: false}
+ const product = mockStandardProductNotOrderable
+ const quantity = 1
+ const stockLevel = 0
- test('add to cart button is enabled if isBasketLoading is false', async () => {
- renderWithProviders(
- {}}
- isBasketLoading={false}
- />
- )
- expect(screen.getByRole('button', {name: /add to cart/i})).toBeEnabled()
+ const result = validateOrderability(variant, product, quantity, stockLevel)
+ expect(result).toBe(false)
})
-})
-test('renders a product bundle properly - parent item', () => {
- const parent = mockProductBundle
- renderWithProviders(
- {}} addToWishlist={() => {}} />
- )
+ test('returns false when quantity is invalid even if product/variant are orderable', () => {
+ const validateOrderability = (variant, product, quantity, stockLevel) =>
+ (variant?.orderable || product?.inventory?.orderable) &&
+ quantity > 0 &&
+ quantity <= stockLevel
- // NOTE: there can be duplicates of the same element, due to mobile and desktop views
- // (they're hidden with display:none style)
- const addBundleToCartButton = screen.getAllByRole('button', {name: /add bundle to cart/i})[0]
- const addBundleToWishlistButton = screen.getAllByRole('button', {
- name: /add bundle to wishlist/i
- })[0]
- const quantityPicker = screen.getByRole('spinbutton', {name: /quantity/i})
- const variationAttributes = screen.queryAllByRole('radiogroup') // e.g. sizes, colors
-
- // What should exist:
- expect(addBundleToCartButton).toBeInTheDocument()
- expect(addBundleToWishlistButton).toBeInTheDocument()
- expect(quantityPicker).toBeInTheDocument()
-
- // What should _not_ exist:
- expect(variationAttributes).toHaveLength(0)
-})
+ const variant = undefined
+ const product = mockStandardProductOrderable
+ const quantity = 0 // invalid quantity
+ const stockLevel = 999999
-test('renders a product bundle properly - child item', () => {
- const child = mockProductBundle.bundledProducts[0].product
- renderWithProviders(
- {}}
- addToWishlist={() => {}}
- isProductPartOfBundle={true}
- setChildProductOrderability={() => {}}
- />
- )
+ const result = validateOrderability(variant, product, quantity, stockLevel)
+ expect(result).toBe(false)
+ })
- const addToCartButton = screen.queryByRole('button', {name: /add to cart/i})
- const addToWishlistButton = screen.queryByRole('button', {name: /add to wishlist/i})
- const variationAttributes = screen.getAllByRole('radiogroup') // e.g. sizes, colors
- const quantityPicker = screen.queryByRole('spinbutton', {name: /quantity:/i})
+ test('returns false when quantity exceeds stock level', () => {
+ const validateOrderability = (variant, product, quantity, stockLevel) =>
+ (variant?.orderable || product?.inventory?.orderable) &&
+ quantity > 0 &&
+ quantity <= stockLevel
- // What should exist:
- expect(variationAttributes).toHaveLength(2)
+ const variant = undefined
+ const product = mockStandardProductOrderable
+ const quantity = 1000000 // exceeds stock level
+ const stockLevel = 999999
- // What should _not_ exist:
- expect(addToCartButton).toBeNull()
- expect(addToWishlistButton).toBeNull()
- expect(quantityPicker).toBeNull()
+ const result = validateOrderability(variant, product, quantity, stockLevel)
+ expect(result).toBe(false)
+ })
})
diff --git a/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.js b/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.js
index fbaf6b9100..044706efb4 100644
--- a/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.js
+++ b/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.js
@@ -168,8 +168,8 @@ export const AddToCartModal = () => {
@@ -256,20 +256,22 @@ export const AddToCartModal = () => {
)}
{!isProductABundle &&
itemsAdded.map(({product, variant, quantity}, index) => {
+ const productId = variant?.productId || product?.id
const image = findImageGroupBy(product.imageGroups, {
viewType: 'small',
- selectedVariationAttributes: variant.variationValues
+ selectedVariationAttributes:
+ variant?.variationValues
})?.images?.[0]
const priceData = getPriceData(product, {quantity})
const variationAttributeValues =
getDisplayVariationValues(
product.variationAttributes,
- variant.variationValues
+ variant?.variationValues
)
return (
{
diff --git a/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.test.js b/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.test.js
index 105da546c7..8a9d4f9999 100644
--- a/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.test.js
+++ b/packages/template-chakra-storefront/src/hooks/use-add-to-cart-modal.test.js
@@ -569,6 +569,57 @@ const MOCK_PRODUCT = {
c_size: '9LG',
c_width: 'Z'
}
+
+// Mock data for testing individual products with missing image data
+const MOCK_PRODUCT_NO_IMAGE_LINK = {
+ ...MOCK_PRODUCT,
+ price: 29.99,
+ currency: 'USD',
+ imageGroups: [
+ {
+ viewType: 'small',
+ images: [
+ {
+ alt: 'Test product image',
+ disBaseLink: 'https://example.com/image.jpg'
+ }
+ ]
+ }
+ ]
+}
+
+const MOCK_PRODUCT_NO_IMAGE_GROUPS = {
+ ...MOCK_PRODUCT,
+ price: 29.99,
+ currency: 'USD',
+ imageGroups: []
+}
+
+// Mock data for testing bundle products with missing image data
+const MOCK_BUNDLE_NO_IMAGE_LINK = {
+ ...mockProductBundle,
+ price: 59.99,
+ currency: 'USD',
+ imageGroups: [
+ {
+ viewType: 'small',
+ images: [
+ {
+ alt: 'Bundle product image',
+ disBaseLink: 'https://example.com/bundle-image.jpg'
+ }
+ ]
+ }
+ ]
+}
+
+const MOCK_BUNDLE_NO_IMAGE_GROUPS = {
+ ...mockProductBundle,
+ price: 59.99,
+ currency: 'USD',
+ imageGroups: []
+}
+
beforeEach(() => {
jest.resetModules()
@@ -677,3 +728,103 @@ test('renders product bundle', () => {
})
})
})
+
+test('renders individual product image correctly when there is no image link', async () => {
+ const MOCK_DATA_NO_IMAGE = {
+ product: MOCK_PRODUCT_NO_IMAGE_LINK,
+ itemsAdded: [
+ {
+ product: MOCK_PRODUCT_NO_IMAGE_LINK,
+ variant: MOCK_PRODUCT.variants[0], // Provide a variant to avoid undefined errors
+ quantity: 1
+ }
+ ],
+ selectedQuantity: 1
+ }
+
+ renderWithProviders(
+
+
+
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByText(MOCK_PRODUCT.name)).toBeInTheDocument()
+})
+
+test('renders individual product image when image object is not provided', async () => {
+ const MOCK_DATA_NO_IMAGE_OBJECT = {
+ product: MOCK_PRODUCT_NO_IMAGE_GROUPS,
+ itemsAdded: [
+ {
+ product: MOCK_PRODUCT_NO_IMAGE_GROUPS,
+ variant: MOCK_PRODUCT.variants[0],
+ quantity: 1
+ }
+ ],
+ selectedQuantity: 1
+ }
+
+ renderWithProviders(
+
+
+
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByText(MOCK_PRODUCT.name)).toBeInTheDocument()
+})
+
+test('renders bundle product image correctly when there is no image link', async () => {
+ const MOCK_BUNDLE_DATA_NO_IMAGE = {
+ product: MOCK_BUNDLE_NO_IMAGE_LINK,
+ itemsAdded: mockBundleItemsAdded,
+ selectedQuantity: 1
+ }
+
+ renderWithProviders(
+
+
+
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByText(mockProductBundle.name)).toBeInTheDocument()
+})
+
+test('renders bundle product image when image object is not provided', async () => {
+ const MOCK_BUNDLE_DATA_NO_IMAGE_OBJECT = {
+ product: MOCK_BUNDLE_NO_IMAGE_GROUPS,
+ itemsAdded: mockBundleItemsAdded,
+ selectedQuantity: 1
+ }
+
+ renderWithProviders(
+
+
+
+ )
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.getByText(mockProductBundle.name)).toBeInTheDocument()
+})
diff --git a/packages/template-chakra-storefront/src/pages/product-detail/hooks/use-product-detail-data.js b/packages/template-chakra-storefront/src/pages/product-detail/hooks/use-product-detail-data.js
index bb23f36656..922a657587 100644
--- a/packages/template-chakra-storefront/src/pages/product-detail/hooks/use-product-detail-data.js
+++ b/packages/template-chakra-storefront/src/pages/product-detail/hooks/use-product-detail-data.js
@@ -183,9 +183,9 @@ export const useProductDetailData = () => {
const handleAddToCart = async (productSelectionValues) => {
try {
- const productItems = productSelectionValues.map(({variant, quantity}) => ({
- productId: variant.productId,
- price: variant.price,
+ const productItems = productSelectionValues.map(({product, variant, quantity}) => ({
+ productId: variant?.productId || product?.id,
+ price: variant?.price || product?.price,
quantity
}))
diff --git a/packages/template-chakra-storefront/src/pages/product-detail/index.test.js b/packages/template-chakra-storefront/src/pages/product-detail/index.test.js
index 8751ccb425..80b2c01fe4 100644
--- a/packages/template-chakra-storefront/src/pages/product-detail/index.test.js
+++ b/packages/template-chakra-storefront/src/pages/product-detail/index.test.js
@@ -25,6 +25,7 @@ import {
basketWithProductBundle,
bundleProductItemsForPDP
} from '../../../mocks/product-bundle'
+import {mockStandardProductOrderable} from '../../../mocks/standard-product'
import Toaster, {toaster} from '../../components/toaster'
jest.mock('../../hooks/use-datacloud', () => ({
@@ -596,3 +597,100 @@ describe('product bundles', () => {
})
})
})
+
+describe('standard product', () => {
+ let mockAddToCart = jest.fn()
+
+ beforeEach(() => {
+ mockAddToCart = jest.fn()
+ prependHandlersToServer([
+ {
+ // Use standard product without variants
+ path: '*/products/:productId',
+ method: 'get',
+ res: () => mockStandardProductOrderable
+ },
+ {
+ // Mock the add to cart API call to capture the request
+ path: '*/baskets/:basketId/items',
+ method: 'post',
+ res: (req) => {
+ const requestBody = req.body
+ mockAddToCart(requestBody)
+ return {
+ basketId: 'test-basket-id',
+ productItems: [
+ {
+ productId: requestBody[0].productId,
+ price: requestBody[0].price,
+ quantity: requestBody[0].quantity
+ }
+ ]
+ }
+ }
+ }
+ ])
+ })
+
+ test('should be successfully added to cart', async () => {
+ window.history.pushState({}, 'ProductDetail', '/uk/en-GB/product/a-standard-dress')
+
+ const initialBasket = {basketId: 'test-basket-id'}
+ const {user} = renderWithProviders(, {wrapperProps: {initialBasket}})
+
+ await waitFor(() => {
+ expect(screen.getAllByText('White and Black Tone')[0]).toBeInTheDocument()
+ expect(screen.getByRole('button', {name: /add to cart/i})).toBeInTheDocument()
+ })
+
+ const addToCartButton = screen.getByRole('button', {name: /add to cart/i})
+ await act(async () => {
+ await user.click(addToCartButton)
+ })
+
+ await waitFor(() => {
+ expect(mockAddToCart).toHaveBeenCalledWith([
+ {
+ productId: mockStandardProductOrderable.id,
+ price: mockStandardProductOrderable.price,
+ quantity: 1
+ }
+ ])
+ })
+ })
+
+ test('should handle quantity change before adding to cart', async () => {
+ window.history.pushState({}, 'ProductDetail', '/uk/en-GB/product/a-standard-dress')
+
+ const initialBasket = {basketId: 'test-basket-id'}
+ const {user} = renderWithProviders(, {wrapperProps: {initialBasket}})
+
+ await waitFor(() => {
+ expect(screen.getAllByText('White and Black Tone')[0]).toBeInTheDocument()
+ expect(screen.getByRole('spinbutton', {name: /quantity/i})).toBeInTheDocument()
+ })
+
+ // Change quantity to 3
+ const quantityInput = screen.getByRole('spinbutton', {name: /quantity/i})
+ await act(async () => {
+ await user.clear(quantityInput)
+ await user.type(quantityInput, '3')
+ })
+
+ const addToCartButton = screen.getByRole('button', {name: /add to cart/i})
+ await act(async () => {
+ await user.click(addToCartButton)
+ })
+
+ await waitFor(() => {
+ // Verify that the correct quantity is passed when variant is undefined
+ expect(mockAddToCart).toHaveBeenCalledWith([
+ {
+ productId: mockStandardProductOrderable.id,
+ price: mockStandardProductOrderable.price,
+ quantity: 3
+ }
+ ])
+ })
+ })
+})