├── .gitignore
├── frontend
├── src
│ ├── styles
│ │ ├── variables.scss
│ │ ├── LandingStyles.scss
│ │ ├── DashboardStyles.scss
│ │ └── global.scss
│ ├── images
│ │ ├── featureTrack.png
│ │ ├── featureProfile.png
│ │ ├── featureUpdate.png
│ │ └── homeImages.js
│ ├── index.js
│ ├── pages
│ │ ├── HomePage.jsx
│ │ └── DashboardPage.jsx
│ ├── components
│ │ ├── AppFooter.jsx
│ │ ├── Dashboard
│ │ │ ├── ControlPanel.jsx
│ │ │ └── UserPanel.jsx
│ │ ├── Home
│ │ │ ├── DiscoverSection.jsx
│ │ │ ├── FeatureSection.jsx
│ │ │ └── HeroSection.jsx
│ │ ├── Popup
│ │ │ ├── Modals
│ │ │ │ ├── ConfirmModal.jsx
│ │ │ │ ├── LoginModal.jsx
│ │ │ │ ├── AddTrackModal.jsx
│ │ │ │ ├── EditTrackModal.jsx
│ │ │ │ └── SignUpModal.jsx
│ │ │ └── PopupBtn.jsx
│ │ └── AppHeader.jsx
│ ├── App.js
│ └── context
│ │ ├── AppReducer.js
│ │ └── GlobalState.js
├── .gitignore
├── public
│ └── index.html
└── package.json
├── readme-images
├── dashboard.JPG
├── dashboardEdit.JPG
├── instruction.JPG
├── landingPage.JPG
├── dashboardAddNew.JPG
└── dashboardDelete.JPG
├── routes
├── track.route.js
└── user.route.js
├── config
└── db.js
├── models
├── User.js
└── Track.js
├── middleware
└── auth.js
├── server.js
├── LICENSE
├── package.json
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
├── README.md
└── controllers
├── user.controller.js
└── track.controller.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | *.env
--------------------------------------------------------------------------------
/frontend/src/styles/variables.scss:
--------------------------------------------------------------------------------
1 | $themeColor: #ecf5fe;
2 | $darkThemeColor: #007acc;
3 |
--------------------------------------------------------------------------------
/readme-images/dashboard.JPG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yewyewxd/React-Amazon-Price-Tracker/HEAD/readme-images/dashboard.JPG
--------------------------------------------------------------------------------
/readme-images/dashboardEdit.JPG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yewyewxd/React-Amazon-Price-Tracker/HEAD/readme-images/dashboardEdit.JPG
--------------------------------------------------------------------------------
/readme-images/instruction.JPG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yewyewxd/React-Amazon-Price-Tracker/HEAD/readme-images/instruction.JPG
--------------------------------------------------------------------------------
/readme-images/landingPage.JPG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yewyewxd/React-Amazon-Price-Tracker/HEAD/readme-images/landingPage.JPG
--------------------------------------------------------------------------------
/readme-images/dashboardAddNew.JPG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yewyewxd/React-Amazon-Price-Tracker/HEAD/readme-images/dashboardAddNew.JPG
--------------------------------------------------------------------------------
/readme-images/dashboardDelete.JPG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yewyewxd/React-Amazon-Price-Tracker/HEAD/readme-images/dashboardDelete.JPG
--------------------------------------------------------------------------------
/frontend/src/images/featureTrack.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yewyewxd/React-Amazon-Price-Tracker/HEAD/frontend/src/images/featureTrack.png
--------------------------------------------------------------------------------
/frontend/src/images/featureProfile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yewyewxd/React-Amazon-Price-Tracker/HEAD/frontend/src/images/featureProfile.png
--------------------------------------------------------------------------------
/frontend/src/images/featureUpdate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yewyewxd/React-Amazon-Price-Tracker/HEAD/frontend/src/images/featureUpdate.png
--------------------------------------------------------------------------------
/frontend/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import App from "./App";
4 |
5 | ReactDOM.render( , document.getElementById("root"));
6 |
--------------------------------------------------------------------------------
/frontend/src/images/homeImages.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | heroBg:
3 | "https://res.cloudinary.com/dnkkw3nqk/image/upload/v1601946477/trackerbase/clouds1_s7zehg.png",
4 | heroVector:
5 | "https://res.cloudinary.com/dnkkw3nqk/image/upload/v1601946500/trackerbase/slides1_linufl.png",
6 | featureItemBg:
7 | "https://res.cloudinary.com/dnkkw3nqk/image/upload/v1601946517/trackerbase/clouds9_vprvms.png",
8 | };
9 |
--------------------------------------------------------------------------------
/frontend/.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 |
--------------------------------------------------------------------------------
/routes/track.route.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const {
4 | postTrack,
5 | deleteTracks,
6 | editTrack,
7 | multiTrack,
8 | } = require("../controllers/track.controller");
9 |
10 | router.route("/track").post(postTrack);
11 | router.route("/track/:id").post(editTrack);
12 | router.route("/delete/tracks").post(deleteTracks);
13 | router.route("/multiTrack").post(multiTrack);
14 |
15 | module.exports = router;
16 |
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 | TrackerBase
12 |
13 |
14 | You need to enable JavaScript to run this app.
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/config/db.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 |
3 | const connectDB = async () => {
4 | try {
5 | const conn = await mongoose.connect(process.env.MONGO_URI, {
6 | useNewUrlParser: true,
7 | useCreateIndex: true,
8 | useUnifiedTopology: true,
9 | });
10 |
11 | console.log(`MongoDB Connected: ${conn.connection.host}`);
12 | } catch (err) {
13 | console.log(`Error: ${err.message}`);
14 | process.exit(1);
15 | }
16 | };
17 |
18 | module.exports = connectDB;
19 |
--------------------------------------------------------------------------------
/models/User.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 |
3 | const userSchema = new mongoose.Schema({
4 | displayName: {
5 | type: String,
6 | required: true,
7 | },
8 | email: {
9 | type: String,
10 | required: true,
11 | unique: true,
12 | },
13 | password: {
14 | type: String,
15 | required: true,
16 | minlength: 5,
17 | },
18 | createdTracks: [
19 | {
20 | type: mongoose.Schema.Types.ObjectId,
21 | ref: "Tracks",
22 | },
23 | ],
24 | });
25 |
26 | module.exports = mongoose.model("Users", userSchema);
27 |
--------------------------------------------------------------------------------
/routes/user.route.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const {
4 | registerUser,
5 | loginUser,
6 | deleteUser,
7 | validateToken,
8 | getUser,
9 | } = require("../controllers/user.controller");
10 | const auth = require("../middleware/auth");
11 |
12 | router.route("/register").post(registerUser);
13 | router.route("/login").post(loginUser);
14 | router.delete("/delete", auth, deleteUser);
15 | router.route("/tokenIsValid").post(validateToken);
16 | router.get("/", auth, getUser);
17 |
18 | module.exports = router;
19 |
--------------------------------------------------------------------------------
/models/Track.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 |
3 | const trackSchema = new mongoose.Schema({
4 | productUrl: {
5 | type: String,
6 | },
7 | image: {
8 | type: String,
9 | required: true,
10 | },
11 | name: {
12 | type: String,
13 | required: true,
14 | },
15 | expectedPrice: {
16 | type: Number,
17 | required: true,
18 | },
19 | actualPrice: {
20 | type: Number,
21 | required: true,
22 | },
23 | creator: {
24 | type: mongoose.Schema.Types.ObjectId,
25 | ref: "Users",
26 | required: true,
27 | },
28 | });
29 |
30 | module.exports = mongoose.model("Tracks", trackSchema);
31 |
--------------------------------------------------------------------------------
/middleware/auth.js:
--------------------------------------------------------------------------------
1 | const jwt = require("jsonwebtoken");
2 |
3 | const auth = async (req, res, next) => {
4 | try {
5 | const token = req.header("user-auth-token");
6 | if (!token) {
7 | return res.status(401).json({ error: "No authentication token" });
8 | }
9 |
10 | const verified = jwt.verify(token, process.env.JWT_SECRET);
11 | if (!verified) {
12 | return res.status(401).json({ error: "Token verification failed" });
13 | }
14 |
15 | req.user = verified.userId;
16 |
17 | next();
18 | } catch (err) {
19 | res.status(500).json({ error: err.message });
20 | }
21 | };
22 |
23 | module.exports = auth;
24 |
--------------------------------------------------------------------------------
/frontend/src/pages/HomePage.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import AppFooter from "../components/AppFooter";
3 | import AppHeader from "../components/AppHeader";
4 | import DiscoverSection from "../components/Home/DiscoverSection";
5 | import FeatureSection from "../components/Home/FeatureSection";
6 | import HeroSection from "../components/Home/HeroSection";
7 |
8 | export default function HomePage() {
9 | return (
10 | <>
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | >
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/components/AppFooter.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { AiOutlineMail } from "react-icons/ai";
3 |
4 | export default function AppFooter() {
5 | return (
6 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { BrowserRouter, Switch, Route, Redirect } from "react-router-dom";
3 | import { GlobalProvider } from "./context/GlobalState";
4 |
5 | // styles
6 | import "bootstrap/dist/css/bootstrap.css";
7 | import "./styles/global.scss";
8 | import "./styles/LandingStyles.scss";
9 | import "./styles/DashboardStyles.scss";
10 | import "react-notifications/lib/notifications.css";
11 |
12 | // pages
13 | import HomePage from "./pages/HomePage";
14 | import DashboardPage from "./pages/DashboardPage";
15 |
16 | function App() {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | export default App;
31 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const dotenv = require("dotenv");
3 | const connectDB = require("./config/db");
4 | const cors = require("cors");
5 | const compression = require("compression");
6 |
7 | // middleware
8 | dotenv.config();
9 | connectDB();
10 | const app = express();
11 | app.use(express.json());
12 | app.use(express.urlencoded({ extended: true }));
13 | app.use(cors());
14 | app.use(compression());
15 | const auth = require("./middleware/auth");
16 |
17 | // routes
18 | const userRoute = require("./routes/user.route");
19 | app.use("/api/user", userRoute);
20 | const trackRoute = require("./routes/track.route");
21 | app.use("/api/dashboard", auth, trackRoute);
22 |
23 | if (process.env.NODE_ENV === "production") {
24 | app.use(express.static("frontend/build"));
25 | }
26 |
27 | // port
28 | const PORT = process.env.PORT || 5000;
29 | app.listen(
30 | PORT,
31 | console.log(
32 | `Server is running in ${
33 | process.env.NODE_ENV || "development"
34 | } on port ${PORT}`
35 | )
36 | );
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Yew Kang Wei
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "amazon-tracker",
3 | "version": "1.0.0",
4 | "description": "an app to track amazon product price",
5 | "engines": {
6 | "node": "12.16.2"
7 | },
8 | "main": "server.js",
9 | "scripts": {
10 | "start": "node server.js",
11 | "client-install": "cd frontend && npm install",
12 | "client-start": "cd frontend && npm start",
13 | "build": "cd frontend && npm run build",
14 | "heroku-postbuild": "NPM_CONFIG_PRODUCTION=false npm install --prefix frontend && npm run build --prefix frontend",
15 | "dev": "concurrently \"nodemon server\" \"npm start --prefix frontend\""
16 | },
17 | "keywords": [],
18 | "author": "",
19 | "license": "ISC",
20 | "dependencies": {
21 | "axios": "^0.21.1",
22 | "bcryptjs": "^2.4.3",
23 | "cheerio": "^1.0.0-rc.5",
24 | "compression": "^1.7.4",
25 | "cors": "^2.8.5",
26 | "dotenv": "^8.2.0",
27 | "express": "^4.17.1",
28 | "jsonwebtoken": "^8.5.1",
29 | "mongoose": "^5.10.7",
30 | "puppeteer": "^5.3.1",
31 | "uuid": "^8.3.1"
32 | },
33 | "devDependencies": {
34 | "concurrently": "^5.3.0",
35 | "nodemon": "^2.0.4"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@material-ui/core": "^4.11.0",
7 | "@testing-library/jest-dom": "^4.2.4",
8 | "@testing-library/react": "^9.5.0",
9 | "@testing-library/user-event": "^7.2.1",
10 | "axios": "^0.19.2",
11 | "bootstrap": "^4.5.2",
12 | "node-sass": "^4.14.1",
13 | "react": "^16.13.1",
14 | "react-dom": "^16.13.1",
15 | "react-flash-message": "^1.0.4",
16 | "react-icons": "^3.11.0",
17 | "react-loader-spinner": "^3.1.14",
18 | "react-notifications": "^1.7.2",
19 | "react-router-dom": "^5.2.0",
20 | "react-scripts": "3.4.3"
21 | },
22 | "scripts": {
23 | "start": "react-scripts start",
24 | "build": "react-scripts build",
25 | "test": "react-scripts test",
26 | "eject": "react-scripts eject"
27 | },
28 | "eslintConfig": {
29 | "extends": "react-app"
30 | },
31 | "browserslist": {
32 | "production": [
33 | ">0.2%",
34 | "not dead",
35 | "not op_mini all"
36 | ],
37 | "development": [
38 | "last 1 chrome version",
39 | "last 1 firefox version",
40 | "last 1 safari version"
41 | ]
42 | },
43 | "proxy": "http://localhost:5000"
44 | }
45 |
--------------------------------------------------------------------------------
/frontend/src/components/Dashboard/ControlPanel.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | // import { FaRegUser } from "react-icons/fa";
3 | import { RiDashboardLine } from "react-icons/ri";
4 |
5 | export default function ControlPanel() {
6 | const controlBars = [
7 | // {
8 | // text: "Profile",
9 | // icon: ,
10 | // },
11 | {
12 | text: "My Tracks",
13 | icon: ,
14 | },
15 | ];
16 | return (
17 |
18 |
19 | {controlBars.map((controlBar, index) => (
20 |
25 | {controlBar.icon}
26 |
27 | {controlBar.text}
28 |
29 |
30 | ))}
31 |
32 | More feature coming soon!
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | When contributing to this repository, it's optional, but encouraged to first discuss the change you wish to make via issue,
4 | email, or any other method with the owners of this repository before making a change.
5 |
6 | Please note we have a code of conduct, please follow it in all your interactions with the project.
7 |
8 | ## Ways to contact
9 | [LinkedIn](https://www.linkedin.com/in/yewyewxd/)
10 |
11 |
12 | ## Pull Request Process
13 |
14 | 1. Fork the repository, clone the repository you forked, and start editing.
15 | 2. Create a branch with a descriptive name with `git checkout -b your-branch-name`. For example, if you want to add an email feature, you could name it as `add-email-feature`.
16 | 3. Commit the changes with descriptive commit message and finally push it with `git push origin your-branch-name`
17 | - Note that `your-branch-name` means your branch's name, don't take it literally
18 | 4. Submit a pull request with details of changes to the interface, this includes new environment
19 | variables, exposed ports, useful file locations, etc.
20 | 5. Your pull request will be merged once the moderators have reviewed your code.
21 |
22 | ## Become a moderator
23 | To become a moderator and help maintaining this project (code review, helpful comments, etc), please contact the creator via
24 | [LinkedIn](https://www.linkedin.com/in/yewyewxd/),
25 | or email (yewyew6933 `at` gmail `dot` com)
26 |
--------------------------------------------------------------------------------
/frontend/src/components/Home/DiscoverSection.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { heroBg, heroVector } from "../../images/homeImages";
3 |
4 | export default function DiscoverSection() {
5 | return (
6 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | our mission
19 |
20 |
Say Goodbye to Bookmarks
21 |
22 | Are you're tired of bookmarking Amazon product pages and
23 | checking them out one by one, to see if their prices drop? Don't
24 | worry, TrackerBase is here for the
25 | rescue.
26 |
27 |
28 |
29 | We automate all the boring process and show the latest prices of
30 | the Amazon products you tracked in one place. While waiting for
31 | our system to finish the trace, you can just sit back and have a
32 | cup of tea.
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/context/AppReducer.js:
--------------------------------------------------------------------------------
1 | export default (state, action) => {
2 | switch (action.type) {
3 | case "LOGIN_USER":
4 | return {
5 | ...state,
6 | token: action.payload.token,
7 | user: action.payload.user,
8 | errMsg: null,
9 | notification: action.payload.notification,
10 | };
11 |
12 | case "LOGOUT_USER":
13 | return {
14 | ...state,
15 | token: null,
16 | user: null,
17 | errMsg: null,
18 | notification: action.payload.notification,
19 | };
20 |
21 | case "UPDATE_USER_LOADING":
22 | return {
23 | ...state,
24 | userLoading: action.payload,
25 | };
26 |
27 | case "ADD_TRACK":
28 | return {
29 | ...state,
30 | user: {
31 | ...state.user,
32 | createdTracks: [action.payload.data, ...state.user.createdTracks],
33 | },
34 | notification: action.payload.notification,
35 | isTracking: false,
36 | };
37 |
38 | case "UPDATE_TRACKS":
39 | return {
40 | ...state,
41 | user: {
42 | ...state.user,
43 | createdTracks: action.payload.data,
44 | },
45 | notification: action.payload.notification,
46 | };
47 |
48 | case "MULTI_TRACK":
49 | return {
50 | ...state,
51 | user: {
52 | ...state.user,
53 | createdTracks: action.payload.data,
54 | },
55 | notification: action.payload.notification,
56 | isTracking: false,
57 | };
58 |
59 | case "LOG_ERROR_MESSAGE":
60 | return {
61 | ...state,
62 | errMsg: action.payload.message,
63 | notification: action.payload.notification,
64 | isTracking: false,
65 | };
66 |
67 | case "CLEAR_LOGS":
68 | return {
69 | ...state,
70 | errMsg: null,
71 | notification: null,
72 | isTracking: true,
73 | };
74 |
75 | default:
76 | return state;
77 | }
78 | };
79 |
--------------------------------------------------------------------------------
/frontend/src/components/Home/FeatureSection.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { featureItemBg } from "../../images/homeImages";
3 | import featureItem1 from "../../images/featureProfile.png";
4 | import featureItem2 from "../../images/featureTrack.png";
5 | import featureItem3 from "../../images/featureUpdate.png";
6 |
7 | export default function FeatureSection() {
8 | const featureCols = [
9 | {
10 | image: featureItem1,
11 | title: "Create an account",
12 | subtitle:
13 | "To access to your personal dashboard, you have to first login or create an account.",
14 | },
15 | {
16 | image: featureItem2,
17 | title: "Track a new product",
18 | subtitle:
19 | "Go to any product detail page on Amazon, copy & paste the link into the Product URL field, label the record with a personalized name, enter your desired price, and run the trace.",
20 | },
21 | {
22 | image: featureItem3,
23 | title: "Keep it updated",
24 | subtitle:
25 | "You can re-track all the recorded products in just one click. Do it once in a while to get the latest price and information of the products.",
26 | },
27 | ];
28 | return (
29 |
30 |
31 |
It's Easy to Use
32 |
33 | {featureCols.map((featureCol, index) => (
34 |
35 |
36 |
41 |
46 |
47 |
{featureCol.title}
48 |
{featureCol.subtitle}
49 |
50 | ))}
51 |
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/frontend/src/styles/LandingStyles.scss:
--------------------------------------------------------------------------------
1 | @import "./variables.scss";
2 |
3 | .home-page {
4 | .hero {
5 | background: $themeColor;
6 | height: 90vh;
7 | .hero-background {
8 | background-position: center !important;
9 | background-repeat: no-repeat !important;
10 | background-size: cover !important;
11 | }
12 |
13 | .caption {
14 | .title {
15 | font-size: 3rem;
16 | }
17 | .subtitle {
18 | font-size: 1.1rem;
19 | }
20 | }
21 |
22 | .vector {
23 | width: 50em;
24 | }
25 | }
26 |
27 | .feature {
28 | .column {
29 | .front-image {
30 | z-index: 1;
31 | }
32 | }
33 | }
34 |
35 | .discover {
36 | background: $darkThemeColor;
37 | .title {
38 | font-size: 2rem;
39 | }
40 | }
41 | }
42 |
43 | @media (max-width: 1199.98px) {
44 | .home-page {
45 | .hero {
46 | height: 80vh;
47 | .vector {
48 | width: 40em;
49 | }
50 | }
51 | }
52 | }
53 |
54 | @media (max-width: 991.98px) {
55 | .home-page {
56 | .hero {
57 | height: 70vh;
58 | }
59 | }
60 |
61 | .discover {
62 | .discover-background {
63 | background: none !important;
64 | }
65 | }
66 | }
67 |
68 | @media (max-width: 767.98px) {
69 | .home-page {
70 | .hero {
71 | height: 60vh;
72 | .caption {
73 | .title {
74 | font-size: 2.5rem;
75 | line-height: 3rem;
76 | margin-bottom: 1rem;
77 | }
78 | .subtitle {
79 | font-size: 1rem;
80 | }
81 | }
82 | .vector {
83 | width: 30rem;
84 | }
85 | }
86 |
87 | .feature {
88 | h1.title {
89 | font-size: 1.9rem;
90 | }
91 | .column {
92 | .front-image {
93 | width: 90px;
94 | }
95 | }
96 | }
97 | }
98 | }
99 |
100 | @media (max-width: 350px) {
101 | .home-page {
102 | .hero {
103 | .buttons {
104 | flex-direction: column-reverse !important;
105 | button {
106 | margin: 0.5em 0;
107 | }
108 | }
109 | }
110 |
111 | .discover {
112 | .title {
113 | font-size: 1.8rem;
114 | }
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/frontend/src/components/Popup/Modals/ConfirmModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState } from "react";
2 | import { GlobalContext } from "../../../context/GlobalState";
3 | import Loader from "react-loader-spinner";
4 |
5 | export default function ConfirmModal({
6 | type,
7 | open,
8 | handleClose,
9 | selectedTracks,
10 | setSelectedTracks,
11 | }) {
12 | const { isTracking, deleteTracks, multiTrack } = useContext(GlobalContext);
13 | const [isSubmitting, setIsSubmitting] = useState(false);
14 |
15 | function handleDeleteTrack(e) {
16 | e.preventDefault();
17 | deleteTracks(selectedTracks);
18 | setSelectedTracks([]);
19 | handleClose();
20 | }
21 |
22 | function handleMultiTrack() {
23 | multiTrack();
24 | setIsSubmitting(true);
25 | }
26 |
27 | if (!isTracking && open) {
28 | handleClose();
29 | if (isSubmitting) {
30 | setIsSubmitting(false);
31 | }
32 | }
33 |
34 | return (
35 | <>
36 | {isSubmitting && (
37 |
38 |
39 |
40 | Please wait while we are tracking the products
41 |
42 |
43 | )}
44 |
45 | {!isSubmitting && (
46 | <>
47 | Please confirm your action
48 |
49 |
54 | Cancel
55 |
56 | {type === "deleteTrack" && (
57 |
61 | Delete
62 |
63 | )}
64 |
65 | {type === "multiTrack" && (
66 |
71 | Update All
72 |
73 | )}
74 |
75 | >
76 | )}
77 | >
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/frontend/src/pages/DashboardPage.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import AppHeader from "../components/AppHeader";
3 | import ControlPanel from "../components/Dashboard/ControlPanel";
4 | import UserPanel from "../components/Dashboard/UserPanel";
5 | import PopupBtn from "../components/Popup/PopupBtn";
6 | import { GlobalContext } from "../context/GlobalState";
7 | import { heroBg } from "../images/homeImages";
8 |
9 | export default function DashboardPage() {
10 | const { token, loginUser } = useContext(GlobalContext);
11 |
12 | function handleGuestLogin() {
13 | const email = "tester@mail.com";
14 | const pw = "tester";
15 |
16 | loginUser(email, pw);
17 | }
18 |
19 | if (token) {
20 | return (
21 | <>
22 |
23 |
24 |
25 |
26 |
27 |
28 | >
29 | );
30 | } else {
31 | // if not logged in
32 | return (
33 | <>
34 |
35 |
36 |
37 |
41 |
42 |
43 |
Opps, access denied!
44 |
45 | Please login or create an account to view your dashboard
46 |
47 |
48 |
49 |
50 |
54 | Demo
55 |
56 |
57 |
58 |
59 |
60 | Get started
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | >
70 | );
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/frontend/src/components/Home/HeroSection.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import { heroBg, heroVector } from "../../images/homeImages";
3 | import PopupBtn from "../Popup/PopupBtn";
4 | import { GlobalContext } from "../../context/GlobalState";
5 | import { Link } from "react-router-dom";
6 |
7 | export default function HeroSection() {
8 | const { token, loginUser } = useContext(GlobalContext);
9 |
10 | function handleGuestLogin() {
11 | const email = "tester@mail.com";
12 | const pw = "tester";
13 |
14 | loginUser(email, pw);
15 | }
16 |
17 | return (
18 |
19 |
23 |
24 |
25 |
26 | Welcome to TrackerBase
27 |
28 |
29 | We help you track any Amazon product and maintain your records in
30 | one place.
31 |
32 |
33 |
34 | {!token && (
35 | <>
36 |
37 |
38 |
42 | Demo
43 |
44 |
45 |
46 |
47 |
48 |
49 | Get started
50 |
51 |
52 | >
53 | )}
54 |
55 | {token && (
56 |
60 | View Dashboard
61 |
62 | )}
63 |
64 |
65 |
66 |
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/frontend/src/components/AppHeader.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import { Link } from "react-router-dom";
3 | import PopupBtn from "./Popup/PopupBtn";
4 | import { GlobalContext } from "../context/GlobalState";
5 | import {
6 | NotificationContainer,
7 | NotificationManager,
8 | } from "react-notifications";
9 |
10 | export default function AppHeader({ isDashboard }) {
11 | const { token, logoutUser, notification } = useContext(GlobalContext);
12 |
13 | if (notification) {
14 | const type = notification.type;
15 | const message = notification.message;
16 | if (type === "info") {
17 | NotificationManager.info(message, null, 4000);
18 | } else if (type === "success") {
19 | NotificationManager.success(message, null, 3000);
20 | } else if (type === "warning") {
21 | NotificationManager.warning(message, null, 5000);
22 | } else if (type === "error") {
23 | NotificationManager.error(notification.title, message, 6000);
24 | }
25 | }
26 |
27 | return (
28 |
29 | <>
30 |
31 |
32 |
33 |
34 |
35 | TrackerBase
36 |
37 |
38 |
39 | {/* not logged in */}
40 | {!token && (
41 | <>
42 |
43 | Login
44 |
45 |
46 |
47 | Sign Up
48 |
49 |
50 | >
51 | )}
52 |
53 | {/* logged in */}
54 | {token && (
55 | <>
56 |
60 | Logout
61 |
62 | {!isDashboard && (
63 |
67 | Dashboard
68 |
69 | )}
70 | >
71 | )}
72 |
73 |
74 |
75 | >
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/frontend/src/components/Popup/Modals/LoginModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState } from "react";
2 | import { GlobalContext } from "../../../context/GlobalState";
3 | import { AiFillEyeInvisible, AiFillEye } from "react-icons/ai";
4 | import Loader from "react-loader-spinner";
5 |
6 | export default function LoginModal({ handleClose, handleSwitchType }) {
7 | const { loginUser, token, errMsg, userLoading } = useContext(GlobalContext);
8 | const [isShowingPw, setIsShowingPw] = useState(false);
9 |
10 | const [email, setEmail] = useState("");
11 | const [pw, setPw] = useState("");
12 |
13 | function handleLoginUser(e) {
14 | e.preventDefault();
15 |
16 | loginUser(email, pw);
17 |
18 | if (token) {
19 | handleClose();
20 | }
21 | }
22 |
23 | function togglePwRevealer() {
24 | setIsShowingPw(!isShowingPw);
25 | }
26 |
27 | return (
28 | <>
29 | {!token && userLoading && (
30 |
31 |
32 |
33 | Please wait while we're logging you in
34 |
35 |
36 | )}
37 |
38 | {!userLoading && (
39 | <>
40 | Log In
41 |
91 | >
92 | )}
93 | >
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/frontend/src/styles/DashboardStyles.scss:
--------------------------------------------------------------------------------
1 | @import "./variables.scss";
2 |
3 | // locked-page
4 | .dashboard-locked-page {
5 | .dashboard-locked {
6 | background: $themeColor;
7 | height: 90vh;
8 |
9 | .dashboard-locked-background {
10 | background-position: center !important;
11 | background-repeat: no-repeat !important;
12 | background-size: cover !important;
13 | }
14 |
15 | .caption {
16 | .title {
17 | font-size: 3rem;
18 | }
19 | .subtitle {
20 | font-size: 1.1rem;
21 | }
22 | }
23 | }
24 | }
25 |
26 | // authenticated dashboard page
27 | .dashboard-page {
28 | // min-height: 100vh;
29 | .control-panel {
30 | width: 15%;
31 | height: 100vh;
32 | background: linear-gradient(to bottom, #007acc, #007acc);
33 |
34 | .control-bars {
35 | .control-bar {
36 | opacity: 1; // temporary
37 | background: rgba($color: blue, $alpha: 0.1);
38 | padding: 22px 0;
39 | padding-left: 44px;
40 | transition: 0.2s;
41 | border-left: 5px solid white;
42 | white-space: nowrap;
43 | &:hover {
44 | opacity: 1;
45 | }
46 | .icon {
47 | font-size: 1.4rem;
48 | }
49 | }
50 | }
51 | }
52 |
53 | .user-panel {
54 | background: $themeColor;
55 | width: 85%;
56 | height: 100vh;
57 |
58 | .title {
59 | font-size: 3rem;
60 | }
61 |
62 | .tracks {
63 | padding: 0 48px;
64 | .track {
65 | user-select: none;
66 | * {
67 | pointer-events: none;
68 | }
69 | .edit-btn,
70 | .check-btn {
71 | pointer-events: initial;
72 | }
73 | border: none;
74 | transition: 0.3s;
75 | border: solid 1px white;
76 | cursor: pointer;
77 | &:hover {
78 | border: solid 1px #007bff;
79 | }
80 | }
81 | }
82 | }
83 | }
84 |
85 | @media (max-width: 1500px) {
86 | .dashboard-page {
87 | .control-panel {
88 | .control-bars {
89 | .control-bar {
90 | padding-left: 0;
91 | justify-content: center !important;
92 | }
93 | }
94 | }
95 | }
96 | }
97 |
98 | @media (max-width: 767.98px) {
99 | .dashboard-page {
100 | .control-panel {
101 | width: 15%;
102 | // padding: 0;
103 | }
104 |
105 | .user-panel {
106 | width: 85%;
107 |
108 | .tracks {
109 | padding: 0 24px;
110 | }
111 | }
112 | }
113 | }
114 |
115 | @media (max-width: 575.98px) {
116 | .dashboard-page {
117 | .control-panel {
118 | position: fixed;
119 | bottom: 0;
120 | background: #007bff;
121 | height: 50px;
122 | width: 100vw;
123 | .control-bars {
124 | .control-bar {
125 | border-left: none;
126 | padding: 0;
127 | align-items: center !important;
128 | margin: 0 36px;
129 | }
130 | }
131 | }
132 |
133 | .user-panel {
134 | height: 100vh;
135 | width: 100vw;
136 | font-size: 0.9rem;
137 | .title {
138 | font-size: 2.5rem;
139 | }
140 | .tracks {
141 | padding: 0 12px;
142 | }
143 | }
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/frontend/src/styles/global.scss:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;700&display=swap");
2 | @import "./variables.scss";
3 |
4 | // sizing, positioning, typography
5 | body {
6 | margin: 0;
7 | padding: 0;
8 | font-family: "Nunito", sans-serif;
9 | overflow-x: hidden;
10 | }
11 | .bold {
12 | font-weight: 700;
13 | }
14 | .all-center {
15 | display: flex;
16 | justify-content: center;
17 | align-items: center;
18 | }
19 | .all-center-column {
20 | display: flex;
21 | justify-content: center;
22 | align-items: center;
23 | flex-direction: column;
24 | }
25 | .light {
26 | color: $themeColor !important;
27 | }
28 | .dark {
29 | color: $darkThemeColor !important;
30 | }
31 |
32 | // button
33 | .btn {
34 | box-shadow: none !important;
35 | transition: 0.15s;
36 | }
37 | .btn-sm {
38 | border-radius: 15px !important;
39 | padding: 3px 9px !important;
40 | }
41 | .btn-md {
42 | border-radius: 20px;
43 | padding: 8px 24px !important;
44 | }
45 |
46 | // header
47 | .header {
48 | .buttons {
49 | white-space: nowrap;
50 | margin-left: auto;
51 | }
52 | .notification-container {
53 | width: 290px;
54 | margin-top: 70px;
55 | }
56 | }
57 |
58 | // footer
59 | .footer {
60 | background: $themeColor;
61 | .report-problem {
62 | position: absolute;
63 | right: 0;
64 | }
65 | }
66 |
67 | //modal
68 | .popup-modal {
69 | overflow-y: initial !important;
70 | background: $themeColor;
71 | .form-container-image {
72 | width: 40%;
73 | border-right: solid 1px $themeColor;
74 | }
75 |
76 | .form-container {
77 | padding: 48px;
78 | width: 60%;
79 | }
80 | }
81 |
82 | // password revealer
83 | .pw-revealer {
84 | font-size: 1.4rem;
85 | float: right;
86 | position: relative;
87 | margin-right: 10px;
88 | z-index: 2;
89 | }
90 |
91 | @media (max-width: 991.98px) {
92 | // modal
93 | .popup-modal {
94 | background: white;
95 | .form-container-image {
96 | display: none;
97 | }
98 | .form-container {
99 | width: 100%;
100 | }
101 | }
102 | }
103 |
104 | @media (max-width: 767.98px) {
105 | .popup-modal {
106 | .form-container {
107 | max-height: 70vh;
108 | overflow-y: auto;
109 | padding: 24px 48px;
110 |
111 | .track-detail-image {
112 | width: 35%;
113 | }
114 | }
115 | }
116 |
117 | .footer {
118 | .report-problem {
119 | position: initial;
120 | right: initial;
121 | }
122 | }
123 | }
124 | @media (max-width: 500px) {
125 | .popup-modal {
126 | .form-container {
127 | .track-detail-image {
128 | width: 50%;
129 | margin-bottom: 24px;
130 | }
131 | }
132 | }
133 | }
134 |
135 | @media (max-width: 350px) {
136 | // header
137 | .header {
138 | .notification-container {
139 | width: 290px;
140 | }
141 | .container {
142 | display: flex !important;
143 | justify-content: center;
144 | align-items: center;
145 | flex-direction: column;
146 | }
147 | .buttons,
148 | .navbar-brand {
149 | margin: 0.1em 0;
150 | }
151 | }
152 |
153 | // modal
154 | .popup-modal {
155 | .form-container {
156 | padding: 12px 24px;
157 | .track-detail-image {
158 | width: 60%;
159 | }
160 | }
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to make participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies within all project spaces, and it also applies when
49 | an individual is representing the project or its community in public spaces.
50 | Examples of representing a project or community include using an official
51 | project e-mail address, posting via an official social media account, or acting
52 | as an appointed representative at an online or offline event. Representation of
53 | a project may be further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/frontend/src/components/Popup/PopupBtn.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext } from "react";
2 | import { makeStyles } from "@material-ui/core/styles";
3 | import { GlobalContext } from "../../context/GlobalState";
4 | import Modal from "@material-ui/core/Modal";
5 | import Backdrop from "@material-ui/core/Backdrop";
6 | import Fade from "@material-ui/core/Fade";
7 | import { heroVector } from "../../images/homeImages";
8 |
9 | import SignUpModal from "./Modals/SignUpModal";
10 | import LoginModal from "./Modals/LoginModal";
11 | import AddTrackModal from "./Modals/AddTrackModal";
12 | import EditTrackModal from "./Modals/EditTrackModal";
13 | import ConfirmModal from "./Modals/ConfirmModal";
14 |
15 | const useStyles = makeStyles((theme) => ({
16 | paper: {
17 | boxShadow: theme.shadows[5],
18 | border: "none",
19 | outline: "none",
20 | },
21 | }));
22 |
23 | export default function PopupBtn({
24 | children,
25 | type,
26 | track,
27 | selectedTracks,
28 | setSelectedTracks,
29 | }) {
30 | const classes = useStyles();
31 | const { token } = useContext(GlobalContext);
32 |
33 | const [open, setOpen] = useState(false);
34 | const [isSignUp, setIsSignUp] = useState(type === "signUp");
35 |
36 | const handleOpen = () => {
37 | setOpen(true);
38 | };
39 | const handleClose = () => {
40 | setOpen(false);
41 | };
42 |
43 | function handleSwitchType() {
44 | setIsSignUp((prevIsSignUp) => !prevIsSignUp);
45 | }
46 |
47 | return (
48 |
49 |
50 | {children}
51 |
52 |
62 |
63 |
66 |
71 | {type !== "deleteTrack" && type !== "multiTrack" && (
72 |
73 |
74 |
75 | )}
76 |
77 |
78 | {isSignUp && !token && (
79 |
83 | )}
84 |
85 | {!isSignUp && !token && (
86 |
90 | )}
91 |
92 | {type === "addTrack" && token && (
93 |
94 | )}
95 |
96 | {type === "editTrack" && token && (
97 |
98 | )}
99 |
100 | {(type === "deleteTrack" || type === "multiTrack") && token && (
101 |
108 | )}
109 |
110 |
111 |
112 |
113 |
114 |
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Amazon Product Tracking App V2
2 |
3 | ## Description
4 |
5 | A fullstack web app to track the price of any Amazon product and store user's traces in one place
6 | This repo will be achieved due to Amazon restrictions and Heroku limitations
7 |
8 |
9 | ## To contribute
10 |
11 | [Learn more](https://github.com/yewyewXD/React-Amazon-Price-Tracker/blob/master/CONTRIBUTING.md)
12 |
13 | ## Build status
14 |
15 | Started on: 13 Aug 2020
16 | Completed on: 16 Aug 2020
17 |
18 | ## Screenshots (V2)
19 |
20 | 
21 |
22 | 
23 |
24 | 
25 |
26 | 
27 |
28 | 
29 |
30 | 
31 |
32 | ## Tech/framework used
33 |
34 | - MERN Stack (MongoDB, Express, React, Node)
35 | - Bootstrap
36 | - Sass
37 |
38 | ## Features
39 |
40 | - Create an account
41 | - Track any product's price on Amazon
42 |
43 | ## How to use it locally like it's yours (Not for contribution)
44 |
45 | > Get your MongoDB connection string
46 |
47 | Follow from Part 1 (Create an Atlas Account) to Part 5 (Connect to Your Cluster) in [this documentation](https://docs.atlas.mongodb.com/getting-started/).
48 |
49 | In [Part 5](https://docs.atlas.mongodb.com/tutorial/connect-to-your-cluster/), skip to [Connect to Your Atlas Cluster](https://docs.atlas.mongodb.com/tutorial/connect-to-your-cluster/#connect-to-your-atlas-cluster) and follow from Step 1 to Step 4 to get the connection string.
50 |
51 | Now, clone the repository, then:
52 |
53 | > cd into the working directory and install dependencies in both server & client side:
54 |
55 | ```bash
56 | cd React-Amazon-Price-Tracker
57 | npm i
58 | cd frontend
59 | npm i
60 | ```
61 |
62 | > Back to the root folder and create a ".env" file:
63 |
64 | ```bash
65 | cd ..
66 | cd ..
67 | touch .env
68 | ```
69 |
70 | > In ".env", enter your mongoDB connection string and JWT secret key:
71 |
72 | - If you're using VS Code, you can use this command to start editing
73 |
74 | ```bash
75 | code .
76 | ```
77 |
78 | - Paste in the code, replace mongodb-connection-string with your MongoDB connection string, and edit yourJwtSecret (for better security, use a complex string).
79 |
80 | ```bash
81 | NODE_ENV=development
82 | MONGO_URI=mongodb-connection-string
83 | JWT_SECRET=yourJwtSecret
84 | ```
85 |
86 | > Install concurrently to run both server and client side in one terminal, and run the app:
87 |
88 | ```bash
89 | npm i -D concurrently
90 | npm run dev
91 | ```
92 |
93 | ## Future Update
94 |
95 | - Add email management & notification about price raise/drop
96 | - Secure token with httpOnly cookie
97 | - Make user profile customizable
98 | - Add more authentication methods
99 | - Add password reset and email confirmation
100 | - Add price analytics
101 |
102 | ## Credits
103 |
104 | #### Project Inspiration:
105 |
106 | [Video by Web Dev Simplified](https://www.youtube.com/watch?v=H5ObmDUjKV4&ab_channel=WebDevSimplified)
107 |
108 | ##### Design Inspiration:
109 |
110 | [Landing Page](https://html.crumina.net/html-utouch/index.html)
111 | [Dashboard](https://dribbble.com/shots/3699047-dashX-Income)
112 | [Dashboard CRUD](https://dribbble.com/shots/8491396-Frappe-Accounting-Customers)
113 | [Popup Modal](https://dribbble.com/shots/8491396-Frappe-Accounting-Customers)
114 |
115 | ##### Images:
116 |
117 | [Flaticon](https://www.flaticon.com/home)
118 |
--------------------------------------------------------------------------------
/controllers/user.controller.js:
--------------------------------------------------------------------------------
1 | const User = require("../models/User");
2 | const bcrypt = require("bcryptjs");
3 | const jwt = require("jsonwebtoken");
4 |
5 | // @desc Register user
6 | // @route POST /api/user/register
7 | // @access public
8 | exports.registerUser = async (req, res, next) => {
9 | try {
10 | const { displayName, email, password, confirmPassword } = req.body;
11 |
12 | // Validation
13 | if (!displayName || !email || !password || !confirmPassword) {
14 | return res.status(401).json({
15 | success: false,
16 | error: "Please enter all field",
17 | });
18 | }
19 | const emailExist = await User.findOne({ email });
20 | if (emailExist) {
21 | return res.status(401).json({
22 | success: false,
23 | error: "This email has been used",
24 | });
25 | }
26 | if (password.length < 5) {
27 | return res.status(401).json({
28 | success: false,
29 | error: "Password needs to be at least 5 characters long",
30 | });
31 | }
32 | if (password !== confirmPassword) {
33 | return res.status(401).json({
34 | success: false,
35 | error: "Passwords do not match",
36 | });
37 | }
38 |
39 | // encrypt and create user
40 | const hashedPassword = await bcrypt.hash(password, 12);
41 | const userInfo = {
42 | displayName,
43 | email,
44 | password: hashedPassword,
45 | };
46 | await User.create(userInfo);
47 |
48 | return res.status(201).json({
49 | success: true,
50 | data: { displayName, email }, // not used in client side
51 | });
52 | } catch (err) {
53 | return res.status(500).json({ error: err.message });
54 | }
55 | };
56 |
57 | // @desc Login user
58 | // @route POST /api/user/login
59 | // @access public
60 | exports.loginUser = async (req, res, next) => {
61 | try {
62 | const { email, password } = req.body;
63 |
64 | // Validation
65 | const user = await User.findOne({ email }).populate("createdTracks");
66 | if (!user) {
67 | return res.status(401).json({
68 | success: false,
69 | error: "User does not exist",
70 | });
71 | }
72 |
73 | const verified = await bcrypt.compare(password, user.password);
74 | if (!verified) {
75 | return res.status(401).json({
76 | success: false,
77 | error: "Password is incorrect",
78 | });
79 | }
80 |
81 | // authenticate user
82 | const jwtToken = jwt.sign(
83 | { userId: user.id, email: user.email },
84 | process.env.JWT_SECRET,
85 | {}
86 | );
87 |
88 | return res.status(201).json({
89 | success: true,
90 | data: {
91 | token: jwtToken,
92 | user: {
93 | userId: user.id,
94 | displayName: user.displayName,
95 | email: user.email,
96 | createdTracks: user.createdTracks,
97 | },
98 | },
99 | });
100 | } catch (err) {
101 | return res.status(500).json({ error: err.message });
102 | }
103 | };
104 |
105 | // @desc Delete user
106 | // @route POST /api/user/delete
107 | // @access private
108 | exports.deleteUser = async (req, res, next) => {
109 | try {
110 | const deletedUser = await User.findByIdAndDelete(req.user);
111 |
112 | return res.status(201).json({
113 | success: true,
114 | deleted: deletedUser,
115 | });
116 | } catch (err) {
117 | return res.status(500).json({ error: err.message });
118 | }
119 | };
120 |
121 | // @desc Validate token
122 | // @route POST /api/user/tokenIsValid
123 | // @access public
124 | exports.validateToken = async (req, res, next) => {
125 | try {
126 | const token = req.header("user-auth-token");
127 | if (!token) return res.json(false);
128 |
129 | const verified = jwt.verify(token, process.env.JWT_SECRET);
130 | if (!verified) return res.json(false);
131 |
132 | const user = await User.findById(verified.userId);
133 | if (!user) return res.json(false);
134 |
135 | return res.json(true);
136 | } catch (err) {
137 | return res.status(500).json({ error: err.message });
138 | }
139 | };
140 |
141 | // @desc Get user with validated token
142 | // @route GET /api/user/
143 | // @access private
144 | exports.getUser = async (req, res, next) => {
145 | try {
146 | const user = await User.findById(req.user).populate("createdTracks");
147 | res.json({
148 | userId: user.id,
149 | displayName: user.displayName,
150 | email: user.email,
151 | createdTracks: user.createdTracks,
152 | });
153 | } catch (err) {
154 | return res.status(500).json({ error: err.message });
155 | }
156 | };
157 |
--------------------------------------------------------------------------------
/frontend/src/components/Popup/Modals/AddTrackModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useRef, useState } from "react";
2 | import { GlobalContext } from "../../../context/GlobalState";
3 | import Loader from "react-loader-spinner";
4 | import FlashMessage from "react-flash-message";
5 |
6 | export default function AddTrackModal({ handleClose, open }) {
7 | const { user, addTrack, isTracking } = useContext(GlobalContext);
8 | const [isSubmitting, setIsSubmitting] = useState(false);
9 | const [hasError, setHasError] = useState(false);
10 |
11 | const urlElRef = useRef();
12 | const nameElRef = useRef();
13 | const expectedPriceElRef = useRef();
14 |
15 | function handleAddTrack(e) {
16 | e.preventDefault();
17 | if (hasError) {
18 | setHasError(false);
19 | }
20 |
21 | const url = urlElRef.current.value;
22 | const name = nameElRef.current.value;
23 | const expectedPrice = expectedPriceElRef.current.value;
24 |
25 | // Validation
26 | const hasDuplicate =
27 | user.createdTracks.filter((createdTrack) => createdTrack.name === name)
28 | .length > 0;
29 | if (hasDuplicate) {
30 | setHasError(true);
31 | } else {
32 | setHasError(false);
33 | // Check and submit
34 | if (url.indexOf("/ref=") === -1) {
35 | addTrack(url, name, +expectedPrice);
36 | } else {
37 | const trimmedUrl = url.substr(0, url.indexOf("/ref="));
38 | const finalUrl =
39 | trimmedUrl.indexOf("https://") === -1
40 | ? `https://"${trimmedUrl}`
41 | : trimmedUrl;
42 |
43 | addTrack(finalUrl, name, +expectedPrice);
44 | }
45 |
46 | setIsSubmitting(true);
47 | }
48 | }
49 |
50 | function handleAddDemoLink() {
51 | urlElRef.current.value =
52 | "https://www.amazon.de/Dymatize-ISO-100-Gourmet-Vanilla/dp/B01N9EYUZ8/";
53 | }
54 |
55 | if (!isTracking && open) {
56 | handleClose();
57 | if (isSubmitting) {
58 | setIsSubmitting(false);
59 | }
60 | }
61 |
62 | return (
63 | <>
64 | {isSubmitting && (
65 |
66 |
67 |
68 | Please wait while we are tracking the product
69 |
70 |
71 | )}
72 |
73 | {!isSubmitting && (
74 | <>
75 | Track a new product
76 |
77 |
138 | >
139 | )}
140 | >
141 | );
142 | }
143 |
--------------------------------------------------------------------------------
/frontend/src/components/Popup/Modals/EditTrackModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState } from "react";
2 | import { GlobalContext } from "../../../context/GlobalState";
3 | import FlashMessage from "react-flash-message";
4 |
5 | export default function EditTrackModal({ handleClose, track }) {
6 | const { user, editTrack } = useContext(GlobalContext);
7 | const [trackName, setTrackName] = useState(track.name);
8 | const [trackExpectedPrice, setTrackExpectedPrice] = useState(
9 | track.expectedPrice
10 | );
11 | const [hasError, setHasError] = useState(false);
12 |
13 | function handleEditTrack(e) {
14 | if (hasError) {
15 | setHasError(false);
16 | }
17 |
18 | e.preventDefault();
19 | const name = trackName;
20 | const expectedPrice = parseFloat(trackExpectedPrice).toFixed(2);
21 | const id = track._id;
22 |
23 | // validate
24 | const hasDuplicate =
25 | user.createdTracks.filter((createdTrack) => createdTrack.name === name)
26 | .length > 0;
27 | if (hasDuplicate) {
28 | setHasError(true);
29 | } else {
30 | editTrack(id, name, expectedPrice);
31 | setHasError(false);
32 | handleClose();
33 | }
34 | }
35 |
36 | function handleCloseModal() {
37 | handleClose();
38 | }
39 |
40 | const priceCompare = {
41 | value:
42 | // ideal price
43 | track.actualPrice === track.expectedPrice
44 | ? "Ideal"
45 | : track.actualPrice > 0
46 | ? // price compare
47 | track.expectedPrice > track.actualPrice
48 | ? "Cheap"
49 | : "Costly"
50 | : // if price is 0
51 | "No Price",
52 | style:
53 | // ideal price
54 | track.actualPrice === track.expectedPrice
55 | ? "text-success"
56 | : // price compare
57 | track.actualPrice > 0 && track.expectedPrice > track.actualPrice
58 | ? "text-success"
59 | : "text-danger",
60 | };
61 |
62 | return (
63 | <>
64 |
65 | Edit your tracked product
66 |
67 |
158 | >
159 | );
160 | }
161 |
--------------------------------------------------------------------------------
/frontend/src/components/Popup/Modals/SignUpModal.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState } from "react";
2 | import { GlobalContext } from "../../../context/GlobalState";
3 | import { AiFillEyeInvisible, AiFillEye } from "react-icons/ai";
4 | import Loader from "react-loader-spinner";
5 |
6 | export default function SignUpModal({ handleClose, handleSwitchType }) {
7 | const { registerUser, token, errMsg, userLoading } = useContext(
8 | GlobalContext
9 | );
10 | const [isShowingPw, setIsShowingPw] = useState(false);
11 | const [isShowingConfirmPw, setIsShowingConfirmPw] = useState(false);
12 |
13 | const [email, setEmail] = useState("");
14 | const [pw, setPw] = useState("");
15 | const [confirmPw, setConfirmPw] = useState("");
16 | const [displayName, setDisplayName] = useState("");
17 |
18 | function handleRegisterUser(e) {
19 | e.preventDefault();
20 |
21 | if (isShowingPw || isShowingConfirmPw) {
22 | setIsShowingPw(false);
23 | setIsShowingConfirmPw(false);
24 | }
25 |
26 | registerUser(displayName, email, pw, confirmPw);
27 |
28 | if (token) {
29 | handleClose();
30 | }
31 | }
32 |
33 | function togglePwRevealer() {
34 | setIsShowingPw(!isShowingPw);
35 | }
36 |
37 | function toggleConfirmPwRevealer() {
38 | setIsShowingConfirmPw(!isShowingConfirmPw);
39 | }
40 |
41 | return (
42 | <>
43 | {!token && userLoading && (
44 |
45 |
46 |
47 | Please wait while we're creating your account
48 |
49 |
50 | )}
51 |
52 | {!userLoading && (
53 | <>
54 | Sign Up
55 |
150 | >
151 | )}
152 | >
153 | );
154 | }
155 |
--------------------------------------------------------------------------------
/controllers/track.controller.js:
--------------------------------------------------------------------------------
1 | const Track = require("../models/Track");
2 | const User = require("../models/User");
3 | const axios = require("axios");
4 | const cheerio = require("cheerio");
5 | const { v4: uuidv4 } = require("uuid");
6 |
7 | // @desc Add track
8 | // @route POST /api/dashboard/track
9 | // @access private
10 | exports.postTrack = async (req, res, next) => {
11 | try {
12 | const { userId, trackUrl, name, expectedPrice } = req.body;
13 | const user = await User.findById(userId);
14 | if (!user) {
15 | return res.status(401).json({
16 | success: false,
17 | error: "User does not exist",
18 | });
19 | }
20 |
21 | if (trackUrl.indexOf("amazon") < 0) {
22 | return res.status(401).json({
23 | success: false,
24 | error: "Only Amazon url is accepted",
25 | });
26 | } else {
27 | console.log("tracking: ", trackUrl);
28 | }
29 |
30 | // -- Crawling starts here --
31 | console.log("crawling starts");
32 | const page = await axios.get(trackUrl);
33 | const $ = cheerio.load(page.data);
34 |
35 | let actualPrice = 0;
36 |
37 | // const imageSrc = $("#imageBlock").find("img").attr("src");
38 | const imageSrc = $("#landingImage").attr("data-old-hires");
39 | const ourPrice = $("#priceblock_ourprice").text();
40 | const salePrice = $("#priceblock_saleprice").text();
41 | const dealPrice = $("#priceblock_dealprice").text();
42 |
43 | if (ourPrice) {
44 | actualPrice = ourPrice;
45 | } else if (salePrice) {
46 | actualPrice = salePrice;
47 | } else if (dealPrice) {
48 | actualPrice = dealPrice;
49 | }
50 |
51 | console.log("crawling ends");
52 | // -- Crawling ends here --
53 |
54 | // // create track
55 | const newTrack = {
56 | productUrl: trackUrl,
57 | image: imageSrc ? imageSrc : "",
58 | name,
59 | expectedPrice,
60 | actualPrice: parseFloat(actualPrice.replace(/[^0-9\.-]+/g, "")),
61 | creator: user._id,
62 | };
63 |
64 | console.log(newTrack);
65 |
66 | // If it's not a guest account
67 | if (user.email !== "tester@mail.com") {
68 | const track = await Track.create(newTrack);
69 | user.createdTracks.unshift(track._id);
70 | await user.save();
71 | return res.status(201).json({
72 | success: true,
73 | data: track,
74 | });
75 | } else {
76 | newTrack._id = uuidv4();
77 | return res.status(201).json({
78 | success: true,
79 | data: newTrack,
80 | });
81 | }
82 | } catch (err) {
83 | console.log("crawling failed");
84 | console.log(err.message);
85 | return res.status(500).json({ error: err.message });
86 | }
87 | };
88 |
89 | // @desc Edit track
90 | // @route POST /api/dashboard/track/:id
91 | // @access private
92 | exports.editTrack = async (req, res, next) => {
93 | try {
94 | const { name, expectedPrice } = req.body;
95 | const track = await Track.findById(req.params.id);
96 |
97 | if (!track) {
98 | return res.status(401).json({
99 | success: false,
100 | error: "No track found",
101 | });
102 | }
103 |
104 | track.name = name;
105 | track.expectedPrice = expectedPrice;
106 | await track.save();
107 |
108 | return res.status(201).json({
109 | success: true,
110 | edited: track,
111 | });
112 | } catch (err) {
113 | return res.status(500).json({ error: err.message });
114 | }
115 | };
116 |
117 | // @desc Delete track
118 | // @route POST /api/dashboard/delete/tracks
119 | // @access private
120 | exports.deleteTracks = async (req, res, next) => {
121 | try {
122 | const { userId, selectedTracks } = req.body;
123 | const trackIds = selectedTracks.map((track) => track._id);
124 |
125 | const user = await User.findById(userId);
126 | if (!user) {
127 | return res.status(401).json({
128 | success: false,
129 | error: "User does not exist",
130 | });
131 | }
132 |
133 | const tracks = await Track.find({ _id: { $in: trackIds } });
134 | if (!tracks) {
135 | return res.status(401).json({
136 | success: false,
137 | error: "No track found",
138 | });
139 | }
140 |
141 | await Track.deleteMany({
142 | _id: { $in: trackIds },
143 | });
144 |
145 | trackIds.forEach(async (trackId) => {
146 | const index = user.createdTracks.indexOf(trackId);
147 | if (index > -1) {
148 | user.createdTracks.splice(index, 1);
149 | await user.save();
150 | }
151 | });
152 |
153 | return res.status(201).json({
154 | success: true,
155 | deleted: tracks,
156 | });
157 | } catch (err) {
158 | return res.status(500).json({ error: err.message });
159 | }
160 | };
161 |
162 | // @desc Run multiple tracks
163 | // @route POST /api/dashboard/multiTrack
164 | // @access private
165 | exports.multiTrack = async (req, res, next) => {
166 | try {
167 | const { userId, createdTracks } = req.body;
168 | const trackIds = createdTracks.map((createdTrack) => createdTrack._id);
169 |
170 | const user = await User.findById(userId);
171 | if (!user) {
172 | return res.status(401).json({
173 | success: false,
174 | error: "User does not exist",
175 | });
176 | }
177 |
178 | try {
179 | // loop through each track START
180 | await new Promise((resolve, reject) => {
181 | createdTracks.forEach(async (createdTrack) => {
182 | const existingTrack = await Track.findById(createdTrack._id);
183 | if (!existingTrack) {
184 | reject();
185 | }
186 |
187 | // crawl Amazon product
188 | console.log(`${createdTrack.name} re-crawling starts`);
189 | const browser = await puppeteer.launch();
190 | const page = await browser.newPage();
191 |
192 | await page.goto(createdTrack.productUrl, {
193 | waitUntil: "networkidle2",
194 | });
195 |
196 | const crawledProduct = await page.evaluate(() => {
197 | let actualPrice = 0;
198 |
199 | const image = document.querySelector("#landingImage").src;
200 | const ourPrice = document.querySelector("#priceblock_ourprice");
201 | const salePrice = document.querySelector("#priceblock_saleprice");
202 | const dealPrice = document.querySelector("#priceblock_dealprice");
203 |
204 | if (ourPrice) {
205 | actualPrice = +ourPrice.innerText.substring(1);
206 | } else if (salePrice) {
207 | actualPrice = +salePrice.innerText.substring(1);
208 | } else if (dealPrice) {
209 | actualPrice = +dealPrice.innerText.substring(1);
210 | }
211 |
212 | return {
213 | image,
214 | actualPrice,
215 | };
216 | });
217 | console.log(`${createdTrack.name} re-crawling ends`);
218 | await browser.close();
219 |
220 | const { image, actualPrice } = crawledProduct;
221 |
222 | if (existingTrack.image !== image) {
223 | existingTrack.image = image;
224 | await existingTrack.save();
225 | }
226 |
227 | if (existingTrack.actualPrice !== actualPrice) {
228 | existingTrack.actualPrice = actualPrice;
229 | await existingTrack.save();
230 | }
231 |
232 | resolve();
233 | });
234 | });
235 | // loop through each track END
236 | } catch {
237 | return res.status(401).json({
238 | success: false,
239 | error: "Found invalid track id",
240 | });
241 | }
242 |
243 | const tracks = await Track.find({ _id: { $in: trackIds } });
244 |
245 | return res.status(201).json({
246 | success: true,
247 | data: tracks,
248 | });
249 | } catch (err) {
250 | console.log("crawling failed");
251 | return res.status(500).json({ error: err.message });
252 | }
253 | };
254 |
--------------------------------------------------------------------------------
/frontend/src/components/Dashboard/UserPanel.jsx:
--------------------------------------------------------------------------------
1 | import React, { useContext, useState } from "react";
2 | import PopupBtn from "../Popup/PopupBtn";
3 | import { GlobalContext } from "../../context/GlobalState";
4 |
5 | export default function UserPanel() {
6 | const { user } = useContext(GlobalContext);
7 | const [selectedTracks, setSelectedTracks] = useState([]);
8 | const createdTracks = user.createdTracks;
9 |
10 | function handleSelectTrack(e, track) {
11 | if (e.target.querySelector("input[type='checkbox']")) {
12 | const checkOnCard = e.target.querySelector("input[type='checkbox']");
13 | checkOnCard.checked = !checkOnCard.checked;
14 | updateSelectedTrack(checkOnCard, track);
15 | } else if (e.target.nodeName === "INPUT") {
16 | const checkOnCheckbox = e.target;
17 | updateSelectedTrack(checkOnCheckbox, track);
18 | }
19 | }
20 |
21 | function updateSelectedTrack(checkbox, selectedTrack) {
22 | if (checkbox.checked) {
23 | // check
24 | const newSelectedTracks = [...selectedTracks, selectedTrack];
25 | setSelectedTracks(newSelectedTracks);
26 | } else {
27 | // uncheck
28 | const newSelectedTracks = selectedTracks.filter(
29 | (track) => track !== selectedTrack
30 | );
31 | setSelectedTracks(newSelectedTracks);
32 | }
33 | }
34 |
35 | function handleSelectAllTracks(e) {
36 | const checkButtons = document.querySelectorAll(".check-btn");
37 | if (createdTracks.length > 0 && e.target.checked) {
38 | checkButtons.forEach((button) => {
39 | button.checked = true;
40 | });
41 | setSelectedTracks(user.createdTracks);
42 | } else if (createdTracks.length > 0 && !e.target.checked) {
43 | checkButtons.forEach((button) => {
44 | button.checked = false;
45 | });
46 | setSelectedTracks([]);
47 | }
48 | }
49 |
50 | // auto check checkAllBtn
51 | if (document.getElementById("checkAllBtn")) {
52 | const checkAllBtn = document.getElementById("checkAllBtn");
53 | if (
54 | createdTracks.length > 0 &&
55 | selectedTracks.length === createdTracks.length
56 | ) {
57 | checkAllBtn.checked = true;
58 | } else {
59 | checkAllBtn.checked = false;
60 | }
61 | }
62 |
63 | // show delete button if checked
64 | if (document.getElementById("deleteBtn")) {
65 | const deleteBtn = document.getElementById("deleteBtn");
66 | if (selectedTracks.length > 0) {
67 | deleteBtn.style.display = "inline-block";
68 | } else {
69 | deleteBtn.style.display = "none";
70 | }
71 | }
72 |
73 | return (
74 |
75 |
My Tracks
76 |
77 |
78 | {/* actions */}
79 |
80 |
81 |
82 | Update all
83 |
84 |
85 |
86 |
87 | +
88 |
89 |
90 |
95 | {/* update selectedTracks */}
96 |
101 | Delete
102 |
103 |
104 |
105 |
106 | {/* categories */}
107 |
108 |
109 |
110 |
111 |
116 |
117 |
118 |
name
119 |
120 | expected
121 |
122 |
actual
123 |
124 | compare
125 |
126 |
127 |
128 |
129 |
130 | {/* track */}
131 | {user &&
132 | user.createdTracks.map((track) => (
133 |
{
137 | handleSelectTrack(e, track);
138 | }}
139 | >
140 |
141 |
142 |
143 | handleSelectTrack(e, track)}
147 | />
148 |
149 |
150 |
156 |
157 |
158 | {track.name}
159 |
160 |
161 | ${track.expectedPrice}
162 |
163 |
164 | ${track.actualPrice}
165 |
166 |
167 | {track.actualPrice === 0 && (
168 | No Price
169 | )}
170 |
171 | {track.actualPrice !== 0 &&
172 | track.expectedPrice > track.actualPrice && (
173 | Cheap
174 | )}
175 |
176 | {track.actualPrice !== 0 &&
177 | track.expectedPrice < track.actualPrice && (
178 | Costly
179 | )}
180 |
181 | {track.actualPrice === track.expectedPrice && (
182 | Ideal
183 | )}
184 |
185 |
189 |
190 |
191 | Edit
192 |
193 |
194 | Detail
195 |
196 |
197 |
198 |
199 |
200 |
201 | ))}
202 |
203 |
204 | );
205 | }
206 |
--------------------------------------------------------------------------------
/frontend/src/context/GlobalState.js:
--------------------------------------------------------------------------------
1 | import React, { createContext, useReducer, useEffect } from "react";
2 | import AppReducer from "./AppReducer";
3 | import axios from "axios";
4 |
5 | // Initial state
6 | const initialState = {
7 | token: null,
8 | user: null,
9 | errMsg: null,
10 | notification: null,
11 | isTracking: true,
12 | userLoading: false,
13 | };
14 |
15 | export const GlobalContext = createContext(initialState);
16 |
17 | export const GlobalProvider = ({ children }) => {
18 | const [state, dispatch] = useReducer(AppReducer, initialState);
19 |
20 | //Actions
21 | async function loginUser(email, password) {
22 | try {
23 | dispatch({
24 | type: "UPDATE_USER_LOADING",
25 | payload: true,
26 | });
27 |
28 | const res = await axios.post("/api/user/login", {
29 | email,
30 | password,
31 | });
32 |
33 | const { token, user } = res.data.data;
34 |
35 | dispatch({
36 | type: "UPDATE_USER_LOADING",
37 | payload: false,
38 | });
39 |
40 | // console.log(res);
41 | localStorage.setItem("auth-token", token);
42 |
43 | const notification = {
44 | type: "success",
45 | message: `Welcome back, ${user.displayName}!`,
46 | };
47 |
48 | dispatch({
49 | type: "LOGIN_USER",
50 | payload: { token, user, notification },
51 | });
52 |
53 | setTimeout(() => {
54 | dispatch({
55 | type: "CLEAR_LOGS",
56 | payload: null,
57 | });
58 | }, 100);
59 | } catch (err) {
60 | dispatch({
61 | type: "UPDATE_USER_LOADING",
62 | payload: false,
63 | });
64 |
65 | dispatch({
66 | type: "LOG_ERROR_MESSAGE",
67 | payload: { message: err.response.data.error, notification: null },
68 | });
69 | setTimeout(() => {
70 | dispatch({
71 | type: "CLEAR_LOGS",
72 | payload: null,
73 | });
74 | }, 5000);
75 | }
76 | }
77 |
78 | async function registerUser(displayName, email, password, confirmPassword) {
79 | try {
80 | dispatch({
81 | type: "UPDATE_USER_LOADING",
82 | payload: true,
83 | });
84 |
85 | await axios.post("/api/user/register", {
86 | displayName,
87 | email,
88 | password,
89 | confirmPassword,
90 | });
91 | // console.log(res.data.data);
92 | loginUser(email, password);
93 | } catch (err) {
94 | dispatch({
95 | type: "UPDATE_USER_LOADING",
96 | payload: false,
97 | });
98 |
99 | dispatch({
100 | type: "LOG_ERROR_MESSAGE",
101 | payload: { message: err.response.data.error, notification: null },
102 | });
103 | setTimeout(() => {
104 | dispatch({
105 | type: "CLEAR_LOGS",
106 | payload: null,
107 | });
108 | }, 5000);
109 | }
110 | }
111 |
112 | function logoutUser() {
113 | localStorage.removeItem("auth-token");
114 |
115 | const notification = {
116 | type: "success",
117 | message: `Successfully logged out!`,
118 | };
119 |
120 | dispatch({
121 | type: "LOGOUT_USER",
122 | payload: { notification },
123 | });
124 |
125 | setTimeout(() => {
126 | dispatch({
127 | type: "CLEAR_LOGS",
128 | payload: null,
129 | });
130 | }, 100);
131 | }
132 |
133 | async function addTrack(trackUrl, name, expectedPrice) {
134 | try {
135 | const res = await axios.post(
136 | "/api/dashboard/track",
137 | {
138 | userId: state.user.userId,
139 | trackUrl,
140 | name,
141 | expectedPrice,
142 | },
143 | { headers: { "user-auth-token": state.token } }
144 | );
145 |
146 | // console.log(res.data.data);
147 |
148 | let notification;
149 | if (res.data.data.actualPrice === 0) {
150 | notification = {
151 | type: "warning",
152 | message: `Failed to track price, please report to us through the footer of homepage`,
153 | };
154 | } else {
155 | notification = {
156 | type: "success",
157 | message: `New product added!`,
158 | };
159 | }
160 |
161 | dispatch({
162 | type: "ADD_TRACK",
163 | payload: { data: res.data.data, notification },
164 | });
165 | setTimeout(() => {
166 | dispatch({
167 | type: "CLEAR_LOGS",
168 | payload: null,
169 | });
170 | }, 100);
171 | } catch (err) {
172 | console.log("crawling failed");
173 | const notification = {
174 | type: "error",
175 | message: "Track Failed!",
176 | title:
177 | "Please try again or, contact host through the footer of the homepage",
178 | };
179 |
180 | dispatch({
181 | type: "LOG_ERROR_MESSAGE",
182 | payload: { message: null, notification },
183 | });
184 |
185 | setTimeout(() => {
186 | dispatch({
187 | type: "CLEAR_LOGS",
188 | payload: null,
189 | });
190 | }, 100);
191 | }
192 | }
193 |
194 | async function editTrack(id, name, expectedPrice) {
195 | try {
196 | const tracks = state.user.createdTracks;
197 | const editedTrack = tracks.filter((track) => track._id === id);
198 | const editedTrackName = editedTrack[0].name;
199 | editedTrack[0].name = name;
200 | editedTrack[0].expectedPrice = expectedPrice;
201 | const prevTracks = tracks.filter((track) => track._id !== id);
202 | const newTracks = [...editedTrack, ...prevTracks];
203 |
204 | if (state.user.email !== "tester@mail.com") {
205 | await axios.post(
206 | `/api/dashboard/track/${id}`,
207 | {
208 | name,
209 | expectedPrice,
210 | },
211 | { headers: { "user-auth-token": state.token } }
212 | );
213 | }
214 |
215 | const notification = {
216 | type: "info",
217 | message: `Product "${editedTrackName}" has been edited`,
218 | };
219 |
220 | dispatch({
221 | type: "UPDATE_TRACKS",
222 | payload: { data: newTracks, notification },
223 | });
224 |
225 | setTimeout(() => {
226 | dispatch({
227 | type: "CLEAR_LOGS",
228 | payload: null,
229 | });
230 | }, 100);
231 | } catch {
232 | const notification = {
233 | type: "warning",
234 | message:
235 | "Error detected, please report to us through the footer of homepage",
236 | };
237 |
238 | dispatch({
239 | type: "LOG_ERROR_MESSAGE",
240 | payload: { message: null, notification },
241 | });
242 |
243 | setTimeout(() => {
244 | dispatch({
245 | type: "CLEAR_LOGS",
246 | payload: null,
247 | });
248 | }, 100);
249 | }
250 | }
251 |
252 | async function deleteTracks(selectedTracks) {
253 | try {
254 | if (state.user.email !== "tester@mail.com") {
255 | // backend update
256 | await axios.post(
257 | `/api/dashboard/delete/tracks`,
258 | { userId: state.user.userId, selectedTracks },
259 | { headers: { "user-auth-token": state.token } }
260 | );
261 | }
262 |
263 | const notification = {
264 | type: "success",
265 | message: `Successfully deleted!`,
266 | };
267 |
268 | // frontend update
269 | if (selectedTracks.length === state.user.createdTracks.length) {
270 | dispatch({
271 | type: "UPDATE_TRACKS",
272 | payload: { data: [], notification },
273 | });
274 | } else {
275 | const newTracks = state.user.createdTracks;
276 | selectedTracks.forEach((selectedTrack) => {
277 | const index = newTracks.indexOf(selectedTrack);
278 | if (index > -1) {
279 | newTracks.splice(index, 1);
280 | }
281 | });
282 |
283 | dispatch({
284 | type: "UPDATE_TRACKS",
285 | payload: { data: newTracks, notification },
286 | });
287 | }
288 |
289 | setTimeout(() => {
290 | dispatch({
291 | type: "CLEAR_LOGS",
292 | payload: null,
293 | });
294 | }, 100);
295 | } catch {
296 | const notification = {
297 | type: "warning",
298 | message:
299 | "Error detected, please report to us through the footer of homepage",
300 | };
301 |
302 | dispatch({
303 | type: "LOG_ERROR_MESSAGE",
304 | payload: { message: null, notification },
305 | });
306 |
307 | setTimeout(() => {
308 | dispatch({
309 | type: "CLEAR_LOGS",
310 | payload: null,
311 | });
312 | }, 100);
313 | }
314 | }
315 |
316 | async function multiTrack() {
317 | try {
318 | if (state.user.email === "tester@mail.com") {
319 | const notification = {
320 | type: "warning",
321 | message: `Track update is not available in guest mode!`,
322 | };
323 | dispatch({
324 | type: "LOG_ERROR_MESSAGE",
325 | payload: { message: null, notification },
326 | });
327 | } else if (state.user.createdTracks.length > 0) {
328 | const res = await axios.post(
329 | "/api/dashboard/multiTrack",
330 | {
331 | userId: state.user.userId,
332 | createdTracks: state.user.createdTracks,
333 | },
334 | { headers: { "user-auth-token": state.token } }
335 | );
336 | // console.log(res.data.data);
337 |
338 | const notification = {
339 | type: "success",
340 | message: `All product has been updated!`,
341 | };
342 | dispatch({
343 | type: "MULTI_TRACK",
344 | payload: { data: res.data.data, notification },
345 | });
346 | } else {
347 | const notification = {
348 | type: "warning",
349 | message: `No product is detected!`,
350 | };
351 | dispatch({
352 | type: "LOG_ERROR_MESSAGE",
353 | payload: { message: null, notification },
354 | });
355 | }
356 | setTimeout(() => {
357 | dispatch({
358 | type: "CLEAR_LOGS",
359 | payload: null,
360 | });
361 | }, 100);
362 | } catch (err) {
363 | console.log("crawling failed");
364 | const notification = {
365 | type: "error",
366 | message: "Track Failed!",
367 | title:
368 | "Please try again, or contact host through the footer of the homepage",
369 | };
370 | dispatch({
371 | type: "LOG_ERROR_MESSAGE",
372 | payload: { message: null, notification },
373 | });
374 | setTimeout(() => {
375 | dispatch({
376 | type: "CLEAR_LOGS",
377 | payload: null,
378 | });
379 | }, 100);
380 | }
381 | }
382 |
383 | // auto login START--------------------------------------------------------------
384 | async function checkLoggedIn() {
385 | try {
386 | let token = localStorage.getItem("auth-token");
387 |
388 | // if token hasn't been set
389 | if (token === null) {
390 | localStorage.setItem("auth-token", "");
391 | token = "";
392 | }
393 |
394 | const tokenRes = await axios.post("/api/user/tokenIsValid", null, {
395 | headers: { "user-auth-token": token },
396 | });
397 |
398 | // get and login user data
399 | if (tokenRes.data) {
400 | const userRes = await axios.get("/api/user/", {
401 | headers: { "user-auth-token": token },
402 | });
403 |
404 | dispatch({
405 | type: "LOGIN_USER",
406 | payload: { token, user: userRes.data, notification: null },
407 | });
408 | } else {
409 | dispatch({
410 | type: "LOGOUT_USER",
411 | payload: { notification: null },
412 | });
413 | }
414 | } catch (err) {
415 | console.log(err.response.data);
416 | }
417 | }
418 |
419 | useEffect(() => {
420 | checkLoggedIn();
421 | }, []);
422 | // auto login END--------------------------------------------------------------
423 |
424 | return (
425 |
442 | {children}
443 |
444 | );
445 | };
446 |
--------------------------------------------------------------------------------