Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React, {useMemo} from 'react'
import PropTypes from 'prop-types'
import {
VStack,
AspectRatio,
Skeleton,
Text,
Box,
Checkbox
} from '@salesforce/retail-react-app/app/components/shared/ui'
import DynamicImage from '@salesforce/retail-react-app/app/components/dynamic-image'
import {findImageGroupBy} from '@salesforce/retail-react-app/app/utils/image-groups-utils'
import {filterImageGroups} from '@salesforce/retail-react-app/app/utils/product-utils'

const BonusProductItem = ({product, productData, isSelected, onToggle, isLoading}) => {
const productName = product?.productName || product?.title

// Get the appropriate image group from the passed product data
const imageGroup = useMemo(() => {
if (!productData?.imageGroups) return null

// If the product has variationValues, use filterImageGroups to get variant-specific images
if (productData.variationValues && Object.keys(productData.variationValues).length > 0) {
const filteredGroups = filterImageGroups(productData.imageGroups, {
viewType: 'small',
variationValues: productData.variationValues
})
return filteredGroups?.[0] || null
}

// Fallback to the original logic for non-variant products
return findImageGroupBy(productData.imageGroups, {
viewType: 'small'
})
}, [productData])

const image = imageGroup?.images?.[0]
const showLoading = isLoading

if (showLoading) {
return (
<VStack spacing={3} p={4} bg="gray.50">
<AspectRatio ratio={1} width="150px" minWidth="150px">
<Skeleton data-testid="skeleton" />
</AspectRatio>
<VStack spacing={2} align="center">
<Skeleton height="16px" width="100px" data-testid="skeleton" />
<Skeleton height="20px" width="20px" data-testid="skeleton" />
</VStack>
</VStack>
)
}

return (
<VStack spacing={3} p={4} bg="white">
<AspectRatio
ratio={1}
width="150px"
minWidth="150px"
cursor="pointer"
onClick={() => onToggle(product)}
>
{image && (
<DynamicImage
src={`${image.disBaseLink || image.link}[?sw={width}&q=60]`}
widths={{
base: '150px'
}}
imageProps={{
alt: productName,
borderRadius: 'md',
objectFit: 'cover'
}}
/>
)}
</AspectRatio>
<VStack spacing={2} align="center" width="full">
<Text
fontSize="sm"
fontWeight="semibold"
lineHeight="1.2"
textAlign="center"
noOfLines={{base: 2, md: 2}}
width={{base: '150px', md: 'full'}}
>
{productName}
</Text>
<Box width="full" display="flex" justifyContent="center">
<Checkbox
isChecked={isSelected}
onChange={() => onToggle(product)}
cursor="pointer"
aria-label={`Select bonus product: ${productName}`}
/>
</Box>
</VStack>
</VStack>
)
}

BonusProductItem.propTypes = {
product: PropTypes.object.isRequired,
productData: PropTypes.object,
isSelected: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired,
isLoading: PropTypes.bool
}

export default BonusProductItem
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React from 'react'
import {screen} from '@testing-library/react'
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
import BonusProductItem from '@salesforce/retail-react-app/app/components/bonus-product-item/bonus-product-item'

const baseProduct = {
id: '1',
productId: '1',
productName: 'Test Product',
title: 'Test Product'
}

const baseProductData = {
id: '1',
imageGroups: [
{
viewType: 'small',
images: [{link: 'test-image.jpg'}]
}
]
}

