Skip to content

Commit afb6ea9

Browse files
committed
Manual bonus products port from v4
1 parent 1611a5b commit afb6ea9

File tree

26 files changed

+3045
-62
lines changed

26 files changed

+3045
-62
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,8 @@ lerna-debug.log
1414
/test-results/
1515
/playwright-report/
1616
/playwright/.cache/
17+
18+
# Build directories and distribution files
19+
build/
20+
**/build/
21+
**/dist/

packages/template-retail-react-app/app/components/_app/index.jsx

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
useDntNotification
5858
} from '@salesforce/retail-react-app/app/hooks/use-dnt-notification'
5959
import {AddToCartModalProvider} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal'
60+
import {BonusProductSelectionModalProvider} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-selection-modal'
6061
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
6162
import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
6263
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
@@ -430,34 +431,36 @@ const App = (props) => {
430431
</Island>
431432
{!isOnline && <OfflineBanner />}
432433
<AddToCartModalProvider>
433-
<SkipNavContent
434-
style={{
435-
display: 'flex',
436-
flexDirection: 'column',
437-
flex: 1,
438-
outline: 0
439-
}}
440-
>
441-
<Box
442-
as="main"
443-
id="app-main"
444-
role="main"
445-
display="flex"
446-
flexDirection="column"
447-
flex="1"
434+
<BonusProductSelectionModalProvider>
435+
<SkipNavContent
436+
style={{
437+
display: 'flex',
438+
flexDirection: 'column',
439+
flex: 1,
440+
outline: 0
441+
}}
448442
>
449-
<OfflineBoundary isOnline={false}>
450-
{children}
451-
</OfflineBoundary>
452-
</Box>
453-
</SkipNavContent>
454-
455-
<Island hydrateOn={'visible'}>
456-
{!isCheckout ? <Footer /> : <CheckoutFooter />}
457-
</Island>
458-
459-
<AuthModal {...authModal} />
460-
<DntNotification {...dntNotification} />
443+
<Box
444+
as="main"
445+
id="app-main"
446+
role="main"
447+
display="flex"
448+
flexDirection="column"
449+
flex="1"
450+
>
451+
<OfflineBoundary isOnline={false}>
452+
{children}
453+
</OfflineBoundary>
454+
</Box>
455+
</SkipNavContent>
456+
457+
<Island hydrateOn={'visible'}>
458+
{!isCheckout ? <Footer /> : <CheckoutFooter />}
459+
</Island>
460+
461+
<AuthModal {...authModal} />
462+
<DntNotification {...dntNotification} />
463+
</BonusProductSelectionModalProvider>
461464
</AddToCartModalProvider>
462465
</Box>
463466
</CurrencyProvider>
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/*
2+
* Copyright (c) 2021, salesforce.com, 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+
8+
import React, {useMemo, useCallback} from 'react'
9+
import PropTypes from 'prop-types'
10+
import {
11+
Modal,
12+
ModalOverlay,
13+
ModalContent,
14+
ModalBody,
15+
ModalCloseButton,
16+
Button,
17+
Box,
18+
Text
19+
} from '@salesforce/retail-react-app/app/components/shared/ui'
20+
import ProductView from '@salesforce/retail-react-app/app/components/product-view'
21+
import {useProductViewModal} from '@salesforce/retail-react-app/app/hooks/use-product-view-modal'
22+
import {useIntl} from 'react-intl'
23+
import {useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react'
24+
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
25+
import {findAvailableBonusDiscountLineItemId} from '@salesforce/retail-react-app/app/utils/bonus-product-utils'
26+
import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
27+
import {useLocation} from 'react-router-dom'
28+
import {productViewModalTheme} from '@salesforce/retail-react-app/app/theme/components/project/product-view-modal'
29+
30+
/**
31+
* A Modal that contains Bonus Product View
32+
*/
33+
const BonusProductViewModal = ({
34+
product,
35+
isOpen,
36+
onClose,
37+
bonusDiscountLineItemId,
38+
promotionId,
39+
...props
40+
}) => {
41+
// Ensure a safe product shape for the modal hook
42+
const safeProduct = useMemo(() => {
43+
if (!product) return {productId: undefined, variants: [], variationAttributes: []}
44+
const id = product.productId || product.id
45+
return {
46+
productId: id,
47+
id,
48+
variants: product.variants || [],
49+
variationAttributes: product.variationAttributes || [],
50+
imageGroups: product.imageGroups || [],
51+
type: product.type || {set: false, bundle: false},
52+
price: product.price,
53+
name: product.name || product.productName
54+
}
55+
}, [product])
56+
57+
const productViewModalData = useProductViewModal(safeProduct)
58+
const {addItemToNewOrExistingBasket} = useShopperBasketsMutationHelper()
59+
const {data: basket} = useCurrentBasket()
60+
const navigate = useNavigation()
61+
const location = useLocation()
62+
63+
const intl = useIntl()
64+
const {formatMessage} = intl
65+
66+
const messages = useMemo(
67+
() => ({
68+
modalLabel: formatMessage(
69+
{
70+
id: 'bonus_product_view_modal.modal_label',
71+
defaultMessage: 'Bonus product selection modal for {productName}'
72+
},
73+
{productName: productViewModalData?.product?.name}
74+
),
75+
viewCart: formatMessage({
76+
id: 'bonus_product_view_modal.button.view_cart',
77+
defaultMessage: 'View Cart'
78+
})
79+
}),
80+
[intl]
81+
)
82+
83+
// Determine context for navigation behavior
84+
const isFromAddToCartModal = location.pathname !== '/cart'
85+
86+
// Custom addToCart handler for bonus products that includes bonusDiscountLineItemId
87+
const handleAddToCart = useCallback(
88+
async (variant, quantity) => {
89+
try {
90+
// Default quantity to 1 if not provided or invalid
91+
const finalQuantity = quantity && quantity > 0 ? quantity : 1
92+
93+
// Find the first available bonus discount line item with capacity
94+
const availableBonusDiscountLineItemId = findAvailableBonusDiscountLineItemId(
95+
basket,
96+
promotionId,
97+
finalQuantity,
98+
bonusDiscountLineItemId // fallback to originally passed id
99+
)
100+
101+
if (!availableBonusDiscountLineItemId) {
102+
console.warn('No available bonus discount line item found')
103+
return null
104+
}
105+
106+
const productItems = [
107+
{
108+
productId: variant?.productId || product?.productId || product?.id,
109+
price: variant?.price || product?.price,
110+
quantity: parseInt(finalQuantity, 10),
111+
bonusDiscountLineItemId: availableBonusDiscountLineItemId
112+
}
113+
]
114+
115+
const result = await addItemToNewOrExistingBasket(productItems)
116+
117+
// Navigate to cart page after successful add to cart
118+
if (result) {
119+
// Close modal immediately and navigate with proper delay
120+
onClose()
121+
// Always use a delay to ensure modal closes cleanly
122+
setTimeout(() => {
123+
navigate('/cart', 'push')
124+
}, 200)
125+
}
126+
127+
return result
128+
} catch (error) {
129+
console.error('Error adding bonus product to cart:', error)
130+
return null
131+
}
132+
},
133+
[
134+
addItemToNewOrExistingBasket,
135+
product,
136+
bonusDiscountLineItemId,
137+
promotionId,
138+
basket,
139+
onClose,
140+
navigate,
141+
isFromAddToCartModal
142+
]
143+
)
144+
145+
// Custom buttons for the ProductView
146+
const handleViewCart = useCallback(() => {
147+
// Close modal immediately and navigate with proper delay
148+
onClose()
149+
// Always use a delay to ensure modal closes cleanly
150+
setTimeout(() => {
151+
navigate('/cart', 'push')
152+
}, 200)
153+
}, [onClose, navigate])
154+
155+
const customButtons = useMemo(
156+
() => [
157+
<Button key="view-cart" variant="outline" onClick={handleViewCart}>
158+
{messages.viewCart}
159+
</Button>
160+
],
161+
[messages.viewCart, handleViewCart]
162+
)
163+
164+
// Aggressively clean product data to prevent SwatchGroup errors while preserving essential fields
165+
const productToRender = useMemo(() => {
166+
const baseProduct = productViewModalData.product || safeProduct
167+
return {
168+
...baseProduct,
169+
variationAttributes: [], // Force empty array
170+
variants: [], // Also remove variants to be safe
171+
variationParams: {},
172+
selectedVariationAttributes: {},
173+
type: {...baseProduct.type, variant: false, master: false},
174+
// Ensure proper inventory and quantity defaults for bonus products
175+
inventory: {
176+
...baseProduct.inventory,
177+
orderable: true,
178+
stockLevel: 999 // High stock level for bonus products
179+
},
180+
minOrderQuantity: 1,
181+
stepQuantity: 1,
182+
// Ensure the product is orderable
183+
orderable: true
184+
}
185+
}, [productViewModalData.product, safeProduct])
186+
187+
return (
188+
<Modal
189+
isOpen={isOpen}
190+
onClose={onClose}
191+
size={productViewModalTheme.modal.size}
192+
closeOnOverlayClick={true}
193+
closeOnEsc={true}
194+
motionPreset="slideInBottom"
195+
preserveScrollBarGap={true}
196+
>
197+
<ModalOverlay bg="blackAlpha.600" />
198+
<ModalContent
199+
data-testid="bonus-product-view-modal"
200+
aria-label={messages.modalLabel}
201+
margin="0"
202+
borderRadius={{base: 'none', md: 'base'}}
203+
bg="white"
204+
maxHeight="85vh"
205+
overflowY="auto"
206+
boxShadow="xl"
207+
>
208+
<ModalBody bg="white" p={6} pb={8} mt={6}>
209+
{productViewModalData.isFetching && !productViewModalData.product ? (
210+
<Box p={8} textAlign="center">
211+
<Text>Loading product details...</Text>
212+
</Box>
213+
) : (
214+
<ProductView
215+
showFullLink={false}
216+
imageSize="sm"
217+
showImageGallery={true}
218+
product={productToRender}
219+
isLoading={false}
220+
addToCart={handleAddToCart}
221+
isProductLoading={false}
222+
customButtons={customButtons}
223+
promotionId={promotionId}
224+
{...props}
225+
/>
226+
)}
227+
</ModalBody>
228+
<ModalCloseButton size="sm" />
229+
</ModalContent>
230+
</Modal>
231+
)
232+
}
233+
234+
BonusProductViewModal.propTypes = {
235+
isOpen: PropTypes.bool.isRequired,
236+
onOpen: PropTypes.func,
237+
onClose: PropTypes.func.isRequired,
238+
product: PropTypes.object,
239+
isLoading: PropTypes.bool,
240+
bonusDiscountLineItemId: PropTypes.string, // The 'id' from bonusDiscountLineItems
241+
promotionId: PropTypes.string // The promotion ID to filter promotions in PromoCallout
242+
}
243+
244+
export default BonusProductViewModal

packages/template-retail-react-app/app/components/product-item-list/index.jsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ const ProductItemList = ({
2828
localQuantity = {},
2929
localIsGiftItems = {},
3030
isCartItemLoading = false,
31-
selectedItem = null
31+
selectedItem = null,
32+
// Styling options
33+
hideBorder = false
3234
}) => {
3335
return (
3436
<Stack spacing={4}>
@@ -65,6 +67,7 @@ const ProductItemList = ({
6567
isCartItemLoading && selectedItem?.itemId === productItem.itemId
6668
}
6769
handleRemoveItem={onRemoveItemClick}
70+
hideBorder={hideBorder}
6871
/>
6972
)
7073
})}
@@ -82,7 +85,8 @@ ProductItemList.propTypes = {
8285
localQuantity: PropTypes.object,
8386
localIsGiftItems: PropTypes.object,
8487
isCartItemLoading: PropTypes.bool,
85-
selectedItem: PropTypes.object
88+
selectedItem: PropTypes.object,
89+
hideBorder: PropTypes.bool
8690
}
8791

