diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 6041edf2c3..7ddaa9afb4 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,4 +1,5 @@ ## v2.7.0-dev (Jan 25, 2023) +- Add Page Designer carousel component [#977](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/977) - Add Page Designer layout components [#993](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/993) ## v2.6.0 (Jan 25, 2023) - Mega menu fixes [#875](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/875) and [#910](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/910) diff --git a/packages/template-retail-react-app/app/components/experience/layouts/carousel/index.jsx b/packages/template-retail-react-app/app/components/experience/layouts/carousel/index.jsx new file mode 100644 index 0000000000..d4ec1a836a --- /dev/null +++ b/packages/template-retail-react-app/app/components/experience/layouts/carousel/index.jsx @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2023, Salesforce, 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, {Fragment, useCallback, useMemo, useRef, useState} from 'react' +import PropTypes from 'prop-types' +import { + AspectRatio, + Box, + Heading, + IconButton, + Stack, + useBreakpoint, + useBreakpointValue +} from '@chakra-ui/react' +import {Component} from '../../component' +import {ChevronLeftIcon, ChevronRightIcon} from '../../../icons' +import {useEffect} from 'react' + +/** + * Display child components in a carousel slider manner. Configurations include the number of + * children to display in view as well as whether or not to show controls and position indicators. + * + * @param {PageProps} props + * @param {string} props.textHeading - Heading text for the carousel. + * @param {boolean} props.xsCarouselIndicators - Show/Hide carousel indicators/pips on "xs" screens. + * @param {boolean} props.smCarouselIndicators - Show/Hide carousel indicators/pips on "sm" screens. + * @param {boolean} props.mdCarouselIndicators - Show/Hide carousel indicators/pips on "md" screens. + * @param {boolean} props.xsCarouselControls - Show/Hide carousel forward/back controls on "xs" screens. + * @param {boolean} props.smCarouselControls - Show/Hide carousel forward/back controls on "sm" screens. + * @param {number} props.xsCarouselSlidesToDisplay - Number of children that will be rendered in view on "xs" screens. + * @param {number} props.smCarouselSlidesToDisplay - Number of children that will be rendered in view on "sm" screens. + * @param {number} props.mdCarouselSlidesToDisplay - Number of children that will be rendered in view on "md" screens. + * @param {Object []} props.region - The regions passed internally to this component by the `commerce-sdk-react` Page component. + * @returns {React.ReactElement} - Carousel component. + */ +export const Carousel = (props = {}) => { + const scrollRef = useRef() + const breakpoint = useBreakpoint() + const [hasOverflow, setHasOverflow] = useState(false) + + const { + textHeadline, + xsCarouselIndicators = false, + smCarouselIndicators = false, + mdCarouselIndicators = false, + xsCarouselControls = false, + smCarouselControls = false, + xsCarouselSlidesToDisplay = 1, + smCarouselSlidesToDisplay = 1, + mdCarouselSlidesToDisplay = 1, + // Internally Provided + regions + } = props + + const controlDisplay = useMemo(() => { + return { + base: xsCarouselControls && hasOverflow ? 'block' : 'none', + sm: xsCarouselControls && hasOverflow ? 'block' : 'none', + md: smCarouselControls && hasOverflow ? 'block' : 'none', + lg: hasOverflow ? 'block' : 'none' + } + }, [hasOverflow]) + + const itemWidth = { + base: `calc(${100 / xsCarouselSlidesToDisplay}%)`, + sm: `calc(${100 / xsCarouselSlidesToDisplay}%)`, + md: `calc(${100 / smCarouselSlidesToDisplay}%)`, + lg: `calc(${100 / mdCarouselSlidesToDisplay}%)` + } + + const overflowXScroll = { + base: xsCarouselIndicators ? 'block' : 'none', + sm: xsCarouselIndicators ? 'block' : 'none', + md: smCarouselIndicators ? 'block' : 'none', + lg: mdCarouselIndicators ? 'block' : 'none' + } + const overflowXScrollValue = useBreakpointValue(overflowXScroll) + + const components = regions[0]?.components || [] + const itemCount = components.length + + // Scroll the container left or right by 100%. Passing no args or `1` + // scrolls to the right, and passing `-1` scrolls left. + const scroll = useCallback((direction = 1) => { + scrollRef.current?.scrollBy({ + top: 0, + left: (direction * window.innerWidth) / itemCount, + behavior: 'smooth' + }) + }) + + // Our indicator implementation uses the scrollbar to show the context of the current + // item selected. Because MacOS hides scroll bars after they come to rest we need to + // force them to show. Please note that this feature only works on web-kit browsers, + // for all other browsers the scroller/indicator will be shown. + const style = { + '.scroll-indicator::-webkit-scrollbar': { + display: overflowXScrollValue, + ['-webkit-appearance']: `none`, + height: `8px` + }, + '.scroll-indicator::-webkit-scrollbar-thumb': { + backgroundColor: 'rgba(0, 0, 0, 0.5)' + } + } + + useEffect(() => { + const {clientWidth, scrollWidth} = scrollRef.current + setHasOverflow(scrollWidth > clientWidth) + }, [breakpoint, props]) + + return ( + + + {textHeadline && ( + + {textHeadline} + + )} + + + {components.map((component, index) => ( + + + + + + ))} + + + + {/* Button Controls */} + + + } + borderRadius="full" + colorScheme="whiteAlpha" + onClick={() => scroll(-1)} + /> + + + + } + borderRadius="full" + colorScheme="whiteAlpha" + onClick={() => scroll(1)} + /> + + + + ) +} + +Carousel.propTypes = { + regions: PropTypes.array.isRequired, + textHeadline: PropTypes.string, + xsCarouselIndicators: PropTypes.bool, + smCarouselIndicators: PropTypes.bool, + mdCarouselIndicators: PropTypes.bool, + xsCarouselControls: PropTypes.bool, + smCarouselControls: PropTypes.bool, + xsCarouselSlidesToDisplay: PropTypes.oneOf([1, 2, 3, 4, 5, 6]), + smCarouselSlidesToDisplay: PropTypes.oneOf([1, 2, 3, 4, 5, 6]), + mdCarouselSlidesToDisplay: PropTypes.oneOf([1, 2, 3, 4, 5, 6]) +} + +Carousel.displayName = 'Carousel' + +export default Carousel diff --git a/packages/template-retail-react-app/app/components/experience/layouts/carousel/index.test.js b/packages/template-retail-react-app/app/components/experience/layouts/carousel/index.test.js new file mode 100644 index 0000000000..5a6e804556 --- /dev/null +++ b/packages/template-retail-react-app/app/components/experience/layouts/carousel/index.test.js @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2023, 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 from 'react' +import {renderWithProviders, withPageProvider} from '../../../../utils/test-utils' +import Carousel from './index' + +const SAMPLE_REGION = { + id: 'TEST_REGION', + components: [ + { + id: 'TEST_COMPONENT', + typeId: 'test-component', + data: {} + } + ] +} + +test('Carousel renders without errors', () => { + const {getByTestId} = renderWithProviders() + + expect(getByTestId('carousel')).toBeDefined() + expect(getByTestId('carousel-container')).toBeDefined() + expect(getByTestId('carousel-container-items')).toBeDefined() + expect(getByTestId('carousel-nav-left')).toBeDefined() + expect(getByTestId('carousel-nav-left')).toBeDefined() +}) + +test('Carousel renders region/children without errors', () => { + const CarouselWithPageProvider = withPageProvider(Carousel) + const {getByTestId} = renderWithProviders( + + ) + + expect(getByTestId('carousel')).toBeDefined() + expect(getByTestId('carousel-container-items').childElementCount).toEqual(1) +}) diff --git a/packages/template-retail-react-app/app/components/experience/layouts/index.js b/packages/template-retail-react-app/app/components/experience/layouts/index.js index 8a78ee6a86..a0a44cb501 100644 --- a/packages/template-retail-react-app/app/components/experience/layouts/index.js +++ b/packages/template-retail-react-app/app/components/experience/layouts/index.js @@ -5,6 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +export * from './carousel' export * from './mobileGrid1r1c' export * from './mobileGrid2r1c' export * from './mobileGrid2r2c' diff --git a/packages/template-retail-react-app/app/pages/page-viewer/index.jsx b/packages/template-retail-react-app/app/pages/page-viewer/index.jsx deleted file mode 100644 index 3f06a49fb6..0000000000 --- a/packages/template-retail-react-app/app/pages/page-viewer/index.jsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2023, Salesforce, 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 from 'react' -import {componentMapProxy} from './utils' -import {Page} from '../../components/experience/page' -import {pageType} from '../../components/experience/types' -import {Box} from '@chakra-ui/react' - -const PageViewer = ({page}) => { - return ( - - - - ) -} - -PageViewer.getProps = async ({api}) => { - const page = await api.shopperExperience.getPage({ - parameters: { - pageId: 'layout-example' - } - }) - return { - page - } -} - -PageViewer.displayName = 'PageViewer' - -PageViewer.propTypes = { - page: pageType.isRequired -} - -export default PageViewer diff --git a/packages/template-retail-react-app/app/pages/page-viewer/utils.js b/packages/template-retail-react-app/app/pages/page-viewer/utils.js deleted file mode 100644 index fd8b02a64c..0000000000 --- a/packages/template-retail-react-app/app/pages/page-viewer/utils.js +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright (c) 2023, Salesforce, 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 from 'react' -import PropTypes from 'prop-types' -import {Region} from '../../components/experience/region' - -import * as Layouts from '../../components/experience/layouts' - -const withTitle = (Component) => { - const WrappedComponent = (props) => { - return ( -
- - {props.typeId.split('.')[1]} - - -
- ) - } - WrappedComponent.propTypes = { - typeId: PropTypes.string - } - - return WrappedComponent -} - -export const componentMapProxy = new Proxy( - {}, - { - // eslint-disable-next-line no-unused-vars - get(_target, prop) { - let componentClass - switch (prop) { - case 'commerce_assets.editorialRichText': - componentClass = ({richText}) => ( -
- ) - break - case 'commerce_layouts.mobileGrid1r1c': - componentClass = withTitle(Layouts['MobileGrid1r1c']) - break - case 'commerce_layouts.mobileGrid2r1c': - componentClass = withTitle(Layouts['MobileGrid2r1c']) - break - case 'commerce_layouts.mobileGrid2r2c': - componentClass = withTitle(Layouts['MobileGrid2r2c']) - break - case 'commerce_layouts.mobileGrid2r3c': - componentClass = withTitle(Layouts['MobileGrid2r3c']) - break - case 'commerce_layouts.mobileGrid3r1c': - componentClass = withTitle(Layouts['MobileGrid3r1c']) - break - case 'commerce_layouts.mobileGrid3r2c': - componentClass = withTitle(Layouts['MobileGrid3r2c']) - break - default: - componentClass = (props) => ( -
- {props.typeId} - {props?.regions?.map((region) => ( - - ))} -
- ) - } - - return componentClass - } - } -) diff --git a/packages/template-retail-react-app/app/routes.jsx b/packages/template-retail-react-app/app/routes.jsx index 011b67cca1..a72f3b8378 100644 --- a/packages/template-retail-react-app/app/routes.jsx +++ b/packages/template-retail-react-app/app/routes.jsx @@ -36,7 +36,6 @@ const ProductDetail = loadable(() => import('./pages/product-detail'), {fallback const ProductList = loadable(() => import('./pages/product-list'), {fallback}) const Wishlist = loadable(() => import('./pages/account/wishlist'), {fallback}) const PageNotFound = loadable(() => import('./pages/page-not-found')) -const PageViewer = loadable(() => import('./pages/page-viewer'), {fallback}) const routes = [ { @@ -99,10 +98,6 @@ const routes = [ path: '/account/wishlist', component: Wishlist }, - { - path: '/page-viewer', - component: PageViewer - }, { path: '*', component: PageNotFound