├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── 1.Bug_Report.md │ └── 2.Feature_Request.md ├── release-draft-template.yml └── workflows │ ├── publish.yml │ ├── release-drafter.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierrc ├── .storybook ├── config.js ├── createMetadata.js ├── main.js └── stories │ ├── Accordion.stories.js │ ├── ActionButton.stories.js │ ├── AutoScrollToNewChildren.stories.js │ ├── BackToTop.stories.js │ ├── Breadcrumbs.stories.js │ ├── Carousel.stories.js │ ├── CartButton.stories.js │ ├── CheckboxFilterGroup.stories.js │ ├── Drawer.stories.js │ ├── ExpandableSection.stories.js │ ├── Image.stories.js │ ├── Lazy.stories.js │ ├── LoadMask.stories.js │ ├── MediaCarousel.stories.js │ ├── Menu.stories.js │ ├── MenuButton.stories.js │ ├── NavTabs.stories.js │ ├── Offline.stories.js │ ├── ProductOptionSelector.stories.js │ ├── QuantitySelector.stories.js │ ├── Rating.stories.js │ ├── ResponsiveTiles.stories.js │ ├── TabPanel.stories.js │ └── ToolbarButton.stories.js ├── .swcrc ├── LICENSE.md ├── README.md ├── RSF BRANDGUIDE JPG.pdf ├── RSF Logo trans.png ├── RSF power.png ├── babel.config.js ├── docs ├── buildDocs.js └── moduleParser.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── Accordion.js ├── ActionButton.js ├── AppBar.js ├── AutoScrollToNewChildren.js ├── BackToTop.js ├── Box.js ├── Breadcrumbs.js ├── CartButton.js ├── CmsSlot.js ├── ErrorBoundary.js ├── ExpandableSection.js ├── Fill.js ├── ForwardThumbnail.js ├── Highlight.js ├── Image.js ├── Label.js ├── Lazy.js ├── LazyHydrate.js ├── LoadMask.js ├── NextScript.js ├── NoScript.js ├── Offline.js ├── PWA.js ├── PWAContext.js ├── QuantitySelector.js ├── Rating.js ├── ResponsiveTiles.js ├── Row.js ├── Spacer.js ├── TabPanel.js ├── ToolbarButton.js ├── api │ ├── addVersion.js │ └── getAPIURL.js ├── carousel │ ├── Carousel.js │ ├── CarouselArrows.js │ ├── CarouselDots.js │ ├── CarouselThumbnails.js │ ├── Lightbox.js │ ├── MagnifyHint.js │ ├── Media.js │ └── MediaCarousel.js ├── drawer │ ├── Drawer.js │ └── DrawerCloseButton.js ├── fetch.js ├── hooks │ ├── useAppStore.js │ ├── useCartTotal.js │ ├── useIntersectionObserver.js │ ├── useJssStyles.js │ ├── useLazyState.js │ ├── useLocalStorage.js │ ├── useNavigationEvent.js │ ├── useStateFromProp.js │ └── useTraceUpdate.js ├── link │ ├── Link.js │ ├── LinkContext.js │ └── LinkContextProvider.js ├── menu │ ├── Menu.js │ ├── MenuBack.js │ ├── MenuBody.js │ ├── MenuBranch.js │ ├── MenuButton.js │ ├── MenuCard.js │ ├── MenuContext.js │ ├── MenuExpanderIcon.js │ ├── MenuFooter.js │ ├── MenuHeader.js │ ├── MenuIcon.js │ ├── MenuItem.js │ ├── MenuItemContent.js │ ├── MenuLeaf.js │ └── SEOLinks.js ├── middlewares │ └── withAmpFormParser.js ├── mock-connector │ ├── .gitignore │ ├── account.js │ ├── addToCart.js │ ├── cart.js │ ├── home.js │ ├── index.js │ ├── product.js │ ├── productMedia.js │ ├── productSuggestions.js │ ├── removeCartItem.js │ ├── search.js │ ├── searchSuggestions.js │ ├── session.js │ ├── subcategory.js │ ├── updateCartItem.js │ └── utils │ │ ├── cartStore.js │ │ ├── colors.js │ │ ├── createAppData.js │ │ ├── createFacets.js │ │ ├── createMedia.js │ │ ├── createMenu.js │ │ ├── createProduct.js │ │ ├── createProducts.js │ │ ├── createSortOptions.js │ │ └── createTabs.js ├── nav │ ├── NavTab.js │ └── NavTabs.js ├── option │ ├── ProductOption.js │ ├── ProductOptionSelector.js │ ├── SwatchProductOption.js │ └── TextProductOption.js ├── plp │ ├── ButtonFilterGroup.js │ ├── CheckboxFilterGroup.js │ ├── FacetGroup.js │ ├── Filter.js │ ├── FilterButton.js │ ├── FilterFooter.js │ ├── FilterHeader.js │ ├── SearchResultsContext.js │ ├── SearchResultsProvider.js │ ├── ShowMore.js │ ├── Sort.js │ ├── SortButton.js │ └── useSearchResultsStore.js ├── plugins │ ├── withReactStorefront.js │ └── withServiceWorker.js ├── profile.js ├── props │ ├── createLazyProps.js │ ├── fetchFromAPI.js │ ├── fetchServerSideProps.js │ └── fulfillAPIRequest.js ├── router │ ├── replaceState.js │ ├── storeInitialPropsInHistory.js │ └── useSimpleNavigation.js ├── search │ ├── SearchButton.js │ ├── SearchContext.js │ ├── SearchDrawer.js │ ├── SearchField.js │ ├── SearchForm.js │ ├── SearchHeader.js │ ├── SearchPopover.js │ ├── SearchProvider.js │ ├── SearchSubmitButton.js │ ├── SearchSuggestionGroup.js │ ├── SearchSuggestionItem.js │ └── SearchSuggestions.js ├── server │ ├── getRoutes.js │ └── listRoutes.js ├── serviceWorker.js ├── session │ ├── SessionContext.js │ └── SessionProvider.js ├── sw │ └── configureServiceWorker.js ├── theme │ └── createTheme.js └── utils │ ├── fetchLatest.js │ ├── format.js │ ├── getBase64ForImage.js │ ├── isBrowser.js │ ├── isSameOrigin.js │ ├── lazyLoadImages.js │ ├── merge.js │ ├── minifyStyles.js │ ├── mod.js │ ├── useDebounce.js │ ├── withCaching.js │ └── withDefaultHandler.js └── test ├── Accordion.test.js ├── ActionButton.test.js ├── AppBar.test.js ├── AutoScrollToNewChildren.test.js ├── BackToTop.test.js ├── Box.test.js ├── BreadCrumbs.test.js ├── CartButton.test.js ├── CmsSlot.test.js ├── ErrorBoundary.test.js ├── ExpandableSection.test.js ├── Fill.test.js ├── ForwardThumbnail.test.js ├── Highlight.test.js ├── Image.test.js ├── Label.test.js ├── Lazy.test.js ├── LazyHydrate.test.js ├── LoadMask.test.js ├── NextScript.test.js ├── NoScript.test.js ├── Offline.test.js ├── PWA.test.js ├── QuantitySelector.test.js ├── Rating.test.js ├── ReponsiveTiles.test.js ├── Row.test.js ├── Spacer.test.js ├── TabPanel.test.js ├── ToolbarButton.test.js ├── api ├── addVersion.test.js └── getAPIURL.test.js ├── carousel ├── Carousel.test.js ├── CarouselArrows.test.js ├── CarouselDots.test.js ├── CarouselThumbnails.test.js ├── Lightbox.test.js ├── MagnifyHint.test.js ├── Media.test.js └── MediaCarousel.test.js ├── config ├── jsdom.js ├── mocks.js ├── mountWrapper.js ├── setup.js ├── sleep.js └── suppressActWarnings.js ├── drawer ├── Drawer.test.js └── DrawerCloseButton.test.js ├── fetch.test.js ├── hooks ├── useAppStore.test.js ├── useCartTotal.test.js ├── useIntersectionObserver.test.js ├── useJssStyles.test.js ├── useLazyState.test.js ├── useLocalStorage.test.js ├── useNavigationEvent.test.js ├── useStateFromProp.test.js └── useTraceUpdate.test.js ├── link ├── Link.test.js └── LinkContextProvider.test.js ├── menu ├── Menu.test.js ├── MenuButton.test.js ├── MenuExpanderIcon.test.js ├── MenuIcon.test.js ├── MenuItem.test.js ├── MenuItemContent.test.js └── SEOLinks.test.js ├── methods └── index.js ├── middlewares └── withAmpFormParser.test.js ├── mocks ├── mockAsset.js ├── mockHelper.js ├── mockNextLink.js ├── mockObservers.js └── mockRouter.js ├── nav ├── NavTab.test.js └── NavTabs.test.js ├── option ├── ProductOption.test.js └── ProductOptionsSelector.test.js ├── plp ├── ButtonFilterGroup.test.js ├── CheckboxFilterGroup.test.js ├── FacetGroup.test.js ├── Filter.test.js ├── FilterButton.test.js ├── FilterFooter.test.js ├── FilterHeader.test.js ├── SearchResultsProvider.test.js ├── ShowMore.test.js ├── Sort.test.js ├── SortButton.test.js └── useSearchResultsStore.test.js ├── profile.test.js ├── props ├── createLazyProps.test.js ├── fetchFromAPI.test.js └── fulfillAPIRequest.test.js ├── router ├── replaceState.test.js ├── storeInitialPropsInHistory.test.js └── useSimpleNavigation.test.js ├── search ├── SearchButton.test.js ├── SearchDrawer.test.js ├── SearchField.test.js ├── SearchForm.test.js ├── SearchHeader.test.js ├── SearchPopover.test.js ├── SearchProvider.test.js ├── SearchSubmitButton.test.js ├── SearchSuggestionGroup.test.js ├── SearchSuggestionItem.test.js └── SearchSuggetions.test.js ├── server ├── getRoutes.test.js ├── listRoutes.test.js └── test-app │ ├── pages-manifest.json │ └── pages │ └── p │ └── [productId].js ├── serviceWorker.test.js ├── session └── SessionProvider.test.js ├── sw └── configureServiceWorker.test.js ├── theme └── createTheme.test.js └── utils ├── fetchLatest.test.js ├── format.test.js ├── getBase64ForImage.test.js ├── isBrowser.test.js ├── lazyLoadImages.test.js ├── merge.test.js ├── withCaching.test.js └── withDefaultHandler.test.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | jest: true, 6 | node: true, 7 | }, 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:react/recommended', 11 | 'plugin:react-hooks/recommended', 12 | 'plugin:prettier/recommended', 13 | ], 14 | parserOptions: { 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | ecmaVersion: 13, 19 | sourceType: 'module', 20 | }, 21 | plugins: ['react'], 22 | rules: { 23 | 'no-unused-vars': ['error', { ignoreRestSiblings: true }], 24 | 'react/display-name': 'off', 25 | 'react/prop-types': [2, { ignore: ['children'] }], 26 | indent: ['error', 2, { offsetTernaryExpressions: true }], 27 | 'linebreak-style': ['error', 'unix'], 28 | quotes: ['error', 'single', { avoidEscape: true }], 29 | semi: ['error', 'never'], 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1.Bug_Report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report for React Storefront 4 | --- 5 | 6 | # Bug report 7 | 8 | ## Describe the bug 9 | 10 | A clear and concise description of what the bug is. 11 | 12 | ## To Reproduce 13 | 14 | Steps to reproduce the behavior, please provide code snippets or a repository: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | ## Expected behavior 22 | 23 | A clear and concise description of what you expected to happen. 24 | 25 | ## Screenshots 26 | 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | ## System information 30 | 31 | - OS: [e.g. macOS, Windows] 32 | - Browser (if applies) [e.g. chrome, safari] 33 | - Version of React Storefront: [e.g. 7.0.2] 34 | 35 | ## Additional context 36 | 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2.Feature_Request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Create a feature request for React Storefront 4 | --- 5 | 6 | # Feature request 7 | 8 | ## Is your feature request related to a problem? Please describe. 9 | 10 | A clear and concise description of what you want and what your use case is. 11 | 12 | ## Describe the solution you'd like 13 | 14 | A clear and concise description of what you want to happen. 15 | 16 | ## Describe alternatives you've considered 17 | 18 | A clear and concise description of any alternative solutions or features you've considered. 19 | 20 | ## Additional context 21 | 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /.github/release-draft-template.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: '🚀 Features' 3 | labels: 4 | - 'feature' 5 | - 'enhancement' 6 | - title: '🐛 Bug Fixes' 7 | labels: 8 | - 'bug' 9 | - title: '🧰 Maintenance' 10 | labels: 11 | - 'chore' 12 | - 'documentation' 13 | template: | 14 | ## What's Changed 15 | 16 | $CHANGES 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | registry-url: https://registry.npmjs.org/ 16 | - name: Publish to npm 17 | run: | 18 | npm ci 19 | OUTPUT=$(npm run release) 20 | VERSION=v$(echo $OUTPUT | rev | cut -d'@' -f 1 | rev) 21 | echo "::set-env name=VERSION::${VERSION}" 22 | env: 23 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 24 | ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' 25 | - uses: actions/checkout@v2 26 | - uses: actions/checkout@v2 27 | with: 28 | repository: storefront-foundation/storefront-foundation.github.io 29 | token: ${{ secrets.MOOVWEB_GITHUB_TOKEN }} 30 | path: pages-repo 31 | - uses: actions/checkout@v2 32 | with: 33 | repository: moovweb/react-storefront-docs 34 | token: ${{ secrets.MOOVWEB_GITHUB_TOKEN }} 35 | path: docs-repo 36 | - uses: actions/setup-node@v1 37 | with: 38 | node-version: 12 39 | registry-url: https://registry.npmjs.org/ 40 | - name: Push API docs to GitHub Pages 41 | run: | 42 | npm ci 43 | npm run docs 44 | npm run build-storybook 45 | VERSION=${{ env.VERSION }} 46 | cd pages-repo 47 | mkdir ${VERSION} 48 | mv ../.storybook/build ${VERSION}/storybook 49 | cp ../docs/build/modules.json ${VERSION} 50 | cp -r ../docs-repo/guides ${VERSION} 51 | mv ${VERSION}/guides/guides.json ${VERSION} 52 | sed -i "$ s/$/,\n${VERSION}/" versions.csv 53 | git add -A 54 | git config --local user.email "action@github.com" 55 | git config --local user.name "GitHub Action" 56 | git commit -am "Docs for version ${VERSION}" 57 | git push -u origin 58 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v5 13 | with: 14 | config-name: release-draft-template.yml 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [12.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | persist-credentials: false 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - name: Reconfigure git to use HTTP authentication 22 | run: > 23 | git config --global url."https://github.com/".insteadOf 24 | ssh://git@github.com/ 25 | - name: npm install and test 26 | run: | 27 | npm ci 28 | npm test 29 | env: 30 | CI: true 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | /dist 18 | 19 | # docs 20 | /docs/build 21 | 22 | # misc 23 | .DS_Store 24 | .env* 25 | .serverless_nextjs 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # IDEs 33 | .idea/* 34 | 35 | # Index files are built automatically by create-index 36 | src/**/index.js 37 | 38 | .vscode -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /src 2 | /test 3 | /coverage 4 | yarn-error.log 5 | yarn.lock 6 | .prettierrc 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.22.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "all", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react' 2 | 3 | configure(require.context('./stories', true, /\.stories\.js$/), module) 4 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | addons: ['@storybook/addon-knobs/register'], 3 | } 4 | -------------------------------------------------------------------------------- /.storybook/stories/Accordion.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ExpandableSection from '../../src/ExpandableSection' 3 | import Accordion from '../../src/Accordion' 4 | 5 | export default { title: 'Accordion' } 6 | 7 | export const defaults = () => ( 8 | 9 | 10 |
The first section
11 |
12 | 13 |
The second section
14 |
15 | 16 |
The third section
17 |
18 |
19 | ) 20 | -------------------------------------------------------------------------------- /.storybook/stories/ActionButton.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withKnobs, text } from '@storybook/addon-knobs' 3 | import ActionButton from '../../src/ActionButton' 4 | 5 | export default { title: 'ActionButton', decorators: [withKnobs] } 6 | 7 | export const options = () => ( 8 | 9 | ) 10 | -------------------------------------------------------------------------------- /.storybook/stories/AutoScrollToNewChildren.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import AutoScrollToNewChildren from '../../src/AutoScrollToNewChildren' 3 | 4 | export default { title: 'AutoScrollToNewChildren' } 5 | 6 | const styles = { 7 | background: '#7f8fa6', 8 | color: '#f5f6fa', 9 | fontSize: 30, 10 | fontFamily: 'monospace', 11 | display: 'flex', 12 | justifyContent: 'center', 13 | alignItems: 'center', 14 | height: 700, 15 | marginBottom: 5, 16 | } 17 | 18 | export const defaults = () => { 19 | const [count, setCount] = useState(1) 20 | return ( 21 | 22 | {Array(count) 23 | .fill(0) 24 | .map((e, i) => ( 25 |
setCount(count + 1)} style={styles}> 26 | Click Me ({i}) 27 |
28 | ))} 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /.storybook/stories/BackToTop.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Pets } from '@mui/icons-material' 3 | import { withKnobs, select } from '@storybook/addon-knobs' 4 | import BackToTop from '../../src/BackToTop' 5 | 6 | export default { title: 'BackToTop', decorators: [withKnobs] } 7 | 8 | const styles = { 9 | background: '#7f8fa6', 10 | color: '#f5f6fa', 11 | fontSize: 30, 12 | fontFamily: 'monospace', 13 | display: 'flex', 14 | justifyContent: 'center', 15 | alignItems: 'center', 16 | height: 1500, 17 | } 18 | 19 | export const sizes = () => ( 20 |
21 |
Please scroll down
22 | 25 |
26 | ) 27 | 28 | export const customIcon = () => ( 29 |
30 |
Please scroll down
31 | 32 |
33 | ) 34 | -------------------------------------------------------------------------------- /.storybook/stories/Breadcrumbs.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Breadcrumbs from '../../src/Breadcrumbs' 3 | 4 | export default { title: 'Breadcrumbs' } 5 | 6 | export const defaults = () => ( 7 | 15 | ) 16 | -------------------------------------------------------------------------------- /.storybook/stories/Carousel.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withKnobs, boolean, number, select } from '@storybook/addon-knobs' 3 | import Carousel from '../../src/carousel/Carousel' 4 | 5 | export default { title: 'Carousel', decorators: [withKnobs] } 6 | 7 | const slideStyle = { 8 | width: '100%', 9 | height: 300, 10 | display: 'flex', 11 | justifyContent: 'center', 12 | alignItems: 'center', 13 | fontFamily: 'monospace', 14 | fontSize: 32, 15 | color: 'white', 16 | } 17 | 18 | const slides = [ 19 |
Red
, 20 |
Blue
, 21 |
Green
, 22 | ] 23 | 24 | export const options = () => ( 25 | 31 | {slides} 32 | 33 | ) 34 | -------------------------------------------------------------------------------- /.storybook/stories/CartButton.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import CartButton from '../../src/CartButton' 3 | 4 | export default { title: 'CartButton' } 5 | 6 | export const defaults = () => 7 | -------------------------------------------------------------------------------- /.storybook/stories/CheckboxFilterGroup.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import CheckboxFilterGroup from '../../src/plp/CheckboxFilterGroup' 3 | import SearchResultsContext from '../../src/plp/SearchResultsContext' 4 | 5 | export default { title: 'CheckboxFilterGroup' } 6 | 7 | export const defaults = () => ( 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | -------------------------------------------------------------------------------- /.storybook/stories/Drawer.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { withKnobs, text, boolean, select } from '@storybook/addon-knobs' 4 | import Drawer from '../../src/drawer/Drawer' 5 | 6 | export default { title: 'Drawer', decorators: [withKnobs] } 7 | 8 | export const defaults = () => ( 9 | <> 10 |
Use the knobs to open the drawer.
11 |
Knobs can be adjusted when the drawer is closed.
12 | 23 |
33 | These are the drawer contents 34 |
35 |
36 | 37 | ) 38 | -------------------------------------------------------------------------------- /.storybook/stories/ExpandableSection.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withKnobs, text, boolean } from '@storybook/addon-knobs' 3 | import ExpandableSection from '../../src/ExpandableSection' 4 | import { Typography } from '@mui/material' 5 | 6 | export default { title: 'ExpandableSection', decorators: [withKnobs] } 7 | 8 | export const options = () => ( 9 | 14 | {text('Content', 'This is a help section')} 15 | 16 | ) 17 | -------------------------------------------------------------------------------- /.storybook/stories/Image.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Image from '../../src/Image' 3 | 4 | export default { title: 'Image' } 5 | 6 | const styles = { 7 | display: 'flex', 8 | justifyContent: 'center', 9 | marginTop: 500, 10 | } 11 | 12 | export const lazy = () => ( 13 |
14 | Please scroll down 15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 | 27 |
28 |
29 |
30 | ) 31 | -------------------------------------------------------------------------------- /.storybook/stories/Lazy.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Lazy from '../../src/Lazy' 3 | import PWAContext from '../../src/PWAContext' 4 | 5 | export default { title: 'Lazy' } 6 | 7 | const styles = { 8 | display: 'flex', 9 | justifyContent: 'center', 10 | alignItems: 'center', 11 | marginTop: 200, 12 | background: '#7f8fa6', 13 | height: 500, 14 | } 15 | 16 | export const lazy = () => ( 17 | 18 |
19 | Please scroll down 20 |
21 | 22 |
Hello
23 |
24 | 25 |
Hello
26 |
27 | 28 |
Hello
29 |
30 | 31 |
Hello
32 |
33 |
34 |
35 |
36 | ) 37 | -------------------------------------------------------------------------------- /.storybook/stories/LoadMask.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { withKnobs, boolean, select } from '@storybook/addon-knobs' 4 | import LoadMask from '../../src/LoadMask' 5 | 6 | export default { title: 'LoadMask', decorators: [withKnobs] } 7 | 8 | export const defaults = () => ( 9 |
10 | 15 |
This content is being masked.
16 |
17 | This content is also being masked. 18 |
19 |
20 | ) 21 | -------------------------------------------------------------------------------- /.storybook/stories/MediaCarousel.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | createTheme as createMuiTheme, 4 | ThemeProvider as MuiThemeProvider, 5 | } from '@mui/material/styles' 6 | import MediaCarousel from '../../src/carousel/MediaCarousel' 7 | 8 | export default { title: 'MediaCarousel' } 9 | 10 | const theme = createMuiTheme({ 11 | margins: { 12 | container: 16, 13 | }, 14 | }) 15 | 16 | const media = { 17 | full: [ 18 | { src: '//placehold.it/400?text=1', alt: 'One', type: 'image' }, 19 | { src: '//placehold.it/400?text=2', alt: 'Two', type: 'image' }, 20 | { src: '//placehold.it/400?text=3', alt: 'Three', type: 'image' }, 21 | ], 22 | thumbnails: [ 23 | { src: '//placehold.it/200?text=1', alt: 'One', type: 'image' }, 24 | { src: '//placehold.it/200?text=2', alt: 'Two', type: 'image' }, 25 | { src: '//placehold.it/200?text=3', alt: 'Three', type: 'image' }, 26 | ], 27 | thumbnail: { 28 | src: '//placehold.it/200?text=1', 29 | alt: 'One', 30 | }, 31 | } 32 | 33 | const slideStyle = { 34 | width: '100%', 35 | height: 300, 36 | display: 'flex', 37 | justifyContent: 'center', 38 | alignItems: 'center', 39 | fontFamily: 'monospace', 40 | fontSize: 32, 41 | color: 'white', 42 | background: 'grey', 43 | } 44 | 45 | const Test = props => { 46 | return ( 47 | 48 | 49 | 50 | ) 51 | } 52 | 53 | export const defaults = () => 54 | 55 | export const customMediaComponent = () => ( 56 |
{src}
} /> 57 | ) 58 | 59 | export const customThumbnailsComponent = () => ( 60 | { 62 | return
{selected}
63 | }} 64 | /> 65 | ) 66 | 67 | export const noThumbnails = () => 68 | -------------------------------------------------------------------------------- /.storybook/stories/Menu.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Menu from '../../src/menu/Menu' 3 | 4 | export default { title: 'Menu' } 5 | 6 | const root = { 7 | text: 'category', 8 | items: [ 9 | { text: 'item1', href: '/item1', as: '/item1', items: [] }, 10 | { text: 'item2', href: '/item2', as: '/item2' }, 11 | { text: 'item3', href: '/item3', as: '/item3' }, 12 | ], 13 | } 14 | 15 | export const open = () => 16 | -------------------------------------------------------------------------------- /.storybook/stories/MenuButton.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MenuButton from '../../src/menu/MenuButton' 3 | 4 | export default { title: 'MenuButton' } 5 | 6 | export const closed = () => 7 | export const open = () => 8 | -------------------------------------------------------------------------------- /.storybook/stories/NavTabs.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import NavTab from '../../src/nav/NavTab' 3 | 4 | export default { title: 'NavTabs' } 5 | 6 | export const defaults = () => ( 7 |
8 | 9 | 10 | 11 | 12 |
13 | ) 14 | -------------------------------------------------------------------------------- /.storybook/stories/Offline.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Pets } from '@mui/icons-material' 3 | import { withKnobs, text } from '@storybook/addon-knobs' 4 | import Offline from '../../src/Offline' 5 | 6 | export default { title: 'Offline', decorators: [withKnobs] } 7 | 8 | export const options = () => ( 9 | 13 | ) 14 | export const customIcon = () => 15 | -------------------------------------------------------------------------------- /.storybook/stories/ProductOptionSelector.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import ProductOptionSelector from '../../src/option/ProductOptionSelector' 3 | import SwatchProductOption from '../../src/option/SwatchProductOption' 4 | 5 | export default { title: 'ProductOptionSelector' } 6 | 7 | const colors = [ 8 | { 9 | text: 'Red', 10 | id: 'red', 11 | image: { 12 | src: 'https://via.placeholder.com/48x48/f44336?text=%20', 13 | alt: 'red', 14 | }, 15 | }, 16 | { 17 | text: 'Green', 18 | id: 'green', 19 | image: { 20 | src: 'https://via.placeholder.com/48x48/4caf50?text=%20', 21 | alt: 'green', 22 | }, 23 | }, 24 | { 25 | text: 'Blue', 26 | id: 'blue', 27 | image: { 28 | src: 'https://via.placeholder.com/48x48/2196f3?text=%20', 29 | alt: 'blue', 30 | }, 31 | }, 32 | { 33 | text: 'Grey', 34 | id: 'grey', 35 | image: { 36 | src: 'https://via.placeholder.com/48x48/e0e0e0?text=%20', 37 | alt: 'grey', 38 | }, 39 | }, 40 | ] 41 | 42 | export const swatch = () => { 43 | const [color, setColor] = useState(colors[0]) 44 | 45 | return ( 46 | setColor(color)} /> 47 | ) 48 | } 49 | 50 | export const text = () => { 51 | const [color, setColor] = useState(colors[0]) 52 | 53 | return ( 54 | setColor(color)} 58 | variant="text" 59 | /> 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /.storybook/stories/QuantitySelector.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { ArrowDownward, ArrowUpward } from '@mui/icons-material' 3 | import QuantitySelector from '../../src/QuantitySelector' 4 | 5 | export default { title: 'QuantitySelector' } 6 | 7 | export const plain = () => { 8 | const [count, setCount] = useState(0) 9 | return 10 | } 11 | 12 | export const withMinAndMax = () => { 13 | const [count, setCount] = useState(6) 14 | return 15 | } 16 | 17 | export const customIcons = () => { 18 | const [count, setCount] = useState(0) 19 | return ( 20 | } 22 | subtractIcon={} 23 | value={count} 24 | onChange={setCount} 25 | /> 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /.storybook/stories/Rating.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Pets } from '@mui/icons-material' 3 | import Rating from '../../src/Rating' 4 | 5 | export default { title: 'Rating' } 6 | 7 | export const defaults = () => 8 | export const fillEmpty = () => 9 | export const customIcons = () => 10 | -------------------------------------------------------------------------------- /.storybook/stories/ResponsiveTiles.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ResponsiveTiles from '../../src/ResponsiveTiles' 3 | 4 | export default { title: 'ResponsiveTiles' } 5 | 6 | const data = [ 7 | { color: 'red', textColor: 'white', label: 'Tile 1' }, 8 | { color: 'black', textColor: 'white', label: 'Tile 2' }, 9 | { color: 'blue', textColor: 'white', label: 'Tile 3' }, 10 | { color: 'skyblue', textColor: 'black', label: 'Tile 4' }, 11 | { color: 'purple', textColor: 'white', label: 'Tile 5' }, 12 | { color: 'yellow', textColor: 'black', label: 'Tile 6' }, 13 | { color: 'gray', textColor: 'white', label: 'Tile 7' }, 14 | { color: 'lime', textColor: 'black', label: 'Tile 8' }, 15 | { color: 'pink', textColor: 'black', label: 'Tile 9' }, 16 | { color: 'aquamarine', textColor: 'black', label: 'Tile 10' }, 17 | { color: 'orange', textColor: 'black', label: 'Tile 11' }, 18 | { color: 'indigo', textColor: 'white', label: 'Tile 12' }, 19 | ] 20 | 21 | export const defaults = () => ( 22 | 23 | {data.map(item => ( 24 |
36 | {item.label} 37 |
38 | ))} 39 |
40 | ) 41 | -------------------------------------------------------------------------------- /.storybook/stories/TabPanel.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { withKnobs, boolean } from '@storybook/addon-knobs' 4 | import TabPanel from '../../src/TabPanel' 5 | 6 | export default { title: 'TabPanel', decorators: [withKnobs] } 7 | 8 | export const defaults = () => ( 9 | 10 |
Contents of the first tab
11 |
Contents of the second tab
12 |
Contents of the third tab
13 |
Contents of the fourth tab
14 |
Contents of the fifth tab
15 |
16 | ) 17 | -------------------------------------------------------------------------------- /.storybook/stories/ToolbarButton.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Pets } from '@mui/icons-material' 3 | import ToolbarButton from '../../src/ToolbarButton' 4 | 5 | export default { title: 'ToolbarButton' } 6 | 7 | export const defaults = () => } label="Label" /> 8 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "ecmascript", 5 | "jsx": true, 6 | "dynamicImport": false, 7 | "privateMethod": false, 8 | "functionBind": false, 9 | "exportDefaultFrom": false, 10 | "exportNamespaceFrom": false, 11 | "decorators": false, 12 | "decoratorsBeforeExport": false, 13 | "topLevelAwait": false, 14 | "importMeta": false 15 | }, 16 | "transform": null, 17 | "target": "es5", 18 | "loose": false, 19 | "externalHelpers": false, 20 | "keepClassNames": false 21 | }, 22 | "module": { 23 | "type": "commonjs", 24 | "strict": false, 25 | "strictMode": true, 26 | "lazy": false, 27 | "noInterop": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License Version 2.0 2 | 3 | http://www.apache.org/licenses/ 4 | 5 | Copyright 2020 Moov Corporation 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | -------------------------------------------------------------------------------- /RSF BRANDGUIDE JPG.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storefront-foundation/react-storefront/6338bba57765d7489f2586751b5284d902cba310/RSF BRANDGUIDE JPG.pdf -------------------------------------------------------------------------------- /RSF Logo trans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storefront-foundation/react-storefront/6338bba57765d7489f2586751b5284d902cba310/RSF Logo trans.png -------------------------------------------------------------------------------- /RSF power.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storefront-foundation/react-storefront/6338bba57765d7489f2586751b5284d902cba310/RSF power.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/env', '@babel/preset-react'], 3 | plugins: [ 4 | [ 5 | '@babel/plugin-transform-runtime', 6 | { 7 | regenerator: true, 8 | }, 9 | ], 10 | [ 11 | 'babel-plugin-transform-imports', 12 | { 13 | '@mui/material': { 14 | transform: '@mui/material/${member}', 15 | preventFullImport: true, 16 | }, 17 | '@mui/styles': { 18 | transform: '@mui/styles/${member}', 19 | preventFullImport: true, 20 | }, 21 | '@mui/icons-material': { 22 | transform: '@mui/icons-material/${member}', 23 | preventFullImport: true, 24 | }, 25 | '@mui/lab': { 26 | transform: '@mui/lab/${member}', 27 | preventFullImport: true, 28 | }, 29 | }, 30 | ], 31 | '@babel/plugin-proposal-class-properties', 32 | ], 33 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | testEnvironment: './test/config/jsdom', 4 | setupFiles: ['./test/config/setup.js'], 5 | setupFilesAfterEnv: ['./test/config/mocks.js', './node_modules/jest-enzyme/lib/index.js'], 6 | collectCoverage: true, 7 | collectCoverageFrom: [ 8 | './src/**/*.{js,jsx}', 9 | './service-worker/*.{js,jsx}', 10 | '!./src/plugins/*', 11 | '!./src/mock-connector/**/*', 12 | '!**/index.js', 13 | ], 14 | moduleNameMapper: { 15 | 'react-storefront/(.*)': '/src/$1', 16 | }, 17 | transformIgnorePatterns: [ 18 | 'node_modules/(?!(workbox-expiration|workbox-core|workbox-routing|workbox-strategies|workbox-precaching)/)', 19 | ], 20 | } 21 | -------------------------------------------------------------------------------- /src/Accordion.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React, { useState } from 'react' 3 | 4 | /** 5 | * Accordion which only allows one child `ExpandableSection` to be open at a time 6 | * 7 | * ```js 8 | * 9 | * 10 | *
The first section
11 | *
12 | * 13 | *
The second section
14 | *
15 | * 16 | *
The third section
17 | *
18 | *
19 | * ``` 20 | */ 21 | export default function Accordion({ children }) { 22 | const [expanded, setExpanded] = useState( 23 | () => children && children.findIndex(child => child.props.expanded), 24 | ) 25 | if (!children) { 26 | return null 27 | } 28 | 29 | return ( 30 | // wrapped in a Fragment so react-docgen recognizes this as a Component: 31 | <> 32 | {React.Children.map(children, (child, i) => { 33 | return React.cloneElement(child, { 34 | expanded: expanded === i, 35 | onChange: (e, expanded) => { 36 | e.preventDefault() 37 | setExpanded(expanded ? i : null) 38 | }, 39 | }) 40 | })} 41 | 42 | ) 43 | } 44 | 45 | Accordion.propTypes = { 46 | /** 47 | * A list of `ExpandableSection`s that will be controlled. 48 | */ 49 | children: PropTypes.node, 50 | } 51 | -------------------------------------------------------------------------------- /src/AutoScrollToNewChildren.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react' 2 | 3 | /** 4 | * A wrapper component which scrolls the first new child into view when 5 | * the number of children increases. 6 | */ 7 | const AutoScrollToNewChildren = function({ children }) { 8 | const childCount = React.Children.count(children) 9 | const [priorChildCount, setPriorChildCount] = useState(childCount) 10 | const firstNewChild = useRef(null) 11 | 12 | useEffect(() => { 13 | if (!priorChildCount) { 14 | setPriorChildCount(childCount) 15 | } else if (childCount > priorChildCount) { 16 | firstNewChild.current.scrollIntoView({ behavior: 'smooth' }) 17 | setPriorChildCount(childCount) 18 | } 19 | }, [childCount, setPriorChildCount, priorChildCount]) 20 | 21 | return ( 22 | // wrapped in a Fragment so react-docgen recognizes this as a Component: 23 | <> 24 | {React.Children.map(children, (child, index) => { 25 | return ( 26 | <> 27 | {child} 28 | {index === priorChildCount ?
: null} 29 | 30 | ) 31 | })} 32 | 33 | ) 34 | } 35 | 36 | export default AutoScrollToNewChildren 37 | -------------------------------------------------------------------------------- /src/ErrorBoundary.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | /** 5 | * An internal component that catches errors durring rendering, sets the 6 | * error, stack, and page properties of the app state accordingly, and calls 7 | * the registered error reporter if one is configured. 8 | */ 9 | export default class ErrorBoundary extends Component { 10 | static propTypes = { 11 | /** 12 | * A function to call whenever an error occurs. The function is passed an 13 | * object with `error` (the error message) and `stack` (the stack trace as a string). 14 | */ 15 | onError: PropTypes.func, 16 | } 17 | 18 | static defaultProps = { 19 | onError: Function.prototype, 20 | } 21 | 22 | static getDerivedStateFromError(error) { 23 | return { error } 24 | } 25 | 26 | state = { 27 | error: null, 28 | } 29 | 30 | componentDidMount() { 31 | const { onError } = this.props 32 | 33 | this.windowErrorEvent = window.addEventListener('error', event => { 34 | onError({ error: event.error }) 35 | }) 36 | 37 | this.windowUnhandledRejectionEvent = window.addEventListener('unhandledrejection', event => { 38 | onError({ error: event.reason }) 39 | }) 40 | } 41 | 42 | componentWillUnmount() { 43 | window.removeEventListener('error', this.windowErrorEvent) 44 | window.removeEventListener('unhandledrejection', this.windowUnhandledRejectionEvent) 45 | } 46 | 47 | /** 48 | * When an error is caught, call the error reporter and update the app state 49 | * @param {Error} error 50 | * @param {Object} info 51 | */ 52 | componentDidCatch(error) { 53 | const { onError } = this.props 54 | 55 | // report the error 56 | onError({ error }) 57 | } 58 | 59 | render() { 60 | if (this.state.error) { 61 | return
{this.state.error.message}
62 | } 63 | 64 | return this.props.children 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Fill.js: -------------------------------------------------------------------------------- 1 | import React, { Children } from 'react' 2 | import { styled } from '@mui/material/styles' 3 | import clsx from 'clsx' 4 | import PropTypes from 'prop-types' 5 | 6 | const PREFIX = 'Fill' 7 | 8 | const classes = { 9 | root: `${PREFIX}-root`, 10 | child: `${PREFIX}-child`, 11 | } 12 | 13 | const Root = styled('div')(() => ({ 14 | /** 15 | * Styles applied to the root element. 16 | */ 17 | [`&.${classes.root}`]: { 18 | position: 'relative', 19 | width: '100%', 20 | }, 21 | 22 | /** 23 | * Styles applied to the wrapper element for the children. 24 | */ 25 | [`& .${classes.child}`]: { 26 | position: 'absolute', 27 | top: 0, 28 | left: 0, 29 | width: '100%', 30 | height: '100%', 31 | '& > *': { 32 | width: '100%', 33 | height: '100%', 34 | }, 35 | }, 36 | })) 37 | 38 | /** 39 | * This component sizes the height of the child element as a percentage of its width. It expects 40 | * only a single child. 41 | * 42 | * Example: 43 | * 44 | * ```js 45 | * 46 | *
this element's height will be the same as its width.
47 | *
48 | * ``` 49 | */ 50 | export default function Fill({ height, children, className, ...props }) { 51 | children = Children.only(children) 52 | 53 | if (height == null) { 54 | return children 55 | } 56 | 57 | return ( 58 | 59 |
60 |
{children}
61 | 62 | ) 63 | } 64 | 65 | Fill.propTypes = { 66 | /** 67 | * The height as a percentage of the width in a css compatible expression. For example: 68 | * `"100%"` or `"calc(100% + 50px)"`, etc... If omitted, this component does nothing except render 69 | * the provided child. 70 | */ 71 | height: PropTypes.string, 72 | className: PropTypes.string, 73 | } 74 | -------------------------------------------------------------------------------- /src/ForwardThumbnail.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef, useContext, useEffect } from 'react' 2 | import PWAContext from './PWAContext' 3 | 4 | /** 5 | * Wrap product links in this component to reuse the thumbnail as the main image in the product 6 | * skeleton when transitioning to the PDP to make the transition feel instant. This component 7 | * sets the `thumbnail` ref on the provided `PWAContext` to the `src` prop of the first `img` 8 | * element found amongst the descendant elements in the tree. 9 | * 10 | * Example: 11 | * 12 | * ```js 13 | * 14 | * 15 | * 16 | * {product.media.thumbnail.alt} 17 | *
{product.name}
18 | *
19 | * 20 | *
21 | * ``` 22 | */ 23 | export default function ForwardThumbnail({ children }) { 24 | const ref = useRef(null) 25 | const context = useContext(PWAContext) 26 | const srcRef = useRef(null) 27 | 28 | const setSrcRef = useCallback(() => { 29 | if (ref.current.querySelector('img')) { 30 | srcRef.current = ref.current.querySelector('img').getAttribute('src') 31 | } 32 | }, []) 33 | 34 | useEffect(setSrcRef, [children]) 35 | 36 | const handleClick = useCallback(() => { 37 | if (!srcRef.current) { 38 | setSrcRef() 39 | } 40 | context.thumbnail.current = { src: srcRef.current } 41 | }, []) 42 | 43 | return ( 44 |
45 | {children} 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/Highlight.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useAmp } from 'next/amp' 3 | import PropTypes from 'prop-types' 4 | import { Typography } from '@mui/material' 5 | 6 | const escapeHtml = unsafe => 7 | unsafe 8 | .replace(/&/g, '&') 9 | .replace(//g, '>') 11 | .replace(/"/g, '"') 12 | .replace(/'/g, ''') 13 | 14 | const addHighlight = (query, text, className = '') => { 15 | if (!text) return '' 16 | return escapeHtml(text).replace( 17 | new RegExp(query, 'gi'), 18 | match => `${match}`, 19 | ) 20 | } 21 | 22 | export default function Highlight({ query, text, classes = {}, ...props }) { 23 | if (useAmp()) { 24 | return {text} 25 | } 26 | return ( 27 | 31 | ) 32 | } 33 | 34 | Highlight.propTypes = { 35 | query: PropTypes.string, 36 | text: PropTypes.string, 37 | classes: PropTypes.object, 38 | } 39 | -------------------------------------------------------------------------------- /src/Label.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { styled } from '@mui/material/styles' 3 | import React from 'react' 4 | import clsx from 'clsx' 5 | import { Typography } from '@mui/material' 6 | 7 | const PREFIX = 'RSFLabel' 8 | 9 | const defaultClasses = { 10 | root: `${PREFIX}-root`, 11 | } 12 | 13 | const StyledTypography = styled(Typography)(() => ({ 14 | /** 15 | * Styles applied to the root element\. 16 | */ 17 | [`&.${defaultClasses.root}`]: { 18 | fontWeight: 500, 19 | marginRight: 10, 20 | }, 21 | })) 22 | 23 | export default function Label({ className, classes: c = {}, ...props }) { 24 | const classes = { ...defaultClasses, ...c } 25 | return 26 | } 27 | 28 | Label.propTypes = { 29 | /** 30 | * Override or extend the styles applied to the component. See [CSS API](#css) below for more details. 31 | */ 32 | classes: PropTypes.object, 33 | /** 34 | * CSS class to apply to the root element 35 | */ 36 | className: PropTypes.string, 37 | } 38 | -------------------------------------------------------------------------------- /src/NextScript.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NextScript as OriginalNextScript } from 'next/document' 3 | import PropTypes from 'prop-types' 4 | 5 | /** 6 | * A replacement for NextScript from `next/document` that gives you greater control over how script elements are rendered. 7 | * This should be used in the body of `pages/_document.js` in place of `NextScript`. 8 | */ 9 | export default class NextScript extends OriginalNextScript { 10 | static propTypes = { 11 | /** 12 | * Set to `defer` to use `defer` instead of `async` when rendering script elements. 13 | */ 14 | mode: PropTypes.oneOf(['async', 'defer']), 15 | } 16 | 17 | static defaultProps = { 18 | mode: 'async', 19 | } 20 | 21 | getScripts(files) { 22 | return super.getScripts(files).map(script => { 23 | return React.cloneElement(script, { 24 | key: script.props.src, 25 | defer: this.props.mode === 'defer' ? true : undefined, 26 | async: this.props.mode === 'async' ? true : undefined, 27 | }) 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/NoScript.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /** 4 | * A simple wrapper for HTML noscript that is testable in enzyme. This is 5 | * needed since enzyme won't render the contents of a noscript element. 6 | * @param {*} props 7 | */ 8 | export default function NoScript(props) { 9 | if (process.env.NODE_ENV === 'test') { 10 | return
11 | } 12 | return
21 | } 22 | /> 23 | 24 | ) 25 | } 26 | 27 | MenuBack.propTypes = { 28 | /** 29 | * Goes back to the previous item in the menu hierarchy 30 | */ 31 | goBack: PropTypes.func, 32 | /** 33 | * The menu item being rendered 34 | */ 35 | item: PropTypes.shape({ 36 | text: PropTypes.string, 37 | }).isRequired, 38 | backButtonProps: PropTypes.object, 39 | } 40 | -------------------------------------------------------------------------------- /src/menu/MenuBody.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import PropTypes from 'prop-types' 3 | import MenuContext from './MenuContext' 4 | import MenuCard from './MenuCard' 5 | 6 | const MenuBody = React.memo( 7 | ({ CardComponent, wrapProps, card, cards, rootHeader, rootFooter, children }) => { 8 | const { classes, drawerWidth } = useContext(MenuContext) 9 | const position = -drawerWidth * card 10 | 11 | return ( 12 | <> 13 | {children} 14 |
19 | {cards.map((item, depth) => ( 20 | 28 | ))} 29 |
30 | 31 | ) 32 | }, 33 | ) 34 | 35 | MenuBody.propTypes = { 36 | /** 37 | * Additional props for the wrap element 38 | */ 39 | wrapProps: PropTypes.object, 40 | 41 | /** 42 | * Overrides the default component used to display menu cards 43 | */ 44 | CardComponent: PropTypes.elementType.isRequired, 45 | card: PropTypes.number, 46 | cards: PropTypes.array, 47 | rootHeader: PropTypes.any, 48 | rootFooter: PropTypes.any, 49 | } 50 | 51 | MenuBody.defaultProps = { 52 | wrapProps: {}, 53 | CardComponent: MenuCard, 54 | } 55 | 56 | export default MenuBody 57 | -------------------------------------------------------------------------------- /src/menu/MenuButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { styled } from '@mui/material/styles' 3 | import { Hidden } from '@mui/material' 4 | import PropTypes from 'prop-types' 5 | import clsx from 'clsx' 6 | import ToolbarButton from '../ToolbarButton' 7 | import MenuIcon from './MenuIcon' 8 | 9 | const PREFIX = 'RSFMenuButton' 10 | 11 | const classes = { 12 | link: `${PREFIX}-link`, 13 | } 14 | 15 | const StyledHidden = styled(Hidden)(() => ({ 16 | [`& .${classes.link}`]: { 17 | textDecoration: 'none', 18 | }, 19 | })) 20 | 21 | export {} 22 | 23 | /** 24 | * The button that controls that opens and closes the main app menu. 25 | */ 26 | export default function MenuButton({ MenuIcon, menuIconProps, open, onClick, className, style }) { 27 | return ( 28 | 29 | 34 | } 39 | /> 40 | 41 | 42 | ) 43 | } 44 | 45 | MenuButton.propTypes = { 46 | /** 47 | * A react component to use for the menu icon 48 | */ 49 | MenuIcon: PropTypes.elementType, 50 | 51 | /** 52 | * Props for the menu icon 53 | */ 54 | menuIconProps: PropTypes.object, 55 | open: PropTypes.bool, 56 | onClick: PropTypes.func, 57 | className: PropTypes.string, 58 | style: PropTypes.object, 59 | } 60 | 61 | MenuButton.defaultProps = { 62 | MenuIcon, 63 | menuIconProps: {}, 64 | } 65 | -------------------------------------------------------------------------------- /src/menu/MenuCard.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { List } from '@mui/material' 3 | import PropTypes from 'prop-types' 4 | import MenuItem from './MenuItem' 5 | import MenuBack from './MenuBack' 6 | import MenuHeader from './MenuHeader' 7 | import MenuFooter from './MenuFooter' 8 | import MenuContext from './MenuContext' 9 | 10 | export default function MenuCard({ item, depth, headerProps }) { 11 | const { goBack, classes, expandFirstItem, drawerWidth } = useContext(MenuContext) 12 | 13 | return ( 14 | 22 | {!item.root && ( 23 | goBack(depth - 1)} 26 | item={item} 27 | card={depth} 28 | {...headerProps} 29 | /> 30 | )} 31 | 32 | 33 | 34 | {item.items && 35 | item.items.map((child, i) => ( 36 | 42 | ))} 43 | 44 | 45 | 46 | ) 47 | } 48 | 49 | MenuCard.propTypes = { 50 | /** 51 | * Addition props for the header element 52 | */ 53 | headerProps: PropTypes.object, 54 | item: PropTypes.object, 55 | depth: PropTypes.number, 56 | } 57 | 58 | MenuCard.defaultProps = {} 59 | -------------------------------------------------------------------------------- /src/menu/MenuContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | const MenuContext = createContext() 4 | export default MenuContext 5 | -------------------------------------------------------------------------------- /src/menu/MenuExpanderIcon.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { ChevronRight, ExpandLess, ExpandMore } from '@mui/icons-material' 3 | import PropTypes from 'prop-types' 4 | import { useAmp } from 'next/amp' 5 | import clsx from 'clsx' 6 | import MenuContext from './MenuContext' 7 | 8 | function ExpanderIcon({ ExpandIcon, CollapseIcon, showExpander, sublist, expanded }) { 9 | const { classes } = useContext(MenuContext) 10 | const amp = useAmp() 11 | 12 | ExpandIcon = ExpandIcon || ExpandMore 13 | CollapseIcon = CollapseIcon || ExpandLess 14 | 15 | if (!showExpander) return 16 | 17 | if (amp) { 18 | return ( 19 | <> 20 | rsfMenuState.sublist == '${sublist}' ? '${classes.visible} ${classes.icon}' : '${classes.hidden} ${classes.icon}'`} 23 | /> 24 | rsfMenuState.sublist == '${sublist}' ? '${classes.hidden} ${classes.icon}' : '${classes.visible} ${classes.icon}'`} 27 | /> 28 | 29 | ) 30 | } 31 | return expanded ? ( 32 | 33 | ) : ( 34 | 35 | ) 36 | } 37 | 38 | ExpanderIcon.propTypes = { 39 | ExpandIcon: PropTypes.any, 40 | CollapseIcon: PropTypes.any, 41 | showExpander: PropTypes.bool, 42 | sublist: PropTypes.any, 43 | expanded: PropTypes.any, 44 | } 45 | 46 | export default ExpanderIcon 47 | -------------------------------------------------------------------------------- /src/menu/MenuFooter.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { styled } from '@mui/material/styles' 3 | import PropTypes from 'prop-types' 4 | import CmsSlot from '../CmsSlot' 5 | import MenuContext from './MenuContext' 6 | 7 | const PREFIX = 'RSFMenuFooter' 8 | 9 | const classes = { 10 | root: `${PREFIX}-root`, 11 | } 12 | 13 | const Root = styled('div')(({ theme }) => ({ 14 | [`&.${classes.root}`]: { 15 | padding: theme.spacing(2), 16 | }, 17 | })) 18 | 19 | export {} 20 | 21 | export default function MenuFooter({ item }) { 22 | const { renderFooter } = useContext(MenuContext) 23 | 24 | if (typeof renderFooter === 'function') { 25 | return {renderFooter(item)} 26 | } 27 | 28 | if (item.footer) { 29 | return ( 30 | 31 | {item.footer} 32 | 33 | ) 34 | } 35 | 36 | return null 37 | } 38 | 39 | MenuFooter.propTypes = { 40 | /** 41 | * The menu item record 42 | */ 43 | item: PropTypes.object, 44 | } 45 | 46 | MenuFooter.defaultProps = {} 47 | -------------------------------------------------------------------------------- /src/menu/MenuHeader.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { styled } from '@mui/material/styles' 3 | import PropTypes from 'prop-types' 4 | import CmsSlot from '../CmsSlot' 5 | import MenuContext from './MenuContext' 6 | 7 | const PREFIX = 'RSFMenuHeader' 8 | 9 | const classes = { 10 | root: `${PREFIX}-root`, 11 | } 12 | 13 | const Root = styled('div')(({ theme }) => ({ 14 | [`&.${classes.root}`]: { 15 | padding: theme.spacing(2), 16 | borderBottom: `1px solid ${theme.palette.divider}`, 17 | }, 18 | })) 19 | 20 | export {} 21 | 22 | export default function MenuHeader({ item }) { 23 | const { renderHeader } = useContext(MenuContext) 24 | 25 | if (typeof renderHeader === 'function') { 26 | return {renderHeader(item)} 27 | } 28 | 29 | if (item.header) { 30 | return ( 31 | 32 | {item.header} 33 | 34 | ) 35 | } 36 | 37 | return null 38 | } 39 | 40 | MenuHeader.propTypes = { 41 | /** 42 | * The menu item record 43 | */ 44 | item: PropTypes.object, 45 | } 46 | 47 | MenuHeader.defaultProps = {} 48 | -------------------------------------------------------------------------------- /src/menu/MenuItem.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import PropTypes from 'prop-types' 3 | import MenuBranch from './MenuBranch' 4 | import MenuLeaf from './MenuLeaf' 5 | import MenuContext from './MenuContext' 6 | 7 | export default function MenuItem({ BranchComponent, LeafComponent, item, ...props }) { 8 | const { renderItem } = useContext(MenuContext) 9 | 10 | let NodeType = LeafComponent 11 | let result = null 12 | 13 | if (item.items) { 14 | NodeType = BranchComponent 15 | } 16 | 17 | if (renderItem) { 18 | result = renderItem(item, item.leaf) 19 | } 20 | 21 | if (result == null) { 22 | result = 23 | } 24 | 25 | return result 26 | } 27 | 28 | MenuItem.propTypes = { 29 | BranchComponent: PropTypes.elementType.isRequired, 30 | LeafComponent: PropTypes.any, 31 | } 32 | 33 | MenuItem.defaultProps = { 34 | BranchComponent: MenuBranch, 35 | LeafComponent: MenuLeaf, 36 | } 37 | -------------------------------------------------------------------------------- /src/menu/MenuLeaf.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import PropTypes from 'prop-types' 3 | import MenuItemContent from './MenuItemContent' 4 | import Link from '../link/Link' 5 | import MenuContext from './MenuContext' 6 | 7 | const MenuLeaf = function({ item, ...others }) { 8 | const { close, classes } = useContext(MenuContext) 9 | 10 | return ( 11 | 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | MenuLeaf.propTypes = { 26 | item: PropTypes.shape({ 27 | as: PropTypes.string, 28 | pageData: PropTypes.object, 29 | href: PropTypes.string, 30 | }), 31 | } 32 | 33 | export default React.memo(MenuLeaf) 34 | -------------------------------------------------------------------------------- /src/menu/SEOLinks.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import NoScript from '../NoScript' 4 | 5 | function SEOLinks({ root }) { 6 | if (!root) return null 7 | 8 | const links = [] 9 | let key = 0 10 | 11 | const findLinks = ({ items }) => { 12 | if (!items) return 13 | 14 | for (let i = 0; i < items.length; i++) { 15 | const item = items[i] 16 | 17 | if (item.href) { 18 | links.push( 19 | 20 | {item.text} 21 | , 22 | ) 23 | } 24 | 25 | if (item.items) { 26 | findLinks(item) 27 | } 28 | } 29 | } 30 | 31 | findLinks(root) 32 | 33 | return ( 34 | 48 | ) 49 | } 50 | 51 | const itemShape = { 52 | as: PropTypes.string, 53 | href: PropTypes.string, 54 | } 55 | 56 | itemShape.items = PropTypes.arrayOf(PropTypes.shape(itemShape)) 57 | const root = PropTypes.shape(itemShape) 58 | 59 | SEOLinks.propTypes = { 60 | root, 61 | } 62 | 63 | export default SEOLinks 64 | -------------------------------------------------------------------------------- /src/middlewares/withAmpFormParser.js: -------------------------------------------------------------------------------- 1 | import formidable from 'formidable' 2 | 3 | /** 4 | * Wraps the provided handler in a middleware that parses AMP form submissions correctly. By 5 | * default, next.js's body parser doesn't handle multipart form posts properly, so you 6 | * won't be able to receive data posted from a form in AMP. 7 | * 8 | * When using this middleware, you should always disable next's default body parser by adding 9 | * the following to your api endpoint: 10 | * 11 | * ```js 12 | * export const config = { 13 | * api: { 14 | * bodyParser: false 15 | * } 16 | * } 17 | * ``` 18 | * 19 | * @param {Function} handler An API endpoint 20 | * @return {Function} Your API function with body parsing middleware added. 21 | */ 22 | export default function withAmpFormParser(handler) { 23 | return (req, res) => { 24 | const form = formidable() 25 | 26 | if (req.method.toLowerCase() === 'post') { 27 | try { 28 | form.parse(req, (err, fields) => { 29 | if (err) { 30 | res.status(500).end(err.message) 31 | } else { 32 | req.body = fields 33 | return handler(req, res) 34 | } 35 | }) 36 | } catch (err) { 37 | /* istanbul ignore next */ 38 | res.status(500).end(err.message) 39 | } 40 | } else { 41 | return handler(req, res) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/mock-connector/.gitignore: -------------------------------------------------------------------------------- 1 | !/index.js -------------------------------------------------------------------------------- /src/mock-connector/account.js: -------------------------------------------------------------------------------- 1 | import fulfillAPIRequest from '../props/fulfillAPIRequest' 2 | import createAppData from './utils/createAppData' 3 | 4 | export default async function account(req) { 5 | return await fulfillAPIRequest(req, { 6 | appData: createAppData, 7 | pageData: () => 8 | Promise.resolve({ 9 | title: 'My Account', 10 | account: {}, 11 | breadcrumbs: [ 12 | { 13 | text: 'Home', 14 | href: '/', 15 | }, 16 | ], 17 | }), 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/mock-connector/addToCart.js: -------------------------------------------------------------------------------- 1 | import { addItem } from './utils/cartStore' 2 | 3 | export default async function addToCart({ product, quantity }, req, res) { 4 | return { cart: { items: addItem(product.id, quantity, req, res) } } 5 | } 6 | -------------------------------------------------------------------------------- /src/mock-connector/cart.js: -------------------------------------------------------------------------------- 1 | import fulfillAPIRequest from '../props/fulfillAPIRequest' 2 | import createAppData from './utils/createAppData' 3 | import { getProducts } from './utils/cartStore' 4 | 5 | export default async function cart(req, res) { 6 | return fulfillAPIRequest(req, { 7 | appData: createAppData, 8 | pageData: () => 9 | Promise.resolve({ 10 | title: 'My Cart', 11 | breadcrumbs: [ 12 | { 13 | text: 'Home', 14 | href: '/', 15 | }, 16 | ], 17 | cart: { 18 | items: getProducts(req, res), 19 | }, 20 | }), 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/mock-connector/home.js: -------------------------------------------------------------------------------- 1 | import fulfillAPIRequest from '../props/fulfillAPIRequest' 2 | import createAppData from './utils/createAppData' 3 | 4 | export default async function home(req) { 5 | return await fulfillAPIRequest(req, { 6 | appData: createAppData, 7 | pageData: () => 8 | Promise.resolve({ 9 | title: 'React Storefront', 10 | slots: { 11 | heading: 'Welcome to your new React Storefront app.', 12 | description: ` 13 |

