├── .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 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
43 |
44 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | );
55 | }
56 | }
57 |
58 | export default MyDocument;
59 |
--------------------------------------------------------------------------------
/pages/checkout.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from 'components/organisms/Layout/Layout';
2 | import { CheckoutList } from 'components/molecules/CheckoutList/CheckoutList';
3 | import { AuthChecker } from 'components/organisms/AuthChecker/AuthChecker';
4 | import { useCart } from 'context/CartContext';
5 | import { Header } from 'components/molecules/Header/Header';
6 |
7 | const Checkout = () => {
8 | const { cartItems } = useCart();
9 |
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export default Checkout;
21 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from 'components/organisms/Layout/Layout';
2 | import { AuthChecker } from 'components/organisms/AuthChecker/AuthChecker';
3 | import { Main } from 'components/organisms/Main/Main';
4 | import { DatoCMSData } from 'lib/datocms';
5 | import type { ProductType } from 'types';
6 | import type { GetStaticProps } from 'next';
7 |
8 | const Home = ({ results }: { results: ProductType[] }) => {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | );
16 | };
17 |
18 | export default Home;
19 |
20 | export const getStaticProps: GetStaticProps = async () => {
21 | try {
22 | const data = await DatoCMSData.items.all();
23 | const images = await DatoCMSData.uploads.all();
24 |
25 | data.forEach((product: ProductType, i: number) => {
26 | product.imgurl = images[i].url;
27 | });
28 |
29 | return { props: { results: data }, revalidate: 1 };
30 | } catch {
31 | return {
32 | notFound: true as const,
33 | };
34 | }
35 | };
36 |
--------------------------------------------------------------------------------
/pages/login.tsx:
--------------------------------------------------------------------------------
1 | import { LoginForm } from 'components/organisms/LoginForm/LoginForm';
2 | import { Layout } from 'components/organisms/Layout/Layout';
3 | import { makeStyles } from '@material-ui/core/styles';
4 |
5 | const useStyles = makeStyles(() => ({
6 | main: {
7 | width: '100%',
8 | display: 'flex',
9 | justifyContent: 'center',
10 | alignItems: 'center',
11 | flexFlow: 'column wrap',
12 | },
13 | }));
14 |
15 | const Login = () => {
16 | const classes = useStyles();
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default Login;
28 |
--------------------------------------------------------------------------------
/pages/products/[id].tsx:
--------------------------------------------------------------------------------
1 | import type { ProductSType, ProductType } from 'types';
2 | import { Layout } from 'components/organisms/Layout/Layout';
3 | import { ProductView } from 'components/organisms/ProductView/ProductView';
4 | import { GetStaticProps, GetStaticPaths } from 'next';
5 | import { DatoCMSData } from 'lib/datocms';
6 | import { AuthChecker } from 'components/organisms/AuthChecker/AuthChecker';
7 | import { Header } from '../../components/molecules/Header/Header';
8 |
9 | const Product = ({ product }: { product: ProductType }) => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export default Product;
21 |
22 | export const getStaticProps: GetStaticProps = async (context) => {
23 | try {
24 | const product: ProductSType = await DatoCMSData.items.find(context.params!.id);
25 | const imgurl = await DatoCMSData.uploads.find(product.imgurl.en.uploadId);
26 | product.imgurl = imgurl.url;
27 | console.log(product.imgurl);
28 |
29 | if (!product) {
30 | return {
31 | notFound: true as const,
32 | };
33 | }
34 |
35 | return { props: { product } };
36 | } catch {
37 | return {
38 | notFound: true as const,
39 | };
40 | }
41 | };
42 |
43 | export const getStaticPaths: GetStaticPaths = async () => {
44 | try {
45 | const results: ProductType[] = await DatoCMSData.items.all();
46 | return {
47 | paths: results.map(({ id }) => ({
48 | params: { id },
49 | })),
50 | fallback: 'blocking' as const,
51 | };
52 | } catch (err) {
53 | throw err;
54 | }
55 | };
56 |
--------------------------------------------------------------------------------
/pages/register.tsx:
--------------------------------------------------------------------------------
1 | import { RegisterForm } from 'components/organisms/RegisterForm/RegisterForm';
2 | import { Layout } from 'components/organisms/Layout/Layout';
3 | import { makeStyles } from '@material-ui/core/styles';
4 |
5 | const useStyles = makeStyles(() => ({
6 | main: {
7 | width: '100%',
8 | display: 'flex',
9 | justifyContent: 'center',
10 | alignItems: 'center',
11 | flexFlow: 'column wrap',
12 | },
13 | }));
14 |
15 | const Register = () => {
16 | const classes = useStyles();
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default Register;
28 |
--------------------------------------------------------------------------------
/public/apple-touch-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/apple-touch-icon-120x120.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/apple-touch-icon-152x152.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-167x167.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/apple-touch-icon-167x167.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/apple-touch-icon-180x180.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/apple-touch-icon-60x60.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/apple-touch-icon-76x76.png
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | transparent
10 |
11 |
12 |
--------------------------------------------------------------------------------
/public/coast-228x228.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/coast-228x228.png
--------------------------------------------------------------------------------
/public/favicon-128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/favicon-128x128.png
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/favicon-256x256.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon-48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/favicon-48x48.png
--------------------------------------------------------------------------------
/public/favicon-64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/favicon-64x64.png
--------------------------------------------------------------------------------
/public/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/favicon-96x96.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo_lg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/logo_lg.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Ecommerce App",
3 | "theme_color": "#282827",
4 | "background_color": "#f7f6f6",
5 | "display": "standalone",
6 | "scope": "/",
7 | "start_url": "/",
8 | "short_name": "Ecommerce",
9 | "description": "Ecommerce",
10 | "prefer_related_applications": false,
11 | "icons": [
12 | {
13 | "src": "/favicon-48x48.png",
14 | "sizes": "48x48",
15 | "type": "image/png",
16 | "density": "1.0",
17 | "purpose": "any maskable"
18 | },
19 | {
20 | "src": "/tile70x70.png",
21 | "sizes": "70x70",
22 | "type": "image/png",
23 | "density": "1.5",
24 | "purpose": "any maskable"
25 | },
26 | {
27 | "src": "/favicon-96x96.png",
28 | "sizes": "96x96",
29 | "type": "image/png",
30 | "density": "2.0",
31 | "purpose": "any maskable"
32 | },
33 | {
34 | "src": "/favicon-128x128.png",
35 | "sizes": "128x128",
36 | "type": "image/png",
37 | "density": "3.0",
38 | "purpose": "any maskable"
39 | },
40 | {
41 | "src": "/pwa-192x192.png",
42 | "sizes": "192x192",
43 | "type": "image/png",
44 | "density": "4.0",
45 | "purpose": "any maskable"
46 | },
47 | {
48 | "src": "/pwa-512x512.png",
49 | "sizes": "512x512",
50 | "type": "image/png",
51 | "density": "4.0",
52 | "purpose": "any maskable"
53 | }
54 | ]
55 | }
56 |
--------------------------------------------------------------------------------
/public/pwa-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/pwa-192x192.png
--------------------------------------------------------------------------------
/public/pwa-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/pwa-512x512.png
--------------------------------------------------------------------------------
/public/tile150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/tile150x150.png
--------------------------------------------------------------------------------
/public/tile310x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/tile310x150.png
--------------------------------------------------------------------------------
/public/tile310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/tile310x310.png
--------------------------------------------------------------------------------
/public/tile70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simicoder/ecommerce/4d728026443379bd695cad6fd791049f17b0970c/public/tile70x70.png
--------------------------------------------------------------------------------
/styles/globals.scss:
--------------------------------------------------------------------------------
1 | html {
2 | scroll-behavior: smooth;
3 | font-size: clamp(62.5%, 0.326vw + 50.9%, 100%);
4 | }
5 |
6 | body {
7 | width: 100%;
8 | font-size: 1.6rem;
9 | font-family: Montserrat, sans-serif;
10 | overflow-x: hidden;
11 | }
12 |
13 | *,
14 | ::before,
15 | ::after {
16 | box-sizing: border-box;
17 | margin: 0;
18 | padding: 0;
19 | }
20 |
21 | a {
22 | text-decoration: none;
23 | }
24 |
25 | ul {
26 | list-style-type: none !important;
27 | }
28 |
29 | /*
30 | Improved screen reader only CSS class
31 | @author Gaël Poupard
32 | @note Based on Yahoo!'s technique
33 | @author Thierry Koblentz
34 | @see https://developer.yahoo.com/blogs/ydn/clip-hidden-content-better-accessibility-53456.html
35 | * 1.
36 | @note `clip` is deprecated but works everywhere
37 | @see https://developer.mozilla.org/en-US/docs/Web/CSS/clip
38 | * 2.
39 | @note `clip-path` is the future-proof version, but not very well supported yet
40 | @see https://developer.mozilla.org/en-US/docs/Web/CSS/clip-path
41 | @see http://caniuse.com/#search=clip-path
42 | @author Yvain Liechti
43 | @see https://twitter.com/ryuran78/status/778943389819604992
44 | * 3.
45 | @note preventing text to be condensed
46 | author J. Renée Beach
47 | @see https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe
48 | @note Drupal 8 goes with word-wrap: normal instead
49 | @see https://www.drupal.org/node/2045151
50 | @see http://cgit.drupalcode.org/drupal/commit/?id=5b847ea
51 | * 4.
52 | @note !important is important
53 | @note Obviously you wanna hide something
54 | @author Harry Roberts
55 | @see https://csswizardry.com/2016/05/the-importance-of-important/
56 | */
57 |
58 | .sr-only {
59 | border: 0 !important;
60 | clip: rect(1px, 1px, 1px, 1px) !important; /* 1 */
61 | -webkit-clip-path: inset(50%) !important;
62 | clip-path: inset(50%) !important; /* 2 */
63 | height: 1px !important;
64 | margin: -1px !important;
65 | overflow: hidden !important;
66 | padding: 0 !important;
67 | position: absolute !important;
68 | width: 1px !important;
69 | white-space: nowrap !important; /* 3 */
70 | }
71 |
72 | /*
73 | Use in conjunction with .sr-only to only display content when it's focused.
74 | @note Useful for skip links
75 | @see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1
76 | @note Based on a HTML5 Boilerplate technique, included in Bootstrap
77 | @note Fixed a bug with position: static on iOS 10.0.2 + VoiceOver
78 | @author Sylvain Pigeard
79 | @see https://github.com/twbs/bootstrap/issues/20732
80 | */
81 | .sr-only-focusable:focus,
82 | .sr-only-focusable:active {
83 | clip: auto !important;
84 | -webkit-clip-path: none !important;
85 | clip-path: none !important;
86 | height: auto !important;
87 | margin: auto !important;
88 | overflow: visible !important;
89 | width: auto !important;
90 | white-space: normal !important;
91 | }
92 |
--------------------------------------------------------------------------------
/test/setupEnv.ts:
--------------------------------------------------------------------------------
1 | import { loadEnvConfig } from '@next/env';
2 |
3 | export default async () => {
4 | loadEnvConfig(process.env.PWD as string);
5 | };
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "alwaysStrict": true,
4 | "esModuleInterop": true,
5 | "allowSyntheticDefaultImports": true,
6 | "forceConsistentCasingInFileNames": true,
7 | "isolatedModules": true,
8 | "jsx": "preserve",
9 | "lib": ["dom", "dom.iterable", "esnext"],
10 | "module": "esnext",
11 | "moduleResolution": "node",
12 | "noEmit": true,
13 | "noFallthroughCasesInSwitch": true,
14 | "noImplicitReturns": true,
15 | "noUnusedLocals": true,
16 | "noUnusedParameters": true,
17 | "resolveJsonModule": true,
18 | "skipLibCheck": true,
19 | "strict": true,
20 | "target": "es2019",
21 | "allowJs": true,
22 | "plugins": [],
23 | "baseUrl": ".",
24 | "types": ["cypress"]
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js"],
27 | "exclude": ["node_modules", "cypress"]
28 | }
29 |
--------------------------------------------------------------------------------
/types/index.ts:
--------------------------------------------------------------------------------
1 | export type UserData = {
2 | email: string;
3 | password: string;
4 | };
5 |
6 | export type ProductType = {
7 | id: string;
8 | name: string;
9 | description: string;
10 | price: number;
11 | category: string;
12 | imgurl: string;
13 | };
14 |
15 | export type ProductSType = {
16 | id: string;
17 | name: string;
18 | description: string;
19 | price: number;
20 | category: string;
21 | imgurl: { en: { uploadId: string } };
22 | };
23 |
--------------------------------------------------------------------------------