describe('BonusProductItem', () => {
test('renders product name and image', () => {
renderWithProviders(
<BonusProductItem
product={baseProduct}
productData={baseProductData}
isSelected={false}
onToggle={jest.fn()}
isLoading={false}
/>
)
expect(screen.getByText('Test Product')).toBeInTheDocument()
expect(screen.getByRole('checkbox')).toBeInTheDocument()
expect(screen.getByAltText('Test Product')).toBeInTheDocument()
})

test('renders loading state', () => {
renderWithProviders(
<BonusProductItem
product={baseProduct}
productData={baseProductData}
isSelected={false}
onToggle={jest.fn()}
isLoading={true}
/>
)
expect(screen.getAllByTestId('skeleton').length).toBeGreaterThan(0)
})

test('checkbox is checked when isSelected is true', () => {
renderWithProviders(
<BonusProductItem
product={baseProduct}
productData={baseProductData}
isSelected={true}
onToggle={jest.fn()}
isLoading={false}
/>
)
expect(screen.getByRole('checkbox')).toBeChecked()
})

test('checkbox is not checked when isSelected is false', () => {
renderWithProviders(
<BonusProductItem
product={baseProduct}
productData={baseProductData}
isSelected={false}
onToggle={jest.fn()}
isLoading={false}
/>
)
expect(screen.getByRole('checkbox')).not.toBeChecked()
})

test('checkbox has correct aria-label', () => {
renderWithProviders(
<BonusProductItem
product={baseProduct}
productData={baseProductData}
isSelected={false}
onToggle={jest.fn()}
isLoading={false}
/>
)
expect(screen.getByRole('checkbox')).toHaveAttribute(
'aria-label',
'Select bonus product: Test Product'
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -15,115 +15,13 @@ import {
ModalBody,
Text,
Box,
VStack,
AspectRatio,
Skeleton,
SimpleGrid,
Button,
Checkbox
Button
} from '@salesforce/retail-react-app/app/components/shared/ui'
import {useProducts} from '@salesforce/commerce-sdk-react'
import DynamicImage from '@salesforce/retail-react-app/app/components/dynamic-image'
import PropTypes from 'prop-types'
import {useBonusProductModalContext} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-modal'
import {findImageGroupBy} from '@salesforce/retail-react-app/app/utils/image-groups-utils'
import {FormattedMessage} from 'react-intl'
import {filterImageGroups} from '@salesforce/retail-react-app/app/utils/product-utils'

// Component to display individual bonus product with checkbox for selection
const BonusProductItem = ({product, productData, isSelected, onToggle, isLoading}) => {
const productName = product?.productName || product?.title

// Get the appropriate image group from the passed product data
// Use filterImageGroups to get variant-specific images when available
const imageGroup = useMemo(() => {
if (!productData?.imageGroups) return null

// If the product has variationValues, use filterImageGroups to get variant-specific images
if (productData.variationValues && Object.keys(productData.variationValues).length > 0) {
const filteredGroups = filterImageGroups(productData.imageGroups, {
viewType: 'small',
variationValues: productData.variationValues
})
return filteredGroups?.[0] || null
}

// Fallback to the original logic for non-variant products
return findImageGroupBy(productData.imageGroups, {
viewType: 'small'
})
}, [productData])

const image = imageGroup?.images?.[0]
const showLoading = isLoading

if (showLoading) {
return (
<VStack spacing={3} p={4} bg="gray.50">
<AspectRatio ratio={1} width="150px" minWidth="150px">
<Skeleton data-testid="skeleton" />
</AspectRatio>
<VStack spacing={2} align="center">
<Skeleton height="16px" width="100px" data-testid="skeleton" />
<Skeleton height="20px" width="20px" data-testid="skeleton" />
</VStack>
</VStack>
)
}

return (
<VStack spacing={3} p={4} bg="white">
<AspectRatio
ratio={1}
width="150px"
minWidth="150px"
cursor="pointer"
onClick={() => onToggle(product)}
>
{image && (
<DynamicImage
src={`${image.disBaseLink || image.link}[?sw={width}&q=60]`}
widths={{
base: '150px'
}}
imageProps={{
alt: productName,
borderRadius: 'md',
objectFit: 'cover'
}}
/>
)}
</AspectRatio>
<VStack spacing={2} align="center" width="full">
<Text
fontSize="sm"
fontWeight="semibold"
lineHeight="1.2"
textAlign="center"
noOfLines={2}
width="full"
>
{productName}
</Text>
<Box width="full" display="flex" justifyContent="center">
<Checkbox
isChecked={isSelected}
onChange={() => onToggle(product)}
cursor="pointer"
/>
</Box>
</VStack>
</VStack>
)
}

BonusProductItem.propTypes = {
product: PropTypes.object.isRequired,
productData: PropTypes.object,
isSelected: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired,
isLoading: PropTypes.bool
}
import BonusProductItem from '@salesforce/retail-react-app/app/components/bonus-product-item/bonus-product-item'

export const BonusProductModal = () => {
const {isOpen, onClose, data} = useBonusProductModalContext()
Expand Down Expand Up @@ -196,7 +94,6 @@ export const BonusProductModal = () => {

// Calculate columns based on number of products
const productCount = bonusProducts.length
const columns = Math.min(productCount, 3) // Max 3 columns, but fewer if less products

if (!isOpen) return null

Expand All @@ -213,7 +110,11 @@ export const BonusProductModal = () => {

<ModalBody bgColor="white" padding="6">
{bonusProducts.length > 0 ? (
<SimpleGrid columns={columns} spacing={8} justifyItems="start">
<SimpleGrid
columns={{base: 1, sm: 2, md: Math.min(productCount, 3)}}
spacing={8}
justifyItems={{base: 'center', sm: 'start'}}
>
{bonusProducts.map((product) => {
const productId = product.productId || product.id
const productData = productsDataMap[productId]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-curre
// Mock the hooks
jest.mock('@salesforce/commerce-sdk-react')
jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket')
jest.mock('@salesforce/retail-react-app/app/hooks/use-bonus-product-modal')

describe('BonusProductModal', () => {
const mockProductData = {
Expand Down Expand Up @@ -100,4 +101,21 @@ describe('BonusProductModal', () => {

expect(screen.queryByText('Add Bonus Product')).not.toBeInTheDocument()
})

test('renders bonus products in a single centered column on mobile (no horizontal scroll)', () => {
useCurrentBasket.mockReturnValue({
data: mockBasketWithBonusItems
})
useProducts.mockReturnValue({
data: mockProductData,
isLoading: false
})

window.innerWidth = 375
window.dispatchEvent(new Event('resize'))

renderWithProviders(<div>Test content</div>)

expect(screen.queryByText('Add Bonus Product')).not.toBeInTheDocument()
})
})
Loading