8892
export default ProductItemList

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ const ProductItem = ({
4141
primaryAction,
4242
secondaryActions,
4343
onItemQuantityChange = noop,
44-
showLoading = false
44+
showLoading = false,
45+
hideBorder = false
4546
}) => {
4647
const {stepQuantity, showInventoryMessage, inventoryMessage, quantity, setQuantity} =
4748
useDerivedProduct(product)
@@ -53,7 +54,7 @@ const ProductItem = ({
5354
>
5455
<ItemVariantProvider variant={product}>
5556
{showLoading && <LoadingSpinner />}
56-
<Stack layerStyle="cardBordered" align="flex-start">
57+
<Stack layerStyle={hideBorder ? undefined : "cardBordered"} align="flex-start">
5758
<Flex width="full" alignItems="flex-start" backgroundColor="white">
5859
<CartItemVariantImage width={['88px', '136px']} mr={4} />
5960
<Stack spacing={3} flex={1}>
@@ -124,7 +125,8 @@ ProductItem.propTypes = {
124125
showLoading: PropTypes.bool,
125126
isWishlistItem: PropTypes.bool,
126127
primaryAction: PropTypes.node,
127-
secondaryActions: PropTypes.node
128+
secondaryActions: PropTypes.node,
129+
hideBorder: PropTypes.bool
128130
}
129131

130132
export default ProductItem

0 commit comments

Comments
 (0)