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 ( - + {image.alt} - {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 + ) + }) + } catch { + toast({ + title: formatMessage( + {defaultMessage: '{errorMessage}'}, + {errorMessage: API_ERROR_MESSAGE} + ), + status: 'error' + }) + } + } + const removeItemFromWishlist = async (product) => { + try { + await wishlist.removeListItemByProductId(product.productId) + toast({ + title: formatMessage({defaultMessage: 'Item removed from wishlist'}), + status: 'success', + id: product.productId + }) + } catch { + toast({ + title: formatMessage( + {defaultMessage: '{errorMessage}'}, + {errorMessage: API_ERROR_MESSAGE} + ), + status: 'error' + }) + } + } + return ( - sendClickReco( - { - recommenderName: recommendations.recommenderName, - __recoUUID: recommendations.recoUUID - }, - product - ) - } isLoading={loading} + productTileProps={(product) => ({ + onClick: () => { + sendClickReco( + { + recommenderName: recommendations.recommenderName, + __recoUUID: recommendations.recoUUID + }, + product + ) + }, + enableFavourite: wishlist.isInitialized, + isFavourite: !!wishlist.findItemByProductId(product?.productId), + onFavouriteToggle: (isFavourite) => { + const action = isFavourite ? addItemToWishlist : removeItemFromWishlist + return action(product) + } + })} {...props} /> ) diff --git a/packages/pwa/app/pages/account/constant.js b/packages/pwa/app/pages/account/constant.js index 05b85a0fe7..be7c76afef 100644 --- a/packages/pwa/app/pages/account/constant.js +++ b/packages/pwa/app/pages/account/constant.js @@ -11,7 +11,7 @@ import { LocationIcon, PaymentIcon, ReceiptIcon, - WishlistIcon + HeartIcon } from '../../components/icons' import {noop} from '../../utils/utils' @@ -32,7 +32,7 @@ export const navLinks = [ { name: 'wishlist', path: '/wishlist', - icon: WishlistIcon + icon: HeartIcon }, { name: 'orders', diff --git a/packages/pwa/app/pages/account/wishlist/index.jsx b/packages/pwa/app/pages/account/wishlist/index.jsx index cf647128b6..8387dc7286 100644 --- a/packages/pwa/app/pages/account/wishlist/index.jsx +++ b/packages/pwa/app/pages/account/wishlist/index.jsx @@ -15,7 +15,7 @@ import useWishlist from '../../../hooks/use-wishlist' import {useToast} from '../../../hooks/use-toast' import PageActionPlaceHolder from '../../../components/page-action-placeholder' -import {WishlistIcon} from '../../../components/icons' +import {HeartIcon} from '../../../components/icons' import ProductItem from '../../../components/product-item/index' import WishlistPrimaryAction from './partials/wishlist-primary-action' import WishlistSecondaryButtonGroup from './partials/wishlist-secondary-button-group' @@ -114,7 +114,7 @@ const AccountWishlist = () => { {wishlist.hasDetail && wishlist.isEmpty && ( } + icon={} heading={formatMessage({defaultMessage: 'No Wishlist Items'})} text={formatMessage({ defaultMessage: 'Continue shopping and add items to your wishlist' diff --git a/packages/pwa/app/pages/product-list/index.jsx b/packages/pwa/app/pages/product-list/index.jsx index 273fa8f487..8df56d8ecc 100644 --- a/packages/pwa/app/pages/product-list/index.jsx +++ b/packages/pwa/app/pages/product-list/index.jsx @@ -379,19 +379,17 @@ const ProductList = (props) => { ) return ( - addItemToWishlist(productSearchItem) - } - onRemoveWishlistClick={() => { - removeItemFromWishlist(productSearchItem) + product={productSearchItem} + enableFavourite={wishlist.isInitialized} + isFavourite={isInWishlist} + onFavouriteToggle={(isFavourite) => { + const action = isFavourite + ? addItemToWishlist + : removeItemFromWishlist + return action(productSearchItem) }} - isInWishlist={isInWishlist} /> ) })} diff --git a/packages/pwa/app/pages/product-list/index.test.js b/packages/pwa/app/pages/product-list/index.test.js index 87c1b41c25..50e8a5b799 100644 --- a/packages/pwa/app/pages/product-list/index.test.js +++ b/packages/pwa/app/pages/product-list/index.test.js @@ -22,12 +22,15 @@ import {renderWithProviders} from '../../utils/test-utils' import ProductList from '.' import EmptySearchResults from './partials/empty-results' import useCustomer from '../../commerce-api/hooks/useCustomer' +import useWishlist from '../../hooks/use-wishlist' jest.setTimeout(60000) let mockCategoriesResponse = mockCategories let mockProductListSearchResponse = mockProductSearch jest.useFakeTimers() +jest.mock('../../hooks/use-wishlist') + jest.mock('../../commerce-api/utils', () => { const originalModule = jest.requireActual('../../commerce-api/utils') return { @@ -144,6 +147,12 @@ beforeAll(() => { beforeEach(() => { jest.resetModules() server.listen({onUnhandledRequest: 'error'}) + useWishlist.mockReturnValue({ + isInitialized: true, + isEmpty: false, + data: {}, + findItemByProductId: () => {} + }) }) afterEach(() => { diff --git a/packages/pwa/app/theme/components/project/product-tile.js b/packages/pwa/app/theme/components/project/product-tile.js index c21e446303..b434c24068 100644 --- a/packages/pwa/app/theme/components/project/product-tile.js +++ b/packages/pwa/app/theme/components/project/product-tile.js @@ -5,20 +5,20 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ export default { - baseStyle: (props) => ({ - container: { - position: 'relative' - }, - iconButton: { + baseStyle: () => ({ + container: {}, + favIcon: { position: 'absolute', + variant: 'unstyled', top: 2, - right: 2, - opacity: `${props.isLoading ? 0.5 : 1}` + right: 2 }, imageWrapper: { + position: 'relative', marginBottom: 2 }, image: { + ratio: 1, paddingBottom: 2 }, price: {}, diff --git a/packages/pwa/app/translations/compiled/en-GB.json b/packages/pwa/app/translations/compiled/en-GB.json index 3c622f2f0f..70b18b7e4c 100644 --- a/packages/pwa/app/translations/compiled/en-GB.json +++ b/packages/pwa/app/translations/compiled/en-GB.json @@ -339,12 +339,6 @@ "value": "Please enter a valid date" } ], - "6yVHs9": [ - { - "type": 0, - "value": "wishlist-solid" - } - ], "7PMLfK": [ { "type": 0, diff --git a/packages/pwa/app/translations/en-GB.json b/packages/pwa/app/translations/en-GB.json index e18f502d68..9810b7dab0 100644 --- a/packages/pwa/app/translations/en-GB.json +++ b/packages/pwa/app/translations/en-GB.json @@ -113,9 +113,6 @@ "6DCLcI": { "defaultMessage": "Please enter a valid date" }, - "6yVHs9": { - "defaultMessage": "wishlist-solid" - }, "7PMLfK": { "defaultMessage": "Order Total" },