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