2 |
3 |
15 |
--------------------------------------------------------------------------------
/API/wwwroot/static/media/slick.29518378.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/static/media/slick.29518378.woff
--------------------------------------------------------------------------------
/API/wwwroot/static/media/slick.a4e97f5a.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/static/media/slick.a4e97f5a.eot
--------------------------------------------------------------------------------
/API/wwwroot/static/media/slick.c94f7671.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/API/wwwroot/static/media/slick.c94f7671.ttf
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is the repository for the .Net 6.0, React 16 and React Router 5 version of the course.
2 |
3 | If you are looking for the repository for the latest version of this app created on .Net 7.0 and React v18 then this is available here:
4 |
5 | https://github.com/TryCatchLearn/Restore
6 |
--------------------------------------------------------------------------------
/ReStore.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 16
4 | VisualStudioVersion = 16.0.30114.105
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{B94C1A08-D289-498A-825E-9B0D3CEC4C4A}"
7 | EndProject
8 | Global
9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
10 | Debug|Any CPU = Debug|Any CPU
11 | Debug|x64 = Debug|x64
12 | Debug|x86 = Debug|x86
13 | Release|Any CPU = Release|Any CPU
14 | Release|x64 = Release|x64
15 | Release|x86 = Release|x86
16 | EndGlobalSection
17 | GlobalSection(SolutionProperties) = preSolution
18 | HideSolutionNode = FALSE
19 | EndGlobalSection
20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
21 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
22 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|Any CPU.Build.0 = Debug|Any CPU
23 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|x64.ActiveCfg = Debug|Any CPU
24 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|x64.Build.0 = Debug|Any CPU
25 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|x86.ActiveCfg = Debug|Any CPU
26 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Debug|x86.Build.0 = Debug|Any CPU
27 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|Any CPU.ActiveCfg = Release|Any CPU
28 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|Any CPU.Build.0 = Release|Any CPU
29 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|x64.ActiveCfg = Release|Any CPU
30 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|x64.Build.0 = Release|Any CPU
31 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|x86.ActiveCfg = Release|Any CPU
32 | {B94C1A08-D289-498A-825E-9B0D3CEC4C4A}.Release|x86.Build.0 = Release|Any CPU
33 | EndGlobalSection
34 | EndGlobal
35 |
--------------------------------------------------------------------------------
/client/.env.development:
--------------------------------------------------------------------------------
1 | REACT_APP_API_URL=http://localhost:5000/api/
--------------------------------------------------------------------------------
/client/.env.production:
--------------------------------------------------------------------------------
1 | REACT_APP_API_URL=/api/
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@emotion/react": "^11.4.1",
7 | "@emotion/styled": "^11.3.0",
8 | "@hookform/resolvers": "^2.8.1",
9 | "@mui/icons-material": "^5.0.1",
10 | "@mui/lab": "^5.0.0-alpha.48",
11 | "@mui/material": "^5.0.1",
12 | "@reduxjs/toolkit": "^1.6.1",
13 | "@stripe/react-stripe-js": "^1.5.0",
14 | "@stripe/stripe-js": "^1.19.0",
15 | "@testing-library/jest-dom": "^5.14.1",
16 | "@testing-library/react": "^11.2.7",
17 | "@testing-library/user-event": "^12.8.3",
18 | "@types/jest": "^26.0.24",
19 | "@types/node": "^12.20.26",
20 | "@types/react": "^17.0.24",
21 | "@types/react-dom": "^17.0.9",
22 | "@types/react-router-dom": "^5.3.0",
23 | "@types/react-slick": "^0.23.5",
24 | "axios": "^0.21.4",
25 | "react": "^17.0.2",
26 | "react-dom": "^17.0.2",
27 | "react-dropzone": "^11.4.2",
28 | "react-hook-form": "^7.13.0",
29 | "react-redux": "^7.2.5",
30 | "react-router-dom": "^5.3.0",
31 | "react-scripts": "4.0.3",
32 | "react-slick": "^0.28.1",
33 | "react-toastify": "^8.0.2",
34 | "redux": "^4.1.1",
35 | "slick-carousel": "^1.8.1",
36 | "typescript": "^4.4.3",
37 | "web-vitals": "^1.1.2",
38 | "yup": "^0.32.9"
39 | },
40 | "scripts": {
41 | "start": "react-scripts start",
42 | "build": "BUILD_PATH='../API/wwwroot' react-scripts build",
43 | "test": "react-scripts test",
44 | "eject": "react-scripts eject"
45 | },
46 | "eslintConfig": {
47 | "extends": [
48 | "react-app",
49 | "react-app/jest"
50 | ]
51 | },
52 | "browserslist": {
53 | "production": [
54 | ">0.2%",
55 | "not dead",
56 | "not op_mini all"
57 | ],
58 | "development": [
59 | "last 1 chrome version",
60 | "last 1 firefox version",
61 | "last 1 safari version"
62 | ]
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/public/images/hero1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/hero1.jpg
--------------------------------------------------------------------------------
/client/public/images/hero2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/hero2.jpg
--------------------------------------------------------------------------------
/client/public/images/hero3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/hero3.jpg
--------------------------------------------------------------------------------
/client/public/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/logo.png
--------------------------------------------------------------------------------
/client/public/images/placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/placeholder.png
--------------------------------------------------------------------------------
/client/public/images/products/boot-ang1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/boot-ang1.png
--------------------------------------------------------------------------------
/client/public/images/products/boot-ang2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/boot-ang2.png
--------------------------------------------------------------------------------
/client/public/images/products/boot-core1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/boot-core1.png
--------------------------------------------------------------------------------
/client/public/images/products/boot-core2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/boot-core2.png
--------------------------------------------------------------------------------
/client/public/images/products/boot-redis1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/boot-redis1.png
--------------------------------------------------------------------------------
/client/public/images/products/glove-code1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/glove-code1.png
--------------------------------------------------------------------------------
/client/public/images/products/glove-code2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/glove-code2.png
--------------------------------------------------------------------------------
/client/public/images/products/glove-react1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/glove-react1.png
--------------------------------------------------------------------------------
/client/public/images/products/glove-react2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/glove-react2.png
--------------------------------------------------------------------------------
/client/public/images/products/hat-core1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/hat-core1.png
--------------------------------------------------------------------------------
/client/public/images/products/hat-react1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/hat-react1.png
--------------------------------------------------------------------------------
/client/public/images/products/hat-react2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/hat-react2.png
--------------------------------------------------------------------------------
/client/public/images/products/sb-ang1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-ang1.png
--------------------------------------------------------------------------------
/client/public/images/products/sb-ang2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-ang2.png
--------------------------------------------------------------------------------
/client/public/images/products/sb-core1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-core1.png
--------------------------------------------------------------------------------
/client/public/images/products/sb-core2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-core2.png
--------------------------------------------------------------------------------
/client/public/images/products/sb-react1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-react1.png
--------------------------------------------------------------------------------
/client/public/images/products/sb-ts1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/images/products/sb-ts1.png
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
26 | ReStore
27 |
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/client/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/logo192.png
--------------------------------------------------------------------------------
/client/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/public/logo512.png
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/client/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/client/src/app/api/agent.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosError, AxiosResponse } from "axios";
2 | import { toast } from "react-toastify";
3 | import { history } from "../..";
4 | import { PaginatedResponse } from "../models/pagination";
5 | import { store } from "../store/configureStore";
6 |
7 | const sleep = () => new Promise(resolve => setTimeout(resolve, 500));
8 |
9 | axios.defaults.baseURL = process.env.REACT_APP_API_URL;
10 | axios.defaults.withCredentials = true;
11 |
12 | const responseBody = (response: AxiosResponse) => response.data;
13 |
14 | axios.interceptors.request.use(config => {
15 | const token = store.getState().account.user?.token;
16 | if (token) config.headers.Authorization = `Bearer ${token}`;
17 | return config;
18 | })
19 |
20 | axios.interceptors.response.use(async response => {
21 | if (process.env.NODE_ENV === 'development') await sleep();
22 | const pagination = response.headers['pagination'];
23 | if (pagination) {
24 | response.data = new PaginatedResponse(response.data, JSON.parse(pagination));
25 | return response;
26 | }
27 | return response;
28 | }, (error: AxiosError) => {
29 | const { data, status } = error.response!;
30 | switch (status) {
31 | case 400:
32 | if (data.errors) {
33 | const modelStateErrors: string[] = [];
34 | for (const key in data.errors) {
35 | if (data.errors[key]) {
36 | modelStateErrors.push(data.errors[key])
37 | }
38 | }
39 | throw modelStateErrors.flat();
40 | }
41 | toast.error(data.title);
42 | break;
43 | case 401:
44 | toast.error(data.title);
45 | break;
46 | case 403:
47 | toast.error('You are not allowed to do that!');
48 | break;
49 | case 500:
50 | history.push({
51 | pathname: '/server-error',
52 | state: {error: data}
53 | });
54 | break;
55 | default:
56 | break;
57 | }
58 | return Promise.reject(error.response);
59 | })
60 |
61 | const requests = {
62 | get: (url: string, params?: URLSearchParams) => axios.get(url, {params}).then(responseBody),
63 | post: (url: string, body: {}) => axios.post(url, body).then(responseBody),
64 | put: (url: string, body: {}) => axios.put(url, body).then(responseBody),
65 | delete: (url: string) => axios.delete(url).then(responseBody),
66 | postForm: (url: string, data: FormData) => axios.post(url, data, {
67 | headers: {'Content-type': 'multipart/form-data'}
68 | }).then(responseBody),
69 | putForm: (url: string, data: FormData) => axios.put(url, data, {
70 | headers: {'Content-type': 'multipart/form-data'}
71 | }).then(responseBody)
72 | }
73 |
74 | function createFormData(item: any) {
75 | let formData = new FormData();
76 | for (const key in item) {
77 | formData.append(key, item[key])
78 | }
79 | return formData;
80 | }
81 |
82 | const Admin = {
83 | createProduct: (product: any) => requests.postForm('products', createFormData(product)),
84 | updateProduct: (product: any) => requests.putForm('products', createFormData(product)),
85 | deleteProduct: (id: number) => requests.delete(`products/${id}`)
86 | }
87 |
88 | const Catalog = {
89 | list: (params: URLSearchParams) => requests.get('products', params),
90 | details: (id: number) => requests.get(`products/${id}`),
91 | fetchFilters: () => requests.get('products/filters')
92 | }
93 |
94 | const TestErrors = {
95 | get400Error: () => requests.get('buggy/bad-request'),
96 | get401Error: () => requests.get('buggy/unauthorised'),
97 | get404Error: () => requests.get('buggy/not-found'),
98 | get500Error: () => requests.get('buggy/server-error'),
99 | getValidationError: () => requests.get('buggy/validation-error'),
100 | }
101 |
102 | const Basket = {
103 | get: () => requests.get('basket'),
104 | addItem: (productId: number, quantity = 1) => requests.post(`basket?productId=${productId}&quantity=${quantity}`, {}),
105 | removeItem: (productId: number, quantity = 1) => requests.delete(`basket?productId=${productId}&quantity=${quantity}`)
106 | }
107 |
108 | const Account = {
109 | login: (values: any) => requests.post('account/login', values),
110 | register: (values: any) => requests.post('account/register', values),
111 | currentUser: () => requests.get('account/currentUser'),
112 | fetchAddress: () => requests.get('account/savedAddress')
113 | }
114 |
115 | const Orders = {
116 | list: () => requests.get('orders'),
117 | fetch: (id: number) => requests.get(`orders/${id}`),
118 | create: (values: any) => requests.post('orders', values)
119 | }
120 |
121 | const Payments = {
122 | createPaymentIntent: () => requests.post('payments', {})
123 | }
124 |
125 | const agent = {
126 | Catalog,
127 | TestErrors,
128 | Basket,
129 | Account,
130 | Orders,
131 | Payments,
132 | Admin
133 | }
134 |
135 | export default agent;
--------------------------------------------------------------------------------
/client/src/app/components/AppCheckbox.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox, FormControlLabel } from "@mui/material";
2 | import { useController, UseControllerProps } from "react-hook-form"
3 |
4 | interface Props extends UseControllerProps {
5 | label: string;
6 | disabled: boolean;
7 | }
8 |
9 | export default function AppCheckbox(props: Props) {
10 | const {field} = useController({...props, defaultValue: false});
11 | return (
12 |
20 | }
21 | label={props.label}
22 | />
23 | )
24 | }
--------------------------------------------------------------------------------
/client/src/app/components/AppDropzone.tsx:
--------------------------------------------------------------------------------
1 | import { UploadFile } from '@mui/icons-material';
2 | import { FormControl, FormHelperText, Typography } from '@mui/material';
3 | import { useCallback } from 'react'
4 | import { useDropzone } from 'react-dropzone'
5 | import { useController, UseControllerProps } from 'react-hook-form'
6 |
7 | interface Props extends UseControllerProps { }
8 |
9 | export default function AppDropzone(props: Props) {
10 | const { fieldState, field } = useController({ ...props, defaultValue: null });
11 |
12 | const dzStyles = {
13 | display: 'flex',
14 | border: 'dashed 3px #eee',
15 | borderColor: '#eee',
16 | borderRadius: '5px',
17 | paddingTop: '30px',
18 | alignItems: 'center',
19 | height: 200,
20 | width: 500
21 | }
22 |
23 | const dzActive = {
24 | borderColor: 'green'
25 | }
26 |
27 | const onDrop = useCallback(acceptedFiles => {
28 | acceptedFiles[0] = Object.assign(acceptedFiles[0],
29 | {preview: URL.createObjectURL(acceptedFiles[0])});
30 | field.onChange(acceptedFiles[0]);
31 | }, [field])
32 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop })
33 |
34 | return (
35 |
36 |
37 |
38 |
39 | Drop image here
40 | {fieldState.error?.message}
41 |
42 |
43 | )
44 | }
--------------------------------------------------------------------------------
/client/src/app/components/AppPagination.tsx:
--------------------------------------------------------------------------------
1 | import { Typography, Pagination } from "@mui/material";
2 | import { Box } from "@mui/system";
3 | import { useState } from "react";
4 | import { MetaData } from "../models/pagination";
5 |
6 | interface Props {
7 | metaData: MetaData;
8 | onPageChange: (page: number) => void;
9 | }
10 |
11 | export default function AppPagination({metaData, onPageChange}: Props) {
12 | const {currentPage, totalCount, totalPages, pageSize} = metaData;
13 | const [pageNumber, setPageNumber] = useState(currentPage);
14 |
15 | function handlePageChange(page: number) {
16 | setPageNumber(page);
17 | onPageChange(page);
18 | }
19 |
20 | return (
21 |
22 |
23 | Displaying {(currentPage-1)*pageSize+1}-
24 | {currentPage*pageSize > totalCount
25 | ? totalCount
26 | : currentPage*pageSize} of {totalCount} items
27 |
28 | handlePageChange(page)}
34 | />
35 |
36 | )
37 | }
--------------------------------------------------------------------------------
/client/src/app/components/AppSelectList.tsx:
--------------------------------------------------------------------------------
1 | import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material";
2 | import { useController, UseControllerProps } from "react-hook-form";
3 |
4 | interface Props extends UseControllerProps {
5 | label: string;
6 | items: string[];
7 | }
8 |
9 | export default function AppSelectList(props: Props) {
10 | const { fieldState, field } = useController({ ...props, defaultValue: '' });
11 | return (
12 |
13 | {props.label}
14 |
23 | {fieldState.error?.message}
24 |
25 | )
26 | }
--------------------------------------------------------------------------------
/client/src/app/components/AppTextInput.tsx:
--------------------------------------------------------------------------------
1 | import { TextField } from "@mui/material";
2 | import { useController, UseControllerProps } from "react-hook-form";
3 |
4 | interface Props extends UseControllerProps {
5 | label: string;
6 | multiline?: boolean;
7 | rows?: number;
8 | type?: string;
9 | }
10 |
11 | export default function AppTextInput(props: Props) {
12 | const {fieldState, field} = useController({...props, defaultValue: ''})
13 | return (
14 |
25 | )
26 | }
--------------------------------------------------------------------------------
/client/src/app/components/CheckboxButtons.tsx:
--------------------------------------------------------------------------------
1 | import { FormGroup, FormControlLabel, Checkbox } from "@mui/material";
2 | import { useState } from "react";
3 |
4 | interface Props {
5 | items: string[];
6 | checked?: string[];
7 | onChange: (items: string[]) => void;
8 | }
9 |
10 | export default function CheckboxButtons({items, checked, onChange}: Props) {
11 | const [checkedItems, setCheckedItems] = useState(checked || []);
12 |
13 | function handleChecked(value: string) {
14 | const currentIndex = checkedItems.findIndex(item => item === value);
15 | let newChecked: string[] = [];
16 | if (currentIndex === -1) newChecked = [...checkedItems, value];
17 | else newChecked = checkedItems.filter(item => item !== value);
18 | setCheckedItems(newChecked);
19 | onChange(newChecked);
20 | }
21 |
22 | return (
23 |
24 | {items.map(item => (
25 | handleChecked(item)}
29 | />}
30 | label={item}
31 | key={item}
32 | />
33 | ))}
34 |
35 | )
36 | }
--------------------------------------------------------------------------------
/client/src/app/components/RadioButtonGroup.tsx:
--------------------------------------------------------------------------------
1 | import { FormControl, RadioGroup, FormControlLabel, Radio } from "@mui/material";
2 |
3 | interface Props {
4 | options: any[];
5 | onChange: (event: any) => void;
6 | selectedValue: string;
7 | }
8 |
9 | export default function RadioButtonGroup({options, onChange, selectedValue}: Props) {
10 | return (
11 |
12 |
13 | {options.map(({ value, label }) => (
14 | } label={label} key={value} />
15 | ))}
16 |
17 |
18 | )
19 | }
--------------------------------------------------------------------------------
/client/src/app/context/StoreContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, PropsWithChildren, useContext, useState } from "react";
2 | import { Basket } from "../models/basket";
3 |
4 | interface StoreContextValue {
5 | basket: Basket | null;
6 | setBasket: (basket: Basket) => void;
7 | removeItem: (productId: number, quantity: number) => void;
8 | }
9 |
10 | export const StoreContext = createContext(undefined);
11 |
12 | export function useStoreContext() {
13 | const context = useContext(StoreContext);
14 |
15 | if (context === undefined) {
16 | throw Error('Oops - we do not seem to be inside the provider');
17 | }
18 |
19 | return context;
20 | }
21 |
22 | export function StoreProvider({children}: PropsWithChildren) {
23 | const [basket, setBasket] = useState(null);
24 |
25 | function removeItem(productId: number, quantity: number) {
26 | if (!basket) return;
27 | const items = [...basket.items];
28 | const itemIndex = items.findIndex(i => i.productId === productId);
29 | if (itemIndex >= 0) {
30 | items[itemIndex].quantity -= quantity;
31 | if (items[itemIndex].quantity === 0) items.splice(itemIndex, 1);
32 | setBasket(prevState => {
33 | return {...prevState!, items}
34 | })
35 | }
36 | }
37 |
38 | return (
39 |
40 | {children}
41 |
42 | )
43 | }
--------------------------------------------------------------------------------
/client/src/app/errors/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Container, Divider, Paper, Typography } from "@mui/material";
2 | import { Link } from "react-router-dom";
3 |
4 | export default function NotFound() {
5 | return (
6 |
7 | Oops - we could not find what you are looking for
8 |
9 |
10 |
11 | )
12 | }
--------------------------------------------------------------------------------
/client/src/app/errors/ServerError.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Container, Divider, Paper, Typography } from "@mui/material";
2 | import { useHistory, useLocation } from "react-router";
3 |
4 | export default function ServerError() {
5 | const history = useHistory();
6 | const { state } = useLocation();
7 |
8 | return (
9 |
10 | {state?.error ? (
11 | <>
12 | {state.error.title}
13 |
14 | {state.error.detail || 'Internal server error'}
15 | >
16 | ) : (
17 | Server Error
18 | )}
19 |
20 |
21 | )
22 | }
--------------------------------------------------------------------------------
/client/src/app/hooks/useProducts.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { productSelectors, fetchProductsAsync, fetchFilters } from "../../features/catalog/catalogSlice";
3 | import { useAppSelector, useAppDispatch } from "../store/configureStore";
4 |
5 | export default function useProducts() {
6 | const products = useAppSelector(productSelectors.selectAll);
7 | const { productsLoaded, filtersLoaded, brands, types, metaData } = useAppSelector(state => state.catalog);
8 | const dispatch = useAppDispatch();
9 |
10 | useEffect(() => {
11 | if (!productsLoaded) dispatch(fetchProductsAsync());
12 | }, [productsLoaded, dispatch])
13 |
14 | useEffect(() => {
15 | if (!filtersLoaded) dispatch(fetchFilters());
16 | }, [filtersLoaded, dispatch]);
17 |
18 | return {
19 | products,
20 | productsLoaded,
21 | filtersLoaded,
22 | brands,
23 | types,
24 | metaData
25 | }
26 | }
--------------------------------------------------------------------------------
/client/src/app/layout/App.tsx:
--------------------------------------------------------------------------------
1 | import { Container, createTheme, CssBaseline, ThemeProvider } from "@mui/material";
2 | import { useCallback, useEffect, useState } from "react";
3 | import { Route, Switch } from "react-router";
4 | import { ToastContainer } from "react-toastify";
5 | import AboutPage from "../../features/about/AboutPage";
6 | import Catalog from "../../features/catalog/Catalog";
7 | import ProductDetails from "../../features/catalog/ProductDetails";
8 | import ContactPage from "../../features/contact/ContactPage";
9 | import HomePage from "../../features/home/HomePage";
10 | import Header from "./Header";
11 | import 'react-toastify/dist/ReactToastify.css';
12 | import ServerError from "../errors/ServerError";
13 | import NotFound from "../errors/NotFound";
14 | import BasketPage from "../../features/basket/BasketPage";
15 | import LoadingComponent from "./LoadingComponent";
16 | import { useAppDispatch } from "../store/configureStore";
17 | import { fetchBasketAsync } from "../../features/basket/basketSlice";
18 | import Login from "../../features/account/Login";
19 | import Register from "../../features/account/Register";
20 | import { fetchCurrentUser } from "../../features/account/accountSlice";
21 | import PrivateRoute from "./PrivateRoute";
22 | import Orders from "../../features/orders/Orders";
23 | import CheckoutWrapper from "../../features/checkout/CheckoutWrapper";
24 | import Inventory from "../../features/admin/Inventory";
25 |
26 | function App() {
27 | const dispatch = useAppDispatch();
28 | const [loading, setLoading] = useState(true);
29 |
30 | const initApp = useCallback(async () => {
31 | try {
32 | await dispatch(fetchCurrentUser());
33 | await dispatch(fetchBasketAsync());
34 | } catch (error) {
35 | console.log(error);
36 | }
37 | }, [dispatch])
38 |
39 | useEffect(() => {
40 | initApp().then(() => setLoading(false));
41 | }, [initApp])
42 |
43 | const [darkMode, setDarkMode] = useState(false);
44 | const paletteType = darkMode ? 'dark' : 'light'
45 | const theme = createTheme({
46 | palette: {
47 | mode: paletteType,
48 | background: {
49 | default: paletteType === 'light' ? '#eaeaea' : '#121212'
50 | }
51 | }
52 | })
53 |
54 | function handleThemeChange() {
55 | setDarkMode(!darkMode);
56 | }
57 |
58 | if (loading) return
59 |
60 | return (
61 |
62 |
63 |
64 |
65 |
66 | (
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 | )} />
84 |
85 |
86 | );
87 | }
88 |
89 | export default App;
90 |
--------------------------------------------------------------------------------
/client/src/app/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | import { ShoppingCart } from "@mui/icons-material";
2 | import { AppBar, Badge, Box, IconButton, List, ListItem, Switch, Toolbar, Typography } from "@mui/material";
3 | import { Link, NavLink } from "react-router-dom";
4 | import { useAppSelector } from "../store/configureStore";
5 | import SignedInMenu from "./SignedInMenu";
6 |
7 | interface Props {
8 | darkMode: boolean;
9 | handleThemeChange: () => void;
10 | }
11 |
12 | const midLinks = [
13 | { title: 'catalog', path: '/catalog' },
14 | { title: 'about', path: '/about' },
15 | { title: 'contact', path: '/contact' }
16 | ]
17 |
18 | const rightLinks = [
19 | { title: 'login', path: '/login' },
20 | { title: 'register', path: '/register' }
21 | ]
22 |
23 | const navStyles = {
24 | color: 'inherit',
25 | textDecoration: 'none',
26 | typography: 'h6',
27 | '&:hover': {
28 | color: 'grey.500'
29 | },
30 | '&.active': {
31 | color: 'text.secondary'
32 | }
33 | }
34 |
35 | export default function Header({ darkMode, handleThemeChange }: Props) {
36 | const { basket } = useAppSelector(state => state.basket);
37 | const { user } = useAppSelector(state => state.account);
38 | const itemCount = basket?.items.reduce((sum, item) => sum + item.quantity, 0)
39 |
40 | return (
41 |
42 |
43 |
44 |
46 | RE-STORE
47 |
48 |
49 |
50 |
51 | {midLinks.map(({ title, path }) => (
52 |
58 | {title.toUpperCase()}
59 |
60 | ))}
61 | {user && user.roles?.includes('Admin') &&
62 |
67 | INVENTORY
68 | }
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | {user ? (
77 |
78 | ) : (
79 |
80 | {rightLinks.map(({ title, path }) => (
81 |
87 | {title.toUpperCase()}
88 |
89 | ))}
90 |
91 | )}
92 |
93 |
94 |
95 | )
96 | }
--------------------------------------------------------------------------------
/client/src/app/layout/LoadingComponent.tsx:
--------------------------------------------------------------------------------
1 | import { Backdrop, CircularProgress, Typography } from "@mui/material";
2 | import { Box } from "@mui/system";
3 |
4 | interface Props {
5 | message?: string;
6 | }
7 |
8 | export default function LoadingComponent({message = 'Loading...'}: Props) {
9 | return (
10 |
11 |
12 |
13 |
14 | {message}
15 |
16 |
17 |
18 | )
19 | }
--------------------------------------------------------------------------------
/client/src/app/layout/PrivateRoute.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentType } from "react";
2 | import { Redirect, Route, RouteComponentProps, RouteProps } from "react-router";
3 | import { toast } from "react-toastify";
4 | import { useAppSelector } from "../store/configureStore";
5 |
6 | interface Props extends RouteProps {
7 | component: ComponentType> | ComponentType;
8 | roles?: string[];
9 | }
10 |
11 | export default function PrivateRoute({ component: Component, roles, ...rest }: Props) {
12 | const { user } = useAppSelector(state => state.account);
13 | return (
14 | {
15 | if (!user) {
16 | return
17 | }
18 |
19 | if (roles && !roles?.some(r => user.roles?.includes(r))) {
20 | toast.error('Not authorised to access this area');
21 | return
22 | }
23 |
24 | return
25 | }}
26 | />
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/client/src/app/layout/SignedInMenu.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Menu, Fade, MenuItem } from "@mui/material";
2 | import React from "react";
3 | import { Link } from "react-router-dom";
4 | import { signOut } from "../../features/account/accountSlice";
5 | import { clearBasket } from "../../features/basket/basketSlice";
6 | import { useAppDispatch, useAppSelector } from "../store/configureStore";
7 |
8 | export default function SignedInMenu() {
9 | const dispatch = useAppDispatch();
10 | const { user } = useAppSelector(state => state.account);
11 | const [anchorEl, setAnchorEl] = React.useState(null);
12 | const open = Boolean(anchorEl);
13 | const handleClick = (event: any) => {
14 | setAnchorEl(event.currentTarget);
15 | };
16 | const handleClose = () => {
17 | setAnchorEl(null);
18 | };
19 |
20 | return (
21 | <>
22 |
29 |
42 | >
43 | );
44 | }
--------------------------------------------------------------------------------
/client/src/app/layout/styles.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TryCatchLearn/Restore-v6/51bf5dcc3b81a23330f3c8ec5117bbec82aadff0/client/src/app/layout/styles.css
--------------------------------------------------------------------------------
/client/src/app/models/basket.ts:
--------------------------------------------------------------------------------
1 | export interface BasketItem {
2 | productId: number;
3 | name: string;
4 | price: number;
5 | pictureUrl: string;
6 | brand: string;
7 | type: string;
8 | quantity: number;
9 | }
10 |
11 | export interface Basket {
12 | id: number;
13 | buyerId: string;
14 | items: BasketItem[];
15 | paymentIntentId?: string;
16 | clientSecret?: string;
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/app/models/order.ts:
--------------------------------------------------------------------------------
1 | export interface ShippingAddress {
2 | fullName: string;
3 | address1: string;
4 | address2: string;
5 | city: string;
6 | state: string;
7 | zip: string;
8 | country: string;
9 | }
10 |
11 | export interface OrderItem {
12 | productId: number;
13 | name: string;
14 | pictureUrl: string;
15 | price: number;
16 | quantity: number;
17 | }
18 |
19 | export interface Order {
20 | id: number;
21 | buyerId: string;
22 | shippingAddress: ShippingAddress;
23 | orderDate: string;
24 | orderItems: OrderItem[];
25 | subtotal: number;
26 | deliveryFee: number;
27 | orderStatus: string;
28 | total: number;
29 | }
30 |
--------------------------------------------------------------------------------
/client/src/app/models/pagination.ts:
--------------------------------------------------------------------------------
1 | export interface MetaData {
2 | currentPage: number;
3 | totalPages: number;
4 | pageSize: number;
5 | totalCount: number;
6 | }
7 |
8 | export class PaginatedResponse {
9 | items: T;
10 | metaData: MetaData;
11 |
12 | constructor(items: T, metaData: MetaData) {
13 | this.items = items;
14 | this.metaData = metaData;
15 | }
16 | }
--------------------------------------------------------------------------------
/client/src/app/models/product.ts:
--------------------------------------------------------------------------------
1 | export interface Product {
2 | id: number;
3 | name: string;
4 | description: string;
5 | price: number;
6 | pictureUrl: string;
7 | type?: string;
8 | brand: string;
9 | quantityInStock?: number;
10 | }
11 |
12 | export interface ProductParams {
13 | orderBy: string;
14 | searchTerm?: string;
15 | types: string[];
16 | brands: string[];
17 | pageNumber: number;
18 | pageSize: number;
19 | }
--------------------------------------------------------------------------------
/client/src/app/models/user.ts:
--------------------------------------------------------------------------------
1 | import { Basket } from "./basket";
2 |
3 | export interface User {
4 | email: string;
5 | token: string;
6 | basket?: Basket;
7 | roles?: string[];
8 | }
--------------------------------------------------------------------------------
/client/src/app/store/configureStore.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
3 | import { accountSlice } from "../../features/account/accountSlice";
4 | import { basketSlice } from "../../features/basket/basketSlice";
5 | import { catalogSlice } from "../../features/catalog/catalogSlice";
6 | import { counterSlice } from "../../features/contact/counterSlice";
7 |
8 | // export function configureStore() {
9 | // return createStore(counterReducer);
10 | // }
11 |
12 | export const store = configureStore({
13 | reducer: {
14 | counter: counterSlice.reducer,
15 | basket: basketSlice.reducer,
16 | catalog: catalogSlice.reducer,
17 | account: accountSlice.reducer
18 | }
19 | })
20 |
21 | export type RootState = ReturnType;
22 | export type AppDispatch = typeof store.dispatch;
23 |
24 | export const useAppDispatch = () => useDispatch();
25 | export const useAppSelector: TypedUseSelectorHook = useSelector;
--------------------------------------------------------------------------------
/client/src/app/util/util.ts:
--------------------------------------------------------------------------------
1 | export function getCookie(key: string) {
2 | const b = document.cookie.match("(^|;)\\s*" + key + "\\s*=\\s*([^;]+)");
3 | return b ? b.pop() : "";
4 | }
5 |
6 | export function currencyFormat(amount: number) {
7 | return '$' + (amount/100).toFixed(2);
8 | }
--------------------------------------------------------------------------------
/client/src/features/about/AboutPage.tsx:
--------------------------------------------------------------------------------
1 | import { Alert, AlertTitle, Button, ButtonGroup, Container, List, ListItem, ListItemText, Typography } from "@mui/material";
2 | import { useState } from "react";
3 | import agent from "../../app/api/agent";
4 |
5 | export default function AboutPage() {
6 | const [validationErrors, setValidationErrors] = useState([]);
7 |
8 | function getValidationError() {
9 | agent.TestErrors.getValidationError()
10 | .then(() => console.log('should not see this'))
11 | .catch(error => setValidationErrors(error));
12 | }
13 |
14 | return (
15 |
16 | Errors for testing purposes
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | {validationErrors.length > 0 &&
25 |
26 | Validation Errors
27 |
28 | {validationErrors.map(error => (
29 |
30 | {error}
31 |
32 | ))}
33 |
34 |
35 | }
36 |
37 | )
38 | }
--------------------------------------------------------------------------------
/client/src/features/account/Login.tsx:
--------------------------------------------------------------------------------
1 | import Avatar from '@mui/material/Avatar';
2 | import TextField from '@mui/material/TextField';
3 | import Grid from '@mui/material/Grid';
4 | import Box from '@mui/material/Box';
5 | import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
6 | import Typography from '@mui/material/Typography';
7 | import Container from '@mui/material/Container';
8 | import { Paper } from '@mui/material';
9 | import { Link, useHistory, useLocation } from 'react-router-dom';
10 | import { FieldValues, useForm } from 'react-hook-form';
11 | import { LoadingButton } from '@mui/lab';
12 | import { useAppDispatch } from '../../app/store/configureStore';
13 | import { signInUser } from './accountSlice';
14 |
15 | export default function Login() {
16 | const history = useHistory();
17 | const location = useLocation();
18 | const dispatch = useAppDispatch();
19 | const { register, handleSubmit, formState: { isSubmitting, errors, isValid } } = useForm({
20 | mode: 'all'
21 | });
22 |
23 | async function submitForm(data: FieldValues) {
24 | try {
25 | await dispatch(signInUser(data));
26 | history.push(location.state?.from?.pathname || '/catalog');
27 | } catch (error) {
28 | console.log(error);
29 | }
30 | }
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
38 | Sign in
39 |
40 |
41 |
50 |
59 |
67 | Sign In
68 |
69 |
70 |
71 |
72 | {"Don't have an account? Sign Up"}
73 |
74 |
75 |
76 |
77 |
78 | );
79 | }
--------------------------------------------------------------------------------
/client/src/features/account/Register.tsx:
--------------------------------------------------------------------------------
1 | import Avatar from '@mui/material/Avatar';
2 | import TextField from '@mui/material/TextField';
3 | import Grid from '@mui/material/Grid';
4 | import Box from '@mui/material/Box';
5 | import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
6 | import Typography from '@mui/material/Typography';
7 | import Container from '@mui/material/Container';
8 | import { Paper } from '@mui/material';
9 | import { Link, useHistory } from 'react-router-dom';
10 | import { useForm } from 'react-hook-form';
11 | import { LoadingButton } from '@mui/lab';
12 | import agent from '../../app/api/agent';
13 | import { toast } from 'react-toastify';
14 |
15 | export default function Register() {
16 | const history = useHistory();
17 | const { register, handleSubmit, setError, formState: { isSubmitting, errors, isValid } } = useForm({
18 | mode: 'all'
19 | });
20 |
21 | function handleApiErrors(errors: any) {
22 | if (errors) {
23 | errors.forEach((error: string) => {
24 | if (error.includes('Password')) {
25 | setError('password', { message: error })
26 | } else if (error.includes('Email')) {
27 | setError('email', { message: error })
28 | } else if (error.includes('Username')) {
29 | setError('username', { message: error })
30 | }
31 | });
32 | }
33 | }
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 | Register
42 |
43 |
45 | agent.Account.register(data)
46 | .then(() => {
47 | toast.success('Registration successful - you can now login');
48 | history.push('/login');
49 | })
50 | .catch(error => handleApiErrors(error))
51 | )}
52 | noValidate sx={{ mt: 1 }}
53 | >
54 |
63 |
77 |
92 |
100 | Register
101 |
102 |
103 |
104 |
105 | {"Already have an account? Sign In"}
106 |
107 |
108 |
109 |
110 |
111 | );
112 | }
--------------------------------------------------------------------------------
/client/src/features/account/accountSlice.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, createSlice, isAnyOf } from "@reduxjs/toolkit";
2 | import { FieldValues } from "react-hook-form";
3 | import { toast } from "react-toastify";
4 | import { history } from "../..";
5 | import agent from "../../app/api/agent";
6 | import { User } from "../../app/models/user";
7 | import { setBasket } from "../basket/basketSlice";
8 |
9 | interface AccountState {
10 | user: User | null;
11 | }
12 |
13 | const initialState: AccountState = {
14 | user: null
15 | }
16 |
17 | export const signInUser = createAsyncThunk(
18 | 'account/signInUser',
19 | async (data, thunkAPI) => {
20 | try {
21 | const userDto = await agent.Account.login(data);
22 | const {basket, ...user} = userDto;
23 | if (basket) thunkAPI.dispatch(setBasket(basket));
24 | localStorage.setItem('user', JSON.stringify(user));
25 | return user;
26 | } catch (error: any) {
27 | return thunkAPI.rejectWithValue({error: error.data});
28 | }
29 | }
30 | )
31 |
32 | export const fetchCurrentUser = createAsyncThunk(
33 | 'account/fetchCurrentUser',
34 | async (_, thunkAPI) => {
35 | thunkAPI.dispatch(setUser(JSON.parse(localStorage.getItem('user')!)));
36 | try {
37 | const userDto = await agent.Account.currentUser();
38 | const {basket, ...user} = userDto;
39 | if (basket) thunkAPI.dispatch(setBasket(basket));
40 | localStorage.setItem('user', JSON.stringify(user));
41 | return user;
42 | } catch (error: any) {
43 | return thunkAPI.rejectWithValue({error: error.data});
44 | }
45 | },
46 | {
47 | condition: () => {
48 | if (!localStorage.getItem('user')) return false;
49 | }
50 | }
51 | )
52 |
53 | export const accountSlice = createSlice({
54 | name: 'account',
55 | initialState,
56 | reducers: {
57 | signOut: (state) => {
58 | state.user = null;
59 | localStorage.removeItem('user');
60 | history.push('/');
61 | },
62 | setUser: (state, action) => {
63 | let claims = JSON.parse(atob(action.payload.token.split('.')[1]));
64 | let roles = claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'];
65 | state.user = {...action.payload, roles: typeof(roles) === 'string' ? [roles] : roles};
66 | }
67 | },
68 | extraReducers: (builder => {
69 | builder.addCase(fetchCurrentUser.rejected, (state) => {
70 | state.user = null;
71 | localStorage.removeItem('user');
72 | toast.error('Session expired - please login again');
73 | history.push('/');
74 | });
75 | builder.addMatcher(isAnyOf(signInUser.fulfilled, fetchCurrentUser.fulfilled), (state, action) => {
76 | let claims = JSON.parse(atob(action.payload.token.split('.')[1]));
77 | let roles = claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role'];
78 | state.user = {...action.payload, roles: typeof(roles) === 'string' ? [roles] : roles};
79 | });
80 | builder.addMatcher(isAnyOf(signInUser.rejected), (state, action) => {
81 | throw action.payload;
82 | })
83 | })
84 | })
85 |
86 | export const {signOut, setUser} = accountSlice.actions;
--------------------------------------------------------------------------------
/client/src/features/admin/Inventory.tsx:
--------------------------------------------------------------------------------
1 | import { Typography, Button, TableContainer, Paper, Table, TableHead, TableRow, TableCell, TableBody, Box } from "@mui/material";
2 | import { Edit, Delete } from "@mui/icons-material";
3 | import { currencyFormat } from "../../app/util/util";
4 | import useProducts from "../../app/hooks/useProducts";
5 | import AppPagination from "../../app/components/AppPagination";
6 | import { useAppDispatch } from "../../app/store/configureStore";
7 | import { removeProduct, setPageNumber } from "../catalog/catalogSlice";
8 | import { useState } from "react";
9 | import ProductForm from "./ProductForm";
10 | import { Product } from "../../app/models/product";
11 | import agent from "../../app/api/agent";
12 | import { LoadingButton } from "@mui/lab";
13 |
14 | export default function Inventory() {
15 | const {products, metaData} = useProducts();
16 | const dispatch = useAppDispatch();
17 | const [editMode, setEditMode] = useState(false);
18 | const [selectedProduct, setSelectedProduct] = useState(undefined);
19 | const [loading, setLoading] = useState(false);
20 | const [target, setTarget] = useState(0);
21 |
22 | function handleSelectProduct(product: Product) {
23 | setSelectedProduct(product);
24 | setEditMode(true);
25 | }
26 |
27 | function handleDeleteProduct(id: number) {
28 | setLoading(true);
29 | setTarget(id);
30 | agent.Admin.deleteProduct(id)
31 | .then(() => dispatch(removeProduct(id)))
32 | .catch(error => console.log(error))
33 | .finally(() => setLoading(false));
34 | }
35 |
36 | function cancelEdit() {
37 | if (selectedProduct) setSelectedProduct(undefined);
38 | setEditMode(false);
39 | }
40 |
41 | if (editMode) return
42 |
43 | return (
44 | <>
45 |
46 | Inventory
47 |
48 |
49 |
50 |
51 |
52 |
53 | #
54 | Product
55 | Price
56 | Type
57 | Brand
58 | Quantity
59 |
60 |
61 |
62 |
63 | {products.map((product) => (
64 |
68 |
69 | {product.id}
70 |
71 |
72 |
73 |
74 | {product.name}
75 |
76 |
77 | {currencyFormat(product.price)}
78 | {product.type}
79 | {product.brand}
80 | {product.quantityInStock}
81 |
82 |
89 |
90 | ))}
91 |
92 |
93 |
94 | {metaData &&
95 |
96 | dispatch(setPageNumber({pageNumber: page}))}
99 | />
100 |
101 | }
102 | >
103 | )
104 | }
--------------------------------------------------------------------------------
/client/src/features/admin/ProductForm.tsx:
--------------------------------------------------------------------------------
1 | import { Typography, Grid, Paper, Box, Button } from "@mui/material";
2 | import { useEffect } from "react";
3 | import { FieldValues, useForm } from "react-hook-form";
4 | import AppDropzone from "../../app/components/AppDropzone";
5 | import AppSelectList from "../../app/components/AppSelectList";
6 | import AppTextInput from "../../app/components/AppTextInput";
7 | import useProducts from "../../app/hooks/useProducts";
8 | import { Product } from "../../app/models/product";
9 | import {yupResolver} from '@hookform/resolvers/yup';
10 | import { validationSchema } from "./productValidation";
11 | import agent from "../../app/api/agent";
12 | import { useAppDispatch } from "../../app/store/configureStore";
13 | import { setProduct } from "../catalog/catalogSlice";
14 | import { LoadingButton } from "@mui/lab";
15 |
16 | interface Props {
17 | product?: Product;
18 | cancelEdit: () => void;
19 | }
20 |
21 | export default function ProductForm({ product, cancelEdit }: Props) {
22 | const { control, reset, handleSubmit, watch, formState: {isDirty, isSubmitting} } = useForm({
23 | mode: 'all',
24 | resolver: yupResolver(validationSchema)
25 | });
26 | const { brands, types } = useProducts();
27 | const watchFile = watch('file', null);
28 | const dispatch = useAppDispatch();
29 |
30 | useEffect(() => {
31 | if (product && !watchFile && !isDirty) reset(product);
32 | return () => {
33 | if (watchFile) URL.revokeObjectURL(watchFile.preview);
34 | }
35 | }, [product, reset, watchFile, isDirty]);
36 |
37 | async function handleSubmitData(data: FieldValues) {
38 | try {
39 | let response: Product;
40 | if (product) {
41 | response = await agent.Admin.updateProduct(data);
42 | } else {
43 | response = await agent.Admin.createProduct(data);
44 | }
45 | dispatch(setProduct(response));
46 | cancelEdit();
47 | } catch (error) {
48 | console.log(error)
49 | }
50 | }
51 |
52 | return (
53 |
54 |
55 | Product Details
56 |
57 |
94 |
95 | )
96 | }
--------------------------------------------------------------------------------
/client/src/features/admin/productValidation.ts:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 |
3 | export const validationSchema = yup.object({
4 | name: yup.string().required(),
5 | brand: yup.string().required(),
6 | type: yup.string().required(),
7 | price: yup.number().required().moreThan(100),
8 | quantityInStock: yup.number().required().min(0),
9 | description: yup.string().required(),
10 | file: yup.mixed().when('pictureUrl', {
11 | is: (value: string) => !value,
12 | then: yup.mixed().required('Please provide an image')
13 | })
14 | })
--------------------------------------------------------------------------------
/client/src/features/basket/BasketPage.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Grid, Typography } from "@mui/material";
2 | import { Link } from "react-router-dom";
3 | import { useAppSelector } from "../../app/store/configureStore";
4 | import BasketSummary from "./BasketSummary";
5 | import BasketTable from "./BasketTable";
6 |
7 | export default function BasketPage() {
8 | const { basket } = useAppSelector(state => state.basket);
9 |
10 | if (!basket) return Your basket is empty
11 |
12 | return (
13 | <>
14 |
15 |
16 |
17 |
18 |
19 |
28 |
29 |
30 | >
31 |
32 | )
33 | }
--------------------------------------------------------------------------------
/client/src/features/basket/BasketSummary.tsx:
--------------------------------------------------------------------------------
1 | import { TableContainer, Paper, Table, TableBody, TableRow, TableCell } from "@mui/material";
2 | import { useAppSelector } from "../../app/store/configureStore";
3 | import { currencyFormat } from "../../app/util/util";
4 |
5 | interface Props {
6 | subtotal?: number;
7 | }
8 |
9 | export default function BasketSummary({subtotal}: Props) {
10 | const {basket} = useAppSelector(state => state.basket);
11 | if (subtotal === undefined)
12 | subtotal = basket?.items.reduce((sum, item) => sum + (item.quantity * item.price), 0) ?? 0;
13 | const deliveryFee = subtotal > 10000 ? 0 : 500;
14 |
15 | return (
16 | <>
17 |
18 |
19 |
20 |
21 | Subtotal
22 | {currencyFormat(subtotal)}
23 |
24 |
25 | Delivery fee*
26 | {currencyFormat(deliveryFee)}
27 |
28 |
29 | Total
30 | {currencyFormat(subtotal + deliveryFee)}
31 |
32 |
33 |
34 | *Orders over $100 qualify for free delivery
35 |
36 |
37 |
38 |
39 |
40 | >
41 | )
42 | }
--------------------------------------------------------------------------------
/client/src/features/basket/BasketTable.tsx:
--------------------------------------------------------------------------------
1 | import { Remove, Add, Delete } from "@mui/icons-material";
2 | import { LoadingButton } from "@mui/lab";
3 | import { TableContainer, Paper, Table, TableHead, TableRow, TableCell, TableBody } from "@mui/material";
4 | import { Box } from "@mui/system";
5 | import { BasketItem } from "../../app/models/basket";
6 | import { useAppSelector, useAppDispatch } from "../../app/store/configureStore";
7 | import { removeBasketItemAsync, addBasketItemAsync } from "./basketSlice";
8 |
9 | interface Props {
10 | items: BasketItem[];
11 | isBasket?: boolean;
12 | }
13 |
14 | export default function BasketTable({ items, isBasket = true }: Props) {
15 | const { status } = useAppSelector(state => state.basket);
16 | const dispatch = useAppDispatch();
17 | return (
18 |
19 |
20 |
21 |
22 | Product
23 | Price
24 | Quantity
25 | Subtotal
26 | {isBasket &&
27 | }
28 |
29 |
30 |
31 | {items.map(item => (
32 |
36 |
37 |
38 |
39 | {item.name}
40 |
41 |
42 | ${(item.price / 100).toFixed(2)}
43 |
44 | {isBasket &&
45 | dispatch(removeBasketItemAsync({ productId: item.productId, quantity: 1, name: 'rem' }))}
48 | color='error'
49 | >
50 |
51 | }
52 | {item.quantity}
53 | {isBasket &&
54 | dispatch(addBasketItemAsync({ productId: item.productId }))}
57 | color='secondary'
58 | >
59 |
60 | }
61 |
62 | ${((item.price / 100) * item.quantity).toFixed(2)}
63 | {isBasket &&
64 |
65 | dispatch(removeBasketItemAsync({ productId: item.productId, quantity: item.quantity, name: 'del' }))}
68 | color='error'
69 | >
70 |
71 |
72 | }
73 |
74 | ))}
75 |
76 |
77 |
78 | )
79 | }
--------------------------------------------------------------------------------
/client/src/features/basket/basketSlice.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, createSlice, isAnyOf } from "@reduxjs/toolkit";
2 | import agent from "../../app/api/agent";
3 | import { Basket } from "../../app/models/basket";
4 | import { getCookie } from "../../app/util/util";
5 |
6 | interface BasketState {
7 | basket: Basket | null;
8 | status: string;
9 | }
10 |
11 | const initialState: BasketState = {
12 | basket: null,
13 | status: 'idle'
14 | }
15 |
16 | export const fetchBasketAsync = createAsyncThunk(
17 | 'basket/fetchBasketAsync',
18 | async (_, thunkAPI) => {
19 | try {
20 | return await agent.Basket.get();
21 | } catch (error: any) {
22 | return thunkAPI.rejectWithValue({error: error.data});
23 | }
24 | },
25 | {
26 | condition: () => {
27 | if (!getCookie('buyerId')) return false;
28 | }
29 | }
30 | )
31 |
32 | export const addBasketItemAsync = createAsyncThunk(
33 | 'basket/addBasketItemAsync',
34 | async ({productId, quantity = 1}, thunkAPI) => {
35 | try {
36 | return await agent.Basket.addItem(productId, quantity);
37 | } catch (error: any) {
38 | return thunkAPI.rejectWithValue({error: error.data})
39 | }
40 | }
41 | )
42 |
43 | export const removeBasketItemAsync = createAsyncThunk(
45 | 'basket/removeBasketItemAsync',
46 | async ({productId, quantity}, thunkAPI) => {
47 | try {
48 | await agent.Basket.removeItem(productId, quantity);
49 | } catch (error: any) {
50 | return thunkAPI.rejectWithValue({error: error.data})
51 | }
52 | }
53 | )
54 |
55 | export const basketSlice = createSlice({
56 | name: 'basket',
57 | initialState,
58 | reducers: {
59 | setBasket: (state, action) => {
60 | state.basket = action.payload
61 | },
62 | clearBasket: (state) => {
63 | state.basket = null;
64 | }
65 | },
66 | extraReducers: (builder => {
67 | builder.addCase(addBasketItemAsync.pending, (state, action) => {
68 | state.status = 'pendingAddItem' + action.meta.arg.productId;
69 | });
70 | builder.addCase(removeBasketItemAsync.pending, (state, action) => {
71 | state.status = 'pendingRemoveItem' + action.meta.arg.productId + action.meta.arg.name;
72 | });
73 | builder.addCase(removeBasketItemAsync.fulfilled, (state, action) => {
74 | const {productId, quantity} = action.meta.arg;
75 | const itemIndex = state.basket?.items.findIndex(i => i.productId === productId);
76 | if (itemIndex === -1 || itemIndex === undefined) return;
77 | state.basket!.items[itemIndex].quantity -= quantity;
78 | if (state.basket?.items[itemIndex].quantity === 0)
79 | state.basket.items.splice(itemIndex, 1);
80 | state.status = 'idle';
81 | });
82 | builder.addCase(removeBasketItemAsync.rejected, (state, action) => {
83 | console.log(action.payload);
84 | state.status = 'idle';
85 | });
86 | builder.addMatcher(isAnyOf(addBasketItemAsync.fulfilled, fetchBasketAsync.fulfilled), (state, action) => {
87 | state.basket = action.payload;
88 | state.status = 'idle';
89 | });
90 | builder.addMatcher(isAnyOf(addBasketItemAsync.rejected, fetchBasketAsync.rejected), (state, action) => {
91 | console.log(action.payload);
92 | state.status = 'idle';
93 | });
94 | })
95 | })
96 |
97 | export const {setBasket, clearBasket} = basketSlice.actions;
--------------------------------------------------------------------------------
/client/src/features/catalog/Catalog.tsx:
--------------------------------------------------------------------------------
1 | import { Grid, Paper } from "@mui/material";
2 | import AppPagination from "../../app/components/AppPagination";
3 | import CheckboxButtons from "../../app/components/CheckboxButtons";
4 | import RadioButtonGroup from "../../app/components/RadioButtonGroup";
5 | import useProducts from "../../app/hooks/useProducts";
6 | import LoadingComponent from "../../app/layout/LoadingComponent";
7 | import { useAppDispatch, useAppSelector } from "../../app/store/configureStore";
8 | import { setPageNumber, setProductParams } from "./catalogSlice";
9 | import ProductList from "./ProductList";
10 | import ProductSearch from "./ProductSearch";
11 |
12 | const sortOptions = [
13 | { value: 'name', label: 'Alphabetical' },
14 | { value: 'priceDesc', label: 'Price - High to low' },
15 | { value: 'price', label: 'Price - Low to high' },
16 | ]
17 |
18 | export default function Catalog() {
19 | const {products, brands, types, filtersLoaded, metaData} = useProducts();
20 | const { productParams, } = useAppSelector(state => state.catalog);
21 | const dispatch = useAppDispatch();
22 |
23 | if (!filtersLoaded) return
24 |
25 | return (
26 |
27 |
28 |
29 |
30 |
31 |
32 | dispatch(setProductParams({ orderBy: e.target.value }))}
36 | />
37 |
38 |
39 | dispatch(setProductParams({ brands: items }))}
43 | />
44 |
45 |
46 | dispatch(setProductParams({ types: items }))}
50 | />
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | {metaData &&
59 | dispatch(setPageNumber({pageNumber: page}))}
62 | />}
63 |
64 |
65 | )
66 | }
--------------------------------------------------------------------------------
/client/src/features/catalog/ProductCard.tsx:
--------------------------------------------------------------------------------
1 | import { LoadingButton } from "@mui/lab";
2 | import { Avatar, Button, Card, CardActions, CardContent, CardHeader, CardMedia, Typography } from "@mui/material";
3 | import { Link } from "react-router-dom";
4 | import { Product } from "../../app/models/product";
5 | import { useAppDispatch, useAppSelector } from "../../app/store/configureStore";
6 | import { currencyFormat } from "../../app/util/util";
7 | import { addBasketItemAsync } from "../basket/basketSlice";
8 |
9 | interface Props {
10 | product: Product
11 | }
12 |
13 | export default function ProductCard({ product }: Props) {
14 | const {status} = useAppSelector(state => state.basket);
15 | const dispatch = useAppDispatch();
16 |
17 | return (
18 |
19 |
22 | {product.name.charAt(0).toUpperCase()}
23 |
24 | }
25 | title={product.name}
26 | titleTypographyProps={{
27 | sx: { fontWeight: 'bold', color: 'primary.main' }
28 | }}
29 | />
30 |
35 |
36 |
37 | {currencyFormat(product.price)}
38 |
39 |
40 | {product.brand} / {product.type}
41 |
42 |
43 |
44 | dispatch(addBasketItemAsync({productId: product.id}))}
47 | size="small">
48 | Add to cart
49 |
50 |
51 |
52 |
53 | )
54 | }
--------------------------------------------------------------------------------
/client/src/features/catalog/ProductCardSkeleton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardActions,
4 | CardContent,
5 | CardHeader,
6 | Grid,
7 | Skeleton
8 | } from "@mui/material";
9 |
10 | export default function ProductCardSkeleton() {
11 | return (
12 |
13 |
16 | }
17 | title={
18 |
24 | }
25 | />
26 |
27 |
28 | <>
29 |
30 |
31 | >
32 |
33 |
34 | <>
35 |
36 |
37 | >
38 |
39 |
40 | )
41 | }
--------------------------------------------------------------------------------
/client/src/features/catalog/ProductDetails.tsx:
--------------------------------------------------------------------------------
1 | import { LoadingButton } from "@mui/lab";
2 | import { Divider, Grid, Table, TableBody, TableCell, TableContainer, TableRow, TextField, Typography } from "@mui/material";
3 | import { useEffect, useState } from "react";
4 | import { useParams } from "react-router";
5 | import NotFound from "../../app/errors/NotFound";
6 | import LoadingComponent from "../../app/layout/LoadingComponent";
7 | import { useAppDispatch, useAppSelector } from "../../app/store/configureStore";
8 | import { addBasketItemAsync, removeBasketItemAsync } from "../basket/basketSlice";
9 | import { fetchProductAsync, productSelectors } from "./catalogSlice";
10 |
11 | export default function ProductDetails() {
12 | const {basket, status} = useAppSelector(state => state.basket);
13 | const dispatch = useAppDispatch();
14 | const {id} = useParams<{id: string}>();
15 | const product = useAppSelector(state => productSelectors.selectById(state, id));
16 | const {status: productStatus} = useAppSelector(state => state.catalog);
17 | const [quantity, setQuantity] = useState(0);
18 | const item = basket?.items.find(i => i.productId === product?.id);
19 |
20 | useEffect(() => {
21 | if (item) setQuantity(item.quantity);
22 | if (!product) dispatch(fetchProductAsync(parseInt(id)))
23 | }, [id, item, dispatch, product]);
24 |
25 | function handleInputChange(event: any) {
26 | if (event.target.value > 0) {
27 | setQuantity(parseInt(event.target.value));
28 | }
29 | }
30 |
31 | function handleUpdateCart() {
32 | if (!item || quantity > item.quantity) {
33 | const updatedQuantity = item ? quantity - item.quantity : quantity;
34 | dispatch(addBasketItemAsync({productId: product?.id!, quantity: updatedQuantity}))
35 | } else {
36 | const updatedQuantity = item.quantity - quantity;
37 | dispatch(removeBasketItemAsync({productId: product?.id!, quantity: updatedQuantity}))
38 | }
39 | }
40 |
41 | if (productStatus.includes('pending')) return
42 |
43 | if (!product) return
44 |
45 | return (
46 |
47 |
48 |
49 |
50 |
51 | {product.name}
52 |
53 | ${(product.price / 100).toFixed(2)}
54 |
55 |
56 |
57 |
58 | Name
59 | {product.name}
60 |
61 |
62 | Description
63 | {product.description}
64 |
65 |
66 | Type
67 | {product.type}
68 |
69 |
70 | Brand
71 | {product.brand}
72 |
73 |
74 | Quantity in stock
75 | {product.quantityInStock}
76 |
77 |
78 |
79 |
80 |
81 |
82 |
90 |
91 |
92 |
102 | {item ? 'Update Quantity' : 'Add to Cart'}
103 |
104 |
105 |
106 |
107 |
108 | )
109 | }
--------------------------------------------------------------------------------
/client/src/features/catalog/ProductList.tsx:
--------------------------------------------------------------------------------
1 | import { Grid } from "@mui/material";
2 | import { Product } from "../../app/models/product";
3 | import { useAppSelector } from "../../app/store/configureStore";
4 | import ProductCard from "./ProductCard";
5 | import ProductCardSkeleton from "./ProductCardSkeleton";
6 |
7 | interface Props {
8 | products: Product[];
9 | }
10 |
11 | export default function ProductList({ products }: Props) {
12 | const { productsLoaded } = useAppSelector(state => state.catalog);
13 | return (
14 |
15 | {products.map(product => (
16 |
17 | {!productsLoaded ? (
18 |
19 | ) : (
20 |
21 | )}
22 |
23 | ))}
24 |
25 | )
26 | }
--------------------------------------------------------------------------------
/client/src/features/catalog/ProductSearch.tsx:
--------------------------------------------------------------------------------
1 | import { debounce, TextField } from "@mui/material";
2 | import { useState } from "react";
3 | import { useAppDispatch, useAppSelector } from "../../app/store/configureStore";
4 | import { setProductParams } from "./catalogSlice";
5 |
6 | export default function ProductSearch() {
7 | const {productParams} = useAppSelector(state => state.catalog);
8 | const [searchTerm, setSearchTerm] = useState(productParams.searchTerm);
9 | const dispatch = useAppDispatch();
10 |
11 | const debouncedSearch = debounce((event: any) => {
12 | dispatch(setProductParams({searchTerm: event.target.value}))
13 | }, 1000)
14 |
15 | return (
16 | {
22 | setSearchTerm(event.target.value);
23 | debouncedSearch(event);
24 | }}
25 | />
26 | )
27 | }
--------------------------------------------------------------------------------
/client/src/features/catalog/catalogSlice.ts:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, createEntityAdapter, createSlice } from "@reduxjs/toolkit";
2 | import agent from "../../app/api/agent";
3 | import { MetaData } from "../../app/models/pagination";
4 | import { Product, ProductParams } from "../../app/models/product";
5 | import { RootState } from "../../app/store/configureStore";
6 |
7 | interface CatalogState {
8 | productsLoaded: boolean;
9 | filtersLoaded: boolean;
10 | status: string;
11 | brands: string[];
12 | types: string[];
13 | productParams: ProductParams;
14 | metaData: MetaData | null;
15 | }
16 |
17 | const productsAdapter = createEntityAdapter();
18 |
19 | function getAxiosParams(productParams: ProductParams) {
20 | const params = new URLSearchParams();
21 | params.append('pageNumber', productParams.pageNumber.toString());
22 | params.append('pageSize', productParams.pageSize.toString());
23 | params.append('orderBy', productParams.orderBy);
24 | if (productParams.searchTerm) params.append('searchTerm', productParams.searchTerm);
25 | if (productParams.brands.length > 0) params.append('brands', productParams.brands.toString());
26 | if (productParams.types.length > 0) params.append('types', productParams.types.toString());
27 | return params;
28 | }
29 |
30 | export const fetchProductsAsync = createAsyncThunk(
31 | 'catalog/fetchProductsAsync',
32 | async (_, thunkAPI) => {
33 | const params = getAxiosParams(thunkAPI.getState().catalog.productParams);
34 | try {
35 | const response = await agent.Catalog.list(params);
36 | thunkAPI.dispatch(setMetaData(response.metaData));
37 | return response.items;
38 | } catch (error: any) {
39 | return thunkAPI.rejectWithValue({error: error.data})
40 | }
41 | }
42 | )
43 |
44 | export const fetchProductAsync = createAsyncThunk(
45 | 'catalog/fetchProductAsync',
46 | async (productId, thunkAPI) => {
47 | try {
48 | return await agent.Catalog.details(productId);
49 | } catch (error: any) {
50 | return thunkAPI.rejectWithValue({error: error.data})
51 | }
52 | }
53 | )
54 |
55 | export const fetchFilters = createAsyncThunk(
56 | 'catalog/fetchFilters',
57 | async (_, thunkAPI) => {
58 | try {
59 | return agent.Catalog.fetchFilters();
60 | } catch (error: any) {
61 | return thunkAPI.rejectWithValue({error: error.data})
62 | }
63 | }
64 | )
65 |
66 | function initParams() {
67 | return {
68 | pageNumber: 1,
69 | pageSize: 6,
70 | orderBy: 'name',
71 | brands: [],
72 | types: []
73 | }
74 | }
75 |
76 | export const catalogSlice = createSlice({
77 | name: 'catalog',
78 | initialState: productsAdapter.getInitialState({
79 | productsLoaded: false,
80 | filtersLoaded: false,
81 | status: 'idle',
82 | brands: [],
83 | types: [],
84 | productParams: initParams(),
85 | metaData: null
86 | }),
87 | reducers: {
88 | setProductParams: (state, action) => {
89 | state.productsLoaded = false;
90 | state.productParams = {...state.productParams, ...action.payload, pageNumber: 1};
91 | },
92 | setPageNumber: (state, action) => {
93 | state.productsLoaded = false;
94 | state.productParams = {...state.productParams, ...action.payload};
95 | },
96 | setMetaData: (state, action) => {
97 | state.metaData = action.payload;
98 | },
99 | resetProductParams: (state) => {
100 | state.productParams = initParams();
101 | },
102 | setProduct: (state, action) => {
103 | productsAdapter.upsertOne(state, action.payload);
104 | state.productsLoaded = false;
105 | },
106 | removeProduct: (state, action) => {
107 | productsAdapter.removeOne(state, action.payload);
108 | state.productsLoaded = false;
109 | }
110 | },
111 | extraReducers: (builder => {
112 | builder.addCase(fetchProductsAsync.pending, (state) => {
113 | state.status = 'pendingFetchProducts';
114 | });
115 | builder.addCase(fetchProductsAsync.fulfilled, (state, action) => {
116 | productsAdapter.setAll(state, action.payload);
117 | state.status = 'idle';
118 | state.productsLoaded = true;
119 | });
120 | builder.addCase(fetchProductsAsync.rejected, (state, action) => {
121 | console.log(action.payload);
122 | state.status = 'idle';
123 | });
124 | builder.addCase(fetchProductAsync.pending, (state) => {
125 | state.status = 'pendingFetchProduct';
126 | });
127 | builder.addCase(fetchProductAsync.fulfilled, (state, action) => {
128 | productsAdapter.upsertOne(state, action.payload);
129 | state.status = 'idle';
130 | });
131 | builder.addCase(fetchProductAsync.rejected, (state, action) => {
132 | console.log(action);
133 | state.status = 'idle';
134 | });
135 | builder.addCase(fetchFilters.pending, (state) => {
136 | state.status = 'pendingFetchFilters';
137 | });
138 | builder.addCase(fetchFilters.fulfilled, (state, action) => {
139 | state.brands = action.payload.brands;
140 | state.types = action.payload.types;
141 | state.filtersLoaded = true;
142 | state.status = 'idle';
143 | });
144 | builder.addCase(fetchFilters.rejected, (state, action) => {
145 | state.status = 'idle';
146 | console.log(action.payload);
147 | })
148 | })
149 | })
150 |
151 | export const productSelectors = productsAdapter.getSelectors((state: RootState) => state.catalog);
152 |
153 | export const {setProductParams, resetProductParams, setMetaData, setPageNumber, setProduct, removeProduct} = catalogSlice.actions;
--------------------------------------------------------------------------------
/client/src/features/checkout/AddressForm.tsx:
--------------------------------------------------------------------------------
1 | import Grid from '@mui/material/Grid';
2 | import Typography from '@mui/material/Typography';
3 | import { useFormContext } from 'react-hook-form';
4 | import AppTextInput from '../../app/components/AppTextInput';
5 | import AppCheckbox from '../../app/components/AppCheckbox';
6 |
7 | export default function AddressForm() {
8 | const { control, formState } = useFormContext();
9 | return (
10 | <>
11 |
12 | Shipping address
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
43 |
44 |
45 | >
46 | );
47 | }
--------------------------------------------------------------------------------
/client/src/features/checkout/CheckoutWrapper.tsx:
--------------------------------------------------------------------------------
1 | import { Elements } from "@stripe/react-stripe-js";
2 | import { loadStripe } from "@stripe/stripe-js";
3 | import { useEffect, useState } from "react";
4 | import agent from "../../app/api/agent";
5 | import LoadingComponent from "../../app/layout/LoadingComponent";
6 | import { useAppDispatch } from "../../app/store/configureStore";
7 | import { setBasket } from "../basket/basketSlice";
8 | import CheckoutPage from "./CheckoutPage";
9 |
10 | const stripePromise = loadStripe("pk_test_51IzwHFErFg8RLNropkfWpnL37TzyR3eTpn0vY0EmatAeBwxlNPFJT2e2VtfIt2V8975y2W7kC1gcQ5tB5B332Y2x00yktsLIxN")
11 |
12 | export default function CheckoutWrapper() {
13 | const dispatch = useAppDispatch();
14 | const [loading, setLoading] = useState(true);
15 |
16 | useEffect(() => {
17 | agent.Payments.createPaymentIntent()
18 | .then(basket => dispatch(setBasket(basket)))
19 | .catch(error => console.log(error))
20 | .finally(() => setLoading(false));
21 | }, [dispatch]);
22 |
23 | if (loading) return
24 |
25 | return (
26 |
27 |
28 |
29 | )
30 | }
--------------------------------------------------------------------------------
/client/src/features/checkout/PaymentForm.tsx:
--------------------------------------------------------------------------------
1 | import Typography from '@mui/material/Typography';
2 | import Grid from '@mui/material/Grid';
3 | import TextField from '@mui/material/TextField';
4 | import { useFormContext } from 'react-hook-form';
5 | import AppTextInput from '../../app/components/AppTextInput';
6 | import { CardCvcElement, CardExpiryElement, CardNumberElement } from '@stripe/react-stripe-js';
7 | import { StripeInput } from './StripeInput';
8 | import { StripeElementType } from '@stripe/stripe-js';
9 |
10 | interface Props {
11 | cardState: { elementError: { [key in StripeElementType]?: string } };
12 | onCardInputChange: (event: any) => void;
13 | }
14 |
15 | export default function PaymentForm({cardState, onCardInputChange}: Props) {
16 | const { control } = useFormContext();
17 |
18 | return (
19 | <>
20 |
21 | Payment method
22 |
23 |
24 |
25 |
26 |
27 |
28 |
44 |
45 |
46 |
63 |
64 |
65 |
82 |
83 |
84 | >
85 | );
86 | }
--------------------------------------------------------------------------------
/client/src/features/checkout/Review.tsx:
--------------------------------------------------------------------------------
1 | import { Grid } from '@mui/material';
2 | import Typography from '@mui/material/Typography';
3 | import { useAppSelector } from '../../app/store/configureStore';
4 | import BasketSummary from '../basket/BasketSummary';
5 | import BasketTable from '../basket/BasketTable';
6 |
7 | export default function Review() {
8 | const {basket} = useAppSelector(state => state.basket);
9 | return (
10 | <>
11 |
12 | Order summary
13 |
14 | {basket &&
15 | }
16 |
17 |
18 |
19 |
20 |
21 |
22 | >
23 | );
24 | }
--------------------------------------------------------------------------------
/client/src/features/checkout/StripeInput.tsx:
--------------------------------------------------------------------------------
1 | import { InputBaseComponentProps } from "@mui/material";
2 | import { forwardRef, Ref, useImperativeHandle, useRef } from "react";
3 |
4 | interface Props extends InputBaseComponentProps {}
5 |
6 | export const StripeInput = forwardRef(function StripeInput({component: Component, ...props}: Props,
7 | ref: Ref){
8 | const elementRef = useRef();
9 |
10 | useImperativeHandle(ref, () => ({
11 | focus: () => elementRef.current.focus
12 | }));
13 |
14 | return (
15 | elementRef.current = element}
17 | {...props}
18 | />
19 | )
20 | });
--------------------------------------------------------------------------------
/client/src/features/checkout/checkoutValidation.ts:
--------------------------------------------------------------------------------
1 | import * as yup from 'yup';
2 |
3 | export const validationSchema = [
4 | yup.object({
5 | fullName: yup.string().required('Full name is required'),
6 | address1: yup.string().required('Addres line 1 is required'),
7 | address2: yup.string().required(),
8 | city: yup.string().required(),
9 | state: yup.string().required(),
10 | zip: yup.string().required(),
11 | country: yup.string().required(),
12 | }),
13 | yup.object(),
14 | yup.object({
15 | nameOnCard: yup.string().required()
16 | })
17 | ]
--------------------------------------------------------------------------------
/client/src/features/contact/ContactPage.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonGroup, Typography } from "@mui/material";
2 | import { useAppDispatch, useAppSelector } from "../../app/store/configureStore";
3 | import { decrement, increment } from "./counterSlice";
4 |
5 | export default function ContactPage() {
6 | const dispatch = useAppDispatch();
7 | const { data, title } = useAppSelector(state => state.counter);
8 |
9 | return (
10 | <>
11 |
12 | {title}
13 |
14 |
15 | The data is: {data}
16 |
17 |
18 |
19 |
20 |
21 |
22 | >
23 |
24 | )
25 | }
--------------------------------------------------------------------------------
/client/src/features/contact/counterReducer.ts:
--------------------------------------------------------------------------------
1 | export const INCREMENT_COUNTER = "INCREMENT_COUNTER";
2 | export const DECREMENT_COUNTER = "DECREMENT_COUNTER";
3 |
4 | export interface CounterState {
5 | data: number;
6 | title: string;
7 | }
8 |
9 | const initialState: CounterState = {
10 | data: 42,
11 | title: 'YARC (yet another redux counter)'
12 | }
13 |
14 | export function increment(amount = 1) {
15 | return {
16 | type: INCREMENT_COUNTER,
17 | payload: amount
18 | }
19 | }
20 |
21 | export function decrement(amount = 1) {
22 | return {
23 | type: DECREMENT_COUNTER,
24 | payload: amount
25 | }
26 | }
27 |
28 | export default function counterReducer(state = initialState, action: any) {
29 | switch (action.type) {
30 | case INCREMENT_COUNTER:
31 | return {
32 | ...state,
33 | data: state.data + action.payload
34 | }
35 | case DECREMENT_COUNTER:
36 | return {
37 | ...state,
38 | data: state.data - action.payload
39 | }
40 | default:
41 | return state;
42 | }
43 | }
--------------------------------------------------------------------------------
/client/src/features/contact/counterSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit"
2 |
3 | export interface CounterState {
4 | data: number;
5 | title: string;
6 | }
7 |
8 | const initialState: CounterState = {
9 | data: 42,
10 | title: 'YARC (yet another redux counter with redux toolkit)'
11 | }
12 |
13 | export const counterSlice = createSlice({
14 | name: 'counter',
15 | initialState,
16 | reducers: {
17 | increment: (state, action) => {
18 | state.data += action.payload
19 | },
20 | decrement: (state, action) => {
21 | state.data -= action.payload
22 | }
23 | }
24 | })
25 |
26 | export const {increment, decrement} = counterSlice.actions;
--------------------------------------------------------------------------------
/client/src/features/home/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Typography } from "@mui/material";
2 | import Slider from "react-slick";
3 |
4 | export default function HomePage() {
5 | const settings = {
6 | dots: true,
7 | infinite: true,
8 | speed: 500,
9 | slidesToShow: 1,
10 | slidesToScroll: 1
11 | };
12 |
13 | return (
14 | <>
15 |
16 |
17 |

18 |
19 |
20 |

21 |
22 |
23 |

24 |
25 |
26 |
27 | Welcome to the store
28 |
29 | >
30 | )
31 | }
--------------------------------------------------------------------------------
/client/src/features/orders/OrderDetailed.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Grid, Typography } from "@mui/material";
2 | import { Box } from "@mui/system";
3 | import { BasketItem } from "../../app/models/basket";
4 | import { Order } from "../../app/models/order";
5 | import BasketSummary from "../basket/BasketSummary";
6 | import BasketTable from "../basket/BasketTable";
7 |
8 | interface Props {
9 | order: Order;
10 | setSelectedOrder: (id: number) => void;
11 | }
12 |
13 | export default function OrderDetailed({ order, setSelectedOrder }: Props) {
14 | const subtotal = order.orderItems.reduce((sum, item) => sum + (item.quantity * item.price), 0) ?? 0;
15 | return (
16 | <>
17 |
18 | Order# {order.id} - {order.orderStatus}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | >
29 | )
30 | }
--------------------------------------------------------------------------------
/client/src/features/orders/Orders.tsx:
--------------------------------------------------------------------------------
1 | import { TableContainer, Paper, Table, TableHead, TableRow, TableCell, TableBody, Button } from "@mui/material";
2 | import { useEffect, useState } from "react";
3 | import agent from "../../app/api/agent";
4 | import LoadingComponent from "../../app/layout/LoadingComponent";
5 | import { Order } from "../../app/models/order";
6 | import { currencyFormat } from "../../app/util/util";
7 | import OrderDetailed from "./OrderDetailed";
8 |
9 | export default function Orders() {
10 | const [orders, setOrders] = useState(null);
11 | const [loading, setLoading] = useState(true);
12 | const [selectedOrderNumber, setSelectedOrderNumber] = useState(0);
13 |
14 | useEffect(() => {
15 | agent.Orders.list()
16 | .then(orders => setOrders(orders))
17 | .catch(error => console.log(error))
18 | .finally(() => setLoading(false));
19 | }, [])
20 |
21 | if (loading) return
22 |
23 | if (selectedOrderNumber > 0) return (
24 | o.id === selectedOrderNumber)!}
26 | setSelectedOrder={setSelectedOrderNumber}
27 | />
28 | )
29 |
30 | return (
31 |
32 |
33 |
34 |
35 | Order number
36 | Total
37 | Order Date
38 | Order Status
39 |
40 |
41 |
42 |
43 | {orders?.map((order) => (
44 |
48 |
49 | {order.id}
50 |
51 | {currencyFormat(order.total)}
52 | {order.orderDate.split('T')[0]}
53 | {order.orderStatus}
54 |
55 |
58 |
59 |
60 | ))}
61 |
62 |
63 |
64 | )
65 | }
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './app/layout/styles.css';
4 | import App from './app/layout/App';
5 | import reportWebVitals from './reportWebVitals';
6 | import { Router } from 'react-router-dom';
7 | import { createBrowserHistory } from 'history';
8 | import { Provider } from 'react-redux';
9 | import { store } from './app/store/configureStore';
10 | import 'slick-carousel/slick/slick.css';
11 | import 'slick-carousel/slick/slick-theme.css';
12 |
13 | export const history = createBrowserHistory();
14 |
15 | ReactDOM.render(
16 |
17 |
18 |
19 |
20 |
21 |
22 | ,
23 | document.getElementById('root')
24 | );
25 |
26 | // If you want to start measuring performance in your app, pass a function
27 | // to log results (for example: reportWebVitals(console.log))
28 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
29 | reportWebVitals();
30 |
--------------------------------------------------------------------------------
/client/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/client/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------