Skip to content

Commit 5c84cdf

Browse files
Scrolling and Refactored code to extract bonus product item sub-component
1 parent 1372791 commit 5c84cdf

File tree

4 files changed

+289
-106
lines changed

4 files changed

+289
-106
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import React, {useMemo} from 'react'
8+
import PropTypes from 'prop-types'
9+
import {
10+
VStack,
11+
AspectRatio,
12+
Skeleton,
13+
Text,
14+
Box,
15+
Checkbox
16+
} from '@salesforce/retail-react-app/app/components/shared/ui'
17+
import DynamicImage from '@salesforce/retail-react-app/app/components/dynamic-image'
18+
import {findImageGroupBy} from '@salesforce/retail-react-app/app/utils/image-groups-utils'
19+
import {filterImageGroups} from '@salesforce/retail-react-app/app/utils/product-utils'
20+
21+
const BonusProductItem = ({product, productData, isSelected, onToggle, isLoading}) => {
22+
const productName = product?.productName || product?.title
23+
24+
// Get the appropriate image group from the passed product data
25+
// Use filterImageGroups to get variant-specific images when available
26+
const imageGroup = useMemo(() => {
27+
if (!productData?.imageGroups) return null
28+
29+
// If the product has variationValues, use filterImageGroups to get variant-specific images
30+
if (productData.variationValues && Object.keys(productData.variationValues).length > 0) {
31+
const filteredGroups = filterImageGroups(productData.imageGroups, {
32+
viewType: 'small',
33+
variationValues: productData.variationValues
34+
})
35+
return filteredGroups?.[0] || null
36+
}
37+
38+
// Fallback to the original logic for non-variant products
39+
return findImageGroupBy(productData.imageGroups, {
40+
viewType: 'small'
41+
})
42+
}, [productData])
43+
44+
const image = imageGroup?.images?.[0]
45+
const showLoading = isLoading
46+
47+
if (showLoading) {
48+
return (
49+
<VStack spacing={3} p={4} bg="gray.50">
50+
<AspectRatio ratio={1} width="150px" minWidth="150px">
51+
<Skeleton data-testid="skeleton" />
52+
</AspectRatio>
53+
<VStack spacing={2} align="center">
54+
<Skeleton height="16px" width="100px" data-testid="skeleton" />
55+
<Skeleton height="20px" width="20px" data-testid="skeleton" />
56+
</VStack>
57+
</VStack>
58+
)
59+
}
60+
61+
return (
62+
<VStack spacing={3} p={4} bg="white">
63+
<AspectRatio
64+
ratio={1}
65+
width="150px"
66+
minWidth="150px"
67+
cursor="pointer"
68+
onClick={() => onToggle(product)}
69+
>
70+
{image && (
71+
<DynamicImage
72+
src={`${image.disBaseLink || image.link}[?sw={width}&q=60]`}
73+
widths={{
74+
base: '150px'
75+
}}
76+
imageProps={{
77+
alt: productName,
78+
borderRadius: 'md',
79+
objectFit: 'cover'
80+
}}
81+
/>
82+
)}
83+
</AspectRatio>
84+
<VStack spacing={2} align="center" width="full">
85+
<Text
86+
fontSize="sm"
87+
fontWeight="semibold"
88+
lineHeight="1.2"
89+
textAlign="center"
90+
noOfLines={{base: 2, md: 2}}
91+
width={{base: '150px', md: 'full'}}
92+
>
93+
{productName}
94+
</Text>
95+
<Box width="full" display="flex" justifyContent="center">
96+
<Checkbox
97+
isChecked={isSelected}
98+
onChange={() => onToggle(product)}
99+
cursor="pointer"
100+
aria-label={`Select bonus product: ${productName}`}
101+
/>
102+
</Box>
103+
</VStack>
104+
</VStack>
105+
)
106+
}
107+
108+
BonusProductItem.propTypes = {
109+
product: PropTypes.object.isRequired,
110+
productData: PropTypes.object,
111+
isSelected: PropTypes.bool.isRequired,
112+
onToggle: PropTypes.func.isRequired,
113+
isLoading: PropTypes.bool
114+
}
115+
116+
export default BonusProductItem
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import React from 'react'
8+
import {screen} from '@testing-library/react'
9+
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
10+
import BonusProductItem from '@salesforce/retail-react-app/app/components/bonus-product-modal/bonus-product-item'
11+
12+
const baseProduct = {
13+
id: '1',
14+
productId: '1',
15+
productName: 'Test Product',
16+
title: 'Test Product'
17+
}
18+
19+
const baseProductData = {
20+
id: '1',
21+
imageGroups: [
22+
{
23+
viewType: 'small',
24+
images: [{link: 'test-image.jpg'}]
25+
}
26+
]
27+
}
28+
29+
describe('BonusProductItem', () => {
30+
test('renders product name and image', () => {
31+
renderWithProviders(
32+
<BonusProductItem
33+
product={baseProduct}
34+
productData={baseProductData}
35+
isSelected={false}
36+
onToggle={jest.fn()}
37+
isLoading={false}
38+
/>
39+
)
40+
expect(screen.getByText('Test Product')).toBeInTheDocument()
41+
expect(screen.getByRole('checkbox')).toBeInTheDocument()
42+
// Image alt text
43+
expect(screen.getByAltText('Test Product')).toBeInTheDocument()
44+
})
45+
46+
test('renders loading state', () => {
47+
renderWithProviders(
48+
<BonusProductItem
49+
product={baseProduct}
50+
productData={baseProductData}
51+
isSelected={false}
52+
onToggle={jest.fn()}
53+
isLoading={true}
54+
/>
55+
)
56+
expect(screen.getAllByTestId('skeleton').length).toBeGreaterThan(0)
57+
})
58+
59+
test('checkbox is checked when isSelected is true', () => {
60+
renderWithProviders(
61+
<BonusProductItem
62+
product={baseProduct}
63+
productData={baseProductData}
64+
isSelected={true}
65+
onToggle={jest.fn()}
66+
isLoading={false}
67+
/>
68+
)
69+
expect(screen.getByRole('checkbox')).toBeChecked()
70+
})
71+
72+
test('checkbox is not checked when isSelected is false', () => {
73+
renderWithProviders(
74+
<BonusProductItem
75+
product={baseProduct}
76+
productData={baseProductData}
77+
isSelected={false}
78+
onToggle={jest.fn()}
79+
isLoading={false}
80+
/>
81+
)
82+
expect(screen.getByRole('checkbox')).not.toBeChecked()
83+
})
84+
85+
test('checkbox has correct aria-label', () => {
86+
renderWithProviders(
87+
<BonusProductItem
88+
product={baseProduct}
89+
productData={baseProductData}
90+
isSelected={false}
91+
onToggle={jest.fn()}
92+
isLoading={false}
93+
/>
94+
)
95+
expect(screen.getByRole('checkbox')).toHaveAttribute(
96+
'aria-label',
97+
'Select bonus product: Test Product'
98+
)
99+
})
100+
})

