diff --git a/packages/pwa/CHANGELOG.md b/packages/pwa/CHANGELOG.md
index f8a9d220c3..0dca7184de 100644
--- a/packages/pwa/CHANGELOG.md
+++ b/packages/pwa/CHANGELOG.md
@@ -1,3 +1,7 @@
+## To be released
+
+- Integrate wishlist with einstein recommended products. [#131](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/131)
+
## v1.1.0 (Sep 27, 2021)
- Fix wishlist bugs and provide better hooks for wishlist features. [#64](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/64)
diff --git a/packages/pwa/app/assets/svg/wishlist-solid.svg b/packages/pwa/app/assets/svg/heart-solid.svg
similarity index 100%
rename from packages/pwa/app/assets/svg/wishlist-solid.svg
rename to packages/pwa/app/assets/svg/heart-solid.svg
diff --git a/packages/pwa/app/assets/svg/wishlist.svg b/packages/pwa/app/assets/svg/heart.svg
similarity index 100%
rename from packages/pwa/app/assets/svg/wishlist.svg
rename to packages/pwa/app/assets/svg/heart.svg
diff --git a/packages/pwa/app/commerce-api/hooks/useCustomerProductLists.js b/packages/pwa/app/commerce-api/hooks/useCustomerProductLists.js
index 23edf6a028..bdadd5143e 100644
--- a/packages/pwa/app/commerce-api/hooks/useCustomerProductLists.js
+++ b/packages/pwa/app/commerce-api/hooks/useCustomerProductLists.js
@@ -238,7 +238,7 @@ export default function useCustomerProductLists() {
)
return
}
- self.removeListItem(listId, item.id)
+ return self.removeListItem(listId, item.id)
},
/**
diff --git a/packages/pwa/app/components/header/index.jsx b/packages/pwa/app/components/header/index.jsx
index 78d159637c..3f0d1896c5 100644
--- a/packages/pwa/app/components/header/index.jsx
+++ b/packages/pwa/app/components/header/index.jsx
@@ -40,7 +40,7 @@ import {
BasketIcon,
HamburgerIcon,
ChevronDownIcon,
- WishlistIcon,
+ HeartIcon,
SignoutIcon
} from '../icons'
@@ -241,7 +241,7 @@ const Header = ({
aria-label={intl.formatMessage({
defaultMessage: 'Wishlist'
})}
- icon={}
+ icon={}
variant="unstyled"
{...styles.icons}
onClick={onWishlistClick}
diff --git a/packages/pwa/app/components/icons/index.jsx b/packages/pwa/app/components/icons/index.jsx
index 0d49ad86c0..498136ccf4 100644
--- a/packages/pwa/app/components/icons/index.jsx
+++ b/packages/pwa/app/components/icons/index.jsx
@@ -47,8 +47,8 @@ import '../../assets/svg/signout.svg'
import '../../assets/svg/user.svg'
import '../../assets/svg/visibility.svg'
import '../../assets/svg/visibility-off.svg'
-import '../../assets/svg/wishlist.svg'
-import '../../assets/svg/wishlist-solid.svg'
+import '../../assets/svg/heart.svg'
+import '../../assets/svg/heart-solid.svg'
import '../../assets/svg/close.svg'
// For non-square SVGs, we can use the symbol data from the import to set the
@@ -145,6 +145,6 @@ export const UserIcon = icon('user')
export const VisaIcon = icon('cc-visa', {viewBox: VisaSymbol.viewBox})
export const VisibilityIcon = icon('visibility')
export const VisibilityOffIcon = icon('visibility-off')
-export const WishlistIcon = icon('wishlist')
-export const WishlistSolidIcon = icon('wishlist-solid')
+export const HeartIcon = icon('heart')
+export const HeartSolidIcon = icon('heart-solid')
export const CloseIcon = icon('close')
diff --git a/packages/pwa/app/components/product-scroller/index.jsx b/packages/pwa/app/components/product-scroller/index.jsx
index 2bfce48bad..8f7a458f61 100644
--- a/packages/pwa/app/components/product-scroller/index.jsx
+++ b/packages/pwa/app/components/product-scroller/index.jsx
@@ -23,7 +23,7 @@ const ProductScroller = forwardRef(
isLoading,
scrollProps,
itemWidth = {base: '70%', md: '40%', lg: 'calc(33.33% - 10px)'},
- onProductClick = () => null,
+ productTileProps,
...props
},
ref
@@ -94,8 +94,10 @@ const ProductScroller = forwardRef(
) : (
onProductClick(product)}
+ product={product}
+ {...(typeof productTileProps === 'function'
+ ? {...productTileProps(product)}
+ : {...productTileProps})}
/>
)}
@@ -161,7 +163,7 @@ ProductScroller.propTypes = {
isLoading: PropTypes.bool,
scrollProps: PropTypes.object,
itemWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.object]),
- onProductClick: PropTypes.func
+ productTileProps: PropTypes.oneOfType([PropTypes.func, PropTypes.object])
}
export default ProductScroller
diff --git a/packages/pwa/app/components/product-scroller/index.test.js b/packages/pwa/app/components/product-scroller/index.test.js
index 91f930dd5d..b11684ffe3 100644
--- a/packages/pwa/app/components/product-scroller/index.test.js
+++ b/packages/pwa/app/components/product-scroller/index.test.js
@@ -17,7 +17,7 @@ window.HTMLElement.prototype.scrollBy = jest.fn()
const testProducts = [1, 2, 3, 4].map((i) => ({
id: i,
- productId: i,
+ productId: `${i}`,
productName: `Product ${i}`,
image: {disBaseLink: '/testimage'},
price: 9.99,
@@ -30,10 +30,10 @@ describe('Product Scroller', () => {
expect(screen.getAllByTestId('product-scroller-item-skeleton')).toHaveLength(4)
})
test('renders nothing when no products and not loading', () => {
- renderWithProviders()
+ renderWithProviders()
expect(screen.queryByTestId('product-scroller')).not.toBeInTheDocument()
})
- test('Renders scrollable product tiles and title', () => {
+ test('Renders product items', () => {
renderWithProviders()
expect(screen.getByText('Scroller Title')).toBeInTheDocument()
expect(screen.getAllByTestId('product-scroller-item')).toHaveLength(4)
@@ -71,4 +71,23 @@ describe('Product Scroller', () => {
expect(screen.queryByTestId('product-scroller-nav-left')).not.toBeInTheDocument()
expect(screen.queryByTestId('product-scroller-nav-right')).not.toBeInTheDocument()
})
+ test('productTileProps as object', () => {
+ const onClickMock = jest.fn()
+ renderWithProviders(
+
+ )
+ user.click(screen.getByText(testProducts[0].productName))
+ expect(onClickMock).toHaveBeenCalled()
+ })
+ test('productTileProps as function', () => {
+ const onClickMock = jest.fn()
+ renderWithProviders(
+ ({onClick: onClickMock})}
+ />
+ )
+ user.click(screen.getByText(testProducts[0].productName))
+ expect(onClickMock).toHaveBeenCalled()
+ })
})
diff --git a/packages/pwa/app/components/product-tile/index.jsx b/packages/pwa/app/components/product-tile/index.jsx
index 485b145125..7c3c832458 100644
--- a/packages/pwa/app/components/product-tile/index.jsx
+++ b/packages/pwa/app/components/product-tile/index.jsx
@@ -5,9 +5,9 @@
* 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, {useState} from 'react'
import PropTypes from 'prop-types'
-import {WishlistIcon, WishlistSolidIcon} from '../icons'
+import {HeartIcon, HeartSolidIcon} from '../icons'
// Components
import {
@@ -50,80 +50,60 @@ export const Skeleton = () => {
}
/**
- * The ProductTile is a simple visual representation of a product search hit
- * object. It will show it's default image, name and price.
+ * The ProductTile is a simple visual representation of a
+ * product object. It will show it's default image, name and price.
+ * It also supports favourite products, controlled by a heart icon.
*/
const ProductTile = (props) => {
const intl = useIntl()
-
- // eslint-disable-next-line react/prop-types
- const {
- productSearchItem,
- // eslint-disable-next-line react/prop-types
- staticContext,
- onAddToWishlistClick,
- onRemoveWishlistClick,
- isInWishlist,
- isWishlistLoading,
- ...rest
- } = props
- const {currency, image, price, productName} = productSearchItem
- const styles = useMultiStyleConfig('ProductTile', {isLoading: isWishlistLoading})
+ const {product, enableFavourite = false, isFavourite, onFavouriteToggle, ...rest} = props
+ const {currency, image, price, productName, productId} = product
+ const [isFavouriteLoading, setFavouriteLoading] = useState(false)
+ const styles = useMultiStyleConfig('ProductTile')
return (
-
+
- {onAddToWishlistClick && onRemoveWishlistClick && (
- <>
- {isInWishlist ? (
- }
- variant="unstyled"
- {...styles.iconButton}
- onClick={(e) => {
- e.preventDefault()
- if (isWishlistLoading) return
- onRemoveWishlistClick()
- }}
- />
- ) : (
- }
- variant="unstyled"
- {...styles.iconButton}
- onClick={() => {
- if (isWishlistLoading) return
- onAddToWishlistClick()
- }}
- />
- )}
- >
+
+ {enableFavourite && (
+ {
+ // stop click event from bubbling
+ // to avoid user from clicking the underlying
+ // product while the favourite icon is disabled
+ e.preventDefault()
+ }}
+ >
+ : }
+ {...styles.favIcon}
+ disabled={isFavouriteLoading}
+ onClick={async () => {
+ setFavouriteLoading(true)
+ await onFavouriteToggle(!isFavourite)
+ setFavouriteLoading(false)
+ }}
+ />
+
)}
{/* Title */}
-
- {productName}
-
+ {productName}
{/* Price */}
-
- {intl.formatNumber(price, {style: 'currency', currency})}
-
+ {intl.formatNumber(price, {style: 'currency', currency})}
)
}
@@ -135,20 +115,30 @@ ProductTile.propTypes = {
* The product search hit that will be represented in this
* component.
*/
- productSearchItem: PropTypes.object.isRequired,
+ product: PropTypes.shape({
+ currency: PropTypes.string,
+ image: PropTypes.shape({
+ alt: PropTypes.string,
+ disBaseLink: PropTypes.string
+ }),
+ price: PropTypes.number,
+ productName: PropTypes.string,
+ productId: PropTypes.string
+ }),
/**
- * Types of lists the product/variant is added to. (eg: wishlist)
+ * Enable adding/removing product as a favourite.
+ * Use case: wishlist.
*/
- isInWishlist: PropTypes.bool,
+ enableFavourite: PropTypes.bool,
/**
- * Callback function to be invoked when the user add item to wishlist
+ * Display the product as a faviourite.
*/
- onAddToWishlistClick: PropTypes.func,
+ isFavourite: PropTypes.bool,
/**
- * Callback function to be invoked when the user removes item to wishlist
+ * Callback function to be invoked when the user
+ * interacts with favourite icon/button.
*/
- onRemoveWishlistClick: PropTypes.func,
- isWishlistLoading: PropTypes.bool
+ onFavouriteToggle: PropTypes.func
}
export default ProductTile
diff --git a/packages/pwa/app/components/product-tile/index.test.js b/packages/pwa/app/components/product-tile/index.test.js
index 093287ee92..fa6185fb79 100644
--- a/packages/pwa/app/components/product-tile/index.test.js
+++ b/packages/pwa/app/components/product-tile/index.test.js
@@ -20,9 +20,7 @@ const mockProductSearchItem = {
}
test('Renders Breadcrumb', () => {
- const {getAllByRole} = renderWithProviders(
-
- )
+ const {getAllByRole} = renderWithProviders()
const link = getAllByRole('link')
const img = getAllByRole('img')
diff --git a/packages/pwa/app/components/recommended-products/index.jsx b/packages/pwa/app/components/recommended-products/index.jsx
index c5b8967b2e..c0d8d0d77c 100644
--- a/packages/pwa/app/components/recommended-products/index.jsx
+++ b/packages/pwa/app/components/recommended-products/index.jsx
@@ -6,9 +6,15 @@
*/
import React, {useEffect, useRef, useState} from 'react'
import PropTypes from 'prop-types'
+import {useIntl} from 'react-intl'
+import {Button} from '@chakra-ui/react'
import ProductScroller from '../../components/product-scroller'
import useEinstein from '../../commerce-api/hooks/useEinstein'
import useIntersectionObserver from '../../hooks/use-intersection-observer'
+import useWishlist from '../../hooks/use-wishlist'
+import {useToast} from '../../hooks/use-toast'
+import useNavigation from '../../hooks/use-navigation'
+import {API_ERROR_MESSAGE} from '../../constants'
/**
* A component for fetching and rendering product recommendations from the Einstein API
@@ -24,6 +30,10 @@ const RecommendedProducts = ({zone, recommender, products, title, shouldFetch, .
sendClickReco,
sendViewReco
} = useEinstein()
+ const wishlist = useWishlist()
+ const toast = useToast()
+ const navigate = useNavigation()
+ const {formatMessage} = useIntl()
const ref = useRef()
const isOnScreen = useIntersectionObserver(ref, {useOnce: true})
@@ -98,21 +108,86 @@ const RecommendedProducts = ({zone, recommender, products, title, shouldFetch, .
return null
}
+ // TODO: DRY the wishlist handlers when intl
+ // provider is available globally
+ const addItemToWishlist = async (product) => {
+ try {
+ await wishlist.createListItem({
+ id: product.productId,
+ quantity: 1
+ })
+ toast({
+ title: formatMessage(
+ {
+ defaultMessage:
+ '{quantity} {quantity, plural, one {item} other {items}} added to wishlist'
+ },
+ {quantity: 1}
+ ),
+ status: 'success',
+ action: (
+ // it would be better if we could use