├── .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 | 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 | TrackBase step item background 41 | {featureCol.title} 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 | 56 | {type === "deleteTrack" && ( 57 | 63 | )} 64 | 65 | {type === "multiTrack" && ( 66 | 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 | 56 | 57 | 58 | 59 | 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 | 44 |
45 |
46 | 47 | 48 | 51 | 52 | 53 | )} 54 | 55 | {token && ( 56 | 60 | View Dashboard 61 | 62 | )} 63 |
64 |
65 | 66 | vector 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 | 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 |
42 |
43 | 46 | setEmail(e.target.value)} 52 | /> 53 |
54 | 55 |
56 | 59 | setPw(e.target.value)} 66 | /> 67 | 68 | {/* Password Revealer */} 69 | 74 | {!isShowingPw && } 75 | {isShowingPw && } 76 | 77 |
78 | 79 | {/* Error message */} 80 | {errMsg && ( 81 | {errMsg} 82 | )} 83 | 84 |
85 | 86 | 87 | Create an account instead 88 | 89 |
90 |
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 | ![Landing Page](https://github.com/yewyewXD/React-Amazon-Price-Tracker/blob/master/readme-images/landingPage.JPG?raw=true "Landing Page") 21 | 22 | ![Instruction](https://github.com/yewyewXD/React-Amazon-Price-Tracker/blob/master/readme-images/instruction.JPG?raw=true "Instruction") 23 | 24 | ![Dashboard](https://github.com/yewyewXD/React-Amazon-Price-Tracker/blob/master/readme-images/dashboard.JPG?raw=true "Dashboard") 25 | 26 | ![Track New Product](https://github.com/yewyewXD/React-Amazon-Price-Tracker/blob/master/readme-images/dashboardAddNew.JPG?raw=true "Track New Product") 27 | 28 | ![Edit Tracked Product](https://github.com/yewyewXD/React-Amazon-Price-Tracker/blob/master/readme-images/dashboardEdit.JPG?raw=true "Edit Tracked Product") 29 | 30 | ![Delete Tracked Product](https://github.com/yewyewXD/React-Amazon-Price-Tracker/blob/master/readme-images/dashboardDelete.JPG?raw=true "Delete Tracked Product") 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 |
78 |
79 | 91 | 98 |
99 | 100 |
101 | 104 | 111 |
112 | 113 |
114 | 117 | 125 |
126 | 127 | {/* Error message */} 128 | {hasError && ( 129 | 130 | 131 | Name already exists! 132 | 133 | 134 | )} 135 | 136 | 137 |
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 |
68 |
69 | {track.name} 74 |
75 | 76 |
77 | 80 | { 87 | setTrackName(e.target.value); 88 | }} 89 | /> 90 |
91 | 92 |
93 | 96 | { 104 | setTrackExpectedPrice(e.target.value); 105 | }} 106 | /> 107 |
108 | 109 |
110 | 113 | e.stopPropagation()} 120 | /> 121 |
122 | 123 |
124 | 127 | e.stopPropagation()} 133 | /> 134 |
135 | 136 | {/* Error message */} 137 | {hasError && ( 138 | 139 | 140 | Name already exists! 141 | 142 | 143 | )} 144 | 145 |
146 | 153 | 156 |
157 |
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 |
56 |
57 | 60 | setDisplayName(e.target.value)} 66 | /> 67 |
68 | 69 |
70 | 73 | setEmail(e.target.value)} 79 | /> 80 |
81 | 82 |
83 | 86 | setPw(e.target.value)} 93 | /> 94 |
95 | 96 | {/* Password Revealer */} 97 | 102 | {!isShowingPw && } 103 | {isShowingPw && } 104 | 105 | 106 |
107 | 110 | setConfirmPw(e.target.value)} 118 | /> 119 |
120 | 121 | {/*ConfirmPassword Revealer */} 122 | 127 | {!isShowingConfirmPw && } 128 | {isShowingConfirmPw && } 129 | 130 | 131 | {/* Error message */} 132 | {errMsg && ( 133 | {errMsg} 134 | )} 135 | 136 |
137 | 140 | 141 | 146 | Login instead 147 | 148 |
149 |
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 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 95 | {/* update selectedTracks */} 96 | 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 | --------------------------------------------------------------------------------