packages/template-retail-react-app/app/components/bonus-product-modal/index.jsx

Lines changed: 7 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -15,115 +15,13 @@ import {
1515
ModalBody,
1616
Text,
1717
Box,
18-
VStack,
19-
AspectRatio,
20-
Skeleton,
2118
SimpleGrid,
22-
Button,
23-
Checkbox
19+
Button
2420
} from '@salesforce/retail-react-app/app/components/shared/ui'
2521
import {useProducts} from '@salesforce/commerce-sdk-react'
26-
import DynamicImage from '@salesforce/retail-react-app/app/components/dynamic-image'
27-
import PropTypes from 'prop-types'
2822
import {useBonusProductModalContext} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-modal'
29-
import {findImageGroupBy} from '@salesforce/retail-react-app/app/utils/image-groups-utils'
3023
import {FormattedMessage} from 'react-intl'
31-
import {filterImageGroups} from '@salesforce/retail-react-app/app/utils/product-utils'
32-
33-
// Component to display individual bonus product with checkbox for selection
34-
const BonusProductItem = ({product, productData, isSelected, onToggle, isLoading}) => {
35-
const productName = product?.productName || product?.title
36-
37-
// Get the appropriate image group from the passed product data
38-
// Use filterImageGroups to get variant-specific images when available
39-
const imageGroup = useMemo(() => {
40-
if (!productData?.imageGroups) return null
41-
42-
// If the product has variationValues, use filterImageGroups to get variant-specific images
43-
if (productData.variationValues && Object.keys(productData.variationValues).length > 0) {
44-
const filteredGroups = filterImageGroups(productData.imageGroups, {
45-
viewType: 'small',
46-
variationValues: productData.variationValues
47-
})
48-
return filteredGroups?.[0] || null
49-
}
50-
51-
// Fallback to the original logic for non-variant products
52-
return findImageGroupBy(productData.imageGroups, {
53-
viewType: 'small'
54-
})
55-
}, [productData])
56-
57-
const image = imageGroup?.images?.[0]
58-
const showLoading = isLoading
59-
60-
if (showLoading) {
61-
return (
62-
<VStack spacing={3} p={4} bg="gray.50">
63-
<AspectRatio ratio={1} width="150px" minWidth="150px">
64-
<Skeleton data-testid="skeleton" />
65-
</AspectRatio>
66-
<VStack spacing={2} align="center">
67-
<Skeleton height="16px" width="100px" data-testid="skeleton" />
68-
<Skeleton height="20px" width="20px" data-testid="skeleton" />
69-
</VStack>
70-
</VStack>
71-
)
72-
}
73-
74-
return (
75-
<VStack spacing={3} p={4} bg="white">
76-
<AspectRatio
77-
ratio={1}
78-
width="150px"
79-
minWidth="150px"
80-
cursor="pointer"
81-
onClick={() => onToggle(product)}
82-
>
83-
{image && (
84-
<DynamicImage
85-
src={`${image.disBaseLink || image.link}[?sw={width}&q=60]`}
86-
widths={{
87-
base: '150px'
88-
}}
89-
imageProps={{
90-
alt: productName,
91-
borderRadius: 'md',
92-
objectFit: 'cover'
93-
}}
94-
/>
95-
)}
96-
</AspectRatio>
97-
<VStack spacing={2} align="center" width="full">
98-
<Text
99-
fontSize="sm"
100-
fontWeight="semibold"
101-
lineHeight="1.2"
102-
textAlign="center"
103-
noOfLines={2}
104-
width="full"
105-
>
106-
{productName}
107-
</Text>
108-
<Box width="full" display="flex" justifyContent="center">
109-
<Checkbox
110-
isChecked={isSelected}
111-
onChange={() => onToggle(product)}
112-
cursor="pointer"
113-
/>
114-
</Box>
115-
</VStack>
116-
</VStack>
117-
)
118-
}
119-
120-
BonusProductItem.propTypes = {
121-
product: PropTypes.object.isRequired,
122-
productData: PropTypes.object,
123-
isSelected: PropTypes.bool.isRequired,
124-
onToggle: PropTypes.func.isRequired,
125-
isLoading: PropTypes.bool
126-
}
24+
import BonusProductItem from 'components/bonus-product-modal/bonus-product-item'
12725

12826
export const BonusProductModal = () => {
12927
const {isOpen, onClose, data} = useBonusProductModalContext()
@@ -196,7 +94,6 @@ export const BonusProductModal = () => {
19694

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

20198
if (!isOpen) return null
20299

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

214111
<ModalBody bgColor="white" padding="6">
215112
{bonusProducts.length > 0 ? (
216-
<SimpleGrid columns={columns} spacing={8} justifyItems="start">
113+
<SimpleGrid
114+
columns={{base: 1, sm: 2, md: Math.min(productCount, 3)}}
115+
spacing={8}
116+
justifyItems={{base: 'center', sm: 'start'}}
117+
>
217118
{bonusProducts.map((product) => {
218119
const productId = product.productId || product.id
219120
const productData = productsDataMap[productId]

0 commit comments

Comments
 (0)