Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ lerna-debug.log
/test-results/
/playwright-report/
/playwright/.cache/

# Build directories and distribution files
build/
**/build/
**/dist/
57 changes: 30 additions & 27 deletions packages/template-retail-react-app/app/components/_app/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
useDntNotification
} from '@salesforce/retail-react-app/app/hooks/use-dnt-notification'
import {AddToCartModalProvider} from '@salesforce/retail-react-app/app/hooks/use-add-to-cart-modal'
import {BonusProductSelectionModalProvider} from '@salesforce/retail-react-app/app/hooks/use-bonus-product-selection-modal'
import useMultiSite from '@salesforce/retail-react-app/app/hooks/use-multi-site'
import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
Expand Down Expand Up @@ -430,34 +431,36 @@ const App = (props) => {
</Island>
{!isOnline && <OfflineBanner />}
<AddToCartModalProvider>
<SkipNavContent
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
outline: 0
}}
>
<Box
as="main"
id="app-main"
role="main"
display="flex"
flexDirection="column"
flex="1"
<BonusProductSelectionModalProvider>
<SkipNavContent
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
outline: 0
}}
>
<OfflineBoundary isOnline={false}>
{children}
</OfflineBoundary>
</Box>
</SkipNavContent>

<Island hydrateOn={'visible'}>
{!isCheckout ? <Footer /> : <CheckoutFooter />}
</Island>

<AuthModal {...authModal} />
<DntNotification {...dntNotification} />
<Box
as="main"
id="app-main"
role="main"
display="flex"
flexDirection="column"
flex="1"
>
<OfflineBoundary isOnline={false}>
{children}
</OfflineBoundary>
</Box>
</SkipNavContent>

<Island hydrateOn={'visible'}>
{!isCheckout ? <Footer /> : <CheckoutFooter />}
</Island>

<AuthModal {...authModal} />
<DntNotification {...dntNotification} />
</BonusProductSelectionModalProvider>
</AddToCartModalProvider>
</Box>
</CurrencyProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
/*
* Copyright (c) 2021, 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, {useMemo, useCallback} from 'react'
import PropTypes from 'prop-types'
import {
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton,
Button,
Box,
Text
} from '@salesforce/retail-react-app/app/components/shared/ui'
import ProductView from '@salesforce/retail-react-app/app/components/product-view'
import {useProductViewModal} from '@salesforce/retail-react-app/app/hooks/use-product-view-modal'
import {useIntl} from 'react-intl'
import {useShopperBasketsMutationHelper} from '@salesforce/commerce-sdk-react'
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
import {findAvailableBonusDiscountLineItemId} from '@salesforce/retail-react-app/app/utils/bonus-product-utils'
import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
import {useLocation} from 'react-router-dom'
import {productViewModalTheme} from '@salesforce/retail-react-app/app/theme/components/project/product-view-modal'

/**
* A Modal that contains Bonus Product View
*/
const BonusProductViewModal = ({
product,
isOpen,
onClose,
bonusDiscountLineItemId,
promotionId,
...props
}) => {
// Ensure a safe product shape for the modal hook
const safeProduct = useMemo(() => {
if (!product) return {productId: undefined, variants: [], variationAttributes: []}
const id = product.productId || product.id
return {
productId: id,
id,
variants: product.variants || [],
variationAttributes: product.variationAttributes || [],
imageGroups: product.imageGroups || [],
type: product.type || {set: false, bundle: false},
price: product.price,
name: product.name || product.productName
}
}, [product])

const productViewModalData = useProductViewModal(safeProduct)
const {addItemToNewOrExistingBasket} = useShopperBasketsMutationHelper()
const {data: basket} = useCurrentBasket()
const navigate = useNavigation()
const location = useLocation()

const intl = useIntl()
const {formatMessage} = intl

const messages = useMemo(
() => ({
modalLabel: formatMessage(
{
id: 'bonus_product_view_modal.modal_label',
defaultMessage: 'Bonus product selection modal for {productName}'
},
{productName: productViewModalData?.product?.name}
),
viewCart: formatMessage({
id: 'bonus_product_view_modal.button.view_cart',
defaultMessage: 'View Cart'
})
}),
[intl]
)

// Determine context for navigation behavior
const isFromAddToCartModal = location.pathname !== '/cart'

// Custom addToCart handler for bonus products that includes bonusDiscountLineItemId
const handleAddToCart = useCallback(
async (variant, quantity) => {
try {
// Default quantity to 1 if not provided or invalid
const finalQuantity = quantity && quantity > 0 ? quantity : 1

// Find the first available bonus discount line item with capacity
const availableBonusDiscountLineItemId = findAvailableBonusDiscountLineItemId(
basket,
promotionId,
finalQuantity,
bonusDiscountLineItemId // fallback to originally passed id
)

if (!availableBonusDiscountLineItemId) {
console.warn('No available bonus discount line item found')
return null
}

const productItems = [
{
productId: variant?.productId || product?.productId || product?.id,
price: variant?.price || product?.price,
quantity: parseInt(finalQuantity, 10),
bonusDiscountLineItemId: availableBonusDiscountLineItemId
}
]

const result = await addItemToNewOrExistingBasket(productItems)

// Navigate to cart page after successful add to cart
if (result) {
// Close modal immediately and navigate with proper delay
onClose()
// Always use a delay to ensure modal closes cleanly
setTimeout(() => {
navigate('/cart', 'push')
}, 200)
}

return result
} catch (error) {
console.error('Error adding bonus product to cart:', error)
return null
}
},
[
addItemToNewOrExistingBasket,
product,
bonusDiscountLineItemId,
promotionId,
basket,
onClose,
navigate,
isFromAddToCartModal
]
)

// Custom buttons for the ProductView
const handleViewCart = useCallback(() => {
// Close modal immediately and navigate with proper delay
onClose()
// Always use a delay to ensure modal closes cleanly
setTimeout(() => {
navigate('/cart', 'push')
}, 200)
}, [onClose, navigate])

const customButtons = useMemo(
() => [
<Button key="view-cart" variant="outline" onClick={handleViewCart}>
{messages.viewCart}
</Button>
],
[messages.viewCart, handleViewCart]
)

// Aggressively clean product data to prevent SwatchGroup errors while preserving essential fields
const productToRender = useMemo(() => {
const baseProduct = productViewModalData.product || safeProduct
return {
...baseProduct,
variationAttributes: [], // Force empty array
variants: [], // Also remove variants to be safe
variationParams: {},
selectedVariationAttributes: {},
type: {...baseProduct.type, variant: false, master: false},
// Ensure proper inventory and quantity defaults for bonus products
inventory: {
...baseProduct.inventory,
orderable: true,
stockLevel: 999 // High stock level for bonus products
},
minOrderQuantity: 1,
stepQuantity: 1,
// Ensure the product is orderable
orderable: true
}
}, [productViewModalData.product, safeProduct])

return (
<Modal
isOpen={isOpen}
onClose={onClose}
size={productViewModalTheme.modal.size}
closeOnOverlayClick={true}
closeOnEsc={true}
motionPreset="slideInBottom"
preserveScrollBarGap={true}
>
<ModalOverlay bg="blackAlpha.600" />
<ModalContent
data-testid="bonus-product-view-modal"
aria-label={messages.modalLabel}
margin="0"
borderRadius={{base: 'none', md: 'base'}}
bg="white"
maxHeight="85vh"
overflowY="auto"
boxShadow="xl"
>
<ModalBody bg="white" p={6} pb={8} mt={6}>
{productViewModalData.isFetching && !productViewModalData.product ? (
<Box p={8} textAlign="center">
<Text>Loading product details...</Text>
</Box>
) : (
<ProductView
showFullLink={false}
imageSize="sm"
showImageGallery={true}
product={productToRender}
isLoading={false}
addToCart={handleAddToCart}
isProductLoading={false}
customButtons={customButtons}
promotionId={promotionId}
{...props}
/>
)}
</ModalBody>
<ModalCloseButton size="sm" />
</ModalContent>
</Modal>
)
}

BonusProductViewModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onOpen: PropTypes.func,
onClose: PropTypes.func.isRequired,
product: PropTypes.object,
isLoading: PropTypes.bool,
bonusDiscountLineItemId: PropTypes.string, // The 'id' from bonusDiscountLineItems
promotionId: PropTypes.string // The promotion ID to filter promotions in PromoCallout
}

export default BonusProductViewModal
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ const ProductItemList = ({
localQuantity = {},
localIsGiftItems = {},
isCartItemLoading = false,
selectedItem = null
selectedItem = null,
// Styling options
hideBorder = false
}) => {
return (
<Stack spacing={4}>
Expand Down Expand Up @@ -65,6 +67,7 @@ const ProductItemList = ({
isCartItemLoading && selectedItem?.itemId === productItem.itemId
}
handleRemoveItem={onRemoveItemClick}
hideBorder={hideBorder}
/>
)
})}
Expand All @@ -82,7 +85,8 @@ ProductItemList.propTypes = {
localQuantity: PropTypes.object,
localIsGiftItems: PropTypes.object,
isCartItemLoading: PropTypes.bool,
selectedItem: PropTypes.object
selectedItem: PropTypes.object,
hideBorder: PropTypes.bool
}

export default ProductItemList
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ const ProductItem = ({
primaryAction,
secondaryActions,
onItemQuantityChange = noop,
showLoading = false
showLoading = false,
hideBorder = false
}) => {
const {stepQuantity, showInventoryMessage, inventoryMessage, quantity, setQuantity} =
useDerivedProduct(product)
Expand All @@ -53,7 +54,7 @@ const ProductItem = ({
>
<ItemVariantProvider variant={product}>
{showLoading && <LoadingSpinner />}
<Stack layerStyle="cardBordered" align="flex-start">
<Stack layerStyle={hideBorder ? undefined : "cardBordered"} align="flex-start">
<Flex width="full" alignItems="flex-start" backgroundColor="white">
<CartItemVariantImage width={['88px', '136px']} mr={4} />
<Stack spacing={3} flex={1}>
Expand Down Expand Up @@ -124,7 +125,8 @@ ProductItem.propTypes = {
showLoading: PropTypes.bool,
isWishlistItem: PropTypes.bool,
primaryAction: PropTypes.node,
secondaryActions: PropTypes.node
secondaryActions: PropTypes.node,
hideBorder: PropTypes.bool
}

export default ProductItem
Loading
Loading