├── .babelrc ├── .env-example ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── components ├── atoms │ ├── CartProduct │ │ └── CartProduct.tsx │ ├── CheckoutProduct │ │ └── CheckoutProduct.tsx │ ├── ProductTile │ │ └── ProductTile.tsx │ └── ThemeSwitch │ │ └── ThemeSwitch.tsx ├── molecules │ ├── CartList │ │ ├── CartList.spec.tsx │ │ └── CartList.tsx │ ├── CheckoutList │ │ ├── CheckoutList.spec.tsx │ │ └── CheckoutList.tsx │ ├── Header │ │ └── Header.tsx │ ├── HomeBanner │ │ └── HomeBanner.tsx │ ├── Preferences │ │ └── Preferences.tsx │ ├── ProductsList │ │ ├── ProductsList.spec.tsx │ │ └── ProductsList.tsx │ └── SizeSelect │ │ ├── SizeSelect.spec.tsx │ │ └── SizeSelect.tsx └── organisms │ ├── AuthChecker │ ├── AuthChecker.spec.tsx │ └── AuthChecker.tsx │ ├── Layout │ └── Layout.tsx │ ├── Loader │ └── Loader.tsx │ ├── LoginForm │ ├── LoginForm.spec.tsx │ └── LoginForm.tsx │ ├── Main │ └── Main.tsx │ ├── ProductView │ └── ProductView.tsx │ └── RegisterForm │ ├── RegisterForm.spec.tsx │ └── RegisterForm.tsx ├── context ├── CartContext.tsx ├── MainContext.tsx └── ProductContext.tsx ├── cypress.json ├── cypress ├── e2e │ └── e2e.spec.tsx ├── fixtures │ └── example.json ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── jest.config.js ├── jest.setup.ts ├── lib ├── datocms │ └── index.ts ├── firebase │ └── index.ts └── utils │ ├── consts.ts │ ├── hooks.ts │ └── methods.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── checkout.tsx ├── index.tsx ├── login.tsx ├── products │ └── [id].tsx └── register.tsx ├── public ├── apple-touch-icon-120x120.png ├── apple-touch-icon-152x152.png ├── apple-touch-icon-167x167.png ├── apple-touch-icon-180x180.png ├── apple-touch-icon-60x60.png ├── apple-touch-icon-76x76.png ├── browserconfig.xml ├── coast-228x228.png ├── favicon-128x128.png ├── favicon-16x16.png ├── favicon-256x256.png ├── favicon-32x32.png ├── favicon-48x48.png ├── favicon-64x64.png ├── favicon-96x96.png ├── favicon.ico ├── logo_lg.png ├── manifest.json ├── pwa-192x192.png ├── pwa-512x512.png ├── tile150x150.png ├── tile310x150.png ├── tile310x310.png └── tile70x70.png ├── styles └── globals.scss ├── test └── setupEnv.ts ├── tsconfig.json ├── types └── index.ts └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /.env-example: -------------------------------------------------------------------------------- 1 | # DatoCMS 2 | NEXT_PUBLIC_DATOCMS_API_TOKEN= 3 | 4 | # Firebase 5 | NEXT_PUBLIC_FIREBASE_API_KEY= 6 | NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= 7 | NEXT_PUBLIC_FIREBASE_PROJECT_ID= 8 | NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= 9 | NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= 10 | NEXT_PUBLIC_FIREBASE_APP_ID= 11 | NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": ["plugin:react/recommended", "airbnb", "plugin:@typescript-eslint/recommended"], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaFeatures": { 11 | "jsx": true 12 | }, 13 | "ecmaVersion": 12, 14 | "sourceType": "module" 15 | }, 16 | "plugins": ["react", "@typescript-eslint", "jest-dom", "testing-library"], 17 | "rules": { 18 | "no-use-before-define": "off", 19 | "import/no-unresolved": "off", 20 | "import/extensions": "off", 21 | "react/jsx-filename-extension": "off", 22 | "comma-dangle": "off", 23 | "object-curly-newline": "off", 24 | "import/prefer-default-export": "off", 25 | "no-underscore-dangle": "off", 26 | "no-unused-vars": "off", 27 | "no-console": "off", 28 | "react/prop-types": "off", 29 | "indent": "off", 30 | "react/jsx-one-expression-per-line": "off", 31 | "no-confusing-arrow": "off", 32 | "no-unused-expressions": "off", 33 | "react/jsx-boolean-value": "off", 34 | "@typescript-eslint/explicit-module-boundary-types": "off", 35 | "@typescript-eslint/no-explicit-any": "off", 36 | "implicit-arrow-linebreak": "off", 37 | "operator-linebreak": "off", 38 | "react/button-has-type": "off", 39 | "function-paren-newline": "off", 40 | "@typescript-eslint/no-var-requires": "off", 41 | "@typescript-eslint/no-unused-vars": "off", 42 | "react/jsx-curly-newline": "off", 43 | "jsx-a11y/control-has-associated-label": "off", 44 | "jsx-a11y/click-events-have-key-events": "off", 45 | "jsx-a11y/no-noninteractive-element-interactions": "off", 46 | "eqeqeq": "off", 47 | "@typescript-eslint/no-non-null-assertion": "off", 48 | "jsx-a11y/no-static-element-interactions": "off", 49 | "react/self-closing-comp": "off", 50 | "@typescript-eslint/ban-types": "off", 51 | "jsx-a11y/href-no-hash": "off", 52 | "arrow-parens": "off", 53 | "react/no-array-index-key": "off", 54 | "import/no-extraneous-dependencies": "off", 55 | "react/require-default-props": "off" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | #workers 4 | sw.js 5 | workbox* 6 | 7 | 8 | # dependencies 9 | /node_modules 10 | /.pnp 11 | .pnp.js 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # local env files 33 | .env.local 34 | .env.development.local 35 | .env.test 36 | .env.test.local 37 | .env.production.local 38 | 39 | # vercel 40 | .vercel 41 | 42 | 43 | # cypress 44 | cypress/screenshots 45 | cypress/videos 46 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 100 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

ecommerce store

2 | 3 | ## Project Overview 🎉 4 | 5 | You can publish here your products to sell them online. 6 | 7 | ## Tech/framework used 🔧 8 | 9 | | Tech | Description | 10 | | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | 11 | | [Typescript](https://www.typescriptlang.org/) | Javascript superset language | 12 | | [React](https://reactjs.org/) | Library for building user interfaces | 13 | | [Next](https://nextjs.org) | Powerful React Framework | 14 | | [Material-UI](https://material-ui.com/) | React components for faster and easier web development | 15 | | [Context API](https://reactjs.org/docs/context.html) | React structure that enables to share data with multiple components | 16 | | [SCSS](https://sass-lang.com) | CSS with superpowers | 17 | | [Firebase](https://firebase.google.com) | Powerful for apps that don't use backend (e. g. for authentication) | 18 | | [React Hook Form](https://react-hook-form.com) | Forms with easy-to-use validation | 19 | | [React Select](https://react-select.com) | React library for creating beatiful select inputs | 20 | | [Stripe](https://stripe.com) | Library for easy payment processing | 21 | | [DatoCMS](https://www.datocms.com) | Complete, user-friendly and performant Headless CMS | 22 | | [Jest](https://jestjs.io) | Javascript Testing Framework | 23 | | [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) | Useful for testing React components | 24 | | [Cypress](https://www.cypress.io/) | Next generation testing tool (I used it for e2e testing) | 25 | | [Eslint](https://eslint.org/) | Javascript Linter | 26 | | [Prettier](https://prettier.io/) | Code formatter | 27 | 28 | ## Screenshots 📺 29 | 30 |

31 | Screenshot 32 |

33 | 34 |

35 | Screenshot 36 | Screenshot 37 |

38 | 39 |

40 | Screenshot 41 |

