diff --git a/.env.example b/.env.example index 801c13d..f7e2f22 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,11 @@ -BOUNDLESS_BASE_URL=< your shop url, i.e. http://www.myshop.com > BOUNDLESS_API_PERMANENT_TOKEN=<< your permanent token here>> BOUNDLESS_INSTANCE_ID= # Optional params: +# BOUNDLESS_BASE_URL=< your shop url, i.e. http://www.myshop.com > # BOUNDLESS_PRODUCTS_IMAGE_PROPORTION= # For dev team: # BOUNDLESS_API_BASE_URL=https://api.rick.dev.boundless-commerce.com -# BOUNDLESS_S3_PREFIX=dev-rick \ No newline at end of file +# BOUNDLESS_S3_PREFIX=dev-rick +# BOUNDLESS_MEDIA_SERVER=https://dev-img.boundless-commerce.com \ No newline at end of file diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml new file mode 100644 index 0000000..66acf7c --- /dev/null +++ b/.github/workflows/linters.yml @@ -0,0 +1,27 @@ +name: Linters +#on: push + +on: + push: + branches-ignore: [master] + pull_request: + branches: [master] + +jobs: + super-lint: + name: Lint code base + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Run Super-Linter + uses: github/super-linter@v4 + env: + DEFAULT_BRANCH: master + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +# validate only typescript: + VALIDATE_TYPESCRIPT_ES: true + VALIDATE_CSS: true + CSS_FILE_NAME: .stylelintrc.json + LINTER_RULES_PATH: / \ No newline at end of file diff --git a/.stylelintrc.json b/.stylelintrc.json index b92a31b..4a503d2 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -8,5 +8,5 @@ "selector-class-pattern": null, "selector-no-qualifying-type": [true, {"ignore": ["attribute", "class", "id"]}] }, - "ignoreFiles": ["**/*.css", "**/*.js", "**/*.ts", "**/*.tsx"] + "ignoreFiles": ["**/*.css", "**/*.js", "**/*.ts", "**/*.tsx", "**/*.md"] } \ No newline at end of file diff --git a/@types/category.ts b/@types/category.ts index 1da33c1..13a9eda 100644 --- a/@types/category.ts +++ b/@types/category.ts @@ -1,3 +1,3 @@ -import {ICategory} from 'boundless-api-client/types/catalog/category'; +import {ICategory} from 'boundless-api-client'; export type ICategoryPartial = Pick & {children?: ICategory[] | null}; \ No newline at end of file diff --git a/@types/components.ts b/@types/components.ts new file mode 100644 index 0000000..6f665ab --- /dev/null +++ b/@types/components.ts @@ -0,0 +1,19 @@ +export interface IBreadCrumbItem { + title: string; + isActive?: boolean; + url?: string +} + +export interface IMenuItem { + title: string, + url?: string, + img?: IMenuItemImage, + isActive?: boolean, + children?: IMenuItem[] +} + +export interface IMenuItemImage { + src: string, + width?: number, + height?: number +} \ No newline at end of file diff --git a/@types/image.ts b/@types/image.ts deleted file mode 100644 index 9ba755e..0000000 --- a/@types/image.ts +++ /dev/null @@ -1,21 +0,0 @@ -export enum TThumbMode { - scale = 'scale' -} - -export enum TThumbQuality { - low = 'low', - normal = 'normal', - high = 'high' -} - -export enum TThumbRatio { - '1-1' = '1-1', - '2-3' = '2-3', - '3-2' = '3-2', - '4-5' = '4-5', - '5-4' = '5-4', - '3-4' = '3-4', - '4-3' = '4-3', - '16-9' = '16-9', - '9-16' = '9-16', -} \ No newline at end of file diff --git a/@types/settings.ts b/@types/settings.ts new file mode 100644 index 0000000..e29af7b --- /dev/null +++ b/@types/settings.ts @@ -0,0 +1,6 @@ +import {ICurrency, ILocaleSettings} from 'boundless-api-client'; + +export interface IBasicSettings { + 'system.locale': ILocaleSettings, + 'system.currency': ICurrency +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a5d7f10 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2012-2022 Scott Chacon and others + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ce7098 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Next.js E-Commerce template + +Ready to use [Next.js](https://nextjs.org) (TypeScript) starter kit for high-performance +e-commerce websites 🔥. + +Free 👍. Ready to use 😎. Just clone & deploy! 🤘 + +## Online Demo ⚡️ + +[blank.demos.my-boundless.app](https://blank.demos.my-boundless.app/) - deployed to Netlify. + +## About Boundless Commerce + +![Boundless-commerce.com](assets/logo.svg) + +[Boundless-commerce.com](https://boundless-commerce.com/) - API’s First Headless E-commerce CMS: We Provide An +Admin-Side For Store Management, Powerful API, And Ready-To-Use Checkout Area. + +## Getting Started + +1. Go to [Boundless-commerce.com](https://boundless-commerce.com/) and create a free account (no credit card required). +2. Go to "Control panel" -> "Settings" -> "Access tokens" and create a new one: + +![](assets/settings-menu.png) +![](assets/create-access-token-btn.png) + +3. Copy `.env.example` to `.env` +4. Modify `.env`: put values for the `BOUNDLESS_API_PERMANENT_TOKEN` and `BOUNDLESS_INSTANCE_ID`, copy values from: + +![](assets/copy-credentials.png) + +5. Install dependencies: `yarn install` +6. `yarn dev` - to start locally, then open `http://localhost:3000` +7. If you want to have absolute URLs in the sitemap.xml and OpenGraph meta tags +you need to also specify `BOUNDLESS_BASE_URL`. + +### Optional parameters + +- `BOUNDLESS_BASE_URL` - Base URL if you want to have absolute +URLs in the sitemap.xml and OpenGraph meta tags. +- `BOUNDLESS_PRODUCTS_IMAGE_PROPORTION` - Specify resize proportion so product images in lists have +the same size. Possible values are: `1-1`, `2-3`, `3-2`,`4-5`, `5-4`, `3-4`, `4-3`, `16-9`, `9-16`. + +## What is included? + +Fully ready E-Commerce website with a hierarchical catalog, product widgets, +filters, and marketing components like sliders and carousels. + +We developed the template keeping SEO in mind: schema markup, sitemap, and +all necessary meta-tags. + +Shopping cart with a nice animated widget and [Fully customizable React checkout](https://github.com/kirill-zhirnov/boundless-checkout-react). + +User friendly mobile version. + +## Video tutorial + +[![How to build React E-commerce Website with Next.js, TypeScript, and Redux in 2023 (Tutorial)](https://img.youtube.com/vi/BOZRLereGig/0.jpg)](https://youtu.be/BOZRLereGig "How to build React E-commerce Website with Next.js, TypeScript, and Redux in 2023 (Tutorial)") + +## Ways to deploy Node.js Apps + +[![Ways to deploy NodeJs (and/or NextJS) apps - quick overview](https://img.youtube.com/vi/pRj0o-B57O4/0.jpg)](https://www.youtube.com/watch?v=pRj0o-B57O4 " Ways to deploy NodeJs (and/or NextJS) apps - quick overview") + +--- + +[NextJS eCommerce templates](https://boundless-commerce.com/templates) - Free. Ready to use. Just clone & deploy! \ No newline at end of file diff --git a/assets/cliff_1.jpg b/assets/cliff_1.jpg new file mode 100644 index 0000000..e867378 Binary files /dev/null and b/assets/cliff_1.jpg differ diff --git a/assets/cliff_2.jpg b/assets/cliff_2.jpg new file mode 100644 index 0000000..8c1af10 Binary files /dev/null and b/assets/cliff_2.jpg differ diff --git a/assets/copy-credentials.png b/assets/copy-credentials.png new file mode 100644 index 0000000..a360cde Binary files /dev/null and b/assets/copy-credentials.png differ diff --git a/assets/cover-bg-portrait.jpg b/assets/cover-bg-portrait.jpg new file mode 100644 index 0000000..a633016 Binary files /dev/null and b/assets/cover-bg-portrait.jpg differ diff --git a/assets/cover-bg.jpeg b/assets/cover-bg.jpeg new file mode 100644 index 0000000..c866784 Binary files /dev/null and b/assets/cover-bg.jpeg differ diff --git a/assets/create-access-token-btn.png b/assets/create-access-token-btn.png new file mode 100644 index 0000000..3fabe76 Binary files /dev/null and b/assets/create-access-token-btn.png differ diff --git a/public/logo.svg b/assets/logo.svg similarity index 100% rename from public/logo.svg rename to assets/logo.svg diff --git a/assets/noImg.png b/assets/no-img.png similarity index 100% rename from assets/noImg.png rename to assets/no-img.png diff --git a/assets/settings-menu.png b/assets/settings-menu.png new file mode 100644 index 0000000..4b405e7 Binary files /dev/null and b/assets/settings-menu.png differ diff --git a/public/icons/shopping_cart.svg b/assets/shopping-cart.svg similarity index 100% rename from public/icons/shopping_cart.svg rename to assets/shopping-cart.svg diff --git a/components/AsideMenu.tsx b/components/AsideMenu.tsx new file mode 100644 index 0000000..6549377 --- /dev/null +++ b/components/AsideMenu.tsx @@ -0,0 +1,105 @@ +import clsx from 'clsx'; +import {useAppDispatch, useAppSelector} from '../hooks/redux'; +import {RootState} from '../redux/store'; +import {disableBodyScroll, enableBodyScroll, clearAllBodyScrollLocks} from 'body-scroll-lock'; +import {useEffect, useRef} from 'react'; +import {setIsOpened} from '../redux/reducers/asideMenu'; +import HeaderCart from './cart/HeaderCart'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faTimes} from '@fortawesome/free-solid-svg-icons'; +import AsideMenuList from './asideMenu/MenuList'; +import {DragGesture} from '@use-gesture/vanilla'; +import {IMenuItem} from '../@types/components'; + +export default function AsideMenu({menuList}: {menuList?: IMenuItem[]}) { + const rootEl = useRef(null); + const isOpened = useAppSelector((state: RootState) => state.asideMenu.isOpened); + const isRouteChanging = useAppSelector((state: RootState) => state.app.isRouteChanging); + const dispatch = useAppDispatch(); + const gesture = useRef(null); + + const closeIfOpened = () => { + if (isOpened) { + dispatch(setIsOpened(false)); + } + }; + const onEscPressed = (e: KeyboardEvent) => { + if (e.defaultPrevented || !isOpened) { + return; + } + + if (e.key !== undefined && e.key === 'Escape') { + closeIfOpened(); + } else if (e.keyCode !== undefined && e.keyCode === 27) { + closeIfOpened(); + } + }; + + const enableSwipe = () => { + const body = document.querySelector('body'); + + body!.style.touchAction = 'none'; + gesture.current = new DragGesture(body!, ({last, movement}) => { + // if (last && vx > 0.3 && (Math.abs(vy) < Math.abs(vx)) && dx === 1) { + if (last && movement[0] > 100) { + closeIfOpened(); + } + }); + }; + + const disableSwipe = () => { + const body = document.querySelector('body'); + + if (gesture.current) gesture.current.destroy(); + gesture.current = null; + body!.style.touchAction = 'auto'; + }; + + useEffect(() => { + if (isRouteChanging) closeIfOpened(); + }, [isRouteChanging]); //eslint-disable-line + + useEffect(() => { + if (isOpened) { + if (rootEl.current) { + disableBodyScroll(rootEl.current); + } + + enableSwipe(); + + window.addEventListener('resize', closeIfOpened); + window.addEventListener('keydown', onEscPressed); + } else { + if (rootEl.current) { + enableBodyScroll(rootEl.current); + } + + disableSwipe(); + + window.removeEventListener('resize', closeIfOpened); + window.removeEventListener('keydown', onEscPressed); + } + + return () => { + clearAllBodyScrollLocks(); + window.removeEventListener('resize', closeIfOpened); + window.removeEventListener('keydown', onEscPressed); + + disableSwipe(); + }; + }, [isOpened]);//eslint-disable-line + + return ( + + ); +} \ No newline at end of file diff --git a/components/BreadCrumbs.tsx b/components/BreadCrumbs.tsx index 3bebd32..088ce95 100644 --- a/components/BreadCrumbs.tsx +++ b/components/BreadCrumbs.tsx @@ -1,54 +1,48 @@ -import {ICategoryFlatItem} from 'boundless-api-client/types/catalog/category'; import clsx from 'clsx'; import Link from 'next/link'; -import {TQuery} from '../@types/common'; -import {getCategoryUrl} from '../lib/services/urls'; +import {IBreadCrumbItem} from '../@types/components'; -export default function BreadCrumbs({parents, activeParams}: IBreadCrumbsProps) { - const _parents = [...parents]; +export default function BreadCrumbs({items}: {items: IBreadCrumbItem[]}) { + const isEmpty = items.length === 0; const richItemAttrs = { itemProp: 'itemListElement', itemScope: true, - itemType: 'http://schema.org/ListItem' + itemType: '//schema.org/ListItem' }; return ( - - ); -} - -interface IBreadCrumbsProps { - parents: ICategoryFlatItem[]; - activeParams?: TQuery; + ); } \ No newline at end of file diff --git a/components/CoverTextInCenter.tsx b/components/CoverTextInCenter.tsx new file mode 100644 index 0000000..4f4db86 --- /dev/null +++ b/components/CoverTextInCenter.tsx @@ -0,0 +1,87 @@ +import {faChevronDown} from '@fortawesome/free-solid-svg-icons'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import clsx from 'clsx'; +import Link from 'next/link'; +import React from 'react'; + +export default function CoverTextInCenter({img, imgPortrait, shadow, content, link, showChevronDown, fixed = true}: CoverTextInCenterProps) { + const imageUrl = `url(${img})`; + const portraitUrl = `url(${imgPortrait})`; + const isGlobalLink = Boolean(link && /^http/.test(link)); + const linkProps = isGlobalLink ? {target: '_blank'} : {}; + + const scrollDown = (e: React.MouseEvent) => { + e.preventDefault(); + window.scrollBy({ + top: window.innerHeight, + left: 0, + behavior: 'smooth' + }); + }; + + return ( +
+
+
+
+ {shadow &&
} +
+ {link + ? + + + :
+ +
} +
+ {showChevronDown && + + } +
+
+ ); +} + +function CoverContent({intro, head, subHead}: ICoverTextContent) { + return ( + <> + {intro &&
} + {head &&
} + {subHead &&
} + + ); +} + +interface CoverTextInCenterProps { + img: string; + imgPortrait: string; + content: ICoverTextContent; + showChevronDown: boolean; + link?: string; + fixed?: boolean; + shadow?: ICoverShadow +} + +interface ICoverTextContent { + intro?: string; + head?: string; + subHead?: string; +} + +interface ICoverShadow { + backgroundColor: string; + opacity: number; +} \ No newline at end of file diff --git a/components/FilterForm.tsx b/components/FilterForm.tsx index 7d1ee76..29350f5 100644 --- a/components/FilterForm.tsx +++ b/components/FilterForm.tsx @@ -1,7 +1,7 @@ import {IFilterField, IFilterFieldRange, TCharacteristicType, TFilterFieldType} from 'boundless-api-client'; import {TQuery} from '../@types/common'; import {SyntheticEvent, useCallback, useEffect, useState} from 'react'; -import {apiClient} from '../lib/services/api'; +import {apiClient} from '../lib/api'; import PriceRangeField from './filterForm/PriceRange'; import _debounce from 'lodash/debounce'; import _omit from 'lodash/omit'; @@ -12,7 +12,9 @@ import TextCharacteristic from './filterForm/TextCharacteristic'; import BrandSelect from './filterForm/BrandSelect'; import Stock from './filterForm/Stock'; import _isEqualWith from 'lodash/isEqualWith'; -import {filterKeys, filterProductsQuery} from '../lib/services/category'; +import {filterKeys, filterProductsQuery} from '../lib/category'; + +const DEFAULT_DISPLAY_LIMIT = 3; /** * @param filterFields - might be passed manually, e.g. pass: @@ -22,13 +24,13 @@ import {filterKeys, filterProductsQuery} from '../lib/services/category'; * @param onSubmit * @constructor */ -export default function FilterForm({filterFields, queryParams, categoryId, onSearch}: IFilterFormProps) { +export default function FilterForm({filterFields, queryParams, categoryId, onSearch, idsPrefix}: IFilterFormProps) { const [hasChanged, setHasChanged] = useState(false); const [values, setValues] = useState({}); const [ranges, setRanges] = useState([]); const [isFetching, setIsFetching] = useState(false); const [preSearchResult, setPreSearchResult] = useState(null); - const [initedForCategory, setInitedForCategory] = useState(null); + const [initedForCategory, setInitedForCategory] = useState(null); const getData = () => { const sanitizedQuery = sanitizeIncomingQuery(queryParams); @@ -60,18 +62,14 @@ export default function FilterForm({filterFields, queryParams, categoryId, onSea }).catch(console.error); }, 600), [categoryId]); - const onChange = (key: string, value: any, characteristicId?: number) => { - let newValues: TQuery = {}; - if (key === 'props') { - const props = Object.assign({}, values.props, {[characteristicId!]: value}); - newValues = {...values, ...{props: props}}; - } else { - newValues = {...values, ...{[key]: value}}; - } + const onChange = (value: {[key: string]: any}) => { + const newValues: TQuery = 'props' in value + ? {...values, props: Object.assign({}, values.props, value.props)} + : {...values, ...value}; - setHasChanged(true); setValues(newValues); reCalcRanges(newValues); + setHasChanged(true); }; const onSubmit = (e: SyntheticEvent) => { @@ -105,26 +103,33 @@ export default function FilterForm({filterFields, queryParams, categoryId, onSea } return ( -
+ {ranges.map((filterField, i) => { switch (filterField.type) { case TFilterFieldType.price: return ; + key={i} + idsPrefix={idsPrefix} + />; case TFilterFieldType.brand: return ; + displayLimit={DEFAULT_DISPLAY_LIMIT} + key={i} + idsPrefix={idsPrefix} + />; case TFilterFieldType.availability: return ; + key={i} + idsPrefix={idsPrefix} + />; case TFilterFieldType.characteristic: { if (isMultiCaseType(filterField.characteristic!.type)) { @@ -132,27 +137,34 @@ export default function FilterForm({filterFields, queryParams, categoryId, onSea field={filterField} onChange={onChange} values={values} - key={i} />; + displayLimit={DEFAULT_DISPLAY_LIMIT} + key={i} + idsPrefix={idsPrefix} + />; } else { return ; + key={i} + idsPrefix={idsPrefix} + />; } } } })} -
- - +
+
+ + +
); @@ -285,10 +297,13 @@ interface IFilterFormProps { queryParams: TQuery; categoryId: number; onSearch: (data: TQuery) => void; + idsPrefix: string; } export interface IFilterFieldProps { field: IFilterFieldRange; values: TQuery; - onChange: (key: string, value: any, characteristicId?: number) => void; + onChange: (value: {[key: string]: any}) => void; + idsPrefix: string; + displayLimit?: number; } \ No newline at end of file diff --git a/components/Footer.tsx b/components/Footer.tsx index 8057404..86f71c8 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -1,8 +1,27 @@ -export default function Footer() { +import FooterMenu from './footer/FooterMenu'; +import SocialButtons from './footer/SocialButtons'; +import FooterContacts from './footer/Contacts'; +import FooterAbout from './footer/About'; +import {IMenuItem} from '../@types/components'; + +export default function Footer({menuList, companyTitle}: {menuList: IMenuItem[], companyTitle?: string}) { return ( -