14 | Here you'll find mock home, category, subcategory, product, and cart pages that you can 15 | use as a starting point to build your PWA. 16 |

17 |

Happy coding!

18 | `, 19 | }, 20 | }), 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/mock-connector/index.js: -------------------------------------------------------------------------------- 1 | export { default as cart } from './cart.js' 2 | export { default as account } from './account.js' 3 | export { default as addToCart } from './addToCart.js' 4 | export { default as updateCartItem } from './updateCartItem.js' 5 | export { default as removeCartItem } from './removeCartItem.js' 6 | export { default as home } from './home.js' 7 | export { default as product } from './product.js' 8 | export { default as productMedia } from './productMedia.js' 9 | export { default as productSuggestions } from './productSuggestions.js' 10 | export { default as search } from './search.js' 11 | export { default as searchSuggestions } from './searchSuggestions.js' 12 | export { default as session } from './session.js' 13 | export { default as subcategory } from './subcategory.js' 14 | -------------------------------------------------------------------------------- /src/mock-connector/product.js: -------------------------------------------------------------------------------- 1 | import getBase64ForImage from 'react-storefront/utils/getBase64ForImage' 2 | import fulfillAPIRequest from '../props/fulfillAPIRequest' 3 | import createProduct from './utils/createProduct' 4 | import createAppData from './utils/createAppData' 5 | 6 | function asciiSum(string = '') { 7 | return string.split('').reduce((s, e) => s + e.charCodeAt(), 0) 8 | } 9 | 10 | export default async function product(params, req) { 11 | const { id, color, size } = params 12 | 13 | const result = await fulfillAPIRequest(req, { 14 | appData: createAppData, 15 | pageData: () => getPageData(id), 16 | }) 17 | 18 | // When a query parameter exists, we can fetch custom product data 19 | // pertaining to specific filters. 20 | if (color || size) { 21 | const data = await getPageData(id) 22 | data.carousel = { index: 0 } 23 | // A price for the fetched product variant would be included in 24 | // the response, but for demo purposes only, we are setting the 25 | // price based on the color name. 26 | const mockPrice = (asciiSum(color) + asciiSum(size)) / 100 27 | data.product.price = mockPrice 28 | data.product.priceText = `$${mockPrice.toFixed(2)}` 29 | return data 30 | } 31 | 32 | return result 33 | } 34 | 35 | async function getPageData(id) { 36 | const result = { 37 | title: `Product ${id}`, 38 | product: createProduct(id), 39 | breadcrumbs: [ 40 | { 41 | text: 'Home', 42 | href: '/', 43 | }, 44 | { 45 | text: `Subcategory ${id}`, 46 | as: `/s/${id}`, 47 | href: '/s/[subcategoryId]', 48 | }, 49 | ], 50 | } 51 | 52 | const mainProductImage = result.product.media.full[0] 53 | mainProductImage.src = await getBase64ForImage(mainProductImage.src) 54 | 55 | return result 56 | } 57 | -------------------------------------------------------------------------------- /src/mock-connector/productMedia.js: -------------------------------------------------------------------------------- 1 | import createMedia from './utils/createMedia' 2 | 3 | export default async function productMedia({ id, color }) { 4 | return { media: createMedia(id, color) } 5 | } 6 | -------------------------------------------------------------------------------- /src/mock-connector/productSuggestions.js: -------------------------------------------------------------------------------- 1 | import createProduct from './utils/createProduct' 2 | 3 | /** 4 | * An example endpoint that returns mock product suggestions for a PDP. 5 | * @param {*} req 6 | * @param {*} res 7 | */ 8 | export default async function productSuggestions() { 9 | const products = [] 10 | 11 | for (let i = 1; i <= 10; i++) { 12 | products.push(createProduct(i)) 13 | } 14 | 15 | return products 16 | } 17 | -------------------------------------------------------------------------------- /src/mock-connector/removeCartItem.js: -------------------------------------------------------------------------------- 1 | import { removeItem } from './utils/cartStore' 2 | 3 | export default async function removeCartItem(item, req, res) { 4 | return { cart: { items: removeItem(item.id, req, res) } } 5 | } 6 | -------------------------------------------------------------------------------- /src/mock-connector/search.js: -------------------------------------------------------------------------------- 1 | export { default } from './subcategory' 2 | -------------------------------------------------------------------------------- /src/mock-connector/session.js: -------------------------------------------------------------------------------- 1 | import { getProducts } from './utils/cartStore' 2 | 3 | export default async function session(req, res) { 4 | return { 5 | name: 'Mark', 6 | email: 'mark@domain.com', 7 | cart: { 8 | items: getProducts(req, res), 9 | }, 10 | currency: 'USD', 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/mock-connector/updateCartItem.js: -------------------------------------------------------------------------------- 1 | import { updateItem } from './utils/cartStore' 2 | 3 | export default function updateCartItem(item, quantity, req, res) { 4 | return { cart: { items: updateItem(item.id, quantity, req, res) } } 5 | } 6 | -------------------------------------------------------------------------------- /src/mock-connector/utils/cartStore.js: -------------------------------------------------------------------------------- 1 | import createProduct from './createProduct' 2 | 3 | const CART_COOKIE = 'rsf_mock_cart' 4 | 5 | const initialStore = [ 6 | { id: 1, quantity: 1 }, 7 | { id: 2, quantity: 1 }, 8 | ] 9 | 10 | function getStore(req, res) { 11 | if (!req.cookies[CART_COOKIE]) { 12 | res.setHeader('Set-Cookie', `${CART_COOKIE}=${JSON.stringify(initialStore)}; Path=/`) 13 | } 14 | const store = req.cookies[CART_COOKIE] || initialStore 15 | try { 16 | return JSON.parse(store) 17 | } catch (err) { 18 | console.log('Failed parsing store from cookie', req.cookies[CART_COOKIE]) 19 | return [] 20 | } 21 | } 22 | 23 | function toProduct({ id, quantity }) { 24 | return { ...createProduct(id), quantity } 25 | } 26 | 27 | export function getProducts(req, res) { 28 | return getStore(req, res).map(toProduct) 29 | } 30 | 31 | export function updateItem(id, quantity, req, res) { 32 | const newStore = [...getStore(req, res)] 33 | const item = newStore.find(e => e.id === id) 34 | item.quantity = quantity 35 | res.setHeader('Set-Cookie', `${CART_COOKIE}=${JSON.stringify(newStore)}; Path=/`) 36 | return newStore.map(toProduct) 37 | } 38 | 39 | export function removeItem(id, req, res) { 40 | const newStore = [...getStore(req, res)].filter(e => e.id !== id) 41 | res.setHeader('Set-Cookie', `${CART_COOKIE}=${JSON.stringify(newStore)}; Path=/`) 42 | return newStore.map(toProduct) 43 | } 44 | 45 | export function addItem(id, quantity, req, res) { 46 | const newStore = [{ id, quantity }, ...getStore(req, res)] 47 | res.setHeader('Set-Cookie', `${CART_COOKIE}=${JSON.stringify(newStore)}; Path=/`) 48 | return newStore.map(toProduct) 49 | } 50 | -------------------------------------------------------------------------------- /src/mock-connector/utils/colors.js: -------------------------------------------------------------------------------- 1 | import { red, green, blue, grey, teal, orange, purple } from '@mui/material/colors' 2 | 3 | // eslint-disable-next-line no-useless-escape 4 | const color = c => c.toString().replace(/\#/, '') 5 | 6 | const colors = { 7 | red: { background: color(red[500]), foreground: 'ffffff' }, 8 | green: { background: color(green[500]), foreground: 'ffffff' }, 9 | blue: { background: color(blue[500]), foreground: 'ffffff' }, 10 | grey: { background: color(grey[300]), foreground: color(grey[600]) }, 11 | teal: { background: color(teal[500]), foreground: 'ffffff' }, 12 | orange: { background: color(orange[500]), foreground: 'ffffff' }, 13 | purple: { background: color(purple[500]), foreground: 'ffffff' }, 14 | black: { background: color(grey[800]), foreground: 'ffffff' }, 15 | } 16 | 17 | export default colors 18 | 19 | export function colorForId(id) { 20 | const keys = Object.keys(colors) 21 | const index = id % keys.length 22 | return keys[index] 23 | } 24 | 25 | export function indexForColor(color) { 26 | return Object.keys(colors).indexOf(color) 27 | } 28 | -------------------------------------------------------------------------------- /src/mock-connector/utils/createAppData.js: -------------------------------------------------------------------------------- 1 | import createMenu from './createMenu' 2 | import createTabs from './createTabs' 3 | 4 | export default function createAppData() { 5 | return Promise.resolve({ menu: createMenu(), tabs: createTabs() }) 6 | } 7 | -------------------------------------------------------------------------------- /src/mock-connector/utils/createFacets.js: -------------------------------------------------------------------------------- 1 | import capitalize from 'lodash/capitalize' 2 | import colors from './colors' 3 | 4 | export default function createFacets() { 5 | return [ 6 | { 7 | name: 'Color', 8 | ui: 'buttons', 9 | options: Object.keys(colors).map(name => ({ 10 | name: capitalize(name), 11 | code: `color:${name}`, 12 | image: { 13 | src: `https://dummyimage.com/48x48/${colors[name].background}?text=${encodeURIComponent( 14 | ' ', 15 | )}`, 16 | alt: name, 17 | }, 18 | })), 19 | }, 20 | { 21 | name: 'Size', 22 | ui: 'buttons', 23 | options: [ 24 | { name: 'SM', code: 'size:sm' }, 25 | { name: 'MD', code: 'size:md' }, 26 | { name: 'LG', code: 'size:lg' }, 27 | { name: 'XL', code: 'size:xl' }, 28 | { name: 'XXL', code: 'size:xxl' }, 29 | ], 30 | }, 31 | { 32 | name: 'Type', 33 | ui: 'checkboxes', 34 | options: [ 35 | { name: 'New', code: 'type:new', matches: 100 }, 36 | { name: 'Used', code: 'type:used', matches: 20 }, 37 | ], 38 | }, 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /src/mock-connector/utils/createMedia.js: -------------------------------------------------------------------------------- 1 | import colors from './colors' 2 | 3 | export default function createMedia(id, color) { 4 | return { 5 | full: [color].map((key, i) => ({ 6 | src: `https://dummyimage.com/${i === 2 ? 400 : 600}x${i === 1 ? 400 : 600}/${ 7 | colors[key].background 8 | }/${colors[key].foreground}?text=${encodeURIComponent(`Product ${id}`)}`, 9 | alt: `Product ${id}`, 10 | magnify: { 11 | height: i === 1 ? 800 : 1200, 12 | width: i === 2 ? 800 : 1200, 13 | src: `https://dummyimage.com/${i === 2 ? 800 : 1200}x${i === 1 ? 800 : 1200}/${ 14 | colors[key].background 15 | }/${colors[key].foreground}?text=${encodeURIComponent(`Product ${id}`)}`, 16 | }, 17 | })), 18 | thumbnails: [color].map((key, i) => ({ 19 | src: `https://dummyimage.com/${i === 2 ? 300 : 400}x${i === 1 ? 300 : 400}/${ 20 | colors[key].background 21 | }?text=${encodeURIComponent(`Product ${id}`)}`, 22 | alt: key, 23 | })), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/mock-connector/utils/createMenu.js: -------------------------------------------------------------------------------- 1 | export default function createMenu() { 2 | const items = [] 3 | 4 | for (let i = 1; i <= 5; i++) { 5 | items.push(createCategoryItem(i)) 6 | } 7 | 8 | return { 9 | items, 10 | header: 'header', 11 | footer: 'footer', 12 | } 13 | } 14 | 15 | function createCategoryItem(i) { 16 | const items = [] 17 | 18 | for (let j = 1; j <= 5; j++) { 19 | items.push(createSubcategoryItem(j)) 20 | } 21 | 22 | return { 23 | text: `Category ${i}`, 24 | items, 25 | } 26 | } 27 | 28 | function createSubcategoryItem(i) { 29 | const items = [] 30 | 31 | for (let j = 1; j <= 5; j++) { 32 | items.push(createProductItem(j)) 33 | } 34 | 35 | return { 36 | text: `Subcategory ${i}`, 37 | href: '/s/[...categorySlug]', 38 | as: `/s/${i}`, 39 | expanded: i === 1, 40 | items, 41 | } 42 | } 43 | 44 | function createProductItem(i) { 45 | return { 46 | text: `Product ${i}`, 47 | href: '/p/[productId]', 48 | as: `/p/${i}`, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/mock-connector/utils/createProducts.js: -------------------------------------------------------------------------------- 1 | import createProduct from './createProduct' 2 | 3 | export default function createProducts(count, page = 0) { 4 | const products = [] 5 | 6 | const start = page * count 7 | 8 | for (let i = 0; i < count; i++) { 9 | products.push(createProduct(start + i + 1)) 10 | } 11 | 12 | return products 13 | } 14 | -------------------------------------------------------------------------------- /src/mock-connector/utils/createSortOptions.js: -------------------------------------------------------------------------------- 1 | export default function createSortOptions() { 2 | return [ 3 | { name: 'Price - Lowest', code: 'price_asc' }, 4 | { name: 'Price - Highest', code: 'price_desc' }, 5 | { name: 'Most Popular', code: 'pop' }, 6 | { name: 'Highest Rated', code: 'rating' }, 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/mock-connector/utils/createTabs.js: -------------------------------------------------------------------------------- 1 | export default function createTabs() { 2 | const tabs = [] 3 | const subcategories = [] 4 | 5 | for (let i = 1; i <= 3; i++) { 6 | subcategories.push({ as: `/s/${i}`, href: '/s/[...categorySlug]', text: `Subcategory ${i}` }) 7 | } 8 | 9 | for (let i = 1; i <= 10; i++) { 10 | tabs.push({ 11 | as: `/s/${i}`, 12 | href: '/s/[...categorySlug]', 13 | text: `Category ${i}`, 14 | items: subcategories, 15 | }) 16 | } 17 | 18 | return tabs 19 | } 20 | -------------------------------------------------------------------------------- /src/plp/SearchResultsContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | const SearchResultsContext = createContext() 4 | 5 | export default SearchResultsContext 6 | -------------------------------------------------------------------------------- /src/plp/useSearchResultsStore.js: -------------------------------------------------------------------------------- 1 | import useLazyState from '../hooks/useLazyState' 2 | 3 | /** 4 | * Allows for using search results. 5 | * @param lazyProps 6 | * @return {*[]} 7 | */ 8 | export default function useSearchResultsStore(lazyProps) { 9 | const additionalData = { 10 | reloading: false, 11 | pageData: Object.freeze({ 12 | page: 0, 13 | filters: [], 14 | sort: 'rating', 15 | sortSaved: 'rating', 16 | sortOptions: [], 17 | filtersChanged: false, 18 | }), 19 | } 20 | 21 | return useLazyState(lazyProps, additionalData) 22 | } 23 | -------------------------------------------------------------------------------- /src/plugins/withServiceWorker.js: -------------------------------------------------------------------------------- 1 | const withOffline = require('next-offline') 2 | const { join } = require('path') 3 | 4 | module.exports = function withServiceWorker(config) { 5 | const generateInDevMode = process.env.SERVICE_WORKER === 'true' 6 | 7 | if (generateInDevMode) { 8 | console.log('> Using service worker in development mode') 9 | } 10 | 11 | return withOffline({ 12 | ...config, 13 | generateInDevMode, 14 | generateSw: false, 15 | workboxOpts: { 16 | swDest: 'static/service-worker.js', 17 | swSrc: join(process.cwd(), 'sw', 'service-worker.js'), 18 | // The asset names for page chunks contain square brackets, eg [productId].js 19 | // Next internally injects these chunks encoded, eg %5BproductId%5D.js 20 | // For precaching to work the cache keys need to match the name of the assets 21 | // requested, therefore we need to transform the manifest entries with encoding. 22 | manifestTransforms: [ 23 | manifestEntries => { 24 | console.log('> Creating service worker...') 25 | const manifest = manifestEntries 26 | .filter(entry => !entry.url.includes('next/dist')) // these paths fail in development resulting in the service worker not being installed 27 | .map(entry => { 28 | entry.url = encodeURI(entry.url) 29 | return entry 30 | }) 31 | return { manifest, warnings: [] } 32 | }, 33 | ], 34 | }, 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/profile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Executes the callback and returns its result while logging the execution time to the console. 3 | * @param {String} label A string to preface the console.log 4 | * @param {Function} cb A function to execute 5 | * @return {Object} The result of the function 6 | */ 7 | export default function profile(label, cb) { 8 | if (process.env.NODE_ENV === 'production') { 9 | return cb() 10 | } 11 | const start = new Date().getTime() 12 | const result = cb() 13 | const end = new Date().getTime() 14 | console.log(label, `${end - start} ms`) 15 | return result 16 | } 17 | 18 | global.rsf_profile = profile 19 | -------------------------------------------------------------------------------- /src/props/fetchFromAPI.js: -------------------------------------------------------------------------------- 1 | import fetch from '../fetch' 2 | import getAPIURL from '../api/getAPIURL' 3 | 4 | /** 5 | * A convenience function to be used in `getInitialProps` to fetch data for the page from an 6 | * API endpoint at the same path as the page being requested. So for example, when rendering 7 | * `/p/1`, this function will fetch data from `/api/p/1?__v__={__NEXT_DATA__.buildId}`. 8 | * 9 | * ```js 10 | * import fetchFromAPI from 'react-storefront/props/fetchFromAPI' 11 | * import createLazyProps from 'react-storefront/props/createLazyProps' 12 | * 13 | * Product.getInitialProps = createLazyProps(opts => { 14 | * return fetchFromAPI(opts) 15 | * }) 16 | * ``` 17 | * 18 | * Or simply: 19 | * 20 | * ```js 21 | * Product.getInitialProps = createLazyProps(fetchFromAPI) 22 | * ``` 23 | * 24 | * @param {Object} opts The options object provided to `getInitialProps` 25 | * @return {Promise} A promise that resolves to the data that the page should display 26 | */ 27 | export default function fetchFromAPI({ req, asPath, pathname }) { 28 | const host = req ? process.env.API_HOST || req.headers.host : '' 29 | 30 | let protocol = '' 31 | 32 | if (req) { 33 | protocol = 'https://' 34 | 35 | if (host.startsWith('localhost') || host.startsWith('127.0.0.1')) { 36 | protocol = 'http://' 37 | } 38 | } 39 | 40 | let uri = getAPIURL(asPath) 41 | let headers = {} 42 | 43 | if (req) { 44 | // on the server 45 | if (uri.indexOf('?') === -1) { 46 | uri += '?_includeAppData=1' 47 | } else { 48 | uri += '&_includeAppData=1' 49 | } 50 | 51 | headers = { 52 | host: req.headers.host, 53 | 'x-next-page': `/api${pathname.replace(/\/$/, '')}`, 54 | cookie: req.headers.cookie, 55 | } 56 | } 57 | 58 | const url = `${protocol}${host}${uri}` 59 | 60 | return fetch(url, { credentials: 'include', headers }).then(res => res.json()) 61 | } 62 | -------------------------------------------------------------------------------- /src/props/fetchServerSideProps.js: -------------------------------------------------------------------------------- 1 | import fetch from '../fetch' 2 | import getAPIURL from '../api/getAPIURL' 3 | 4 | export default function fetchServerSideProps({ req, resolvedUrl }) { 5 | const host = process.env.API_HOST || req.headers.host 6 | 7 | const protocol = 8 | host.startsWith('localhost') || host.startsWith('127.0.0.1') ? 'http://' : 'https://' 9 | 10 | let uri = getAPIURL(resolvedUrl) 11 | 12 | if (uri.indexOf('?') === -1) { 13 | uri += '?_includeAppData=1' 14 | } else { 15 | uri += '&_includeAppData=1' 16 | } 17 | 18 | const headers = { 19 | host: req.headers.host, 20 | 'x-next-page': `/api${resolvedUrl.split('?')[0].replace(/\/$/, '')}`, 21 | cookie: req.headers.cookie, 22 | } 23 | 24 | const url = `${protocol}${host}${uri}` 25 | 26 | return fetch(url, { credentials: 'include', headers }) 27 | .then(res => res.json()) 28 | .then(props => ({ props })) 29 | } 30 | -------------------------------------------------------------------------------- /src/props/fulfillAPIRequest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates an API response that contains app level data only when `?_includeAppData=1` is present in 3 | * the query string. Otherwise, the `appData` promise provided will not be resolved. 4 | * 5 | * @param {Request} req The request being served 6 | * @param {Object} options 7 | * @param {Function} options.appData An async function that returns a data for shared component in 8 | * the app such as menu, nav, and footer 9 | * @param {Function} options.pageData An async function that return data for the page component 10 | * @return {Object} the result of `appData` and `pageData` merged into a single object. 11 | */ 12 | export default async function fulfillAPIRequest(req, { appData, pageData }) { 13 | const promises = [pageData(req).then(pageData => ({ pageData }))] 14 | 15 | if (req.query._includeAppData === '1') { 16 | promises.push(appData(req).then(appData => ({ appData }))) 17 | } 18 | 19 | const results = await Promise.all(promises) 20 | const data = {} 21 | 22 | for (const result of results) { 23 | Object.assign(data, result) 24 | } 25 | 26 | return data 27 | } 28 | -------------------------------------------------------------------------------- /src/router/replaceState.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Replaces the history state in a way that is compatible with next.js. Use this function 3 | * instead of `history.replaceState` to ensure that next.js uses your new state's URL when going back. 4 | * @param {Object} state A new state. If `null`, the existing state will be preserved. 5 | * @param {String} title A new title for the document, if `null`, the existing title will be preserved. 6 | * @param {String} url The new URL 7 | */ 8 | export default function replaceState(state, title, url) { 9 | if (state == null) { 10 | state = history.state 11 | } 12 | 13 | history.replaceState({ ...state, as: url }, title || document.title, url) 14 | } 15 | -------------------------------------------------------------------------------- /src/router/storeInitialPropsInHistory.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Patches `history.pushState` and `history.replaceState` to stores the props of the 3 | * current main component in `history.state` before navigating. This allows us to instantly 4 | * render the main component when the user goes back 5 | */ 6 | export default function storeInitialPropsInHistory() { 7 | if (typeof window === 'undefined') return 8 | 9 | const { replaceState } = window.history 10 | 11 | history.replaceState = (data, title, url) => { 12 | let { state } = history 13 | if (!state) state = {} 14 | replaceState.call(history, { rsf: state.rsf, ...data }, title, url) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/router/useSimpleNavigation.js: -------------------------------------------------------------------------------- 1 | import delegate from 'delegate' 2 | import { useEffect, useRef, useCallback } from 'react' 3 | import Router from 'next/router' 4 | import qs from 'qs' 5 | import fetch from '../fetch' 6 | import getAPIURL from '../api/getAPIURL' 7 | 8 | /** 9 | * @private 10 | * Watches for clicks on HTML anchor tags and performs client side navigation if 11 | * the URL matches a next route. 12 | */ 13 | export default function useSimpleNavigation() { 14 | const routes = useRef({}) 15 | const nextNavigation = useRef(false) 16 | 17 | const onNextNavigation = useCallback(() => { 18 | nextNavigation.current = true 19 | }, []) 20 | 21 | const onNextNavigationEnd = useCallback(() => { 22 | nextNavigation.current = false 23 | }, []) 24 | 25 | useEffect(() => { 26 | async function doEffect() { 27 | delegate('a', 'click', e => { 28 | const { delegateTarget } = e 29 | const as = delegateTarget.getAttribute('href') 30 | const href = getRoute(as, routes.current) 31 | 32 | // catch if not next link 33 | if (href && !nextNavigation.current) { 34 | e.preventDefault() 35 | const url = toNextURL(href, as) 36 | Router.push(url, as) 37 | } 38 | }) 39 | 40 | routes.current = await fetchRouteManifest() 41 | } 42 | 43 | doEffect() 44 | Router.events.on('routeChangeStart', onNextNavigation) 45 | Router.events.on('routeChangeComplete', onNextNavigationEnd) 46 | 47 | return () => Router.events.off('routeChangeStart', onNextNavigation) 48 | }, []) 49 | } 50 | 51 | function toNextURL(href, as) { 52 | const url = new URL(as, `${window.location.protocol}//${window.location.hostname}`) 53 | 54 | return { 55 | pathname: href, 56 | query: qs.parse(url.search, { ignoreQueryPrefix: true }), 57 | } 58 | } 59 | 60 | function fetchRouteManifest() { 61 | return fetch(getAPIURL('/routes')).then(res => res.json()) 62 | } 63 | 64 | function getRoute(href, routes) { 65 | for (const pattern in routes) { 66 | if (new RegExp(pattern, 'i').test(href)) { 67 | return routes[pattern].as 68 | } 69 | } 70 | 71 | return null 72 | } 73 | -------------------------------------------------------------------------------- /src/search/SearchButton.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { styled } from '@mui/material/styles' 3 | import React from 'react' 4 | import { IconButton } from '@mui/material' 5 | import { Search } from '@mui/icons-material' 6 | 7 | const PREFIX = 'RSFSearchButton' 8 | 9 | const defaultClasses = { 10 | icon: `${PREFIX}-icon`, 11 | large: `${PREFIX}-large`, 12 | } 13 | 14 | const StyledIconButton = styled(IconButton)(({ theme }) => ({ 15 | /** 16 | * Styles applied to the icon, if [children](#prop-children) is empty. 17 | */ 18 | [`& .${defaultClasses.icon}`]: { 19 | color: theme.palette.action.active, 20 | }, 21 | 22 | /** 23 | * Styles applied to the element containing the button's label. 24 | */ 25 | [`& .${defaultClasses.large}`]: { 26 | fontSize: '28px', 27 | }, 28 | })) 29 | 30 | export {} 31 | 32 | /** 33 | * A button that can be used to open a search drawer. 34 | */ 35 | export default function SearchButton({ children, classes: c = {}, ...other }) { 36 | const classes = { ...defaultClasses, ...c } 37 | return ( 38 | 45 | {children || } 46 | 47 | ) 48 | } 49 | 50 | SearchButton.propTypes = { 51 | /** 52 | * Override or extend the styles applied to the component. See [CSS API](#css) below for more details. 53 | */ 54 | classes: PropTypes.object, 55 | 56 | /** 57 | * Optional content to use for the button contents. If empty, a search icon is used. 58 | */ 59 | children: PropTypes.node, 60 | } 61 | -------------------------------------------------------------------------------- /src/search/SearchContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | const SearchContext = createContext() 4 | export default SearchContext 5 | -------------------------------------------------------------------------------- /src/search/SearchForm.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import { styled } from '@mui/material/styles' 3 | import React, { useRef } from 'react' 4 | import { useRouter } from 'next/router' 5 | import qs from 'qs' 6 | 7 | const PREFIX = 'RSFSearchForm' 8 | 9 | const defaultClasses = { 10 | root: `${PREFIX}-root`, 11 | } 12 | 13 | const Root = styled('form')(() => ({ 14 | /** 15 | * Styles applied to the root element. 16 | */ 17 | [`&.${defaultClasses.root}`]: { 18 | position: 'relative', 19 | height: '100%', 20 | display: 'flex', 21 | flexDirection: 'column', 22 | }, 23 | })) 24 | 25 | export {} 26 | 27 | /** 28 | * A form used to submit a search query. 29 | */ 30 | export default function SearchForm({ classes: c = {}, children, action, autoComplete }) { 31 | const classes = { ...defaultClasses, ...c } 32 | const ref = useRef() 33 | const router = useRouter() 34 | 35 | const handleSubmit = async e => { 36 | e.preventDefault() 37 | 38 | const data = new FormData(ref.current) 39 | const query = {} 40 | 41 | for (const [name, value] of data.entries()) { 42 | query[name] = value 43 | } 44 | 45 | const url = `${action}${action.includes('?') ? '&' : '?'}${qs.stringify(query)}` 46 | router.push(action.split(/\?/)[0], url) 47 | return false 48 | } 49 | 50 | return ( 51 | 59 | {children} 60 | 61 | ) 62 | } 63 | 64 | SearchForm.propTypes = { 65 | /** 66 | * Override or extend the styles applied to the component. See [CSS API](#css) below for more details. 67 | */ 68 | classes: PropTypes.object, 69 | 70 | /** 71 | * Children to be rendered inside the form. 72 | */ 73 | children: PropTypes.node, 74 | 75 | /** 76 | * An `action` attribute to use for the `
` element. 77 | */ 78 | action: PropTypes.string, 79 | 80 | /** 81 | * Form auto complete 82 | */ 83 | autoComplete: PropTypes.bool, 84 | } 85 | 86 | SearchForm.defaultProps = { 87 | action: '/search', 88 | autoComplete: false, 89 | } 90 | -------------------------------------------------------------------------------- /src/search/SearchHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { styled } from '@mui/material/styles' 3 | import PropTypes from 'prop-types' 4 | 5 | const PREFIX = 'RSFSearchHeader' 6 | 7 | const defaultClasses = { 8 | root: `${PREFIX}-root`, 9 | } 10 | 11 | const Root = styled('div')(({ theme }) => ({ 12 | /** 13 | * Styles applied to the root element. 14 | */ 15 | [`&.${defaultClasses.root}`]: { 16 | backgroundColor: theme.palette.primary.main, 17 | padding: theme.spacing(6, 2, 2, 2), 18 | display: 'flex', 19 | flexDirection: 'column', 20 | alignItems: 'stretch', 21 | }, 22 | })) 23 | 24 | export {} 25 | 26 | /** 27 | * A element to be placed at the top of a [SearchDrawer](/apiReference/search/SearchDrawer). 28 | */ 29 | export default function SearchHeader({ classes: c = {}, children }) { 30 | const classes = { ...defaultClasses, ...c } 31 | return {children} 32 | } 33 | 34 | SearchHeader.propTypes = { 35 | /** 36 | * Override or extend the styles applied to the component. See [CSS API](#css) below for more details. 37 | */ 38 | classes: PropTypes.object, 39 | 40 | /** 41 | * Children to be rendered inside the header. 42 | */ 43 | children: PropTypes.node, 44 | } 45 | 46 | SearchHeader.defaultProps = {} 47 | -------------------------------------------------------------------------------- /src/search/SearchSubmitButton.js: -------------------------------------------------------------------------------- 1 | import SearchIcon from '@mui/icons-material/Search' 2 | import { styled } from '@mui/material/styles' 3 | import React from 'react' 4 | import PropTypes from 'prop-types' 5 | 6 | const PREFIX = 'RSFSearchSubmitButton' 7 | 8 | const classes = { 9 | root: `${PREFIX}-root`, 10 | label: `${PREFIX}-label`, 11 | } 12 | 13 | /** 14 | * A button to submit the search. All other props are spread to the provided `Component`. 15 | */ 16 | export default function SearchSubmitButton({ Component, ButtonIcon, text, ...others }) { 17 | const StyledComponent = styled(Component)(() => ({ 18 | /** 19 | * Styles applied to the root element. 20 | */ 21 | [`&.${classes.root}`]: {}, 22 | 23 | /** 24 | * Styles applied to the label element. 25 | */ 26 | [`& .${classes.label}`]: { 27 | display: 'flex', 28 | }, 29 | })) 30 | 31 | return ( 32 | 42 | ) 43 | } 44 | 45 | SearchSubmitButton.propTypes = { 46 | /** 47 | * Override or extend the styles applied to the component. See [CSS API](#css) below for more details. 48 | */ 49 | classes: PropTypes.object, 50 | /** 51 | * A Material UI button component to display. 52 | */ 53 | Component: PropTypes.elementType.isRequired, 54 | /** 55 | * A Material UI button component to display. 56 | */ 57 | ButtonIcon: PropTypes.elementType, 58 | /** 59 | * The current search text. 60 | */ 61 | text: PropTypes.string.isRequired, 62 | } 63 | 64 | SearchSubmitButton.defaultProps = { 65 | ButtonIcon: SearchIcon, 66 | } 67 | -------------------------------------------------------------------------------- /src/search/SearchSuggestions.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { styled } from '@mui/material/styles' 3 | import PropTypes from 'prop-types' 4 | import SearchSuggestionGroup from './SearchSuggestionGroup' 5 | import SearchContext from './SearchContext' 6 | import LoadMask from '../LoadMask' 7 | 8 | const PREFIX = 'RSFSearchSuggestions' 9 | 10 | const defaultClasses = { 11 | root: `${PREFIX}-root`, 12 | group: `${PREFIX}-group`, 13 | } 14 | 15 | // TODO jss-to-styled codemod: The Fragment root was replaced by div. Change the tag if needed. 16 | const Root = styled('div')(({ theme }) => ({ 17 | /** 18 | * Styles applied to the root element. 19 | */ 20 | [`& .${defaultClasses.root}`]: { 21 | flex: 1, 22 | position: 'relative', 23 | overflowY: 'auto', 24 | }, 25 | 26 | /** 27 | * Styles applied to each of the group wrapper elements. 28 | */ 29 | [`& .${defaultClasses.group}`]: { 30 | margin: theme.spacing(0, 0, 2, 0), 31 | }, 32 | })) 33 | 34 | export {} 35 | 36 | export default function SearchSuggestions({ render, classes: c = {} }) { 37 | const classes = { ...defaultClasses, ...c } 38 | const { state } = useContext(SearchContext) 39 | 40 | return ( 41 | 42 | 43 |
44 | {render 45 | ? render(state) 46 | : state.groups && 47 | state.groups.map(group => ( 48 |
49 | 50 |
51 | ))} 52 |
53 |
54 | ) 55 | } 56 | 57 | SearchSuggestions.propTypes = { 58 | /** 59 | * Override or extend the styles applied to the component. See [CSS API](#css) below for more details. 60 | */ 61 | classes: PropTypes.object, 62 | render: PropTypes.func, 63 | } 64 | 65 | SearchSuggestions.defaultProps = {} 66 | -------------------------------------------------------------------------------- /src/server/getRoutes.js: -------------------------------------------------------------------------------- 1 | import { getRouteRegex } from 'next/dist/shared/lib/router/utils/route-regex.js' 2 | 3 | export default function routes(pagesManifest) { 4 | const routes = {} 5 | 6 | for (const as in pagesManifest) { 7 | const component = pagesManifest[as] 8 | const route = getRouteRegex(as).re.source 9 | 10 | routes[route] = { 11 | component, 12 | as, 13 | } 14 | } 15 | 16 | return routes 17 | } 18 | -------------------------------------------------------------------------------- /src/server/listRoutes.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import glob from 'glob' 4 | import getRoutes from './getRoutes' 5 | 6 | // Depending on the context (local dev, serverless lambda), the page manifest 7 | // file can be at different locations 8 | const MANIFEST_PATHS = [ 9 | path.join(process.cwd(), 'pages-manifest.json'), 10 | path.join(process.cwd(), '.next', 'server', 'pages-manifest.json'), 11 | path.join(process.cwd(), '.next', 'serverless', 'pages-manifest.json'), 12 | ] 13 | 14 | export default function listRoutes() { 15 | let manifest 16 | 17 | if (process.env.NODE_ENV === 'production') { 18 | const manifestPath = MANIFEST_PATHS.find(path => fs.existsSync(path)) 19 | 20 | /* istanbul ignore else */ 21 | if (manifestPath) { 22 | manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) 23 | } else { 24 | throw new Error(`Could not find pages-manifests.json in ${MANIFEST_PATHS.join(' or ')}`) 25 | } 26 | } else { 27 | manifest = createDevelopmentPagesManifest() 28 | } 29 | 30 | return getRoutes(manifest) 31 | } 32 | 33 | /** 34 | * Creates the equivalent of pages-manifest.json in development. Since 35 | * next.js incrementally compiles pages on demand in development, pages-manifest may 36 | * not have all of the pages until they are visited and thus cannot be used by useSimpleNavigation 37 | * reliably in development. 38 | */ 39 | function createDevelopmentPagesManifest() { 40 | const pages = glob.sync('pages/**/*.js', { cwd: process.cwd() }) 41 | const manifest = {} 42 | 43 | for (const page of pages) { 44 | const route = page.replace(/^pages/, '').replace(/\.[^/]+$/, '') 45 | manifest[route] = page 46 | } 47 | 48 | return manifest 49 | } 50 | -------------------------------------------------------------------------------- /src/session/SessionContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const UserContext = React.createContext() 4 | export default UserContext 5 | -------------------------------------------------------------------------------- /src/theme/createTheme.js: -------------------------------------------------------------------------------- 1 | import { createTheme, adaptV4Theme } from '@mui/material/styles' 2 | // import { createTheme } from '@mui/material/styles'; 3 | import merge from 'lodash/merge' 4 | 5 | /** 6 | * Creates the default theme for your React Storefront app. See Material UI's theme documentation 7 | * for more info: https://mui.com/customization/default-theme/ 8 | * @param {Object} values 9 | * @return {Object} A material UI theme 10 | */ 11 | 12 | export default function createThemeFunc(values = {}) { 13 | const theme = createTheme( 14 | merge( 15 | {}, 16 | { 17 | zIndex: { 18 | modal: 999, 19 | amp: { 20 | modal: 2147483646, 21 | }, 22 | }, 23 | headerHeight: 64, 24 | loadMaskOffsetTop: 64 + 56 + 4, 25 | drawerWidth: 330, 26 | components: {}, 27 | }, 28 | values, 29 | ), 30 | ) 31 | 32 | if (!theme.maxWidth) { 33 | theme.maxWidth = `${theme.breakpoints.values.lg}px` 34 | } 35 | 36 | return theme 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/fetchLatest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a fetch function with an internal incrementing request counter that ensures that out of order 3 | * responses result in a `StaleResponseError`. 4 | * 5 | * Example usage: 6 | * 7 | * ```js 8 | * import { fetchLatest, StaleResponseError } from 'react-storefront/fetchLatest' 9 | * import originalFetch from 'fetch' 10 | * 11 | * const fetch = fetchLatest(originalFetch) 12 | * 13 | * try { 14 | * const response = await fetch('/some/url') 15 | * } catch (e) { 16 | * if (!StaleResponseError.is(e)) { 17 | * throw e // just ignore stale responses, rethrow all other errors 18 | * } 19 | * } 20 | * ``` 21 | * @param {Function} fetch An implementation of the standard browser fetch. 22 | * @return {Function} 23 | */ 24 | export function fetchLatest(fetch) { 25 | let nextId = 0 26 | let controller 27 | 28 | const abort = () => { 29 | controller && controller.abort() 30 | 31 | if (typeof AbortController !== 'undefined') { 32 | return (controller = new AbortController()) 33 | } 34 | return { signal: null } 35 | } 36 | 37 | return (url, options) => { 38 | const id = ++nextId 39 | const { signal } = abort() 40 | 41 | return fetch(url, { ...options, signal }) 42 | .then(response => { 43 | if (id !== nextId) { 44 | throw new StaleResponseError() 45 | } 46 | return response 47 | }) 48 | .catch(error => { 49 | // For browsers that support AbortController, ensure that the behavior is the same as browsers that don't - 50 | // StaleResponseError should be thrown in either case 51 | if (error.name === 'AbortError') { 52 | throw new StaleResponseError() 53 | } else { 54 | throw error 55 | } 56 | }) 57 | } 58 | } 59 | 60 | /** 61 | * Thrown when an out of order response is received from `fetchLatest`. 62 | */ 63 | export class StaleResponseError extends Error { 64 | name = 'StaleResponseError' 65 | 66 | /** 67 | * Returns true if the specified Error is an instance of StaleResponseError 68 | */ 69 | static is = e => e.name === 'StaleResponseError' 70 | } 71 | -------------------------------------------------------------------------------- /src/utils/format.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Formats a price for display. 3 | * @param {Number} price The price as a floating point number 4 | * @param {Object} options 5 | * @param {String} options.currency The currency code 6 | * @param {Number} options.decimals The number of decimal places to display 7 | * @param {String} options.locale The locale code 8 | * @return {String} 9 | */ 10 | export function price(price, { currency = 'USD', decimals = 2, locale = 'en-US' } = {}) { 11 | return new Intl.NumberFormat(locale, { 12 | style: 'currency', 13 | currency, 14 | minimumFractionDigits: decimals, 15 | }).format(price) 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/getBase64ForImage.js: -------------------------------------------------------------------------------- 1 | import fetch from '../fetch' 2 | 3 | export default async function getBase64ForImage(...args) { 4 | const res = await fetch(...args) 5 | const contentType = res.headers.get('content-type') 6 | const buffer = await res.buffer() 7 | return `data:${contentType};base64,${buffer.toString('base64')}` 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/isBrowser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns true when running in the browser, otherwise false. 3 | */ 4 | export default function isBrowser() { 5 | return typeof window !== 'undefined' 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/isSameOrigin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns `true` if the URL's hostname is the same as the origin that served the app, otherwise `false`. 3 | * @param {URL} url A URL instance 4 | * @return {Boolean} 5 | */ 6 | export default function isSameOrigin(url) { 7 | return url.hostname === window.location.hostname 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/lazyLoadImages.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Observes the visibility of all `img` elements inside the specified element 3 | * that match the specified selector. When an image becomes visible, the `data-src` 4 | * attribute is copied to `src`. 5 | * 6 | * See https://developers.google.com/web/fundamentals/performance/lazy-loading-guidance/images-and-video/ 7 | * @param {DOMElement} element The img element to lazy load 8 | * @param {Object} options 9 | * @param {Object} options.lazySrcAttribute The attribute containing the image URL. Defaults to `data-src`. 10 | */ 11 | export default function lazyLoadImages(element, { lazySrcAttribute = 'data-src' } = {}) { 12 | if (!element) return 13 | const lazyImages = [...element.querySelectorAll(`img[${lazySrcAttribute}]`)] 14 | if (!lazyImages.length) return 15 | 16 | let lazyImageObserver 17 | 18 | const load = img => { 19 | const src = img.getAttribute(lazySrcAttribute) 20 | const onload = () => { 21 | img.removeAttribute(lazySrcAttribute) 22 | img.removeEventListener('load', onload) 23 | } 24 | img.addEventListener('load', onload) 25 | img.setAttribute('src', src) 26 | } 27 | 28 | const observerHandler = function(entries, self) { 29 | for (const entry of entries) { 30 | if (entry.isIntersecting) { 31 | load(entry.target) 32 | // the image is now in place, stop watching 33 | if (self) { 34 | self.unobserve(entry.target) 35 | } 36 | } 37 | } 38 | } 39 | 40 | try { 41 | lazyImageObserver = new window.IntersectionObserver(observerHandler) 42 | 43 | for (const img of lazyImages) { 44 | lazyImageObserver.observe(img) 45 | } 46 | 47 | return lazyImageObserver 48 | } catch (e) { 49 | // eagerly load images when we don't have the observer 50 | for (const img of lazyImages) { 51 | load(img) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/merge.js: -------------------------------------------------------------------------------- 1 | import mergeWith from 'lodash/mergeWith' 2 | 3 | /** 4 | * Deep merges sources onto object. The same as [`lodash/merge`](https://lodash.com/docs/4.17.15#merge), 5 | * except arrays are not concatenated. When a source object provides an array, it replaces the value on the target. 6 | * @param {*} object 7 | * @param {...any} sources 8 | */ 9 | export default function merge(object, ...sources) { 10 | return mergeWith(object, ...sources, (_objValue, srcValue) => { 11 | if (Array.isArray(srcValue)) { 12 | return srcValue 13 | } 14 | return undefined 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/minifyStyles.js: -------------------------------------------------------------------------------- 1 | import csso from 'csso' 2 | 3 | export default function minifyStyles(css) { 4 | return csso.minify(css).css 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/mod.js: -------------------------------------------------------------------------------- 1 | // Helper modulus function used in carousel logic 2 | export default function mod(index, count) { 3 | const q = index % count 4 | return q < 0 ? q + count : q 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/useDebounce.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export default function useDebounce(value, delay) { 4 | // State and setters for debounced value 5 | const [debouncedValue, setDebouncedValue] = useState(value) 6 | 7 | useEffect( 8 | () => { 9 | // Update debounced value after delay 10 | const handler = setTimeout(() => { 11 | setDebouncedValue(value) 12 | }, delay) 13 | 14 | // Cancel the timeout if value changes (also on delay change or unmount) 15 | // This is how we prevent debounced value from updating if value is changed ... 16 | // .. within the delay period. Timeout gets cleared and restarted. 17 | return () => { 18 | clearTimeout(handler) 19 | } 20 | }, 21 | [value, delay], // Only re-call effect if value or delay changes 22 | ) 23 | 24 | return debouncedValue 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/withCaching.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a cancelable event handler that will run unless the provided 3 | * handler calls `e.preventDefault()`. 4 | * 5 | * @param {Function} handler The original event handle supplied to the component 6 | * @param {Number} maxAgeSeconds The time in seconds that a result should be kept in the service worker cache. 7 | * @return {Function} 8 | */ 9 | export default function withCaching(handler, maxAgeSeconds) { 10 | return (req, res) => { 11 | if (maxAgeSeconds) { 12 | res.setHeader('x-sw-cache-control', `max-age: ${maxAgeSeconds}`) 13 | } 14 | 15 | return handler(req, res) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/withDefaultHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a cancelable event handler that will run unless the provided 3 | * handler calls `e.preventDefault()`. 4 | * 5 | * @param {Function} handler The original event handle supplied to the component 6 | * @param {Function} defaultHandler A handler to run unless `e.preventDefault()` was called. 7 | * @return {Function} 8 | */ 9 | export default function withDefaultHandler(handler, defaultHandler) { 10 | return (e, ...args) => { 11 | if (handler) { 12 | handler(e, ...args) 13 | } 14 | 15 | if (!e.defaultPrevented) { 16 | defaultHandler(e, ...args) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/ActionButton.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import ActionButton from 'react-storefront/ActionButton' 4 | import { Button, Typography } from '@mui/material' 5 | 6 | describe('ActionButton', () => { 7 | let wrapper 8 | 9 | afterEach(() => { 10 | wrapper.unmount() 11 | }) 12 | 13 | it('should spread props to Button', () => { 14 | wrapper = mount() 15 | 16 | expect(wrapper.find(Button).prop('spreadprops')).toBe('test') 17 | }) 18 | 19 | it('should spread classes to Button', () => { 20 | wrapper = mount() 21 | 22 | expect(wrapper.find(Button).prop('classes').root).toBe('test') 23 | }) 24 | 25 | it('should accept value and label as a string', () => { 26 | wrapper = mount() 27 | 28 | expect( 29 | wrapper 30 | .find(Typography) 31 | .first() 32 | .text(), 33 | ).toBe('testLabel') 34 | 35 | expect( 36 | wrapper 37 | .find(Typography) 38 | .last() 39 | .text(), 40 | ).toBe('testValue') 41 | }) 42 | 43 | it('should accept value and label as element', () => { 44 | wrapper = mount(testValue
} label={
testLabel
} />) 45 | 46 | expect( 47 | wrapper 48 | .find(Typography) 49 | .first() 50 | .text(), 51 | ).toBe('testLabel') 52 | 53 | expect( 54 | wrapper 55 | .find(Typography) 56 | .last() 57 | .text(), 58 | ).toBe('testValue') 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /test/Box.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import Box, { Hbox, Vbox } from 'react-storefront/Box' 4 | 5 | describe('Box', () => { 6 | let wrapper 7 | 8 | afterEach(() => { 9 | wrapper.unmount() 10 | }) 11 | 12 | it('should render component without props', () => { 13 | wrapper = mount() 14 | 15 | expect(wrapper.find(Box).exists()).toBe(true) 16 | }) 17 | 18 | it('should render children', () => { 19 | wrapper = mount( 20 | 21 |
test
22 |
, 23 | ) 24 | 25 | expect(wrapper.find('#test').text()).toBe('test') 26 | }) 27 | 28 | it('should forward props to style', () => { 29 | wrapper = mount() 30 | 31 | expect( 32 | wrapper 33 | .find(Box) 34 | .childAt(0) 35 | .prop('style').test, 36 | ).toBe('test') 37 | }) 38 | 39 | it('should apply split classes when split is set to true', () => { 40 | wrapper = mount() 41 | 42 | expect( 43 | wrapper 44 | .find(Box) 45 | .childAt(0) 46 | .prop('className'), 47 | ).toContain('split') 48 | }) 49 | 50 | describe('Hbox', () => { 51 | it('should render component without props', () => { 52 | wrapper = mount() 53 | 54 | expect(wrapper.find(Hbox).exists()).toBe(true) 55 | }) 56 | 57 | it('should have flex direction set to row', () => { 58 | wrapper = mount() 59 | 60 | expect( 61 | wrapper 62 | .find(Box) 63 | .childAt(0) 64 | .prop('style').flexDirection, 65 | ).toBe('row') 66 | }) 67 | }) 68 | 69 | describe('Vbox', () => { 70 | it('should render component without props', () => { 71 | wrapper = mount() 72 | 73 | expect(wrapper.find(Vbox).exists()).toBe(true) 74 | }) 75 | 76 | it('should have flex direction set to column', () => { 77 | wrapper = mount() 78 | 79 | expect( 80 | wrapper 81 | .find(Box) 82 | .childAt(0) 83 | .prop('style').flexDirection, 84 | ).toBe('column') 85 | }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /test/BreadCrumbs.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import Breadcrumbs from 'react-storefront/Breadcrumbs' 4 | import { Typography, Container } from '@mui/material' 5 | 6 | describe('Breadcrumbs', () => { 7 | let wrapper 8 | 9 | afterEach(() => { 10 | wrapper.unmount() 11 | }) 12 | 13 | it('should render empty span when no items provided', () => { 14 | wrapper = mount() 15 | 16 | expect(wrapper.find(Container).children.length).toBe(1) 17 | }) 18 | 19 | it('should apply bold style to last item', () => { 20 | const items = [{ text: 'test1' }, { text: 'test2' }] 21 | wrapper = mount() 22 | 23 | expect( 24 | wrapper 25 | .findWhere(item => item.type() === 'span' && item.text() === 'test2') 26 | .prop('className'), 27 | ).toContain('current') 28 | }) 29 | 30 | it('should render items with href as a link', () => { 31 | const items = [{ text: 'test1', href: '/test' }] 32 | wrapper = mount() 33 | 34 | expect(wrapper.find('a').text()).toBe('test1') 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /test/CartButton.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import CartButton from 'react-storefront/CartButton' 4 | import Link from 'react-storefront/link/Link' 5 | import ToolbarButton from 'react-storefront/ToolbarButton' 6 | import { AddShoppingCart as CustomIcon } from '@mui/icons-material' 7 | import { Badge } from '@mui/material' 8 | 9 | describe('CartButton', () => { 10 | let wrapper 11 | 12 | afterEach(() => { 13 | wrapper.unmount() 14 | }) 15 | 16 | it('should be able to use custom icon', () => { 17 | wrapper = mount(} />) 18 | 19 | expect(wrapper.find(CustomIcon).exists()).toBe(true) 20 | }) 21 | 22 | it('should show provided quantity', () => { 23 | wrapper = mount() 24 | 25 | expect(wrapper.find(Badge).text()).toBe('10') 26 | }) 27 | 28 | it('should accept custom href', () => { 29 | wrapper = mount() 30 | 31 | expect(wrapper.find(Link).prop('href')).toBe('/test') 32 | }) 33 | 34 | it('should spread link, button and badge props', () => { 35 | wrapper = mount( 36 | , 47 | ) 48 | 49 | expect(wrapper.find(Link).prop('color')).toBe('secondary') 50 | expect(wrapper.find(Badge).prop('color')).toBe('secondary') 51 | expect(wrapper.find(ToolbarButton).prop('color')).toBe('secondary') 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /test/ErrorBoundary.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import { eventListenersMock } from './mocks/mockHelper' 4 | import ErrorBoundary from 'react-storefront/ErrorBoundary' 5 | 6 | describe('ErrorBoundary', () => { 7 | const errorText = 'Test Error' 8 | const map = {} 9 | let wrapper, logger 10 | 11 | afterEach(() => { 12 | wrapper.unmount() 13 | logger = undefined 14 | }) 15 | 16 | beforeAll(() => { 17 | eventListenersMock(map) 18 | }) 19 | 20 | afterAll(() => { 21 | jest.restoreAllMocks() 22 | }) 23 | 24 | const Test = () => { 25 | const handleError = err => { 26 | logger = err.error.message || err.error 27 | } 28 | 29 | return ( 30 | 31 |
App
32 |
33 | ) 34 | } 35 | 36 | it('should render app without error', () => { 37 | wrapper = mount() 38 | 39 | expect(wrapper.find(ErrorBoundary).text()).toBe('App') 40 | }) 41 | 42 | it('should render error text on error', () => { 43 | const error = new Error(errorText) 44 | 45 | wrapper = mount() 46 | 47 | wrapper.find(ErrorBoundary).simulateError(error) 48 | 49 | expect(wrapper.find(ErrorBoundary).text()).toBe(errorText) 50 | expect(logger).toBe(errorText) 51 | }) 52 | 53 | it('should listen to errors and send it to error reporter', () => { 54 | wrapper = mount() 55 | 56 | map.error({ error: errorText }) 57 | 58 | expect(logger).toBe(errorText) 59 | }) 60 | 61 | it('should listen to unhandled rejections and send it to error reporter', () => { 62 | wrapper = mount() 63 | 64 | map.unhandledrejection({ reason: errorText }) 65 | 66 | expect(logger).toBe(errorText) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /test/Fill.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import Fill from 'react-storefront/Fill' 4 | 5 | describe('Fill', () => { 6 | let wrapper 7 | 8 | afterEach(() => { 9 | if (wrapper.exists()) { 10 | wrapper.unmount() 11 | } 12 | }) 13 | 14 | it('should be able to spread props', () => { 15 | wrapper = mount( 16 | 17 |
test1
18 |
, 19 | ) 20 | 21 | expect(wrapper.find(Fill).prop('testprops')).toBe('testprops') 22 | expect(wrapper.find('#test1').text()).toBe('test1') 23 | }) 24 | 25 | it('should return error when more than 1 child provided', () => { 26 | const container = document.createElement('div') 27 | expect(() => 28 | ReactDOM.render( 29 | 30 |
test1
31 |
test2
32 |
, 33 | container, 34 | ), 35 | ).toThrow() 36 | }) 37 | 38 | it('should set right padding top when height is provided', () => { 39 | wrapper = mount( 40 | 41 |
42 |
, 43 | ) 44 | 45 | expect( 46 | wrapper 47 | .find('div') 48 | .filterWhere(n => n.prop('style')) 49 | .first() 50 | .prop('style').paddingTop, 51 | ).toBe('20%') 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /test/Highlight.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | 4 | describe('Highlight', () => { 5 | let wrapper, Highlight, mockAmp 6 | 7 | beforeEach(() => { 8 | jest.isolateModules(() => { 9 | jest.mock('next/amp', () => ({ 10 | useAmp: () => mockAmp, 11 | })) 12 | Highlight = require('react-storefront/Highlight').default 13 | }) 14 | }) 15 | 16 | afterEach(() => { 17 | wrapper.unmount() 18 | }) 19 | 20 | afterAll(() => { 21 | jest.resetModules() 22 | }) 23 | 24 | it('should not highlight in AMP mode', () => { 25 | mockAmp = true 26 | wrapper = mount( 27 | , 28 | ) 29 | expect(wrapper.text()).toBe('the fox jumps over the ox') 30 | }) 31 | it('should not blow up if empty props', () => { 32 | mockAmp = false 33 | wrapper = mount() 34 | expect(wrapper.text()).toBe('') 35 | }) 36 | it('should not add highlights if no matches', () => { 37 | mockAmp = false 38 | wrapper = mount() 39 | expect(wrapper.text()).toBe('the fox jumps over') 40 | }) 41 | it('should escape text', () => { 42 | mockAmp = false 43 | wrapper = mount( 'bar' < zat`} />) 44 | expect(wrapper.text()).toBe('"foo" > 'bar' < zat') 45 | }) 46 | it('should add highlights to matches', () => { 47 | mockAmp = false 48 | wrapper = mount( 49 | , 50 | ) 51 | const matches = wrapper.html().match(/ox<\/span>/g) 52 | expect(matches.length).toBe(2) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /test/Label.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import Label from 'react-storefront/Label' 4 | import { Typography } from '@mui/material' 5 | 6 | describe('Label', () => { 7 | let wrapper 8 | 9 | afterEach(() => { 10 | wrapper.unmount() 11 | }) 12 | 13 | it('should be able to spread props', () => { 14 | wrapper = mount(