42 | 43 | ## Installation 💾 44 | 45 | ```bash 46 | 47 | git clone https://github.com/simicoder/ecommerce.git 48 | 49 | npm install 50 | 51 | # set up environment variables 52 | 53 | npm run start 54 | 55 | ``` 56 | 57 | ## Live 📍 58 | 59 | https://ecommerce-simicoder.vercel.app/ 60 | -------------------------------------------------------------------------------- /components/atoms/CartProduct/CartProduct.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import type { ProductType } from 'types'; 3 | import Grid from '@material-ui/core/Grid'; 4 | import Button from '@material-ui/core/Button'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | import CloseIcon from '@material-ui/icons/Close'; 8 | 9 | type CartProductProps = { 10 | readonly product: ProductType & { quantity: number }; 11 | readonly onRemoveItem: (product: ProductType & { quantity: number }) => void; 12 | }; 13 | 14 | const useStyles = makeStyles((theme) => ({ 15 | root: { 16 | margin: theme.spacing(2), 17 | }, 18 | })); 19 | 20 | export const CartProduct = memo(({ product, onRemoveItem }) => { 21 | const classes = useStyles(); 22 | 23 | return ( 24 |
  • 25 | 26 | 27 | {product.name} 28 | 29 | {product.quantity} x ${product.price} 30 | 31 | 32 | 33 | 36 | 37 | 38 |
  • 39 | ); 40 | }); 41 | 42 | CartProduct.displayName = 'CartProduct'; 43 | -------------------------------------------------------------------------------- /components/atoms/CheckoutProduct/CheckoutProduct.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import type { ProductType } from 'types'; 3 | import Image from 'next/image'; 4 | import Link from 'next/link'; 5 | import Input from '@material-ui/core/Input'; 6 | import Grid from '@material-ui/core/Grid'; 7 | import Button from '@material-ui/core/Button'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import CloseIcon from '@material-ui/icons/Close'; 10 | import MuiLink from '@material-ui/core/Link'; 11 | 12 | type CheckoutProductProps = { 13 | readonly product: ProductType & { quantity: number }; 14 | readonly onChangeItemQuantity: ( 15 | product: ProductType & { quantity: number }, 16 | quantity: number, 17 | ) => void; 18 | readonly onRemoveItem: (product: ProductType & { quantity: number }) => void; 19 | }; 20 | 21 | export const CheckoutProduct = memo( 22 | ({ product, onChangeItemQuantity, onRemoveItem }) => { 23 | return ( 24 |
  • 25 | 33 | 41 | {product.name} 42 | 43 | 51 | 52 | 53 | {product.name} 54 | 55 | 56 | 57 | 65 | onChangeItemQuantity(product, Number(e.target.value))} 70 | /> 71 | 72 | 80 | ${product.quantity * product.price} 81 | 82 | 83 | 86 | 87 | 88 |
  • 89 | ); 90 | }, 91 | ); 92 | 93 | CheckoutProduct.displayName = 'CheckoutProduct'; 94 | -------------------------------------------------------------------------------- /components/atoms/ProductTile/ProductTile.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import type { ProductType } from 'types'; 3 | import Image from 'next/image'; 4 | import Link from 'next/link'; 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Grid from '@material-ui/core/Grid'; 7 | import Typography from '@material-ui/core/Typography'; 8 | 9 | type ProductProps = { 10 | readonly product: ProductType; 11 | }; 12 | 13 | const useStyles = makeStyles((theme) => ({ 14 | root: { 15 | margin: theme.spacing(4), 16 | position: 'relative', 17 | transition: 'all 0.2s ease-in-out', 18 | 19 | '&:hover': { 20 | transform: 'scale(0.95)', 21 | }, 22 | }, 23 | flashCard: { 24 | position: 'absolute', 25 | bottom: '12%', 26 | right: 0, 27 | backgroundColor: theme.palette.background.default, 28 | padding: '1rem 1.5rem', 29 | fontSize: '1.6rem', 30 | }, 31 | })); 32 | 33 | export const ProductTile = memo(({ product }) => { 34 | const classes = useStyles(); 35 | 36 | return ( 37 | 38 | 39 | 40 | {product.name} 47 | 48 | {product.name} 49 | ${product.price} 50 | 51 | 52 | 53 | 54 | ); 55 | }); 56 | 57 | ProductTile.displayName = 'ProductTile'; 58 | -------------------------------------------------------------------------------- /components/atoms/ThemeSwitch/ThemeSwitch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withStyles, Theme, createStyles } from '@material-ui/core/styles'; 3 | import FormGroup from '@material-ui/core/FormGroup'; 4 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 5 | import Switch, { SwitchClassKey, SwitchProps } from '@material-ui/core/Switch'; 6 | import { useMainContext } from 'context/MainContext'; 7 | 8 | interface Styles extends Partial> { 9 | focusVisible?: string; 10 | } 11 | 12 | interface Props extends SwitchProps { 13 | classes: Styles; 14 | } 15 | 16 | const ThemeSwitchStyle = withStyles((theme: Theme) => 17 | createStyles({ 18 | root: { 19 | width: 42, 20 | height: 26, 21 | padding: 0, 22 | margin: theme.spacing(1), 23 | }, 24 | switchBase: { 25 | padding: 1, 26 | '&$checked': { 27 | transform: 'translateX(16px)', 28 | color: theme.palette.common.white, 29 | '& + $track': { 30 | backgroundColor: 'theme.palette.background.default', 31 | border: 'none', 32 | }, 33 | }, 34 | '&$focusVisible $thumb': { 35 | color: 'theme.palette.background.default', 36 | border: '6px solid #fff', 37 | }, 38 | }, 39 | thumb: { 40 | width: 24, 41 | height: 24, 42 | }, 43 | track: { 44 | borderRadius: 26 / 2, 45 | border: `1px solid ${theme.palette.grey[400]}`, 46 | backgroundColor: theme.palette.grey[50], 47 | opacity: 1, 48 | transition: theme.transitions.create(['background-color', 'border']), 49 | }, 50 | checked: {}, 51 | focusVisible: {}, 52 | }), 53 | )(({ classes, ...props }: Props) => { 54 | return ( 55 | 67 | ); 68 | }); 69 | 70 | export const ThemeSwitch = () => { 71 | const { isDarkTheme, setIsDarkTheme } = useMainContext(); 72 | 73 | const handleChange = () => { 74 | setIsDarkTheme(!isDarkTheme); 75 | }; 76 | 77 | return ( 78 | 79 | } 81 | label="Dark Theme" 82 | /> 83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /components/molecules/CartList/CartList.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | import { CartList } from './CartList'; 4 | 5 | test('when cart is empty information should be displayed', () => { 6 | render(); 7 | expect(screen.getByText(/cart is empty/i)).toBeInTheDocument(); 8 | }); 9 | 10 | test('when cart is not empty items should be displayed', () => { 11 | const fakeCartItems = [ 12 | { 13 | id: '1', 14 | name: 'product', 15 | description: 'product', 16 | price: 110, 17 | category: 'trousers', 18 | imgurl: '/product.jpg', 19 | quantity: 3, 20 | }, 21 | { 22 | id: '2', 23 | name: 'product', 24 | description: 'product', 25 | price: 120, 26 | category: 'tshirt', 27 | imgurl: '/product.jpg', 28 | quantity: 2, 29 | }, 30 | ]; 31 | 32 | render(); 33 | expect(screen.queryByText(/cart is empty/i)).not.toBeInTheDocument(); 34 | expect(screen.getAllByTestId('cart-product')).toHaveLength(2); 35 | }); 36 | -------------------------------------------------------------------------------- /components/molecules/CartList/CartList.tsx: -------------------------------------------------------------------------------- 1 | import type { ProductType } from 'types'; 2 | import { memo } from 'react'; 3 | import Link from 'next/link'; 4 | import { useCart } from 'context/CartContext'; 5 | 6 | import { CartProduct } from 'components/atoms/CartProduct/CartProduct'; 7 | 8 | import Grid from '@material-ui/core/Grid'; 9 | import Button from '@material-ui/core/Button'; 10 | import Typography from '@material-ui/core/Typography'; 11 | import { makeStyles } from '@material-ui/core/styles'; 12 | 13 | type CartListProps = { 14 | readonly cartItems: (ProductType & { 15 | quantity: number; 16 | })[]; 17 | }; 18 | 19 | const useStyles = makeStyles((theme) => ({ 20 | root: { 21 | margin: theme.spacing(4), 22 | }, 23 | })); 24 | 25 | export const CartList = memo(({ cartItems }) => { 26 | const { handleRemoveFromCart } = useCart(); 27 | const classes = useStyles(); 28 | 29 | return ( 30 | 31 | 32 | {cartItems.length ? ( 33 | <> 34 |
      35 | {cartItems.map((cartItem) => { 36 | return ( 37 | 42 | ); 43 | })} 44 |
    45 | 46 | 49 | 50 | 51 | ) : ( 52 | Cart is empty 53 | )} 54 |
    55 |
    56 | ); 57 | }); 58 | 59 | CartList.displayName = 'CartList'; 60 | -------------------------------------------------------------------------------- /components/molecules/CheckoutList/CheckoutList.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | import { CheckoutList } from './CheckoutList'; 4 | 5 | test('when cart is empty information should be displayed', () => { 6 | render(); 7 | expect(screen.getByText(/Cart is empty/i)).toBeInTheDocument(); 8 | }); 9 | 10 | test('when cart is not empty items should be displayed', () => { 11 | const fakeCartItems = [ 12 | { 13 | id: '1', 14 | name: 'product', 15 | description: 'product', 16 | price: 110, 17 | category: 'trousers', 18 | imgurl: '/product.jpg', 19 | quantity: 3, 20 | }, 21 | { 22 | id: '2', 23 | name: 'product', 24 | description: 'product', 25 | price: 120, 26 | category: 'tshirt', 27 | imgurl: '/product.jpg', 28 | quantity: 2, 29 | }, 30 | ]; 31 | 32 | render(); 33 | expect(screen.queryByText(/Cart is empty/i)).not.toBeInTheDocument(); 34 | expect(screen.getAllByTestId('checkout-product')).toHaveLength(2); 35 | }); 36 | -------------------------------------------------------------------------------- /components/molecules/CheckoutList/CheckoutList.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import type { ProductType } from 'types'; 3 | import { useCart } from 'context/CartContext'; 4 | import StripeCheckout, { Token } from 'react-stripe-checkout'; 5 | import { CheckoutProduct } from 'components/atoms/CheckoutProduct/CheckoutProduct'; 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | import Grid from '@material-ui/core/Grid'; 8 | import Button from '@material-ui/core/Button'; 9 | import Typography from '@material-ui/core/Typography'; 10 | 11 | type CheckoutListProps = { 12 | readonly cartItems: (ProductType & { quantity: number })[]; 13 | }; 14 | 15 | const useStyles = makeStyles((theme) => ({ 16 | list: { margin: theme.spacing(4) }, 17 | button: { 18 | marginLeft: theme.spacing(4), 19 | }, 20 | })); 21 | 22 | export const CheckoutList = memo(({ cartItems }) => { 23 | const { getTotalCost, handleRemoveFromCart, handleChangeProductQuantity } = useCart(); 24 | 25 | const classes = useStyles(); 26 | 27 | const validPrice = getTotalCost() * 100; 28 | const key = 'test_token'; 29 | const onToken = (token: Token) => console.log(token); 30 | 31 | return ( 32 | 33 | {cartItems.length ? ( 34 | 35 |
      36 | {cartItems.map((cartItem) => { 37 | return ( 38 | 44 | ); 45 | })} 46 |
    47 |
    48 | ) : ( 49 | 50 | Cart is empty 51 | 52 | )} 53 | 54 | ${getTotalCost()} 55 | 67 | 78 | 79 | 80 |
    81 | ); 82 | }); 83 | 84 | CheckoutList.displayName = 'CheckoutList'; 85 | -------------------------------------------------------------------------------- /components/molecules/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, Dispatch, SetStateAction } from 'react'; 2 | import { auth } from 'lib/firebase'; 3 | import { useRouter } from 'next/router'; 4 | import Link from 'next/link'; 5 | import Toolbar from '@material-ui/core/Toolbar'; 6 | import Button from '@material-ui/core/Button'; 7 | import ShoppingCartIcon from '@material-ui/icons/ShoppingCart'; 8 | import MenuIcon from '@material-ui/icons/Menu'; 9 | import AppBar from '@material-ui/core/AppBar'; 10 | import Typography from '@material-ui/core/Typography'; 11 | import { useCart } from 'context/CartContext'; 12 | import { CartList } from 'components/molecules/CartList/CartList'; 13 | import Drawer from '@material-ui/core/Drawer'; 14 | import { emphasize, withStyles, makeStyles, Theme } from '@material-ui/core/styles'; 15 | import Chip from '@material-ui/core/Chip'; 16 | import HomeIcon from '@material-ui/icons/Home'; 17 | import { ThemeSwitch } from 'components/atoms/ThemeSwitch/ThemeSwitch'; 18 | import IconButton from '@material-ui/core/IconButton'; 19 | import MenuItem from '@material-ui/core/MenuItem'; 20 | import Menu from '@material-ui/core/Menu'; 21 | 22 | const StyledBreadcrumb = withStyles((theme: Theme) => ({ 23 | root: { 24 | backgroundColor: theme.palette.grey[100], 25 | height: theme.spacing(3), 26 | color: theme.palette.grey[800], 27 | fontWeight: theme.typography.fontWeightRegular, 28 | fontSize: theme.spacing(2), 29 | '&:hover, &:focus': { 30 | backgroundColor: theme.palette.grey[300], 31 | }, 32 | '&:active': { 33 | boxShadow: theme.shadows[1], 34 | backgroundColor: emphasize(theme.palette.grey[300], 0.12), 35 | }, 36 | }, 37 | }))(Chip) as typeof Chip; 38 | 39 | const useStyles = makeStyles((theme: Theme) => ({ 40 | appBar: { 41 | borderBottom: `1px solid ${theme.palette.divider}`, 42 | }, 43 | toolbar: { 44 | flexWrap: 'wrap', 45 | }, 46 | toolbarTitle: { 47 | flexGrow: 1, 48 | }, 49 | icon: { 50 | marginRight: theme.spacing(1), 51 | fontSize: '2rem', 52 | }, 53 | cartList: { 54 | position: 'absolute', 55 | top: '5.5rem', 56 | right: '-8rem', 57 | zIndex: 10, 58 | }, 59 | fullList: { 60 | width: 'auto', 61 | }, 62 | homeButton: { 63 | backgroundColor: theme.palette.background.default, 64 | color: theme.palette.text.primary, 65 | padding: theme.spacing(2), 66 | }, 67 | margin: { 68 | margin: theme.spacing(1), 69 | }, 70 | })); 71 | 72 | export const Header = () => { 73 | const classes = useStyles(); 74 | 75 | const router = useRouter(); 76 | 77 | const [anchorEl, setAnchorEl] = React.useState(null); 78 | 79 | const { cartItems, getTotalQuantity } = useCart(); 80 | const [isCartOpen, setIsCartOpen] = useState(false); 81 | 82 | const open = Boolean(anchorEl); 83 | 84 | const handleMenu = (event: React.MouseEvent) => { 85 | setAnchorEl(event.currentTarget); 86 | }; 87 | 88 | const handleClose = () => { 89 | setAnchorEl(null); 90 | }; 91 | 92 | const handleLogout = () => { 93 | auth.signOut(); 94 | return router.push('/login'); 95 | }; 96 | 97 | const toggleDrawer = 98 | (isOpen: boolean, setIsOpen: Dispatch>) => 99 | (event: React.KeyboardEvent | React.MouseEvent) => { 100 | if ( 101 | event.type === 'keydown' && 102 | ((event as React.KeyboardEvent).key === 'Tab' || 103 | (event as React.KeyboardEvent).key === 'Shift') 104 | ) { 105 | return; 106 | } 107 | 108 | setIsOpen(isOpen); 109 | }; 110 | 111 | return ( 112 | 113 | 114 | 115 | 116 | } 121 | /> 122 | 123 | 124 | 132 | 133 |
    134 | 135 | 136 | 137 |
    138 | 139 |
    140 | 147 | 148 | 149 | 150 | 165 | 166 |
    167 | 168 |
    169 |
    170 | 171 | 180 | 181 |
    182 |
    183 |
    184 |
    185 | ); 186 | }; 187 | -------------------------------------------------------------------------------- /components/molecules/HomeBanner/HomeBanner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import Typography from '@material-ui/core/Typography'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | 6 | const useStyles = makeStyles(() => ({ 7 | root: { 8 | display: 'grid', 9 | gridTemplateColumns: 'repeat(auto-fit, minmax(25rem, 1fr))', 10 | placeItems: 'center', 11 | margin: '0 auto', 12 | marginTop: '1rem', 13 | marginBottom: '4rem', 14 | '@media all and (min-width: 1000px)': { 15 | minHeight: '65vh', 16 | marginTop: 0, 17 | maxWidth: '90rem', 18 | marginBottom: '2rem', 19 | }, 20 | '@media all and (min-width: 360px)': { 21 | padding: '2rem 5rem', 22 | }, 23 | }, 24 | text: { 25 | position: 'relative', 26 | maxWidth: '40rem', 27 | color: 'white', 28 | margin: '3rem', 29 | 30 | '&::before': { 31 | position: 'absolute', 32 | width: '60%', 33 | height: '120%', 34 | content: '""', 35 | backgroundColor: 'rgb(140, 0, 255)', 36 | zIndex: -1, 37 | top: '-10%', 38 | left: '-5%', 39 | transform: 'rotateZ(60deg) translate(-5em, -7.5em)', 40 | animation: `$sheen 1s forwards`, 41 | }, 42 | 43 | '&::after': { 44 | position: 'absolute', 45 | width: '70%', 46 | height: '120%', 47 | content: '""', 48 | backgroundColor: 'rgb(177, 82, 255)', 49 | zIndex: -1, 50 | top: '0%', 51 | left: '30%', 52 | }, 53 | }, 54 | '@keyframes sheen': { 55 | '100%': { 56 | transform: 'translate(0em, 0em)', 57 | }, 58 | }, 59 | })); 60 | 61 | export const HomeBanner = () => { 62 | const classes = useStyles(); 63 | 64 | return ( 65 | 66 | 67 | Welcome in our ecommerce store! 68 | 69 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /components/molecules/Preferences/Preferences.tsx: -------------------------------------------------------------------------------- 1 | import { useProduct } from 'context/ProductContext'; 2 | import Slider from '@material-ui/core/Slider'; 3 | import Input from '@material-ui/core/Input'; 4 | import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; 5 | import InputLabel from '@material-ui/core/InputLabel'; 6 | import MenuItem from '@material-ui/core/MenuItem'; 7 | import FormControl from '@material-ui/core/FormControl'; 8 | import Select from '@material-ui/core/Select'; 9 | import Grid from '@material-ui/core/Grid'; 10 | import Typography from '@material-ui/core/Typography'; 11 | import Tooltip from '@material-ui/core/Tooltip'; 12 | 13 | const useStyles = makeStyles((theme: Theme) => 14 | createStyles({ 15 | item: { 16 | width: '80%', 17 | margin: theme.spacing(2), 18 | }, 19 | }), 20 | ); 21 | 22 | interface Props { 23 | children: React.ReactElement; 24 | open: boolean; 25 | value: number; 26 | } 27 | 28 | function ValueLabelComponent(props: Props) { 29 | const { children, open, value } = props; 30 | 31 | return ( 32 | 33 | {children} 34 | 35 | ); 36 | } 37 | 38 | export const Preferences = () => { 39 | const { 40 | price, 41 | minPrice, 42 | maxPrice, 43 | handleChangePrice, 44 | searchQuery, 45 | handleChangeSearchQuery, 46 | handleSelectCategories, 47 | productsCategories, 48 | selectedCategory, 49 | } = useProduct(); 50 | 51 | const classes = useStyles(); 52 | 53 | return ( 54 | 55 | 56 | 62 | 63 | 64 | 65 | Type 66 | 76 | 77 | 78 | 79 | Max Price: ${price} 80 | 81 | 90 | 91 | 92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /components/molecules/ProductsList/ProductsList.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | import { ProductsList } from './ProductsList'; 4 | 5 | test('when products list is empty properly message should be displayed', () => { 6 | render(); 7 | expect(screen.getByText(/no products matches your search/i)).toBeInTheDocument(); 8 | }); 9 | 10 | test('when products list is not empty items should be displayed', () => { 11 | const fakeCartItems = [ 12 | { 13 | id: '1', 14 | name: 'product', 15 | description: 'product', 16 | price: 110, 17 | category: 'trousers', 18 | imgurl: '/product.jpg', 19 | quantity: 3, 20 | }, 21 | { 22 | id: '2', 23 | name: 'product', 24 | description: 'product', 25 | price: 120, 26 | category: 'tshirt', 27 | imgurl: '/product.jpg', 28 | quantity: 2, 29 | }, 30 | ]; 31 | 32 | render(); 33 | expect(screen.queryByText(/cart is empty/i)).not.toBeInTheDocument(); 34 | expect(screen.getAllByTestId('product-tile')).toHaveLength(2); 35 | }); 36 | -------------------------------------------------------------------------------- /components/molecules/ProductsList/ProductsList.tsx: -------------------------------------------------------------------------------- 1 | import { ProductTile } from 'components/atoms/ProductTile/ProductTile'; 2 | import type { ProductType } from 'types'; 3 | import { memo } from 'react'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | 8 | type ProductsListProps = { 9 | readonly products: ProductType[]; 10 | }; 11 | 12 | const useStyles = makeStyles((theme) => ({ 13 | emptyResult: { 14 | margin: theme.spacing(4), 15 | }, 16 | })); 17 | 18 | export const ProductsList = memo(({ products }) => { 19 | const classes = useStyles(); 20 | 21 | return ( 22 | 23 | {products.length ? ( 24 | products.map((product) => { 25 | return ; 26 | }) 27 | ) : ( 28 | 29 | No products matches your search 30 | 31 | )} 32 | 33 | ); 34 | }); 35 | 36 | ProductsList.displayName = 'ProductsList'; 37 | -------------------------------------------------------------------------------- /components/molecules/SizeSelect/SizeSelect.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { SizeSelect } from './SizeSelect'; 4 | 5 | test('should S size be checked by default', () => { 6 | render(); 7 | expect(screen.getByLabelText(/S/i)).toBeChecked(); 8 | }); 9 | 10 | test('should call onChange func when other size is selected', () => { 11 | const mockedChangeColorFunc = jest.fn(); 12 | render(); 13 | userEvent.click(screen.getByLabelText(/M/i)); 14 | expect(mockedChangeColorFunc).toHaveBeenCalled(); 15 | }); 16 | -------------------------------------------------------------------------------- /components/molecules/SizeSelect/SizeSelect.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect } from 'react'; 2 | import { ProductSizes } from 'lib/utils/consts'; 3 | import { useProduct } from 'context/ProductContext'; 4 | import Radio from '@material-ui/core/Radio'; 5 | import RadioGroup from '@material-ui/core/RadioGroup'; 6 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 7 | import FormControl from '@material-ui/core/FormControl'; 8 | import FormLabel from '@material-ui/core/FormLabel'; 9 | 10 | type SizeSelectProps = { 11 | readonly name?: string; 12 | readonly onChange: (e: React.ChangeEvent) => void; 13 | }; 14 | 15 | export const SizeSelect = memo(({ onChange }) => { 16 | const { activeProductSize, setActiveProductSize } = useProduct(); 17 | 18 | useEffect(() => { 19 | return () => { 20 | setActiveProductSize('S'); 21 | }; 22 | }, []); 23 | return ( 24 | 25 | Sizes 26 | 32 | {ProductSizes.map((size) => { 33 | return ( 34 | } 39 | label={size.label} 40 | /> 41 | ); 42 | })} 43 | 44 | 45 | ); 46 | }); 47 | 48 | SizeSelect.displayName = 'SizeSelect'; 49 | -------------------------------------------------------------------------------- /components/organisms/AuthChecker/AuthChecker.spec.tsx: -------------------------------------------------------------------------------- 1 | import { screen, render } from "@testing-library/react"; 2 | import { AuthChecker } from "./AuthChecker"; 3 | import * as userHook from "lib/utils/hooks"; 4 | import * as nextRouter from "next/router"; 5 | import type { NextRouter } from "next/router"; 6 | import firebase from "firebase"; 7 | 8 | const useRouter = jest.spyOn(nextRouter, "useRouter"); 9 | 10 | test("when user is not logged in then it should redirect to login page", () => { 11 | jest.spyOn(userHook, "useUser").mockImplementation(() => ({ user: null })); 12 | const mockedRedirectFn = jest.fn(); 13 | useRouter.mockImplementationOnce( 14 | () => 15 | ({ 16 | replace: mockedRedirectFn, 17 | } as unknown as NextRouter) 18 | ); 19 | 20 | render( 21 | 22 | 23 | 24 | ); 25 | 26 | expect( 27 | screen.queryByRole("button", { name: /logged in/i }) 28 | ).not.toBeInTheDocument(); 29 | expect(mockedRedirectFn).toHaveBeenCalled(); 30 | expect(mockedRedirectFn).toHaveBeenCalledWith("/login"); 31 | }); 32 | 33 | test("when user is logged in then it should render children", () => { 34 | jest 35 | .spyOn(userHook, "useUser") 36 | .mockImplementation(() => ({ user: {} as firebase.User })); 37 | render( 38 | 39 | 40 | 41 | ); 42 | 43 | expect( 44 | screen.getByRole("button", { name: /logged in/i }) 45 | ).toBeInTheDocument(); 46 | }); 47 | -------------------------------------------------------------------------------- /components/organisms/AuthChecker/AuthChecker.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { useRouter } from "next/router"; 3 | import { Loader } from "../Loader/Loader"; 4 | import { useUser } from "lib/utils/hooks"; 5 | 6 | type AuthCheckerProps = { 7 | readonly children: React.ReactNode; 8 | }; 9 | 10 | export const AuthChecker = memo(({ children }) => { 11 | const router = useRouter(); 12 | const { user } = useUser(); 13 | 14 | if (!user) { 15 | if (typeof window !== "undefined") { 16 | router.replace("/login"); 17 | } 18 | return ; 19 | } 20 | 21 | return <>{children}; 22 | }); 23 | 24 | AuthChecker.displayName = "AuthChecker"; 25 | -------------------------------------------------------------------------------- /components/organisms/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { titleTemplate as defaultTitleTemplate } from 'pages/_app'; 3 | import { Loader } from 'components/organisms/Loader/Loader'; 4 | import { NextSeo } from 'next-seo'; 5 | import { useMainContext } from 'context/MainContext'; 6 | import { createTheme, ThemeProvider } from '@material-ui/core/styles'; 7 | import CssBaseline from '@material-ui/core/CssBaseline'; 8 | 9 | type LayoutProps = { 10 | readonly children: React.ReactNode; 11 | readonly title?: string; 12 | readonly titleTemplate?: string; 13 | }; 14 | 15 | export const Layout = memo( 16 | ({ children, title, titleTemplate = defaultTitleTemplate }) => { 17 | const { isDarkTheme } = useMainContext(); 18 | 19 | const lightTheme = React.useMemo( 20 | () => 21 | createTheme({ 22 | palette: { 23 | type: 'light', 24 | info: { main: '#1976d2' }, 25 | }, 26 | }), 27 | [isDarkTheme], 28 | ); 29 | 30 | const darkTheme = React.useMemo( 31 | () => 32 | createTheme({ 33 | palette: { 34 | type: 'dark', 35 | info: { main: '#64b5f6' }, 36 | }, 37 | }), 38 | [isDarkTheme], 39 | ); 40 | 41 | const themeConfig = isDarkTheme ? darkTheme : lightTheme; 42 | 43 | return ( 44 | <> 45 | 51 | 52 | 53 | 54 | 55 | 56 | {children} 57 | 58 | 59 | ); 60 | }, 61 | ); 62 | 63 | Layout.displayName = 'Layout'; 64 | -------------------------------------------------------------------------------- /components/organisms/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { useMainContext } from 'context/MainContext'; 2 | import React from 'react'; 3 | import { makeStyles, createStyles } from '@material-ui/core/styles'; 4 | import CircularProgress from '@material-ui/core/CircularProgress'; 5 | 6 | const useStyles = makeStyles(() => 7 | createStyles({ 8 | root: { 9 | position: 'fixed', 10 | top: 0, 11 | left: 0, 12 | width: '100%', 13 | height: '100vh', 14 | backgroundColor: 'rgba(0, 0, 0, 0.5)', 15 | zIndex: 1000, 16 | display: 'grid', 17 | placeItems: 'center', 18 | }, 19 | }), 20 | ); 21 | 22 | export const Loader = () => { 23 | const classes = useStyles(); 24 | const { loading } = useMainContext(); 25 | 26 | if (!loading) { 27 | return null; 28 | } 29 | 30 | return ( 31 |
    32 | 33 |
    34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /components/organisms/LoginForm/LoginForm.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act } from '@testing-library/react'; 2 | import { LoginForm } from './LoginForm'; 3 | import { auth } from 'lib/firebase'; 4 | import userEvent from '@testing-library/user-event'; 5 | import { SnackbarProvider } from 'notistack'; 6 | 7 | describe('email validation', () => { 8 | const mockedLogInFunc = jest.spyOn(auth, 'signInWithEmailAndPassword'); 9 | it('if email field is empty error message should be displayed', async () => { 10 | render( 11 | 12 | 13 | , 14 | ); 15 | 16 | await act(async () => { 17 | userEvent.click(screen.getByRole('button', { name: /sign in/i })); 18 | }); 19 | 20 | expect(screen.getByText(/email is required/i)).toBeInTheDocument(); 21 | expect(mockedLogInFunc).not.toHaveBeenCalled(); 22 | }); 23 | 24 | it("if email field doesn't contain valid email error message should be displayed", async () => { 25 | render( 26 | 27 | 28 | , 29 | ); 30 | 31 | userEvent.type(screen.getByLabelText(/email/i), 'email'); 32 | 33 | await act(async () => { 34 | userEvent.click(screen.getByRole('button', { name: /sign in/i })); 35 | }); 36 | 37 | expect(screen.getByText(/mail must be a valid email/i)).toBeInTheDocument(); 38 | expect(mockedLogInFunc).not.toHaveBeenCalled(); 39 | }); 40 | }); 41 | 42 | describe('password validation', () => { 43 | const mockedLogInFunc = jest.spyOn(auth, 'signInWithEmailAndPassword'); 44 | it('if password field is empty error message should be displayed', async () => { 45 | render( 46 | 47 | 48 | , 49 | ); 50 | 51 | await act(async () => { 52 | userEvent.click(screen.getByRole('button', { name: /sign in/i })); 53 | }); 54 | 55 | expect(screen.getByText(/password is required/i)).toBeInTheDocument(); 56 | expect(mockedLogInFunc).not.toHaveBeenCalled(); 57 | }); 58 | 59 | it("if password field doesn't contain valid password error message should be displayed", async () => { 60 | render( 61 | 62 | 63 | , 64 | ); 65 | 66 | userEvent.type(screen.getByLabelText(/password/i), 'password'); 67 | 68 | await act(async () => { 69 | userEvent.click(screen.getByRole('button', { name: /sign in/i })); 70 | }); 71 | 72 | expect( 73 | screen.getByText( 74 | /password must contain an uppercase letter, a special character, a number and must be at least 8 characters long/i, 75 | ), 76 | ).toBeInTheDocument(); 77 | 78 | expect(mockedLogInFunc).not.toHaveBeenCalled(); 79 | }); 80 | }); 81 | 82 | test('when email and password are valid callback to log in should be called', async () => { 83 | const mockedLogInFunc = jest.spyOn(auth, 'signInWithEmailAndPassword'); 84 | 85 | render( 86 | 87 | 88 | , 89 | ); 90 | 91 | userEvent.type(screen.getByLabelText(/email/i), 'email@email.com'); 92 | 93 | userEvent.type(screen.getByLabelText(/password/i), 'ZAQ!2wsx'); 94 | await act(async () => { 95 | userEvent.click(screen.getByRole('button', { name: /sign in/i })); 96 | }); 97 | 98 | expect(mockedLogInFunc).toHaveBeenCalled(); 99 | expect(mockedLogInFunc).toHaveBeenCalledWith('email@email.com', 'ZAQ!2wsx'); 100 | }); 101 | -------------------------------------------------------------------------------- /components/organisms/LoginForm/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { inputValidation } from 'lib/utils/consts'; 3 | import { UserData } from 'types'; 4 | import Link from '@material-ui/core/Link'; 5 | import { auth } from 'lib/firebase'; 6 | import { useMainContext } from 'context/MainContext'; 7 | import { useEffect } from 'react'; 8 | import { useRouter } from 'next/router'; 9 | import Avatar from '@material-ui/core/Avatar'; 10 | import Button from '@material-ui/core/Button'; 11 | import CssBaseline from '@material-ui/core/CssBaseline'; 12 | import TextField from '@material-ui/core/TextField'; 13 | import Grid from '@material-ui/core/Grid'; 14 | import VpnKeyIcon from '@material-ui/icons/VpnKey'; 15 | import Typography from '@material-ui/core/Typography'; 16 | import { makeStyles } from '@material-ui/core/styles'; 17 | import Container from '@material-ui/core/Container'; 18 | import FormHelperText from '@material-ui/core/FormHelperText'; 19 | import { useSnackbar } from 'notistack'; 20 | import { ThemeSwitch } from '../../atoms/ThemeSwitch/ThemeSwitch'; 21 | 22 | const useStyles = makeStyles((theme) => ({ 23 | paper: { 24 | marginTop: theme.spacing(8), 25 | display: 'flex', 26 | flexDirection: 'column', 27 | alignItems: 'center', 28 | }, 29 | avatar: { 30 | margin: theme.spacing(1), 31 | backgroundColor: theme.palette.info.main, 32 | }, 33 | form: { 34 | width: '100%', 35 | marginTop: theme.spacing(1), 36 | }, 37 | submit: { 38 | margin: theme.spacing(3, 0, 2), 39 | }, 40 | link: { color: theme.palette.info.main }, 41 | })); 42 | 43 | export const LoginForm = () => { 44 | const { 45 | register, 46 | handleSubmit, 47 | formState: { errors }, 48 | } = useForm(); 49 | 50 | const router = useRouter(); 51 | const { enqueueSnackbar } = useSnackbar(); 52 | 53 | const { setLoading } = useMainContext(); 54 | 55 | useEffect(() => { 56 | auth.signOut(); 57 | }, []); 58 | 59 | const handleLogin = async ({ email, password }: UserData) => { 60 | setLoading(true); 61 | try { 62 | await auth.signInWithEmailAndPassword(email, password); 63 | router.push('/'); 64 | } catch (e) { 65 | enqueueSnackbar((e as Error)?.message); 66 | } finally { 67 | setLoading(false); 68 | } 69 | }; 70 | 71 | const classes = useStyles(); 72 | 73 | return ( 74 | 75 | 76 |
    77 | 78 | 79 | 80 | 81 | 82 | Sign in 83 | 84 |
    85 | 96 | {errors?.email?.message} 97 | 108 | {errors?.password?.message} 109 | 119 | 120 | 121 | 122 | {"Don't have an account? Sign Up"} 123 | 124 | 125 | 126 | 127 |
    128 |
    129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /components/organisms/Main/Main.tsx: -------------------------------------------------------------------------------- 1 | import { Header } from 'components/molecules/Header/Header'; 2 | import { HomeBanner } from 'components/molecules/HomeBanner/HomeBanner'; 3 | import { memo, useEffect } from 'react'; 4 | import type { ProductType } from 'types'; 5 | import { ProductsList } from 'components/molecules/ProductsList/ProductsList'; 6 | import { Preferences } from 'components/molecules/Preferences/Preferences'; 7 | import { useProduct } from 'context/ProductContext'; 8 | import Grid from '@material-ui/core/Grid'; 9 | 10 | type MainProps = { 11 | readonly results: ProductType[]; 12 | }; 13 | 14 | export const Main = memo(({ results }) => { 15 | const { setProducts, setFilteredProducts, filteredProducts } = useProduct(); 16 | 17 | useEffect(() => { 18 | setProducts(results); 19 | setFilteredProducts(results); 20 | }, []); 21 | 22 | return ( 23 | 24 |
    25 | 26 |
    27 | 28 | 29 | 30 |
    31 |
    32 | 33 | ); 34 | }); 35 | 36 | Main.displayName = 'Main'; 37 | -------------------------------------------------------------------------------- /components/organisms/ProductView/ProductView.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import type { ProductType } from 'types'; 3 | import { SizeSelect } from 'components/molecules/SizeSelect/SizeSelect'; 4 | import { useProduct } from 'context/ProductContext'; 5 | import { useCart } from 'context/CartContext'; 6 | import Button from '@material-ui/core/Button'; 7 | import { ProductSizes } from 'lib/utils/consts'; 8 | import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'; 9 | import Grid from '@material-ui/core/Grid'; 10 | import Paper from '@material-ui/core/Paper'; 11 | import Typography from '@material-ui/core/Typography'; 12 | import Image from 'next/image'; 13 | import { useSnackbar } from 'notistack'; 14 | 15 | type ProductDescriptionProps = { readonly product: ProductType }; 16 | 17 | const useStyles = makeStyles((theme: Theme) => 18 | createStyles({ 19 | root: { 20 | flexGrow: 1, 21 | margin: theme.spacing(2), 22 | }, 23 | paper: { 24 | padding: theme.spacing(2), 25 | margin: 'auto', 26 | [theme.breakpoints.up('md')]: { 27 | maxWidth: '90%', 28 | }, 29 | }, 30 | info: { margin: theme.spacing(1, 0, 2) }, 31 | buy: { 32 | marginTop: theme.spacing(2), 33 | maxHeight: '30px', 34 | }, 35 | }), 36 | ); 37 | 38 | export const ProductView = memo(({ product }) => { 39 | const { setActiveProductSize } = useProduct(); 40 | const { enqueueSnackbar } = useSnackbar(); 41 | 42 | const handleColorChange = (e: React.ChangeEvent) => { 43 | setActiveProductSize(e.target.value as typeof ProductSizes[number]['label']); 44 | }; 45 | 46 | const { handleAddToCart } = useCart(); 47 | 48 | const handleAddProductToCart = () => { 49 | handleAddToCart(product); 50 | enqueueSnackbar('Successfully added to cart!'); 51 | }; 52 | 53 | const classes = useStyles(); 54 | 55 | return ( 56 |
    57 | 58 | 59 | 60 | product image 67 | 68 | 69 | 70 | 71 | 72 | {product.name} 73 | 74 | 75 | {product.category} 76 | 77 | 78 | {product.description} 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | ${product.price} 88 | 89 | 90 | 101 | 102 | 103 | 104 | 105 |
    106 | ); 107 | }); 108 | 109 | ProductView.displayName = 'ProductView'; 110 | -------------------------------------------------------------------------------- /components/organisms/RegisterForm/RegisterForm.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act } from '@testing-library/react'; 2 | import { RegisterForm } from './RegisterForm'; 3 | import { auth } from 'lib/firebase'; 4 | import userEvent from '@testing-library/user-event'; 5 | import { SnackbarProvider } from 'notistack'; 6 | 7 | describe('email validation', () => { 8 | const mockedSignUpFunc = jest.spyOn(auth, 'createUserWithEmailAndPassword'); 9 | it('if email field is empty error message should be displayed', async () => { 10 | render( 11 | 12 | 13 | , 14 | ); 15 | await act(async () => { 16 | userEvent.click(screen.getByRole('button', { name: /sign up/i })); 17 | }); 18 | expect(screen.getByText(/email is required/i)).toBeInTheDocument(); 19 | expect(mockedSignUpFunc).not.toHaveBeenCalled(); 20 | }); 21 | 22 | it("if email field doesn't contain valid email error message should be displayed", async () => { 23 | render( 24 | 25 | 26 | , 27 | ); 28 | userEvent.type(screen.getByLabelText(/email/i), 'email'); 29 | await act(async () => { 30 | userEvent.click(screen.getByRole('button', { name: /sign up/i })); 31 | }); 32 | expect(screen.getByText(/mail must be a valid email/i)).toBeInTheDocument(); 33 | expect(mockedSignUpFunc).not.toHaveBeenCalled(); 34 | }); 35 | }); 36 | 37 | describe('password validation', () => { 38 | const mockedSignUpFunc = jest.spyOn(auth, 'createUserWithEmailAndPassword'); 39 | it('if password field is empty error message should be displayed', async () => { 40 | render( 41 | 42 | 43 | , 44 | ); 45 | await act(async () => { 46 | userEvent.click(screen.getByRole('button', { name: /sign up/i })); 47 | }); 48 | expect(screen.getByText(/password is required/i)).toBeInTheDocument(); 49 | expect(mockedSignUpFunc).not.toHaveBeenCalled(); 50 | }); 51 | 52 | it("if password field doesn't contain valid password error message should be displayed", async () => { 53 | render( 54 | 55 | 56 | , 57 | ); 58 | userEvent.type(screen.getByLabelText(/password/i), 'password'); 59 | await act(async () => { 60 | userEvent.click(screen.getByRole('button', { name: /sign up/i })); 61 | }); 62 | expect( 63 | screen.getByText( 64 | /password must contain an uppercase letter, a special character, a number and must be at least 8 characters long/i, 65 | ), 66 | ).toBeInTheDocument(); 67 | expect(mockedSignUpFunc).not.toHaveBeenCalled(); 68 | }); 69 | }); 70 | 71 | test('when email and password are valid callback to sign up should be called', async () => { 72 | const mockedSignUpFunc = jest.spyOn(auth, 'createUserWithEmailAndPassword'); 73 | render( 74 | 75 | 76 | , 77 | ); 78 | userEvent.type(screen.getByLabelText(/email/i), 'email@email.com'); 79 | userEvent.type(screen.getByLabelText(/password/i), 'Te$$t1ng'); 80 | await act(async () => { 81 | userEvent.click(screen.getByRole('button', { name: /sign up/i })); 82 | }); 83 | 84 | expect(mockedSignUpFunc).toHaveBeenCalled(); 85 | expect(mockedSignUpFunc).toHaveBeenCalledWith('email@email.com', 'Te$$t1ng'); 86 | }); 87 | -------------------------------------------------------------------------------- /components/organisms/RegisterForm/RegisterForm.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from 'react-hook-form'; 2 | import { inputValidation } from 'lib/utils/consts'; 3 | import { UserData } from 'types'; 4 | import Link from 'next/link'; 5 | import { auth } from 'lib/firebase'; 6 | import { useMainContext } from 'context/MainContext'; 7 | import Avatar from '@material-ui/core/Avatar'; 8 | import Button from '@material-ui/core/Button'; 9 | import CssBaseline from '@material-ui/core/CssBaseline'; 10 | import TextField from '@material-ui/core/TextField'; 11 | import Grid from '@material-ui/core/Grid'; 12 | import VpnKeyIcon from '@material-ui/icons/VpnKey'; 13 | import Typography from '@material-ui/core/Typography'; 14 | import { makeStyles } from '@material-ui/core/styles'; 15 | import Container from '@material-ui/core/Container'; 16 | import FormHelperText from '@material-ui/core/FormHelperText'; 17 | import { useSnackbar } from 'notistack'; 18 | import { ThemeSwitch } from '../../atoms/ThemeSwitch/ThemeSwitch'; 19 | 20 | const useStyles = makeStyles((theme) => ({ 21 | paper: { 22 | marginTop: theme.spacing(8), 23 | display: 'flex', 24 | flexDirection: 'column', 25 | alignItems: 'center', 26 | }, 27 | avatar: { 28 | margin: theme.spacing(1), 29 | backgroundColor: theme.palette.info.main, 30 | }, 31 | form: { 32 | width: '100%', 33 | marginTop: theme.spacing(1), 34 | }, 35 | submit: { 36 | margin: theme.spacing(3, 0, 2), 37 | }, 38 | })); 39 | 40 | export const RegisterForm = () => { 41 | const { 42 | register, 43 | handleSubmit, 44 | formState: { errors }, 45 | reset, 46 | } = useForm(); 47 | 48 | const classes = useStyles(); 49 | const { enqueueSnackbar } = useSnackbar(); 50 | const { setLoading } = useMainContext(); 51 | 52 | const handleRegister = async ({ email, password }: UserData) => { 53 | setLoading(true); 54 | try { 55 | await auth.createUserWithEmailAndPassword(email, password); 56 | enqueueSnackbar('Account was created. Log in!'); 57 | reset(); 58 | } catch (e) { 59 | enqueueSnackbar((e as Error)?.message); 60 | } finally { 61 | setLoading(false); 62 | } 63 | }; 64 | 65 | return ( 66 | 67 | 68 |
    69 | 70 | 71 | 72 | 73 | 74 | 75 | Sign up 76 | 77 |
    78 | 89 | {errors?.email?.message} 90 | 101 | {errors?.password?.message} 102 | 112 | 113 | 114 | {'Already have an account? Sign In'} 115 | 116 | 117 | 118 |
    119 |
    120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /context/CartContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState } from 'react'; 2 | import type { ProductType } from 'types'; 3 | import { 4 | addProductToCart, 5 | removeProductFromCart, 6 | changeProductQuantity, 7 | calculateTotalCartItemsCost, 8 | calculateTotalCartItemsQuantity, 9 | } from 'lib/utils/methods'; 10 | 11 | type CartContext = { 12 | cartItems: (ProductType & { quantity: number })[]; 13 | handleAddToCart: (product: ProductType) => void; 14 | handleRemoveFromCart: (product: ProductType) => void; 15 | handleChangeProductQuantity: (product: ProductType, quantity: number) => void; 16 | getTotalCost: () => number; 17 | getTotalQuantity: () => number; 18 | }; 19 | 20 | const CartContext = createContext({ 21 | cartItems: [], 22 | handleAddToCart: () => {}, 23 | handleRemoveFromCart: () => {}, 24 | handleChangeProductQuantity: () => {}, 25 | getTotalCost: () => 0, 26 | getTotalQuantity: () => 0, 27 | }); 28 | 29 | export const useCart = () => { 30 | const context = useContext(CartContext); 31 | 32 | if (!context) { 33 | throw new Error('Error while reading context!'); 34 | } 35 | 36 | return context; 37 | }; 38 | 39 | export const CartProvider = ({ children }: { children: React.ReactNode }) => { 40 | const [cartItems, setCartItems] = useState<(ProductType & { quantity: number })[]>([]); 41 | 42 | const handleAddToCart = (product: ProductType) => { 43 | setCartItems((cartItems) => addProductToCart(cartItems, product)); 44 | }; 45 | 46 | const handleRemoveFromCart = (product: ProductType) => { 47 | setCartItems((cartItems) => removeProductFromCart(cartItems, product)); 48 | }; 49 | 50 | const handleChangeProductQuantity = (product: ProductType, quantity: number) => { 51 | setCartItems((cartItems) => changeProductQuantity(cartItems, product, quantity)); 52 | }; 53 | 54 | const getTotalCost = () => { 55 | return calculateTotalCartItemsCost(cartItems); 56 | }; 57 | 58 | const getTotalQuantity = () => { 59 | return calculateTotalCartItemsQuantity(cartItems); 60 | }; 61 | 62 | return ( 63 | 73 | {children} 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /context/MainContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState, Dispatch, SetStateAction, useContext } from 'react'; 2 | 3 | const MainContext = createContext({ 4 | loading: false, 5 | setLoading: () => {}, 6 | isDarkTheme: false, 7 | setIsDarkTheme: () => {}, 8 | }); 9 | 10 | type ContextType = { 11 | loading: boolean; 12 | setLoading: Dispatch>; 13 | isDarkTheme: boolean; 14 | setIsDarkTheme: Dispatch>; 15 | }; 16 | 17 | export const useMainContext = () => { 18 | const context = useContext(MainContext); 19 | if (!context) { 20 | throw new Error('Error while reading context!'); 21 | } 22 | 23 | return context; 24 | }; 25 | 26 | export const MainProvider = ({ children }: { children: React.ReactNode }) => { 27 | const [loading, setLoading] = useState(false); 28 | const [isDarkTheme, setIsDarkTheme] = useState(false); 29 | 30 | return ( 31 | 32 | {children} 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /context/ProductContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createContext, useState, Dispatch, SetStateAction, useContext, useEffect } from 'react'; 3 | import { ProductSizes } from 'lib/utils/consts'; 4 | import type { ProductType } from 'types'; 5 | 6 | type ProductContext = { 7 | products: ProductType[]; 8 | setProducts: Dispatch>; 9 | minPrice: number; 10 | setMinPrice: Dispatch>; 11 | maxPrice: number; 12 | setMaxPrice: Dispatch>; 13 | price: number; 14 | setPrice: Dispatch>; 15 | filteredProducts: ProductType[]; 16 | setFilteredProducts: Dispatch>; 17 | searchQuery: string; 18 | setSearchQuery: Dispatch>; 19 | productsCategories: string[]; 20 | setProductsCategories: Dispatch>; 21 | activeProductSize: typeof ProductSizes[number]['label']; 22 | setActiveProductSize: Dispatch>; 23 | handleChangeSearchQuery: (e: React.ChangeEvent) => void; 24 | handleChangePrice: (e: React.ChangeEvent<{}>, newValue: number | number[]) => void; 25 | handleSelectCategories: ( 26 | types: React.ChangeEvent<{ 27 | value: unknown; 28 | }>, 29 | ) => void; 30 | selectedCategory: string; 31 | }; 32 | 33 | const ProductContext = createContext({ 34 | products: [], 35 | setProducts: () => {}, 36 | minPrice: 0, 37 | setMinPrice: () => {}, 38 | maxPrice: 2000, 39 | setMaxPrice: () => {}, 40 | price: 350, 41 | setPrice: () => {}, 42 | filteredProducts: [], 43 | setFilteredProducts: () => {}, 44 | searchQuery: '', 45 | setSearchQuery: () => {}, 46 | productsCategories: [], 47 | setProductsCategories: () => {}, 48 | activeProductSize: 'S', 49 | setActiveProductSize: () => {}, 50 | handleChangeSearchQuery: () => {}, 51 | handleChangePrice: () => {}, 52 | handleSelectCategories: () => {}, 53 | selectedCategory: '', 54 | }); 55 | 56 | export const useProduct = () => { 57 | const context = useContext(ProductContext); 58 | 59 | if (!context) { 60 | throw new Error('Error while reading context!'); 61 | } 62 | 63 | return context; 64 | }; 65 | 66 | export const ProductProvider = ({ children }: { children: React.ReactNode }) => { 67 | const [products, setProducts] = useState([]); 68 | const [filteredProducts, setFilteredProducts] = useState([]); 69 | const [minPrice, setMinPrice] = useState(0); 70 | const [maxPrice, setMaxPrice] = useState(2000); 71 | const [productsCategories, setProductsCategories] = useState([]); 72 | const [selectedCategory, setSelectedCategory] = useState(''); 73 | const [searchQuery, setSearchQuery] = useState(''); 74 | const [price, setPrice] = useState(350); 75 | const [activeProductSize, setActiveProductSize] = 76 | useState('S'); 77 | 78 | const handleSelectCategories = ( 79 | types: React.ChangeEvent<{ 80 | value: unknown; 81 | }>, 82 | ) => { 83 | setSelectedCategory(types.target.value as string); 84 | }; 85 | 86 | useEffect(() => { 87 | handleFilterProducts(); 88 | }, [price, searchQuery, selectedCategory]); 89 | 90 | const handleFilterProducts = () => { 91 | setFilteredProducts( 92 | products 93 | .filter((product) => product.name.toLowerCase().startsWith(searchQuery.toLowerCase())) 94 | .filter((product) => product.price <= price) 95 | .filter((product) => { 96 | if (!selectedCategory.length) { 97 | return true; 98 | } 99 | 100 | if (selectedCategory.includes(product.category)) { 101 | return true; 102 | } 103 | 104 | return false; 105 | }), 106 | ); 107 | }; 108 | 109 | const handleChangeSearchQuery = (e: React.ChangeEvent) => { 110 | setSearchQuery(e.target.value); 111 | }; 112 | 113 | const handleChangePrice = (_e: React.ChangeEvent<{}>, newValue: number | number[]) => { 114 | setPrice(Number(newValue as Number)); 115 | }; 116 | 117 | useEffect(() => { 118 | setMinPrice(Math.min(...products.map((product) => product.price))); 119 | setMaxPrice(Math.max(...products.map((product) => product.price))); 120 | setPrice(Math.max(...products.map((product) => product.price))); 121 | setProductsCategories([...new Set(products.map((product) => product.category))]); 122 | }, [products]); 123 | 124 | return ( 125 | 149 | {children} 150 | 151 | ); 152 | }; 153 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "integrationFolder": "cypress/e2e" 4 | } 5 | -------------------------------------------------------------------------------- /cypress/e2e/e2e.spec.tsx: -------------------------------------------------------------------------------- 1 | describe('full app working', () => { 2 | it('should works :)', () => { 3 | const email = 'email@gmail.com'; 4 | const password = 'ZAQ!2wsx'; 5 | cy.visit('/') 6 | // redirect to login page by default 7 | .url() 8 | .should('include', '/login') 9 | //create a new user 10 | .visit('/register') 11 | .get('#email') 12 | .type(email) 13 | .get('#password') 14 | .type(password) 15 | .get('[data-testid=submit]') 16 | .click() 17 | //login with new user 18 | .visit('/login') 19 | .get('#email') 20 | .type(email) 21 | .get('#password') 22 | .type(password) 23 | .get('[data-testid=submit]') 24 | .click() 25 | .wait(1500) 26 | //after login user should be redirected to home page 27 | .url() 28 | .should('eq', 'http://localhost:3000/') 29 | //add products to cart 30 | .get('main a') 31 | .first() 32 | .click() 33 | .wait(5000) 34 | .get('[data-testid=add-to-cart-btn]') 35 | .click() 36 | .go('back') 37 | .get('main a') 38 | .last() 39 | .click() 40 | .wait(5000) 41 | .get('[data-testid=add-to-cart-btn]') 42 | .click() 43 | //added products should be in the cart 44 | .get('[data-testid=cart-btn]') 45 | .click() 46 | .get('[data-testid=cart-list]') 47 | .find('li') 48 | .should('have.length', 2) 49 | //remove one from the cart 50 | .get('li') 51 | .last() 52 | .find('button') 53 | .click() 54 | .get('[data-testid=cart-list]') 55 | .find('li') 56 | .should('have.length', 1) 57 | //go checkout! 58 | .get('button') 59 | .contains(/checkout/i) 60 | .click() 61 | .wait(1000) 62 | .url() 63 | .should('include', '/checkout') 64 | .get('[data-testid=checkout-list]') 65 | .find('li') 66 | .should('have.length', 1) 67 | .get('[data-testid=pay-now-btn]') 68 | .should('not.be.disabled'); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | } 23 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['/jest.setup.ts'], 3 | testPathIgnorePatterns: ['/.next/', '/node_modules/', '/cypress/'], 4 | moduleDirectories: ['.', 'node_modules'], 5 | testEnvironment: 'jsdom', 6 | globalSetup: '/test/setupEnv.ts', 7 | }; 8 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | -------------------------------------------------------------------------------- /lib/datocms/index.ts: -------------------------------------------------------------------------------- 1 | const SiteClient = require('datocms-client').SiteClient; 2 | 3 | export const DatoCMSData = new SiteClient(process.env.NEXT_PUBLIC_DATOCMS_API_TOKEN); 4 | -------------------------------------------------------------------------------- /lib/firebase/index.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | import 'firebase/auth'; 3 | 4 | const firebaseConfig = { 5 | apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, 6 | authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, 7 | projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, 8 | storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, 9 | messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, 10 | appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, 11 | measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID, 12 | }; 13 | 14 | if (!firebase.apps.length) { 15 | firebase.initializeApp(firebaseConfig); 16 | } else { 17 | firebase.app(); 18 | } 19 | 20 | export const auth = firebase.auth(); 21 | -------------------------------------------------------------------------------- /lib/utils/consts.ts: -------------------------------------------------------------------------------- 1 | export const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; 2 | export const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#\$%\^&\*])(?=.{8,})/; 3 | 4 | export const inputValidation = { 5 | email: { 6 | required: { value: true, message: 'Email is required.' }, 7 | pattern: { 8 | value: EMAIL_REGEX, 9 | message: 'Email must be a valid email.', 10 | }, 11 | }, 12 | password: { 13 | required: { value: true, message: 'Password is required.' }, 14 | pattern: { 15 | value: PASSWORD_REGEX, 16 | message: 17 | 'Password must contain an uppercase letter, a special character, a number and must be at least 8 characters long.', 18 | }, 19 | }, 20 | other: { 21 | required: { value: true, message: 'This field is required.' }, 22 | minLength: { 23 | value: 3, 24 | message: 'This field must be at least 3 characters. ', 25 | }, 26 | }, 27 | }; 28 | 29 | export const ProductSizes = [ 30 | { 31 | label: 'S' as const, 32 | }, 33 | { 34 | label: 'M' as const, 35 | }, 36 | { 37 | label: 'L' as const, 38 | }, 39 | { 40 | label: 'XL' as const, 41 | }, 42 | ]; 43 | -------------------------------------------------------------------------------- /lib/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { auth } from 'lib/firebase'; 3 | import firebase from 'firebase/app'; 4 | 5 | export const useUser = () => { 6 | const [user, setUser] = useState(auth.currentUser || null); 7 | 8 | useEffect(() => { 9 | const unsubscribe = auth.onAuthStateChanged((authUser) => { 10 | setUser(authUser); 11 | }); 12 | 13 | return () => unsubscribe(); 14 | }, [user]); 15 | 16 | return { user }; 17 | }; 18 | -------------------------------------------------------------------------------- /lib/utils/methods.ts: -------------------------------------------------------------------------------- 1 | import type { ProductType } from 'types'; 2 | 3 | export const addProductToCart = ( 4 | cartItems: (ProductType & { quantity: number })[], 5 | productToAdd: ProductType, 6 | ) => { 7 | if (cartItems.reduce((acc, { quantity }) => acc + quantity, 0) === 99) { 8 | return cartItems; 9 | } 10 | const isProductInCart = cartItems.find(({ id }) => id === productToAdd.id); 11 | 12 | if (isProductInCart) { 13 | return cartItems.map((cartItem) => { 14 | return productToAdd.id === cartItem.id 15 | ? { ...productToAdd, quantity: cartItem.quantity + 1 } 16 | : cartItem; 17 | }); 18 | } 19 | 20 | return [...cartItems, { ...productToAdd, quantity: 1 }]; 21 | }; 22 | 23 | export const removeProductFromCart = ( 24 | cartItems: (ProductType & { quantity: number })[], 25 | productToRemove: ProductType, 26 | ) => { 27 | const isProductInCart = cartItems.find(({ id }) => id === productToRemove.id); 28 | 29 | if (Number(isProductInCart?.quantity) <= 1) { 30 | return cartItems.filter(({ id }) => id !== productToRemove.id); 31 | } 32 | 33 | return cartItems.map((cartItem) => { 34 | return cartItem.id === productToRemove.id 35 | ? { ...cartItem, quantity: cartItem.quantity - 1 } 36 | : cartItem; 37 | }); 38 | }; 39 | 40 | export const changeProductQuantity = ( 41 | cartItems: (ProductType & { quantity: number })[], 42 | product: ProductType, 43 | quantity: number, 44 | ) => { 45 | const availableQuantity = quantity > 99 ? 99 : quantity; 46 | 47 | return cartItems.map((cartItem) => { 48 | return cartItem.id === product.id ? { ...cartItem, quantity: availableQuantity } : cartItem; 49 | }); 50 | }; 51 | 52 | export const calculateTotalCartItemsCost = (cartItems: (ProductType & { quantity: number })[]) => { 53 | return cartItems.reduce((acc, { quantity, price }) => acc + quantity * price, 0); 54 | }; 55 | 56 | export const calculateTotalCartItemsQuantity = ( 57 | cartItems: (ProductType & { quantity: number })[], 58 | ) => { 59 | return cartItems.reduce((total, { quantity }) => total + quantity, 0); 60 | }; 61 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withPWA = require('next-pwa'); 2 | 3 | module.exports = withPWA({ 4 | pwa: { 5 | dest: 'public', 6 | register: true, 7 | skipWaiting: true, 8 | }, 9 | images: { 10 | domains: ['www.datocms-assets.com'], 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ecommerce", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "test": "jest --watchAll", 10 | "cy:open-only": "cypress open", 11 | "cy:run-only": "cypress run", 12 | "cy:open": "start-server-and-test dev 3000 cy:open-only", 13 | "cy:run": "start-server-and-test dev 3000 cy:run-only" 14 | }, 15 | "dependencies": { 16 | "@material-ui/core": "^4.12.3", 17 | "@material-ui/icons": "^4.11.2", 18 | "datocms-client": "^3.4.9", 19 | "firebase": "^8.6.0", 20 | "next": "10.2.0", 21 | "next-pwa": "^5.3.1", 22 | "next-seo": "^4.24.0", 23 | "notistack": "^1.0.10", 24 | "react": "17.0.2", 25 | "react-dom": "17.0.2", 26 | "react-hook-form": "^7.5.2", 27 | "react-stripe-checkout": "^2.6.3", 28 | "sass": "^1.38.2" 29 | }, 30 | "devDependencies": { 31 | "@testing-library/jest-dom": "^5.14.1", 32 | "@testing-library/react": "^12.0.0", 33 | "@testing-library/user-event": "^13.2.1", 34 | "@types/faker": "^5.5.7", 35 | "@types/jest": "^26.0.24", 36 | "@types/node": "^15.0.2", 37 | "@types/react": "^17.0.5", 38 | "@types/react-select": "^4.0.15", 39 | "babel-jest": "^27.0.6", 40 | "cypress": "^8.1.0", 41 | "eslint": "^7.32.0", 42 | "jest": "^27.0.6", 43 | "prettier": "^2.3.2", 44 | "start-server-and-test": "^1.13.1", 45 | "typescript": "^4.2.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../styles/globals.scss'; 3 | import { DefaultSeo } from 'next-seo'; 4 | import Head from 'next/head'; 5 | import type { AppProps } from 'next/app'; 6 | import { useRouter } from 'next/router'; 7 | import { MainProvider } from 'context/MainContext'; 8 | import { ProductProvider } from 'context/ProductContext'; 9 | import { CartProvider } from 'context/CartContext'; 10 | import { SnackbarProvider } from 'notistack'; 11 | 12 | const meta = { 13 | title: 'Ecommerce', 14 | description: 'ecommerce app.', 15 | }; 16 | 17 | export const titleTemplate = `%s | ${meta.title}`; 18 | 19 | function MyApp({ Component, pageProps }: AppProps) { 20 | const { asPath } = useRouter(); 21 | 22 | return ( 23 | <> 24 | 42 | 43 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | } 67 | 68 | export default MyApp; 69 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'; 3 | import { ServerStyleSheets } from '@material-ui/core/styles'; 4 | 5 | class MyDocument extends Document { 6 | static async getInitialProps(ctx: DocumentContext) { 7 | const sheets = new ServerStyleSheets(); 8 | const originalRenderPage = ctx.renderPage; 9 | 10 | ctx.renderPage = () => 11 | originalRenderPage({ 12 | enhanceApp: (App) => (props) => sheets.collect(), 13 | }); 14 | 15 | const initialProps = await Document.getInitialProps(ctx); 16 | 17 | return { 18 | ...initialProps, 19 | styles: [...React.Children.toArray(initialProps.styles), sheets.getStyleElement()], 20 | }; 21 | } 22 | 23 | render() { 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 |