Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
1 change: 1 addition & 0 deletions packages/pwa/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- For search engine crawlers, add `hreflang` links to the current page's html [#137](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/137)
- Use the preferred currency when switching locales. [#105](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/105)
- Integrate wishlist with einstein recommended products. [#131](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/131)
- Enable adding wishlist item to the cart. [#158](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/158)

## v1.1.0 (Sep 27, 2021)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,8 @@ export default function useCustomerProductLists() {
const ids = list.customerProductListItems.map((item) => item.productId)
const productDetails = await api.shopperProducts.getProducts({
parameters: {
ids: ids.join(',')
ids: ids.join(','),
allImages: true
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need this for the swatch images.

}
})
const result = self.mergeProductDetailsIntoList(list, productDetails)
Expand Down
52 changes: 31 additions & 21 deletions packages/pwa/app/components/_app/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ import {HideOnDesktop, HideOnMobile} from '../responsive'
import useShopper from '../../commerce-api/hooks/useShopper'
import useCustomer from '../../commerce-api/hooks/useCustomer'
import {AuthModal, useAuthModal} from '../../hooks/use-auth-modal'
import {
AddToCartModal,
useAddToCartModal,
AddToCartModalContext
} from '../../hooks/use-add-to-cart-modal'

// Localization
import {defineMessages, IntlProvider} from 'react-intl'
Expand Down Expand Up @@ -73,6 +78,7 @@ const App = (props) => {
const history = useHistory()
const location = useLocation()
const authModal = useAuthModal()
const addToCartModal = useAddToCartModal()
const customer = useCustomer()
const [isOnline, setIsOnline] = useState(true)
const styles = useStyleConfig('App')
Expand Down Expand Up @@ -228,30 +234,34 @@ const App = (props) => {
</Box>

{!isOnline && <OfflineBanner />}

<SkipNavContent
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
outline: 0
}}
>
<Box
as="main"
id="app-main"
role="main"
display="flex"
flexDirection="column"
flex="1"
<AddToCartModalContext.Provider value={addToCartModal}>
<SkipNavContent
style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
outline: 0
}}
>
<OfflineBoundary isOnline={false}>{children}</OfflineBoundary>
</Box>
</SkipNavContent>
<Box
as="main"
id="app-main"
role="main"
display="flex"
flexDirection="column"
flex="1"
>
<OfflineBoundary isOnline={false}>
{children}
</OfflineBoundary>
</Box>
</SkipNavContent>

{!isCheckout ? <Footer /> : <CheckoutFooter />}
{!isCheckout ? <Footer /> : <CheckoutFooter />}

<AuthModal {...authModal} />
<AuthModal {...authModal} />
<AddToCartModal />
</AddToCartModalContext.Provider>
</Box>
</CurrencyProvider>
</CategoriesProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
* 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 React, {useContext, useState, useEffect} from 'react'
import {useLocation} from 'react-router-dom'
import PropTypes from 'prop-types'
import {useIntl} from 'react-intl'
import {useIntl, FormattedMessage} from 'react-intl'
import {
AspectRatio,
Box,
Expand All @@ -22,33 +23,40 @@ import {
Stack,
useBreakpointValue
} from '@chakra-ui/react'
import Link from '../link'
import useBasket from '../../commerce-api/hooks/useBasket'
import {LockIcon} from '../icons'
import {useCurrency} from '../../hooks'
import {useVariationAttributes} from '../../hooks'
import {findImageGroupBy} from '../../utils/image-groups-utils'
import useBasket from '../commerce-api/hooks/useBasket'
import Link from '../components/link'
import RecommendedProducts from '../components/recommended-products'
import {LockIcon} from '../components/icons'
import {DEFAULT_CURRENCY} from '../constants'
import {useVariationAttributes} from './'
import {findImageGroupBy} from '../utils/image-groups-utils'

export const AddToCartModalContext = React.createContext()
export const useAddToCartModalContext = () => useContext(AddToCartModalContext)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we declare this context inside the index.js under the context folder? How should we decide which context will stay in its own file or inside the context file?

Copy link
Contributor Author

@kevinxh kevinxh Oct 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we declare this context inside the index.js under the context folder?

I'd say no, because i think the AddToCartModalContext itself doesn't work alone, it's not made to be shared with other modules or features. I view it as a part of the "Add to cart modal" module. This file encapsulate everything to make add to cart modal work in the app.

How should we decide which context will stay in its own file or inside the context file?

So i think this question comes down to recognizing the separation of concerns. A mental model for myself is like OO programming's principle Encapsulation, I view the module (e.g. this use-add-to-cart-modal.js file) as a "class" and everything related to this module should be encapsulated in the file. I think this pattern helps developers avoiding "Spagetti code" (generally because of bad separation of concerns)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed develop branch has a bunch of changes made to context.js (the update category context and the new currency context). Seems like we already established a pattern there, i guess i'll follow the pattern and maybe we can chat a bit more in tech discussion session.

TL;DR i'm moving this to context.js

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't do this because there is a circular dependency between the context, hook and the component... I really don't want to separate the component from the hook and contexts because the component itself doesn't work, it is meant to work only with the hook.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind adding a comment to explain that so it won't confuse other developers?


/**
* Visual feedback for adding item to the cart.
* Visual feedback (a modal) for adding item to the cart.
*/
const AddToCartModal = ({product, variant, quantity, isOpen, onClose, ...props}) => {
export const AddToCartModal = () => {
const {isOpen, onClose, data} = useAddToCartModalContext()
const {product, quantity} = data || {}
const intl = useIntl()
const basket = useBasket()
const size = useBreakpointValue({base: 'full', lg: '2xl', xl: '4xl'})
const {currency, productItems, productSubTotal, itemAccumulatedCount} = basket
const variationAttributes = useVariationAttributes(product)
const {productId, variationValues} = variant
const lineItemPrice =
productItems?.find((item) => item.productId === productId)?.basePrice * quantity
if (!isOpen) {
return null
}
const {currency, productItems, productSubTotal, itemAccumulatedCount} = basket
const {id, variationValues} = product
const lineItemPrice = productItems?.find((item) => item.productId === id)?.basePrice * quantity
const image = findImageGroupBy(product.imageGroups, {
viewType: 'small',
selectedVariationAttributes: variationValues
})?.images?.[0]
const {currency: activeCurrency} = useCurrency()

return (
<Modal size={size} isOpen={isOpen} onClose={onClose} {...props}>
<Modal size={size} isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent
margin="0"
Expand Down Expand Up @@ -113,7 +121,7 @@ const AddToCartModal = ({product, variant, quantity, isOpen, onClose, ...props})
{!!lineItemPrice &&
intl.formatNumber(lineItemPrice, {
style: 'currency',
currency: currency || activeCurrency
currency: currency || DEFAULT_CURRENCY
})}
</Text>
</Box>
Expand All @@ -138,7 +146,7 @@ const AddToCartModal = ({product, variant, quantity, isOpen, onClose, ...props})
{productSubTotal &&
intl.formatNumber(productSubTotal, {
style: 'currency',
currency: currency || activeCurrency
currency: currency || DEFAULT_CURRENCY
})}
</Text>
</Flex>
Expand All @@ -164,7 +172,15 @@ const AddToCartModal = ({product, variant, quantity, isOpen, onClose, ...props})
</Box>
</Flex>
</ModalBody>
<Box padding="8">{props.children}</Box>
<Box padding="8">
<RecommendedProducts
title={<FormattedMessage defaultMessage="You Might Also Like" />}
recommender={'pdp-similar-items'}
products={product && [product.id]}
mx={{base: -4, md: -8, lg: 0}}
shouldFetch={() => product?.id}
/>
</Box>
</ModalContent>
</Modal>
)
Expand All @@ -185,4 +201,34 @@ AddToCartModal.propTypes = {
children: PropTypes.any
}

export default AddToCartModal
export const useAddToCartModal = () => {
const [state, setState] = useState({
isOpen: false,
data: null
})

const {pathname} = useLocation()
useEffect(() => {
setState({
...state,
isOpen: false
})
}, [pathname])

return {
isOpen: state.isOpen,
data: state.data,
onOpen: (data) => {
setState({
isOpen: true,
data
})
},
onClose: () => {
setState({
isOpen: false,
data: null
})
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* 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 AddToCartModal from './index'
import {renderWithProviders} from '../../utils/test-utils'
import {AddToCartModal, AddToCartModalContext} from './use-add-to-cart-modal'
import {renderWithProviders} from '../utils/test-utils'

const MOCK_PRODUCT = {
currency: 'USD',
Expand Down Expand Up @@ -569,13 +569,32 @@ const MOCK_PRODUCT = {

test('Renders AddToCartModal', () => {
const {getByText} = renderWithProviders(
<AddToCartModal
product={MOCK_PRODUCT}
variant={MOCK_PRODUCT.variants[0]}
quantity={2}
isOpen={true}
/>
<AddToCartModalContext.Provider
value={{
isOpen: true,
data: {
product: MOCK_PRODUCT,
quantity: 22
}
}}
>
<AddToCartModal />
</AddToCartModalContext.Provider>
)

expect(getByText(MOCK_PRODUCT.name)).toBeInTheDocument()
})

test('Do not render when isOpen is false', () => {
const {queryByText} = renderWithProviders(
<AddToCartModalContext.Provider
value={{
isOpen: false
}}
>
<AddToCartModal />
</AddToCartModalContext.Provider>
)

expect(queryByText(MOCK_PRODUCT.name)).not.toBeInTheDocument()
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import React, {useState} from 'react'
import {Button} from '@chakra-ui/react'
import {Button, useDisclosure} from '@chakra-ui/react'
import useBasket from '../../../../commerce-api/hooks/useBasket'
import {useIntl} from 'react-intl'
import {useItemVariant} from '../../../../components/item-variant'
import ProductViewModal from '../../../../components/product-view-modal'
import {useToast} from '../../../../hooks/use-toast'
import {API_ERROR_MESSAGE} from '../../../../constants'

Expand All @@ -24,28 +25,29 @@ const WishlistPrimaryAction = () => {
const isMasterProduct = variant?.type?.master || false
const showToast = useToast()
const [isLoading, setIsLoading] = useState(false)

const handleAddToCart = async () => {
const {isOpen, onOpen, onClose} = useDisclosure()
const handleAddToCart = async (item, quantity) => {
setIsLoading(true)
const productItem = [
const productItems = [
{
productId: variant.productId,
quantity: variant.quantity,
price: variant.price
productId: item.id || item.productId,
price: item.price,
quantity
}
]
try {
await basket.addItemToBasket(productItem)
await basket.addItemToBasket(productItems)
showToast({
title: formatMessage(
{
defaultMessage:
'{quantity} {quantity, plural, one {item} other {items}} added to cart'
},
{quantity: variant.quantity}
{quantity: quantity}
),
status: 'success'
})
onClose()
} catch (error) {
showToast({
title: formatMessage(
Expand All @@ -61,13 +63,24 @@ const WishlistPrimaryAction = () => {
return (
<>
{isMasterProduct ? (
<Button w={'full'} variant={'solid'}>
Select Options
</Button>
<>
<Button w={'full'} variant={'solid'} onClick={onOpen}>
Select Options
</Button>
{isOpen && (
<ProductViewModal
isOpen={isOpen}
onOpen={onOpen}
onClose={onClose}
product={variant}
addToCart={(variant, quantity) => handleAddToCart(variant, quantity)}
/>
)}
</>
) : (
<Button
variant={'solid'}
onClick={handleAddToCart}
onClick={() => handleAddToCart(variant, variant.quantity)}
w={'full'}
isLoading={isLoading}
>
Expand Down
Loading