├── readme_images
├── demo.gif
├── logo.png
├── light_dark1.gif
├── light_dark2.gif
├── final_signin.png
├── final_portfolio.png
├── final_register.png
├── wireframe_signin.jpg
├── final_transactions.png
├── wireframe_register.jpg
├── wireframe_portfolio.jpg
└── wireframe_transactions.jpg
├── frontend
├── dist
│ ├── favicon.ico
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── apple-touch-icon.png
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ └── site.webmanifest
├── postcss.config.js
├── src
│ ├── reducers
│ │ ├── ui_reducer.js
│ │ ├── entities_reducer.js
│ │ ├── root_reducer.js
│ │ ├── errors_reducer.js
│ │ ├── modal_reducer.js
│ │ ├── trades_reducer.js
│ │ ├── stock_error_reducer.js
│ │ ├── session_errors_reducer.js
│ │ ├── chart_error_reducer.js
│ │ ├── stocks_reducer.js
│ │ └── session_reducer.js
│ ├── middleware
│ │ └── thunk.js
│ ├── util
│ │ ├── trade_api_util.js
│ │ ├── stock_api_util.js
│ │ ├── session_api_util.js
│ │ └── route_util.js
│ ├── actions
│ │ ├── modal_actions.js
│ │ ├── trade_actions.js
│ │ ├── session_actions.js
│ │ └── stock_actions.js
│ ├── components
│ │ ├── root.js
│ │ ├── splash
│ │ │ ├── splash_container.js
│ │ │ └── splash.js
│ │ ├── header
│ │ │ ├── header_container.js
│ │ │ └── header.js
│ │ ├── session
│ │ │ ├── sign_in_container.js
│ │ │ ├── register_container.js
│ │ │ ├── sign_in_form.js
│ │ │ └── register_form.js
│ │ ├── portfolio
│ │ │ ├── portfolio_container.js
│ │ │ ├── portfolio.js
│ │ │ └── portfolio_item.js
│ │ ├── purchase
│ │ │ ├── purchase_container.js
│ │ │ └── purchase_form.js
│ │ ├── transactions
│ │ │ └── transaction_item.js
│ │ ├── theme_switch
│ │ │ └── theme_switch.js
│ │ ├── modal
│ │ │ └── modal.jsx
│ │ ├── app.js
│ │ ├── footer
│ │ │ └── footer.js
│ │ └── stock_chart
│ │ │ ├── stock_chart_util.js
│ │ │ └── stock_chart.js
│ ├── store
│ │ └── store.js
│ └── index.js
├── webpack.prod.js
├── styles
│ ├── theme_switch.scss
│ ├── modal.scss
│ ├── index.scss
│ ├── loading_spinner.scss
│ ├── color_variables.scss
│ ├── footer.scss
│ ├── reset.scss
│ ├── header.scss
│ ├── splash.scss
│ ├── session.scss
│ ├── chart.scss
│ ├── purchase_form.scss
│ └── portfolio.scss
├── webpack.dev.js
├── public
│ └── index.html
├── package.json
└── webpack.common.js
├── .gitignore
├── validation
├── valid-text.js
├── signin.js
├── purchase.js
└── register.js
├── config
├── keys.js
├── keys_prod.js
└── passport.js
├── .dockerignore
├── models
├── User.js
└── Trade.js
├── Dockerfile
├── app.js
├── package.json
├── routes
└── api
│ ├── trades.js
│ ├── stocks.js
│ └── users.js
└── README.md
/readme_images/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/demo.gif
--------------------------------------------------------------------------------
/readme_images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/logo.png
--------------------------------------------------------------------------------
/frontend/dist/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/frontend/dist/favicon.ico
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /config/keys_dev.js
2 | /node_modules
3 | /frontend/node_modules
4 | .DS_Store
5 | bundle.js
6 | bundle.css
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require("autoprefixer")
4 | ]
5 | };
--------------------------------------------------------------------------------
/readme_images/light_dark1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/light_dark1.gif
--------------------------------------------------------------------------------
/readme_images/light_dark2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/light_dark2.gif
--------------------------------------------------------------------------------
/frontend/dist/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/frontend/dist/favicon-16x16.png
--------------------------------------------------------------------------------
/frontend/dist/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/frontend/dist/favicon-32x32.png
--------------------------------------------------------------------------------
/readme_images/final_signin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/final_signin.png
--------------------------------------------------------------------------------
/frontend/dist/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/frontend/dist/apple-touch-icon.png
--------------------------------------------------------------------------------
/readme_images/final_portfolio.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/final_portfolio.png
--------------------------------------------------------------------------------
/readme_images/final_register.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/final_register.png
--------------------------------------------------------------------------------
/readme_images/wireframe_signin.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/wireframe_signin.jpg
--------------------------------------------------------------------------------
/readme_images/final_transactions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/final_transactions.png
--------------------------------------------------------------------------------
/readme_images/wireframe_register.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/wireframe_register.jpg
--------------------------------------------------------------------------------
/readme_images/wireframe_portfolio.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/wireframe_portfolio.jpg
--------------------------------------------------------------------------------
/frontend/dist/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/frontend/dist/android-chrome-192x192.png
--------------------------------------------------------------------------------
/frontend/dist/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/frontend/dist/android-chrome-512x512.png
--------------------------------------------------------------------------------
/readme_images/wireframe_transactions.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/derekwolpert/InvestChest/HEAD/readme_images/wireframe_transactions.jpg
--------------------------------------------------------------------------------
/validation/valid-text.js:
--------------------------------------------------------------------------------
1 | const validText = str => {
2 | return typeof str === "string" && str.trim().length > 0;
3 | };
4 |
5 | module.exports = validText;
--------------------------------------------------------------------------------
/config/keys.js:
--------------------------------------------------------------------------------
1 | if (process.env.NODE_ENV === "production") {
2 | module.exports = require("./keys_prod");
3 | } else {
4 | module.exports = require("./keys_dev");
5 | }
--------------------------------------------------------------------------------
/frontend/src/reducers/ui_reducer.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux";
2 |
3 | import modal from "./modal_reducer";
4 |
5 | export default combineReducers({
6 | modal
7 | });
8 |
--------------------------------------------------------------------------------
/frontend/webpack.prod.js:
--------------------------------------------------------------------------------
1 | const merge = require("webpack-merge");
2 | const common = require("./webpack.common.js");
3 |
4 | module.exports = merge(common, {
5 | mode: "production"
6 | });
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | /config/keys_dev.js
2 | /node_modules
3 | /frontend/node_modules
4 | .DS_Store
5 | bundle.js
6 | bundle.css
7 | /reademe_images
8 | .gitignore
9 | Dockerfile
10 | .dockerignore
11 | README.md
--------------------------------------------------------------------------------
/frontend/styles/theme_switch.scss:
--------------------------------------------------------------------------------
1 | .fa-lightbulb {
2 | font-size: 28px;
3 | margin: auto;
4 | cursor: pointer;
5 | color: $blue;
6 | }
7 |
8 | .fa-lightbulb:hover {
9 | color: $yellow;
10 | }
--------------------------------------------------------------------------------
/config/keys_prod.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | mongoURI: process.env.MONGO_URI,
3 | secretOrKey: process.env.SECRET_OR_KEY,
4 | iexApiToken: process.env.IEX_API_TOKEN,
5 | iexSandboxToken: process.env.IEX_SANDBOX_TOKEN
6 | };
--------------------------------------------------------------------------------
/frontend/src/middleware/thunk.js:
--------------------------------------------------------------------------------
1 | const thunk = store => next => action => {
2 | if (typeof action === "function") {
3 | return action(store.dispatch, store.getState);
4 | }
5 | return next(action);
6 | };
7 |
8 | export default thunk;
--------------------------------------------------------------------------------
/frontend/src/util/trade_api_util.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | export const getTrades = () => {
4 | return axios.get("/api/trades/history");
5 | };
6 |
7 | export const createTrade = data => {
8 | return axios.post("/api/trades/purchase", data);
9 | };
--------------------------------------------------------------------------------
/frontend/src/reducers/entities_reducer.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux";
2 | import tradesReducer from "./trades_reducer";
3 | import stocksReducer from "./stocks_reducer";
4 |
5 | const entitiesReducer = combineReducers({
6 | stocks: stocksReducer,
7 | trades: tradesReducer
8 | });
9 |
10 | export default entitiesReducer;
--------------------------------------------------------------------------------
/frontend/src/actions/modal_actions.js:
--------------------------------------------------------------------------------
1 | export const OPEN_MODAL = "OPEN_MODAL";
2 | export const CLOSE_MODAL = "CLOSE_MODAL";
3 |
4 | export const openModal = modal => {
5 | return {
6 | type: OPEN_MODAL,
7 | modal
8 | };
9 | };
10 |
11 | export const closeModal = () => {
12 | return {
13 | type: CLOSE_MODAL
14 | };
15 | };
--------------------------------------------------------------------------------
/frontend/src/components/root.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Provider } from "react-redux";
3 | import { HashRouter } from "react-router-dom";
4 | import App from "./app";
5 |
6 | const Root = ({ store }) => (
7 |
8 |
9 |
10 |
11 |
12 | );
13 |
14 | export default Root;
15 |
--------------------------------------------------------------------------------
/frontend/src/reducers/root_reducer.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux";
2 | import session from "./session_reducer";
3 | import ui from "./ui_reducer";
4 | import errors from "./errors_reducer";
5 | import entities from "./entities_reducer";
6 |
7 | const rootReducer = combineReducers({
8 | entities,
9 | session,
10 | ui,
11 | errors
12 | });
13 |
14 | export default rootReducer;
--------------------------------------------------------------------------------
/frontend/src/util/stock_api_util.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | export const getStocks = symbols => {
4 | return axios.get(`/api/stocks/batch/${symbols}`);
5 | };
6 |
7 | export const getStock = symbol => {
8 | return axios.get(`/api/stocks/lookup/${symbol}`);
9 | };
10 |
11 | export const getChart = (symbol, range) => {
12 | return axios.get(`/api/stocks/chart/${symbol}/${range}`);
13 | };
--------------------------------------------------------------------------------
/frontend/src/components/splash/splash_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import Splash from "./splash";
3 | import { openModal } from "../../actions/modal_actions";
4 | import { withRouter } from "react-router-dom";
5 |
6 | const mapDispatchToProps = dispatch => ({
7 | openModal: modal => dispatch(openModal(modal))
8 | });
9 |
10 | export default withRouter(connect(null, mapDispatchToProps)(Splash));
--------------------------------------------------------------------------------
/frontend/src/reducers/errors_reducer.js:
--------------------------------------------------------------------------------
1 |
2 | import { combineReducers } from "redux";
3 |
4 | import SessionErrorsReducer from "./session_errors_reducer";
5 | import StockErrorReducer from "./stock_error_reducer";
6 | import ChartErrorReducer from "./chart_error_reducer";
7 |
8 | export default combineReducers({
9 | session: SessionErrorsReducer,
10 | stock: StockErrorReducer,
11 | chart: ChartErrorReducer
12 | });
--------------------------------------------------------------------------------
/frontend/webpack.dev.js:
--------------------------------------------------------------------------------
1 | const merge = require("webpack-merge");
2 | const common = require("./webpack.common.js");
3 | const path = require("path");
4 |
5 | module.exports = merge(common, {
6 | mode: "development",
7 | devtool: "inline-source-map",
8 | watch: true,
9 | devServer: {
10 | contentBase: path.resolve(__dirname, "public"),
11 | publicPath: "/",
12 | proxy: { "/api": "http://localhost:3000" },
13 | compress: true,
14 | watchContentBase: true
15 | }
16 | });
--------------------------------------------------------------------------------
/models/User.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const Schema = mongoose.Schema;
3 |
4 | const UserSchema = new Schema({
5 | name: {
6 | type: String,
7 | required: true
8 | },
9 | email: {
10 | type: String,
11 | required: true
12 | },
13 | password: {
14 | type: String,
15 | required: true
16 | },
17 | cash: {
18 | type: Number,
19 | default: 5000
20 | },
21 | date: {
22 | type: Date,
23 | default: Date.now
24 | }
25 | });
26 |
27 | module.exports = User = mongoose.model("users", UserSchema);
--------------------------------------------------------------------------------
/frontend/src/reducers/modal_reducer.js:
--------------------------------------------------------------------------------
1 | import { OPEN_MODAL, CLOSE_MODAL } from "../actions/modal_actions";
2 | import { RECEIVE_CURRENT_USER } from "../actions/session_actions";
3 |
4 | export default function modalReducer(state = null, action) {
5 | switch (action.type) {
6 | case OPEN_MODAL:
7 | return action.modal;
8 | case CLOSE_MODAL:
9 | return null;
10 | case RECEIVE_CURRENT_USER:
11 | return null;
12 | default:
13 | return state;
14 | }
15 | }
--------------------------------------------------------------------------------
/frontend/dist/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Investchest",
3 | "short_name": "Investchest",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/styles/modal.scss:
--------------------------------------------------------------------------------
1 | .modal-background {
2 | position: fixed;
3 | top: 0;
4 | bottom: 0;
5 | right: 0;
6 | left: 0;
7 | background: rgba(var(--opacitybg), 0.8);
8 | z-index: 200;
9 | cursor: zoom-out;
10 | }
11 |
12 | .modal-child {
13 | position: absolute;
14 | top: 50%;
15 | left: 50%;
16 | width: 50%;
17 | min-width: 360px;
18 | max-width: 520px;
19 | transform: translate(-50%, -50%);
20 | background: var(--bg2);
21 | padding: 16px;
22 | border-radius: 4px;
23 | border: var(--text4) solid 4px;
24 | cursor: default;
25 | }
--------------------------------------------------------------------------------
/frontend/src/reducers/trades_reducer.js:
--------------------------------------------------------------------------------
1 | import {
2 | RECEIVE_ALL_TRADES,
3 | RECEIVE_TRADE
4 | } from "../actions/trade_actions";
5 |
6 | import { RECEIVE_USER_LOGOUT } from "../actions/session_actions";
7 |
8 | export default function(state = null, action) {
9 | switch (action.type) {
10 | case RECEIVE_ALL_TRADES:
11 | return action.trades;
12 | case RECEIVE_TRADE:
13 | return [action.trade, ...state];
14 | case RECEIVE_USER_LOGOUT:
15 | return null;
16 | default:
17 | return state;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/src/store/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from "redux";
2 | import thunk from "../middleware/thunk";
3 | import rootReducer from "../reducers/root_reducer";
4 |
5 | const middlewares = [thunk];
6 |
7 | if (process.env.NODE_ENV !== "production") {
8 | const { logger } = require("redux-logger");
9 | middlewares.push(logger);
10 | }
11 |
12 | const configureStore = (preloadedState = {}) => (
13 | createStore(
14 | rootReducer,
15 | preloadedState,
16 | applyMiddleware(...middlewares)
17 | )
18 | );
19 |
20 | export default configureStore;
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:10-alpine as frontend
2 |
3 | WORKDIR /app
4 | COPY /frontend/package*.json ./
5 | RUN npm install
6 | COPY /frontend ./
7 | RUN npm run build
8 |
9 | FROM node:10-alpine
10 |
11 | WORKDIR /app
12 |
13 | COPY --from=frontend /app/dist ./frontend/dist
14 | COPY --from=frontend /app/public ./frontend/public
15 |
16 | COPY package*.json ./
17 | RUN npm install
18 | COPY /config ./config
19 | COPY /models ./models
20 | COPY /routes ./routes
21 | COPY /validation ./validation
22 | COPY app.js ./
23 |
24 | ENV PORT 3000
25 | ENV NODE_ENV production
26 |
27 | EXPOSE 3000
28 |
29 | CMD ["npm", "start"]
--------------------------------------------------------------------------------
/frontend/src/reducers/stock_error_reducer.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_STOCK, RECEIVE_STOCK_ERROR, REMOVE_STOCK_ERROR } from "../actions/stock_actions";
2 |
3 | const _nullErrors = [];
4 |
5 | const StockErrorReducer = (state = _nullErrors, action) => {
6 | Object.freeze(state);
7 | switch (action.type) {
8 | case RECEIVE_STOCK_ERROR:
9 | return action.error;
10 | case RECEIVE_STOCK:
11 | return _nullErrors;
12 | case REMOVE_STOCK_ERROR:
13 | return _nullErrors;
14 | default:
15 | return state;
16 | }
17 | };
18 |
19 | export default StockErrorReducer;
--------------------------------------------------------------------------------
/frontend/src/util/session_api_util.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | export const setAuthToken = token => {
4 | if (token) {
5 | axios.defaults.headers.common["Authorization"] = token;
6 | } else {
7 | delete axios.defaults.headers.common["Authorization"];
8 | }
9 | };
10 |
11 | export const register = (userData) => {
12 | return axios.post("/api/users/register", userData);
13 | };
14 |
15 | export const signIn = (userData) => {
16 | return axios.post("/api/users/signin", userData);
17 | };
18 |
19 | export const getCurrentUser = () => {
20 | return axios.post("/api/users/current");
21 | };
--------------------------------------------------------------------------------
/frontend/src/components/header/header_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import { logout } from "../../actions/session_actions";
3 | import Header from "./header";
4 | import { openModal } from "../../actions/modal_actions";
5 | import { withRouter } from "react-router-dom";
6 |
7 | const mapStateToProps = state => ({
8 | loggedIn: state.session.isAuthenticated
9 | });
10 |
11 | const mapDispatchToProps = dispatch => ({
12 | logout: () => dispatch(logout()),
13 | openModal: modal => dispatch(openModal(modal)),
14 | });
15 |
16 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Header));
--------------------------------------------------------------------------------
/models/Trade.js:
--------------------------------------------------------------------------------
1 | const mongoose = require("mongoose");
2 | const Schema = mongoose.Schema;
3 |
4 | const TradeSchema = new Schema({
5 | user: {
6 | type: Schema.Types.ObjectId,
7 | ref: "users"
8 | },
9 | symbol: {
10 | type: String,
11 | required: true
12 | },
13 | purchasePrice: {
14 | type: Number,
15 | required: true
16 | },
17 | numberOfShares: {
18 | type: Number,
19 | required: true
20 | },
21 | date: {
22 | type: Date,
23 | default: Date.now
24 | }
25 | });
26 |
27 | module.exports = Trade = mongoose.model("trades", TradeSchema);
28 |
--------------------------------------------------------------------------------
/frontend/src/reducers/session_errors_reducer.js:
--------------------------------------------------------------------------------
1 |
2 | import {
3 | RECEIVE_SESSION_ERRORS,
4 | RECEIVE_CURRENT_USER,
5 | REMOVE_SESSION_ERRORS
6 | } from "../actions/session_actions";
7 | import { CLOSE_MODAL } from "../actions/modal_actions";
8 |
9 | const _nullErrors = [];
10 |
11 | const SessionErrorsReducer = (state = _nullErrors, action) => {
12 | Object.freeze(state);
13 | switch (action.type) {
14 | case RECEIVE_SESSION_ERRORS:
15 | return action.errors;
16 | case RECEIVE_CURRENT_USER:
17 | return _nullErrors;
18 | case REMOVE_SESSION_ERRORS:
19 | return _nullErrors;
20 | default:
21 | return state;
22 | }
23 | };
24 |
25 | export default SessionErrorsReducer;
--------------------------------------------------------------------------------
/frontend/src/actions/trade_actions.js:
--------------------------------------------------------------------------------
1 | import * as APIUtil from "../util/trade_api_util";
2 |
3 | export const RECEIVE_ALL_TRADES = "RECEIVE_ALL_TRADES";
4 | export const RECEIVE_TRADE = "RECEIVE_TRADE";
5 |
6 | const receiveAllTrades = trades => ({
7 | type: RECEIVE_ALL_TRADES,
8 | trades: trades.data
9 | });
10 |
11 | const receiveTrade = trade => ({
12 | type: RECEIVE_TRADE,
13 | trade: trade.data.trade,
14 | user: trade.data.user
15 | });
16 |
17 | export const getTrades = () => dispatch => APIUtil.getTrades()
18 | .then(trades =>
19 | dispatch(receiveAllTrades(trades))
20 | );
21 |
22 | export const createTrade = trade => dispatch => APIUtil.createTrade(trade)
23 | .then(trade =>
24 | dispatch(receiveTrade(trade))
25 | );
--------------------------------------------------------------------------------
/validation/signin.js:
--------------------------------------------------------------------------------
1 | const Validator = require("validator");
2 | const validText = require("./valid-text");
3 |
4 | module.exports = function validateSignInInput(data) {
5 |
6 | let errors = {};
7 |
8 | data.email = validText(data.email) ? data.email : "";
9 | data.password = validText(data.password) ? data.password : "";
10 |
11 | if (!Validator.isEmail(data.email)) {
12 | errors.email = "Email is invalid";
13 | }
14 |
15 | if (Validator.isEmpty(data.email)) {
16 | errors.email = "Email field is required";
17 | }
18 |
19 | if (Validator.isEmpty(data.password)) {
20 | errors.password = "Password field is required";
21 | }
22 |
23 | return {
24 | errors,
25 | isValid: Object.keys(errors).length === 0
26 | };
27 | };
--------------------------------------------------------------------------------
/config/passport.js:
--------------------------------------------------------------------------------
1 | const JwtStrategy = require("passport-jwt").Strategy;
2 | const ExtractJwt = require("passport-jwt").ExtractJwt;
3 | const mongoose = require("mongoose");
4 | const secretOrKey = require("./keys").secretOrKey;
5 |
6 | const User = mongoose.model("users");
7 |
8 | const options = {};
9 | options.jwtFromRequest = ExtractJwt.fromAuthHeaderAsBearerToken();
10 | options.secretOrKey = secretOrKey;
11 |
12 | module.exports = passport => {
13 | passport.use(new JwtStrategy(options, (jwt_payload, done) => {
14 | User.findById(jwt_payload.id)
15 | .then(user => {
16 | if (user) {
17 | return done(null, user);
18 | }
19 | return done(null, false);
20 | })
21 | .catch(err => console.log(err));
22 | }));
23 | };
--------------------------------------------------------------------------------
/frontend/src/reducers/chart_error_reducer.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_CHART, RECEIVE_CHART_ERROR, REMOVE_CHART_ERROR, REMOVE_STOCK_ERROR, RECEIVE_STOCK } from "../actions/stock_actions";
2 |
3 | const _nullErrors = [];
4 |
5 | const ChartErrorReducer = (state = _nullErrors, action) => {
6 | Object.freeze(state);
7 | switch (action.type) {
8 | case RECEIVE_CHART_ERROR:
9 | return action.error;
10 | case RECEIVE_CHART:
11 | return _nullErrors;
12 | case REMOVE_CHART_ERROR:
13 | return _nullErrors;
14 | case RECEIVE_STOCK:
15 | return _nullErrors;
16 | case REMOVE_STOCK_ERROR:
17 | return _nullErrors;
18 | default:
19 | return state;
20 | }
21 | };
22 |
23 | export default ChartErrorReducer;
--------------------------------------------------------------------------------
/frontend/src/components/session/sign_in_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import { signIn, removeSessionErrors } from "../../actions/session_actions";
3 | import { openModal, closeModal } from "../../actions/modal_actions";
4 | import SignInForm from "./sign_in_form";
5 |
6 | const mapStateToProps = (state) => {
7 | return {
8 | errors: state.errors.session
9 | };
10 | };
11 |
12 | const mapDispatchToProps = (dispatch) => {
13 | return {
14 | signIn: user => dispatch(signIn(user)),
15 | otherForm: () => dispatch(openModal("register")),
16 | closeModal: () => dispatch(closeModal()),
17 | removeSessionErrors: () => dispatch(removeSessionErrors())
18 | };
19 | }
20 |
21 | export default connect(mapStateToProps, mapDispatchToProps)(SignInForm);
--------------------------------------------------------------------------------
/frontend/src/reducers/stocks_reducer.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_BATCH_STOCKS, RECEIVE_STOCK, RECEIVE_CHART } from "../actions/stock_actions";
2 | import { RECEIVE_USER_LOGOUT } from "../actions/session_actions";
3 |
4 | export default function(state = null, action) {
5 | Object.freeze(state);
6 | switch (action.type) {
7 | case RECEIVE_BATCH_STOCKS:
8 | return action.stocks;
9 | case RECEIVE_STOCK:
10 | return {...state, [action.stock.symbol]: { quote: action.stock } };
11 | case RECEIVE_CHART:
12 | return { ...state, [action.symbol]: { ...state[action.symbol], chart: { ...state[action.symbol].chart, [action.range]: action.chart } } };
13 | case RECEIVE_USER_LOGOUT:
14 | return null;
15 | default:
16 | return state;
17 | }
18 | }
--------------------------------------------------------------------------------
/frontend/src/components/portfolio/portfolio_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import Portfolio from "./portfolio";
3 | import { getTrades } from "../../actions/trade_actions";
4 | import { getStocks } from "../../actions/stock_actions";
5 | import { withRouter } from "react-router-dom";
6 |
7 | const mapStateToProps = state => {
8 | return {
9 | user: state.session.user,
10 | trades: state.entities.trades ? state.entities.trades : null,
11 | stocks: state.entities.stocks ? state.entities.stocks : null
12 | };
13 | };
14 |
15 | const mapDispatchToProps = dispatch => {
16 | return {
17 | getTrades: () => dispatch(getTrades()),
18 | getStocks: (stocks) => dispatch(getStocks(stocks))
19 | };
20 | };
21 |
22 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Portfolio));
--------------------------------------------------------------------------------
/frontend/src/components/session/register_container.js:
--------------------------------------------------------------------------------
1 |
2 | import { connect } from "react-redux";
3 | import { register, signIn, removeSessionErrors} from "../../actions/session_actions";
4 | import { openModal, closeModal } from "../../actions/modal_actions";
5 | import RegisterForm from "./register_form";
6 |
7 | const mapStateToProps = (state) => {
8 | return {
9 | signedIn: state.session.isSignedIn,
10 | errors: state.errors.session
11 | };
12 | };
13 |
14 | const mapDispatchToProps = (dispatch) => {
15 | return {
16 | register: user => dispatch(register(user)),
17 | signIn: user => dispatch(signIn(user)),
18 | otherForm: () => dispatch(openModal("signIn")),
19 | closeModal: () => dispatch(closeModal()),
20 | removeSessionErrors: () => dispatch(removeSessionErrors())
21 | };
22 | }
23 |
24 | export default connect(mapStateToProps, mapDispatchToProps)(RegisterForm);
--------------------------------------------------------------------------------
/frontend/styles/index.scss:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css?family=Nunito:400,600,700&display=swap");
2 | @import "reset";
3 | @import "color_variables";
4 |
5 | body {
6 | background: var(--bg3);
7 | color: var(--text3);
8 |
9 | * {
10 | font-family: "Nunito", ;
11 | }
12 | }
13 |
14 | html, body {
15 | height: 100%;
16 | }
17 |
18 | input {
19 | line-height: normal;
20 | -webkit-appearance: none;
21 | -moz-appearance: none;
22 | appearance: none;
23 | }
24 |
25 | .body-container {
26 | max-width: 1280px;
27 | margin: auto;
28 | background: var(--bg2);
29 | border-left: var(--text4) solid 2px;
30 | border-right: var(--text4) solid 2px;
31 | height: calc(100%);
32 | }
33 |
34 | @import "modal";
35 | @import "loading_spinner";
36 | @import "session";
37 | @import "header";
38 | @import "theme_switch";
39 | @import "splash";
40 | @import "portfolio";
41 | @import "purchase_form";
42 | @import "chart";
43 | @import "footer";
--------------------------------------------------------------------------------
/frontend/src/components/purchase/purchase_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 | import { getStock, getChart, removeStockError } from "../../actions/stock_actions";
3 | import { createTrade } from "../../actions/trade_actions";
4 | import PurchaseForm from "./purchase_form";
5 |
6 |
7 | const mapStateToProps = state => {
8 | return {
9 | user: state.session.user,
10 | stocks: state.entities.stocks ? state.entities.stocks : {},
11 | stockError: state.errors.stock,
12 | chartError: state.errors.chart
13 | };
14 | };
15 |
16 |
17 | const mapDispatchToProps = dispatch => {
18 | return {
19 | getStock: (stock) => dispatch(getStock(stock)),
20 | getChart: (symbol, range) => dispatch(getChart(symbol, range)),
21 | removeStockError: () => dispatch(removeStockError()),
22 | createTrade: (trade) => dispatch(createTrade(trade))
23 | };
24 | };
25 |
26 | export default connect(mapStateToProps, mapDispatchToProps)(PurchaseForm);
--------------------------------------------------------------------------------
/frontend/src/components/transactions/transaction_item.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as moment from "moment";
3 |
4 | const TransactionItem = (props) => {
5 | return (
6 | <>
7 |
8 |
9 | BUY – {`${props.trade.symbol} (${
10 | props.companyName
11 | }) – ${props.trade.numberOfShares} Share${
12 | props.trade.numberOfShares > 1 ? "s" : ""
13 | } @ $${props.trade.purchasePrice.toFixed(2)}`}
14 |
15 |
16 | Purchase Date:{" "}
17 | {moment(
18 | props.trade.date
19 | ).format("MMMM Do YYYY, h:mm:ss A")}
20 |
21 |
22 |
23 | >
24 | )
25 | };
26 |
27 | export default TransactionItem;
--------------------------------------------------------------------------------
/frontend/src/util/route_util.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { connect } from "react-redux";
3 | import { Route, Redirect, withRouter } from "react-router-dom";
4 |
5 | const Auth = ({ component: Component, path, loggedIn, exact }) => (
6 |
10 | !loggedIn ? (
11 |
12 | ) : (
13 |
14 | )
15 | }
16 | />
17 | );
18 |
19 | const Protected = ({ component: Component, loggedIn, ...rest }) => (
20 |
23 | loggedIn ? (
24 |
25 | ) : (
26 |
27 | )
28 | }
29 | />
30 | );
31 |
32 | const mapStateToProps = state => (
33 | { loggedIn: state.session.isAuthenticated }
34 | );
35 |
36 | export const AuthRoute = withRouter(connect(mapStateToProps)(Auth));
37 | export const ProtectedRoute = withRouter(connect(mapStateToProps)(Protected));
38 |
--------------------------------------------------------------------------------
/frontend/styles/loading_spinner.scss:
--------------------------------------------------------------------------------
1 | .center-spinner {
2 | display: flex;
3 | justify-content: center;
4 | align-items: center;
5 | height: 100%;
6 | }
7 |
8 | .lds-ring {
9 | display: inline-block;
10 | position: relative;
11 | width: 160px;
12 | height: 160px;
13 | align-items: center;
14 |
15 | div {
16 | box-sizing: border-box;
17 | display: block;
18 | position: absolute;
19 | width: 128px;
20 | height: 128px;
21 | margin: 16px;
22 | border: 16px solid var(--text4);
23 | border-radius: 50%;
24 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
25 | border-color: var(--text4) transparent transparent transparent;
26 | }
27 |
28 | div:nth-child(1) {
29 | animation-delay: -0.45s;
30 | }
31 |
32 | div:nth-child(2) {
33 | animation-delay: -0.3s;
34 | }
35 |
36 |
37 | div:nth-child(3) {
38 | animation-delay: -0.15s;
39 | }
40 | }
41 |
42 | @keyframes lds-ring {
43 | 0% {
44 | transform: rotate(0deg);
45 | }
46 | 100% {
47 | transform: rotate(360deg);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/frontend/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render } from "react-dom";
3 | import Root from "./components/root";
4 | import configureStore from "./store/store";
5 | import jwt_decode from "jwt-decode";
6 | import { setAuthToken } from "./util/session_api_util";
7 | import { logout } from "./actions/session_actions";
8 | import "../styles/index.scss";
9 |
10 | document.addEventListener("DOMContentLoaded", () => {
11 | let store;
12 |
13 | if (localStorage.jwtToken) {
14 | setAuthToken(localStorage.jwtToken);
15 | const decodedUser = jwt_decode(localStorage.jwtToken);
16 | const preloadedState = {
17 | session: { isAuthenticated: true, user: decodedUser }
18 | };
19 |
20 | store = configureStore(preloadedState);
21 |
22 | const currentTime = Date.now() / 1000;
23 |
24 | if (decodedUser.exp < currentTime) {
25 | store.dispatch(logout());
26 | window.location.href = "/";
27 | }
28 | } else {
29 | store = configureStore({});
30 | }
31 |
32 | const root = document.getElementById("root");
33 | render( , root);
34 |
35 | });
--------------------------------------------------------------------------------
/frontend/src/reducers/session_reducer.js:
--------------------------------------------------------------------------------
1 | import {
2 | RECEIVE_CURRENT_USER,
3 | RECEIVE_USER_LOGOUT,
4 | RECEIVE_USER_SIGN_IN
5 | } from "../actions/session_actions";
6 |
7 | import { RECEIVE_TRADE } from "../actions/trade_actions";
8 |
9 | const initialState = {
10 | isAuthenticated: false,
11 | user: {}
12 | };
13 |
14 | export default function(state = initialState, action) {
15 | switch (action.type) {
16 | case RECEIVE_CURRENT_USER:
17 | return {
18 | ...state,
19 | isAuthenticated: !!action.currentUser,
20 | user: action.currentUser
21 | };
22 | case RECEIVE_USER_LOGOUT:
23 | return {
24 | isAuthenticated: false,
25 | user: undefined
26 | };
27 | case RECEIVE_TRADE:
28 | const newState = { ...state }
29 | newState.user.cash = action.user.cash;
30 | return newState;
31 | case RECEIVE_USER_SIGN_IN:
32 | return {
33 | ...state,
34 | isSignedIn: true
35 | };
36 | default:
37 | return state;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/frontend/src/components/theme_switch/theme_switch.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faLightbulb } from "@fortawesome/free-regular-svg-icons";
4 |
5 | class ThemeSwitch extends React.Component {
6 |
7 | componentDidMount() {
8 | if (!document.documentElement.getAttribute("theme-mode")) {
9 | if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
10 | document.documentElement.setAttribute("theme-mode", "dark");
11 | } else {
12 | document.documentElement.setAttribute("theme-mode", "light");
13 | }
14 | }
15 | }
16 |
17 | render() {
18 | return (
19 | {
20 | if (document.documentElement.getAttribute("theme-mode") === "dark") {
21 | document.documentElement.setAttribute("theme-mode", "light");
22 | } else {
23 | document.documentElement.setAttribute("theme-mode", "dark");
24 | }
25 | } } />
26 | );
27 | }
28 |
29 | }
30 |
31 | export default ThemeSwitch;
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | InvestChest
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/frontend/src/components/modal/modal.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { closeModal } from "../../actions/modal_actions";
3 | import { connect } from "react-redux";
4 | import RegisterContainer from "../session/register_container";
5 | import SignInContainer from "../session/sign_in_container";
6 |
7 | function Modal({ modal, closeModal }) {
8 | if (!modal) {
9 | return null;
10 | }
11 | let component;
12 | switch (modal) {
13 | case "signIn":
14 | component = ;
15 | break;
16 | case "register":
17 | component = ;
18 | break;
19 | default:
20 | return null;
21 | }
22 | return (
23 |
24 | e.stopPropagation()}>
25 | {component}
26 |
27 |
28 | );
29 | }
30 |
31 | const mapStateToProps = state => {
32 | return {
33 | modal: state.ui.modal
34 | };
35 | };
36 |
37 | const mapDispatchToProps = dispatch => {
38 | return {
39 | closeModal: () => dispatch(closeModal())
40 | };
41 | };
42 |
43 | export default connect(mapStateToProps, mapDispatchToProps)(Modal);
--------------------------------------------------------------------------------
/frontend/src/components/app.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { AuthRoute, ProtectedRoute } from "../util/route_util";
3 | import { Switch } from "react-router-dom";
4 |
5 | import Modal from "./modal/modal";
6 | import HeaderContainer from "./header/header_container";
7 | import Footer from "./footer/footer";
8 | import SplashContainer from "./splash/splash_container";
9 | import PortfolioContainer from "./portfolio/portfolio_container";
10 |
11 | const App = () => (
12 | <>
13 |
14 |
17 |
18 |
19 |
20 |
25 |
30 |
31 |
32 |
35 | >
36 | );
37 |
38 | export default App;
--------------------------------------------------------------------------------
/frontend/styles/color_variables.scss:
--------------------------------------------------------------------------------
1 | :root {
2 | --bg1: #f8f8f8;
3 | --bg2: #e8e8e8;
4 | --bg3: #d8d8d8;
5 | --bg4: #b8b8b8;
6 | --text4: #585858;
7 | --text3: #383838;
8 | --text2: #282828;
9 | --text1: #181818;
10 | --opacitybg: 248, 248, 248;
11 | --chart: #999999;
12 | }
13 |
14 | @media (prefers-color-scheme: dark) {
15 | :root {
16 | --bg1: #181818;
17 | --bg2: #282828;
18 | --bg3: #383838;
19 | --bg4: #585858;
20 | --text4: #b8b8b8;
21 | --text3: #d8d8d8;
22 | --text2: #e8e8e8;
23 | --text1: #f8f8f8;
24 | --opacitybg: 24, 24, 24;
25 | --chart: #666666
26 | }
27 | }
28 |
29 | [theme-mode="light"] {
30 | --bg1: #f8f8f8;
31 | --bg2: #e8e8e8;
32 | --bg3: #d8d8d8;
33 | --bg4: #b8b8b8;
34 | --text4: #585858;
35 | --text3: #383838;
36 | --text2: #282828;
37 | --text1: #181818;
38 | --opacitybg: 248, 248, 248;
39 | --chart: #999999;
40 | }
41 |
42 | [theme-mode="dark"] {
43 | --bg1: #181818;
44 | --bg2: #282828;
45 | --bg3: #383838;
46 | --bg4: #585858;
47 | --text4: #b8b8b8;
48 | --text3: #d8d8d8;
49 | --text2: #e8e8e8;
50 | --text1: #f8f8f8;
51 | --opacitybg: 24, 24, 24;
52 | --chart: #666666;
53 | }
54 |
55 | $red: #ab4642;
56 | $orange: #dc9656;
57 | $yellow: #f7ca88;
58 | $green: #a1b56c;
59 | $cyan: #86c1b9;
60 | $blue: #7cafc2;
61 | $violet: #ba8baf;
62 | $brown: #a16946;
--------------------------------------------------------------------------------
/frontend/styles/footer.scss:
--------------------------------------------------------------------------------
1 | footer {
2 | position: sticky;
3 | bottom: 0;
4 | left: 0;
5 | right: 0;
6 | z-index: 100;
7 | }
8 |
9 | .footer-container {
10 | display: flex;
11 | justify-content: center;
12 | background-color: var(--bg1);
13 | color: var(--text3);
14 | border-top: var(--text4) solid 2px;
15 | box-sizing: content-box;
16 | }
17 |
18 | .footer {
19 | display: flex;
20 | justify-content: space-between;
21 | align-items: center;
22 | width: 100%;
23 | max-width: 1280px;
24 | margin: auto;
25 | height: 32px;
26 | padding: 0 8px;
27 | overflow: hidden;
28 |
29 | a:hover {
30 | color: $blue;
31 | }
32 |
33 | .left {
34 | display: flex;
35 | font-size: 12px;
36 | overflow: hidden;
37 | span {
38 | padding-right: 8px;
39 | margin-right: 8px;
40 | border-right: var(--text4) solid 1px;
41 | }
42 | }
43 |
44 | .right {
45 | display: flex;
46 | font-size: 24px;
47 |
48 | svg {
49 | margin-right: 8px;
50 | cursor: pointer;
51 | color: var(--text4);
52 | }
53 |
54 | svg:hover {
55 | color: $blue;
56 | }
57 |
58 | .fa-envelope {
59 | margin-right: 0;
60 | }
61 |
62 | }
63 |
64 | }
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const compression = require("compression");
3 | const app = express();
4 | const db = require("./config/keys").mongoURI;
5 | const mongoose = require("mongoose");
6 | const bodyParser = require("body-parser");
7 | const passport = require('passport');
8 |
9 | const users = require("./routes/api/users");
10 | const trades = require("./routes/api/trades");
11 | const stocks = require("./routes/api/stocks");
12 | const path = require("path");
13 |
14 | app.use(compression());
15 |
16 | if (process.env.NODE_ENV === "production") {
17 | app.use(express.static("frontend/dist"));
18 | app.get("/", (req, res) => {
19 | res.sendFile(
20 | path.resolve(__dirname, "frontend", "public", "index.html")
21 | );
22 | });
23 | }
24 |
25 | mongoose
26 | .connect(db, { useNewUrlParser: true, useUnifiedTopology: true })
27 | .then(() => console.log("Connected to MongoDB successfully"))
28 | .catch(err => console.log(err));
29 |
30 | app.use(passport.initialize());
31 | require('./config/passport')(passport);
32 |
33 | app.use(bodyParser.urlencoded({ extended: false }));
34 | app.use(bodyParser.json());
35 |
36 | app.use("/api/users", users);
37 | app.use("/api/trades", trades);
38 | app.use("/api/stocks", stocks);
39 |
40 | const port = process.env.PORT || 3000;
41 | app.listen(port, () => console.log(`Server is running on port ${port}`));
--------------------------------------------------------------------------------
/frontend/styles/reset.scss:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v2.0 | 20110126
3 | License: none (public domain)
4 | */
5 |
6 | html, body, div, span, applet, object, iframe,
7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
8 | a, abbr, acronym, address, big, cite, code,
9 | del, dfn, em, img, ins, kbd, q, s, samp,
10 | small, strike, strong, sub, sup, tt, var,
11 | b, u, i, center,
12 | dl, dt, dd, ol, ul, li,
13 | fieldset, form, label, legend,
14 | table, caption, tbody, tfoot, thead, tr, th, td,
15 | article, aside, canvas, details, embed,
16 | figure, figcaption, footer, header, hgroup,
17 | menu, nav, output, ruby, section, summary,
18 | time, mark, audio, video, input, button {
19 | margin: 0;
20 | padding: 0;
21 | border: 0;
22 | font-size: 100%;
23 | font: inherit;
24 | vertical-align: baseline;
25 | box-sizing: border-box;
26 | }
27 | /* HTML5 display-role reset for older browsers */
28 | article, aside, details, figcaption, figure,
29 | footer, header, hgroup, menu, nav, section {
30 | display: block;
31 | }
32 | body {
33 | line-height: 1;
34 | }
35 | ol, ul {
36 | list-style: none;
37 | }
38 | blockquote, q {
39 | quotes: none;
40 | }
41 | blockquote:before, blockquote:after,
42 | q:before, q:after {
43 | content: '';
44 | content: none;
45 | }
46 | table {
47 | border-collapse: collapse;
48 | border-spacing: 0;
49 | }
50 |
51 | a {
52 | color: inherit;
53 | text-decoration: none;
54 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "investchest",
3 | "version": "1.0.0",
4 | "description": "NY TTP W2020",
5 | "main": "app.js",
6 | "scripts": {
7 | "server": "nodemon app.js",
8 | "debug": "nodemon --inspect app.js",
9 | "start": "node app.js",
10 | "frontend-install": "npm install --prefix frontend",
11 | "frontend-update": "npm update --prefix frontend",
12 | "frontend": "npm start --prefix frontend",
13 | "dev": "concurrently \"npm run debug\" \"npm run frontend\"",
14 | "heroku-postbuild": "NPM_CONFIG_PRODUCTION=false npm install --prefix frontend && npm run build --prefix frontend"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/derekwolpert/InvestChest.git"
19 | },
20 | "author": "Derek Wolpert",
21 | "license": "ISC",
22 | "bugs": {
23 | "url": "https://github.com/derekwolpert/InvestChest/issues"
24 | },
25 | "homepage": "https://github.com/derekwolpert/InvestChest#readme",
26 | "dependencies": {
27 | "axios": "^0.21.4",
28 | "bcryptjs": "^2.4.3",
29 | "body-parser": "^1.19.0",
30 | "compression": "^1.7.4",
31 | "concurrently": "^5.3.0",
32 | "express": "^4.18.1",
33 | "jsonwebtoken": "^8.5.1",
34 | "mongoose": "^5.13.14",
35 | "passport": "^0.4.1",
36 | "passport-jwt": "^4.0.0",
37 | "validator": "^12.2.0"
38 | },
39 | "devDependencies": {
40 | "nodemon": "^2.0.19"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/validation/purchase.js:
--------------------------------------------------------------------------------
1 | const Validator = require("validator");
2 | const validText = require("./valid-text");
3 |
4 | module.exports = function validatePurchaseInput(data) {
5 |
6 | let errors = {};
7 |
8 | data.symbol = validText(data.symbol) ? data.symbol : "";
9 | data.purchasePrice = validText(data.purchasePrice) ? data.purchasePrice : "";
10 | data.numberOfShares = validText(data.numberOfShares) ? data.numberOfShares : "";
11 |
12 | if (!Validator.isLength(data.symbol, { min: 1, max: 6 })) {
13 | errors.symbol = "Ticker Symbol must be between 1 and 5 characters";
14 | }
15 |
16 | if (Validator.isEmpty(data.symbol)) {
17 | errors.symbol = "Ticker Symbol field is required";
18 | }
19 |
20 | if (!Validator.isCurrency(data.purchasePrice, { allow_negatives: false })) {
21 | errors.purchasePrice = "Purchase Price must be in a valid currency format greater than 0.00.";
22 | }
23 |
24 | if (Validator.isEmpty(data.purchasePrice)) {
25 | errors.purchasePrice = "Purchase price is required";
26 | }
27 |
28 | if (!Validator.isInt(data.numberOfShares, { gt: 0 })) {
29 | errors.numberOfShares = "Number of Shares must be a whole number greater than 0";
30 | }
31 |
32 | if (Validator.isEmpty(data.numberOfShares)) {
33 | errors.numberOfShares = "Number of Shares field is required";
34 | }
35 |
36 | return {
37 | errors,
38 | isValid: Object.keys(errors).length === 0
39 | };
40 | };
--------------------------------------------------------------------------------
/validation/register.js:
--------------------------------------------------------------------------------
1 | const Validator = require("validator");
2 | const validText = require("./valid-text");
3 |
4 | module.exports = function validateRegisterInput(data) {
5 |
6 | let errors = {};
7 |
8 | data.name = validText(data.name) ? data.name : "";
9 | data.email = validText(data.email) ? data.email : "";
10 | data.password = validText(data.password) ? data.password : "";
11 | data.password2 = validText(data.password2) ? data.password2 : "";
12 |
13 | if (!Validator.isLength(data.name, { min: 2, max: 30 })) {
14 | errors.name = "Name must be between 2 and 30 characters";
15 | }
16 |
17 | if (Validator.isEmpty(data.name)) {
18 | errors.name = "Name field is required";
19 | }
20 |
21 | if (Validator.isEmpty(data.email)) {
22 | errors.email = "Email field is required";
23 | }
24 |
25 | if (!Validator.isEmail(data.email)) {
26 | errors.email = "Email is invalid";
27 | }
28 |
29 | if (Validator.isEmpty(data.password)) {
30 | errors.password = "Password field is required";
31 | }
32 |
33 | if (!Validator.isLength(data.password, { min: 6, max: 30 })) {
34 | errors.password = "Password must be at least 6 characters";
35 | }
36 |
37 | if (Validator.isEmpty(data.password2)) {
38 | errors.password2 = "Confirm Password field is required";
39 | }
40 |
41 | if (!Validator.equals(data.password, data.password2)) {
42 | errors.password2 = "Passwords must match";
43 | }
44 |
45 | return {
46 | errors,
47 | isValid: Object.keys(errors).length === 0
48 | };
49 | };
--------------------------------------------------------------------------------
/frontend/styles/header.scss:
--------------------------------------------------------------------------------
1 | header {
2 | position: sticky;
3 | top: 0;
4 | left: 0;
5 | right: 0;
6 | z-index: 100;
7 | }
8 |
9 | .header-container {
10 | display: flex;
11 | justify-content: center;
12 | height: 40px;
13 | width: 100%;
14 | background-color: var(--bg1);
15 | color: var(--text3);
16 | border-bottom: var(--text4) solid 2px;
17 | box-sizing: content-box;
18 |
19 | * {
20 | white-space: nowrap;
21 | overflow: hidden;
22 | }
23 | }
24 |
25 | .header {
26 | display: flex;
27 | justify-content: space-between;
28 | align-items: center;
29 | width: 100%;
30 | max-width: 1280px;
31 | margin: auto;
32 | padding: 0 8px;
33 |
34 | h1 {
35 |
36 | font-weight: 600;
37 | font-size: 30px;
38 | cursor: pointer;
39 |
40 | svg {
41 | color: $blue;
42 | padding-right: 6px;
43 | }
44 | }
45 |
46 | h1:hover {
47 | color: $blue;
48 |
49 | svg {
50 | color: $yellow;
51 | }
52 | }
53 |
54 | > div {
55 |
56 | display: flex;
57 | justify-content: flex-end;
58 |
59 | > div, a {
60 | font-weight: 600;
61 | padding: 4px;
62 | border-radius: 4px;
63 | border: var(--text4) solid 2px;
64 | cursor: pointer;
65 | margin-right: 8px;
66 | }
67 |
68 | > div:hover, a:hover {
69 | background: $blue;
70 | color: var(--text1);
71 | border-color: var(--text1);
72 | }
73 |
74 | a.active {
75 | border-color: $blue;
76 | }
77 | }
78 | }
--------------------------------------------------------------------------------
/routes/api/trades.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const passport = require('passport');
4 |
5 | const User = require("../../models/User");
6 | const Trade = require("../../models/Trade");
7 | const validatePurchaseInput = require("../../validation/purchase");
8 |
9 | router.get("/history", passport.authenticate("jwt", { session: false }), (req, res) => {
10 | Trade.find({ user: req.user.id })
11 | .sort({ date: -1 })
12 | .then(trades => res.json(trades))
13 | .catch(err => res.status(404).json({ noTradesFound: "No trades found from that user" }));
14 | });
15 |
16 | router.post("/purchase", passport.authenticate("jwt", { session: false }), (req, res) => {
17 | if (req.user.cash < (req.body.purchasePrice * req.body.numberOfShares)) {
18 | return res.status(400).json({ notEnoughCash: "You do not have enough cash to make this purchase" });
19 | }
20 |
21 | const { errors, isValid } = validatePurchaseInput(req.body);
22 |
23 | if (!isValid) return res.status(400).json(errors);
24 |
25 | const newTrade = new Trade({
26 | user: req.user.id,
27 | symbol: req.body.symbol,
28 | purchasePrice: req.body.purchasePrice,
29 | numberOfShares: req.body.numberOfShares
30 | });
31 |
32 | const query = { _id: req.user.id };
33 | const update = {
34 | "$set": {
35 | cash: (req.user.cash - (req.body.purchasePrice * req.body.numberOfShares))
36 | }
37 | };
38 |
39 | User.updateOne(query, update)
40 | .then(() => newTrade.save())
41 | .then(trade => res.json({trade: trade, user: { cash: (req.user.cash - (req.body.purchasePrice * req.body.numberOfShares))}}))
42 | .catch(err => console.log(err));
43 | });
44 |
45 | module.exports = router;
--------------------------------------------------------------------------------
/frontend/src/actions/session_actions.js:
--------------------------------------------------------------------------------
1 |
2 | import * as APIUtil from "../util/session_api_util";
3 | import jwt_decode from "jwt-decode";
4 |
5 | export const RECEIVE_CURRENT_USER = "RECEIVE_CURRENT_USER";
6 | export const RECEIVE_SESSION_ERRORS = "RECEIVE_SESSION_ERRORS";
7 | export const REMOVE_SESSION_ERRORS = 'REMOVE_SESSION_ERRORS';
8 | export const RECEIVE_USER_LOGOUT = "RECEIVE_USER_LOGOUT";
9 | export const RECEIVE_USER_SIGN_IN = "RECEIVE_USER_SIGN_IN";
10 |
11 | export const receiveCurrentUser = currentUser => ({
12 | type: RECEIVE_CURRENT_USER,
13 | currentUser
14 | });
15 |
16 | export const receiveUserSignIn = () => ({
17 | type: RECEIVE_USER_SIGN_IN
18 | });
19 |
20 | export const receiveSessionErrors = errors => ({
21 | type: RECEIVE_SESSION_ERRORS,
22 | errors
23 | });
24 |
25 | export const removeSessionErrors = () => ({
26 | type: REMOVE_SESSION_ERRORS
27 | });
28 |
29 | export const logoutUser = () => ({
30 | type: RECEIVE_USER_LOGOUT
31 | });
32 |
33 | export const register = user => dispatch => (
34 | APIUtil.register(user).then(() => (
35 | dispatch(receiveUserSignIn())
36 | ), err => (
37 | dispatch(receiveSessionErrors(err.response.data))
38 | ))
39 | );
40 |
41 | export const signIn = user => dispatch => (
42 | APIUtil.signIn(user).then(res => {
43 | const { token } = res.data;
44 | localStorage.setItem("jwtToken", token);
45 | APIUtil.setAuthToken(token);
46 | const decoded = jwt_decode(token);
47 | dispatch(receiveCurrentUser(decoded));
48 | })
49 | .catch(err => {
50 | dispatch(receiveSessionErrors(err.response.data));
51 | })
52 | );
53 |
54 | export const logout = () => dispatch => {
55 | localStorage.removeItem("jwtToken");
56 | APIUtil.setAuthToken(false);
57 | dispatch(logoutUser());
58 | };
--------------------------------------------------------------------------------
/frontend/styles/splash.scss:
--------------------------------------------------------------------------------
1 | .splash-container {
2 | width: 100%;
3 | max-width: 1280px;
4 | margin: auto;
5 | background-image: url(https://investchest.s3.us-east-2.amazonaws.com/splash-bg.jpg);
6 | background-repeat: no-repeat;
7 | background-position: center center;
8 | background-size: cover;
9 | color: var(--text2);
10 | overflow: hidden;
11 | }
12 |
13 | .splash {
14 | height: calc(100vh - 72px);
15 | width: 100%;
16 | background: rgba(var(--opacitybg), 0.4);
17 | overflow: hidden;
18 | }
19 |
20 | .splash-content {
21 | position: absolute;
22 | transform: translate(-50%, -50%);
23 | left: 50%;
24 | top: 50%;
25 |
26 | h1 {
27 | font-size: 64px;
28 | font-weight: 700;
29 | -webkit-text-stroke-width: 1px;
30 | -webkit-text-stroke-color: var(--bg1);
31 | text-align: center;
32 | }
33 |
34 | > div {
35 |
36 | margin-top: 16px;
37 |
38 | span {
39 | display: block;
40 | text-align: center;
41 | font-size: 32px;
42 | font-weight: 600;
43 | -webkit-text-stroke-width: 0.5px;
44 | -webkit-text-stroke-color: var(--bg1);
45 | max-width: 500px;
46 | margin: auto;
47 | }
48 | }
49 | }
50 |
51 | .splash-button-container {
52 | display: flex;
53 | justify-content: center;
54 | div {
55 | font-weight: 500;
56 | font-size: 24px;
57 | background: var(--bg1);
58 | padding: 8px;
59 | border-radius: 4px;
60 | border: $blue solid 2px;
61 | cursor: pointer;
62 | text-align: center;
63 | width: max-content;
64 | }
65 |
66 | div:hover {
67 | background-color: $blue;
68 | border-color: var(--text1);
69 | }
70 |
71 | .splash-button {
72 | margin-right: 16px;
73 | }
74 | }
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "1.0.0",
4 | "description": "Frontend | NY TTP W2020",
5 | "main": "./src/index.js",
6 | "scripts": {
7 | "start": "webpack-dev-server --config webpack.dev.js",
8 | "watch": "webpack --watch --config webpack.dev.js",
9 | "build": "webpack --config webpack.prod.js"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "git+https://github.com/derekwolpert/InvestChest.git"
14 | },
15 | "author": "Derek Wolpert",
16 | "license": "ISC",
17 | "bugs": {
18 | "url": "https://github.com/derekwolpert/InvestChest/issues"
19 | },
20 | "homepage": "https://github.com/derekwolpert/InvestChest#readme",
21 | "dependencies": {
22 | "@babel/core": "^7.18.10",
23 | "@babel/preset-env": "^7.18.10",
24 | "@babel/preset-react": "^7.18.6",
25 | "@fortawesome/fontawesome-svg-core": "^1.2.36",
26 | "@fortawesome/free-brands-svg-icons": "^5.15.4",
27 | "@fortawesome/free-regular-svg-icons": "^5.15.4",
28 | "@fortawesome/free-solid-svg-icons": "^5.15.4",
29 | "@fortawesome/react-fontawesome": "^0.1.19",
30 | "autoprefixer": "^9.8.8",
31 | "axios": "^0.21.4",
32 | "babel-loader": "^8.2.5",
33 | "css-loader": "^3.6.0",
34 | "jwt-decode": "^2.2.0",
35 | "mini-css-extract-plugin": "^0.9.0",
36 | "moment": "^2.29.4",
37 | "moment-locales-webpack-plugin": "^1.2.0",
38 | "postcss-loader": "^3.0.0",
39 | "react": "^16.14.0",
40 | "react-dom": "^16.14.0",
41 | "react-redux": "^7.2.8",
42 | "react-router-dom": "^5.3.3",
43 | "recharts": "^2.1.13",
44 | "redux": "^4.2.0",
45 | "sass": "^1.54.3",
46 | "sass-loader": "^8.0.2",
47 | "webpack": "^4.46.0",
48 | "webpack-cli": "^3.3.12",
49 | "webpack-merge": "^4.2.2"
50 | },
51 | "devDependencies": {
52 | "redux-logger": "^3.0.6",
53 | "webpack-dev-server": "^3.11.3"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/frontend/src/actions/stock_actions.js:
--------------------------------------------------------------------------------
1 | import * as APIUtil from "../util/stock_api_util";
2 |
3 | export const RECEIVE_BATCH_STOCKS = "RECEIVE_BATCH_STOCKS";
4 | export const RECEIVE_STOCK = "RECEIVE_STOCK";
5 | export const RECEIVE_CHART = "RECEIVE_CHART";
6 | export const RECEIVE_STOCK_ERROR = "RECEIVE_STOCK_ERROR";
7 | export const REMOVE_STOCK_ERROR = "REMOVE_STOCK_ERROR";
8 | export const RECEIVE_CHART_ERROR = "RECEIVE_CHART_ERROR";
9 | export const REMOVE_CHART_ERROR = "REMOVE_CHART_ERROR";
10 |
11 | const receiveBatchStocks = stocks => ({
12 | type: RECEIVE_BATCH_STOCKS,
13 | stocks: stocks.data
14 | });
15 |
16 | const receiveStock = stock => ({
17 | type: RECEIVE_STOCK,
18 | stock: stock.data
19 | });
20 |
21 | const receiveChart = chart => ({
22 | type: RECEIVE_CHART,
23 | ...chart.data
24 | });
25 |
26 | export const receiveStockError = error => ({
27 | type: RECEIVE_STOCK_ERROR,
28 | error
29 | });
30 |
31 | export const removeStockError = () => ({
32 | type: REMOVE_STOCK_ERROR,
33 | });
34 |
35 | export const receiveChartError = error => ({
36 | type: RECEIVE_CHART_ERROR,
37 | error
38 | });
39 |
40 | export const removeChartError = () => ({
41 | type: REMOVE_CHART_ERROR
42 | });
43 |
44 | export const getStocks = stocks => dispatch =>
45 | APIUtil.getStocks(stocks).then(stocks =>
46 | dispatch(receiveBatchStocks(stocks))
47 | );
48 |
49 | export const getStock = stock => dispatch =>
50 | APIUtil.getStock(stock).then(stock =>
51 | dispatch(receiveStock(stock))
52 | )
53 | .catch (err => {
54 | dispatch(receiveStockError(err.response.data));
55 | });
56 |
57 | export const getChart = (symbol, range) => dispatch =>
58 | APIUtil.getChart(symbol, range).then(chart =>
59 | dispatch(receiveChart(chart))
60 | )
61 | .catch (err => {
62 | dispatch(receiveChartError(err.response.data));
63 | });
64 |
--------------------------------------------------------------------------------
/frontend/webpack.common.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const MiniCssExtractPlugin = require("mini-css-extract-plugin");
3 | const MomentLocalesPlugin = require("moment-locales-webpack-plugin");
4 |
5 | module.exports = {
6 | context: __dirname,
7 | entry: "./src/index.js",
8 | output: {
9 | path: path.resolve(__dirname, "dist"),
10 | filename: "bundle.js",
11 | publicPath: "/"
12 | },
13 | resolve: {
14 | extensions: [".js", ".jsx", "*"]
15 | },
16 | module: {
17 | rules: [
18 | {
19 | test: /\.jsx?$/,
20 | exclude: /(node_modules)/,
21 | use: {
22 | loader: "babel-loader",
23 | query: {
24 | presets: ["@babel/env", "@babel/react"]
25 | }
26 | }
27 | },
28 | {
29 | test: /\.scss$/,
30 | use: [
31 | {
32 | loader: MiniCssExtractPlugin.loader,
33 | options: {
34 | hmr: process.env.NODE_ENV === "development"
35 | }
36 | },
37 | "css-loader",
38 | "sass-loader",
39 | {
40 | loader: "postcss-loader",
41 | options: {
42 | config: {
43 | path: "postcss.config.js"
44 | }
45 | }
46 | }
47 | ]
48 | }
49 | ]
50 | },
51 | plugins: [
52 | new MiniCssExtractPlugin({
53 | filename: "bundle.css",
54 | ignoreOrder: false
55 | }),
56 | require("autoprefixer"),
57 | new MomentLocalesPlugin(),
58 | ]
59 | };
--------------------------------------------------------------------------------
/routes/api/stocks.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const axios = require("axios");
4 | const iexApiToken = require("../../config/keys").iexApiToken;
5 | const iexSandboxToken = require("../../config/keys").iexSandboxToken;
6 |
7 |
8 | router.get("/batch/:symbols", (req, res) => {
9 | axios.get(`https://${process.env.NODE_ENV === "production" ? "cloud" : "sandbox"}.iexapis.com/stable/stock/market/batch?symbols=${req.params.symbols}&filter=symbol,companyName,latestPrice,latestUpdate,previousClose,lastTradeTime&types=quote&token=${iexApiToken}`)
10 | .then(stocks => res.json(stocks.data))
11 | .catch(err => res.status(err.response.status).json({ noStocksFound: err.response.data })
12 | );
13 | });
14 |
15 | router.get("/lookup/:symbol", (req, res) => {
16 | axios.get(`https://${process.env.NODE_ENV === "production" ? "cloud" : "sandbox"}.iexapis.com/stable/stock/${req.params.symbol}/quote?filter=symbol,companyName,latestPrice,latestUpdate,previousClose,lastTradeTime&token=${iexApiToken}`)
17 | .then(stock => res.json(stock.data))
18 | .catch(err => res.status(err.response.status).json({ noStockFound: err.response.data, symbol: req.params.symbol.toUpperCase() }));
19 | });
20 |
21 |
22 | router.get("/chart/:symbol/:range", (req, res) => {
23 |
24 | const rangeSubUrl = {
25 | "1d": "1d/?filter=date,minute,close",
26 | "5dm": "5dm/?filter=date,minute,close",
27 | "1mm": "1mm/?filter=date,minute,close",
28 | "3m": "3m/?filter=date,close",
29 | "6m": "6m/?filter=date,close",
30 | "1y": "1Y/?filter=date,close",
31 | "2y": "2y/?filter=date,close",
32 | "5y": "5y/?filter=date,close"
33 | };
34 |
35 | axios.get(`https://sandbox.iexapis.com/stable/stock/${req.params.symbol}/chart/${rangeSubUrl[req.params.range]}&token=${iexSandboxToken}`)
36 | .then(chart => res.json({ symbol: req.params.symbol, range: req.params.range, chart: chart.data }))
37 | .catch(err => res.status(err.response.status).json({ noChartFound: err.response.data, symbol: req.params.symbol.toUpperCase() }));
38 | });
39 |
40 | module.exports = router;
--------------------------------------------------------------------------------
/frontend/src/components/footer/footer.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faExternalLinkAlt, faEnvelope } from "@fortawesome/free-solid-svg-icons";
4 | import { faGithub, faLinkedinIn, faAngellist } from '@fortawesome/free-brands-svg-icons';
5 |
6 | class Footer extends React.Component {
7 | render() {
8 | return (
9 |
42 | );
43 | }
44 | }
45 |
46 | export default Footer;
--------------------------------------------------------------------------------
/frontend/src/components/stock_chart/stock_chart_util.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as moment from "moment";
3 |
4 | export const CustomizedXTick = props => {
5 | const { x, y, payload, range } = props;
6 |
7 | let value;
8 |
9 | if (range === "1d") {
10 | value = moment(payload.value, 'HH:mm').format('h:mm');
11 | } else if ((new Set(["5dm", "1mm", "3m", "6m", "1y"])).has(range)) {
12 | value = moment(payload.value).format("MMM Do");
13 | } else {
14 | value = moment(payload.value).format("MMM Do YY");
15 | }
16 |
17 | return (
18 |
19 |
20 | {value}
21 |
22 |
23 | );
24 | };
25 |
26 | export const CustomizedYTick = props => {
27 | const { x, y, payload } = props;
28 |
29 | return (
30 |
31 |
32 | {payload.value}
33 |
34 |
35 | );
36 | };
37 |
38 | export const CustomizedTooltip = props => {
39 |
40 | if (props.payload[0]) {
41 | const { close, date } = props.payload[0].payload;
42 | const formatDate = (new Set(["2y", "5y"]).has(props.range)) ?
43 | moment(date).format("MMM Do YY") : moment(date).format("MMM Do");
44 | if (new Set(["1d", "5dm", "1mm"]).has(props.range)) {
45 | const { minute } = props.payload[0].payload;
46 | const formatMinute = moment(minute, 'HH:mm').format('h:mm A');
47 | return (
48 |
49 | Price: ${close.toFixed(2)}
50 | Time: {formatMinute}
51 | Date: {formatDate}
52 |
53 | )
54 | } else {
55 | return (
56 |
57 | Price: ${close.toFixed(2)}
58 | Date: {formatDate}
59 |
60 | )
61 | }
62 | } else {
63 | return null;
64 | }
65 | };
66 |
--------------------------------------------------------------------------------
/frontend/src/components/header/header.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4 | import { faCoins } from "@fortawesome/free-solid-svg-icons";
5 | import ThemeSwitch from "../theme_switch/theme_switch";
6 |
7 | class Header extends React.Component {
8 | constructor(props) {
9 | super(props);
10 | this.logoutUser = this.logoutUser.bind(this);
11 | this.getLinks = this.getLinks.bind(this);
12 | }
13 |
14 | logoutUser(e) {
15 | e.preventDefault();
16 | this.props.logout();
17 | }
18 |
19 | getLinks() {
20 | if (this.props.loggedIn) {
21 | return (
22 |
23 |
24 | Portfolio
25 |
26 |
27 | Transactions
28 |
29 |
Logout
30 |
31 |
32 | );
33 | } else {
34 | return (
35 |
36 |
this.props.openModal("register")}
38 | >
39 | Register
40 |
41 |
this.props.openModal("signIn")}>
42 | Sign In
43 |
44 |
45 |
46 | );
47 | }
48 | }
49 |
50 | render() {
51 | return (
52 |
53 |
54 |
55 |
56 |
57 | InvestChest
58 |
59 |
60 | {this.getLinks()}
61 |
62 |
63 | );
64 | }
65 | }
66 |
67 | export default Header;
--------------------------------------------------------------------------------
/frontend/styles/session.scss:
--------------------------------------------------------------------------------
1 | .session-form-container {
2 | position: relative;
3 |
4 | .fa-times {
5 | color: var(--text3);
6 | position: absolute;
7 | top: 0;
8 | right: 0;
9 | cursor: pointer;
10 | font-size: 24px;
11 | }
12 | .fa-times:hover {
13 | color: $blue;
14 | }
15 | }
16 |
17 | .session-form {
18 |
19 | > div:first-of-type {
20 | font-weight: 700;
21 | display: flex;
22 | justify-content: center;
23 | margin-bottom: 16px;
24 |
25 | span {
26 | color: $blue;
27 | font-size: 24px;
28 | }
29 |
30 | span:first-of-type {
31 | padding-right: 8px;
32 | margin-right: 8px;
33 | border-right: var(--text3) solid 3px;
34 | }
35 |
36 | .session-switch {
37 | cursor: pointer;
38 | color: var(--text3);
39 | }
40 |
41 | .session-switch:hover {
42 | color: $blue;
43 | }
44 | }
45 |
46 | .session-input-container {
47 | display: flex;
48 | flex-direction: column;
49 | align-items: center;
50 | margin: 0 16px;
51 |
52 | input, div.session-submit {
53 | width: 100%;
54 | font-size: 16px;
55 | padding: 8px 16px;
56 | margin-bottom: 8px;
57 | border-radius: 4px;
58 | border: var(--text4) solid 3px;
59 | outline: none;
60 | color: var(--text3);
61 | background-color: var(--bg1);
62 | }
63 |
64 | input:focus {
65 | border-color: $blue;
66 | }
67 |
68 | .session-submit {
69 | display: inline-block;
70 | font-weight: 600;
71 | margin-bottom: 0 !important;
72 | cursor: pointer;
73 | width: min-content !important;
74 | margin: auto;
75 | white-space:nowrap !important;
76 | line-height: normal;
77 | }
78 |
79 | .session-submit:hover {
80 | font-weight: 600;
81 | background-color: $blue;
82 | border-color: var(--text1);
83 | }
84 |
85 | div.session-submit:hover {
86 | background-color: $violet;
87 | }
88 |
89 | input.session-submit {
90 | margin-right: 8px;
91 | }
92 |
93 | .session-error {
94 | color: $red;
95 | font-weight: 600;
96 | margin-bottom: 8px;
97 | }
98 | }
99 | }
--------------------------------------------------------------------------------
/frontend/src/components/splash/splash.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | class Splash extends React.Component {
4 | constructor(props) {
5 | super(props);
6 | this.state = {
7 | height: null
8 | };
9 | this.handleSplashHeight = this.handleSplashHeight.bind(this);
10 | }
11 |
12 | componentDidMount() {
13 | if (this.state.height === null) {
14 | this.handleSplashHeight();
15 | window.addEventListener("resize", this.handleSplashHeight);
16 | } else {
17 | document.title = "InvestChest";
18 | }
19 | }
20 |
21 | componentDidUpdate() {
22 | document.title = "InvestChest";
23 | }
24 |
25 | componentWillUnmount() {
26 | window.removeEventListener("resize", this.handleSplashHeight);
27 | }
28 |
29 | handleSplashHeight() {
30 | this.setState({ height: window.innerHeight });
31 | }
32 |
33 | render() {
34 | return (
35 |
36 |
40 |
41 |
Trade and Grow
42 |
43 |
44 | Welcome to the online destination for building your very
45 | own (mock) stock investment portfolio
46 |
47 |
48 |
49 |
this.props.openModal("register")}
51 | className="splash-button"
52 | >
53 | Register
54 |
55 |
this.props.openModal("signIn")}>
56 | Sign In
57 |
58 |
59 |
this.props.openModal("signIn")}
61 | className="splash-button-container"
62 | >
63 |
Login as a Demo User
64 |
65 |
66 |
67 |
68 | );
69 | }
70 | }
71 |
72 | export default Splash;
--------------------------------------------------------------------------------
/frontend/styles/chart.scss:
--------------------------------------------------------------------------------
1 | .chart-selectors {
2 | display: flex;
3 | justify-content: space-between;
4 | align-items: center;
5 | font-size: 14px;
6 | margin: 16px 0 12px;
7 | border-top: var(--text4) solid 2px;
8 | padding-top: 16px;
9 | color: var(--text4);
10 |
11 | .chart-range, .chart-range-active{
12 | text-align: center;
13 | flex: 1;
14 | letter-spacing: 1px;
15 | font-weight: 600;
16 | vertical-align: middle;
17 | line-height: normal;
18 | }
19 |
20 | .chart-range {
21 | cursor: pointer;
22 | }
23 |
24 | .chart-range:hover {
25 | color: $blue;
26 | }
27 |
28 | .chart-range-active {
29 | color: $blue;
30 | }
31 |
32 | .chart-range.disabled {
33 | cursor: not-allowed;
34 | color: var(--bg4);
35 | }
36 |
37 | .chart-range.disabled:hover {
38 | color: var(--bg4);
39 | }
40 |
41 | .divider {
42 | border-right: var(--text4) solid 1px;
43 | height: 19px;
44 | }
45 |
46 | }
47 |
48 | .stock-chart-container {
49 | font-size: 12px;
50 | background-color: var(--bg1);
51 | border: var(--text4) solid 3px;
52 | border-radius: 4px;
53 | height: 326px;
54 | position: relative;
55 | overflow: hidden;
56 |
57 | tspan {
58 | fill: var(--chart);
59 | }
60 | line {
61 | stroke: var(--chart);
62 | opacity: 0.4;
63 | }
64 | > .chart-ticker {
65 | display: inline-block;
66 | position: absolute;
67 | font-size: 48px;
68 | font-weight: 600;
69 | top: 16px;
70 | left: 16px;
71 | opacity: 0.3;
72 | color: var(--text1);
73 | letter-spacing: 1px;
74 | -webkit-text-stroke-width: 2px;
75 | -webkit-text-stroke-color: var(--bg1);
76 | }
77 | .chart-error {
78 | font-size: 18px;
79 | font-weight: 700;
80 | color: $red;
81 | text-align: center;
82 | vertical-align: middle;
83 | line-height: normal;
84 | position: absolute;
85 | top: 50%;
86 | left: 50%;
87 | transform: translate(-50%, -50%);
88 | -webkit-text-stroke-width: 0.5px;
89 | -webkit-text-stroke-color: var(--bg1);
90 | }
91 | }
92 |
93 | .chart-tooltip {
94 | background: rgba(var(--opacitybg), 0.8);
95 | border-radius: 4px;
96 | padding: 6px 6px 0;
97 | border: var(--text4) solid 1px;
98 | color: var(--text3);
99 | font-weight: 600;
100 |
101 |
102 | p {
103 | text-align: center;
104 | margin-bottom: 6px;
105 | }
106 | }
--------------------------------------------------------------------------------
/routes/api/users.js:
--------------------------------------------------------------------------------
1 | const express = require("express");
2 | const router = express.Router();
3 | const bcrypt = require("bcryptjs");
4 | const jwt = require("jsonwebtoken");
5 | const secretOrKey = require("../../config/keys").secretOrKey;
6 | const passport = require("passport");
7 |
8 | const User = require("../../models/User");
9 |
10 | const validateRegisterInput = require("../../validation/register");
11 | const validateSignInInput = require("../../validation/signin");
12 |
13 | router.get("/current", passport.authenticate("jwt", { session: false }), (req, res) => {
14 | res.json({
15 | id: req.user.id,
16 | name: req.user.name,
17 | email: req.user.email,
18 | cash: req.user.cash
19 | });
20 | });
21 |
22 | router.post("/register", (req, res) => {
23 |
24 | const { errors, isValid } = validateRegisterInput(req.body);
25 |
26 | if (!isValid) return res.status(400).json(errors);
27 |
28 | User.findOne({ email: req.body.email })
29 | .then(user => {
30 | if (user) {
31 | return res.status(400).json({ email: "A user has already registered with this address" });
32 | } else {
33 | const newUser = new User({
34 | name: req.body.name,
35 | email: req.body.email,
36 | password: req.body.password
37 | });
38 |
39 | bcrypt.genSalt(10, (err, salt) => {
40 | bcrypt.hash(newUser.password, salt, (err, hash) => {
41 | if (err) throw err;
42 | newUser.password = hash;
43 | newUser.save()
44 | .then(user => res.json(user))
45 | .catch(err => console.log(err));
46 | });
47 | });
48 | }
49 | });
50 | });
51 |
52 | router.post("/signin", (req, res) => {
53 |
54 | const { errors, isValid } = validateSignInInput(req.body);
55 |
56 | if (!isValid) {
57 | return res.status(400).json(errors);
58 | }
59 |
60 | const email = req.body.email;
61 | const password = req.body.password;
62 |
63 | User.findOne({ email })
64 | .then(user => {
65 | if (!user) return res.status(404).json({ email: "This user does not exist" });
66 |
67 | bcrypt.compare(password, user.password)
68 |
69 | .then(isMatch => {
70 | if (isMatch) {
71 | const payload = { id: user.id, name: user.name, cash: user.cash };
72 |
73 | jwt.sign( payload, secretOrKey, { expiresIn: 3600 }, (err, token) => {
74 | res.json({ success: true, token: "Bearer " + token});}
75 | );
76 |
77 | } else {
78 | return res.status(400).json({ password: "Incorrect password" });
79 | }
80 | });
81 | });
82 | });
83 |
84 | module.exports = router;
--------------------------------------------------------------------------------
/frontend/src/components/session/sign_in_form.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faTimes } from "@fortawesome/free-solid-svg-icons";
4 |
5 | class SignInForm extends React.Component {
6 | constructor(props) {
7 | super(props);
8 |
9 | this.state = {
10 | email: "",
11 | password: "",
12 | };
13 | this.handleSubmit = this.handleSubmit.bind(this);
14 | }
15 |
16 | componentWillUnmount() {
17 | this.props.removeSessionErrors();
18 | }
19 |
20 | update(field) {
21 | return e => this.setState({
22 | [field]: e.currentTarget.value
23 | });
24 | }
25 |
26 | handleSubmit(e) {
27 | e.preventDefault();
28 | let user = {
29 | email: this.state.email,
30 | password: this.state.password
31 | };
32 |
33 | this.props.signIn(user);
34 | }
35 |
36 | render() {
37 | return (
38 |
97 | );
98 | }
99 | }
100 |
101 | export default SignInForm;
--------------------------------------------------------------------------------
/frontend/styles/purchase_form.scss:
--------------------------------------------------------------------------------
1 | .purchase-form-container {
2 | margin-top: 8px;
3 | position: relative;
4 | overflow-y: scroll;
5 | height: 100%;
6 | margin-left: 16px;
7 | padding-right: 16px;
8 | overflow-x: hidden;
9 |
10 | > form {
11 | display: flex;
12 | flex-direction: column;
13 | align-items: center;
14 | }
15 |
16 | input, .purchase-button {
17 | width: 100%;
18 | font-size: 16px;
19 | padding: 8px 16px;
20 | margin: 0 auto 8px !important;
21 | border-radius: 4px;
22 | border: var(--text4) solid 3px;
23 | outline: none;
24 | color: var(--text3);
25 | background-color: var(--bg1);
26 | }
27 |
28 | input:last-of-type {
29 | margin-bottom: 0 !important;
30 | }
31 |
32 | input:focus {
33 | border-color: $blue;
34 | }
35 |
36 | .purchase-button {
37 | text-align: center;
38 | font-weight: 600;
39 | cursor: pointer;
40 | margin: auto;
41 | white-space:nowrap !important;
42 | line-height: normal;
43 | overflow: hidden;
44 | }
45 |
46 | .purchase-button:hover {
47 | background-color: $blue;
48 | border-color: var(--text1);
49 | color: var(--text1);
50 | }
51 |
52 | .purchase-button.disabled, .purchase-button:disabled, input:disabled {
53 | cursor: not-allowed;
54 | color: var(--bg4);
55 | border: var(--bg4) solid 3px;
56 | }
57 |
58 | .purchase-button.disabled:hover, .purchase-button:disabled:hover, input:disabled:hover {
59 | cursor: not-allowed;
60 | color: var(--text3);
61 | background-color: var(--bg1);
62 | border: var(--bg4) solid 3px;
63 | color: var(--bg4);
64 | }
65 |
66 | .purchase-error {
67 | color: $red;
68 | font-weight: 600;
69 | }
70 |
71 | span {
72 | display: block;
73 | margin-bottom: 8px;
74 | width: 100%;
75 | text-align: center;
76 | p {
77 | display: inline-block;
78 | }
79 | .green {
80 | color: $green;
81 | }
82 | .red {
83 | color: $red;
84 | }
85 | svg {
86 | margin-left: 4px;
87 | }
88 | }
89 |
90 | span:first-of-type {
91 | border-top: var(--text4) solid 2px;
92 | margin-top: 8px;
93 | margin-bottom: 16px;
94 | }
95 |
96 | span:last-of-type {
97 | margin-bottom: 16px;
98 | }
99 |
100 | .empty {
101 | height: 16px;
102 | }
103 |
104 | div.purchase-error {
105 | margin-top: 8px;
106 | text-align: center;
107 | word-break: break-word;
108 | }
109 | > p {
110 | display: block;
111 | margin: 12px 12px 0;
112 | font-size: 13px;
113 | line-height: normal;
114 | color: var(--text4);
115 | text-align: center;
116 | }
117 |
118 | a {
119 | color: $blue;
120 | }
121 |
122 | a:hover {
123 | color: var(--text1);
124 | }
125 |
126 | .purchase-loading-container {
127 | height: 64px;
128 | display: block;
129 | margin-bottom: 16px;
130 | .center-spinner {
131 | display: flex;
132 | justify-content: center;
133 | align-items: center;
134 | height: 100%;
135 | }
136 |
137 | .lds-ring {
138 | display: inline-block;
139 | position: relative;
140 | width: 80px;
141 | height: 80px;
142 | align-items: center;
143 |
144 | div {
145 | box-sizing: border-box;
146 | display: block;
147 | position: absolute;
148 | width: 64px;
149 | height: 64px;
150 | margin: 8px;
151 | border: 8px solid var(--text4);
152 | border-radius: 50%;
153 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
154 | border-color: var(--text4) transparent transparent transparent;
155 | }
156 |
157 | div:nth-child(1) {
158 | animation-delay: -0.45s;
159 | }
160 |
161 | div:nth-child(2) {
162 | animation-delay: -0.3s;
163 | }
164 |
165 | div:nth-child(3) {
166 | animation-delay: -0.15s;
167 | }
168 | }
169 |
170 | @keyframes lds-ring {
171 | 0% {
172 | transform: rotate(0deg);
173 | }
174 | 100% {
175 | transform: rotate(360deg);
176 | }
177 | }
178 | }
179 | }
--------------------------------------------------------------------------------
/frontend/src/components/session/register_form.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { faTimes } from "@fortawesome/free-solid-svg-icons";
4 |
5 | class RegisterForm extends React.Component {
6 | constructor(props) {
7 | super(props);
8 | this.state = {
9 | email: "",
10 | name: "",
11 | password: "",
12 | password2: "",
13 | };
14 | this.handleSubmit = this.handleSubmit.bind(this);
15 | }
16 |
17 | update(field) {
18 | return e => this.setState({
19 | [field]: e.currentTarget.value
20 | });
21 | }
22 |
23 | handleSubmit(e) {
24 | e.preventDefault();
25 | let user = {
26 | email: this.state.email,
27 | name: this.state.name,
28 | password: this.state.password,
29 | password2: this.state.password2
30 | };
31 | this.props.register(user);
32 | }
33 |
34 | componentDidUpdate(prevProps) {
35 | if (this.props.signedIn && !prevProps.signedIn) {
36 | this.props.signIn({ email: this.state.email, password: this.state.password});
37 | }
38 | }
39 |
40 | componentWillUnmount() {
41 | this.props.removeSessionErrors();
42 | }
43 |
44 | render() {
45 | return (
46 |
131 | );
132 | }
133 | }
134 |
135 | export default RegisterForm;
--------------------------------------------------------------------------------
/frontend/styles/portfolio.scss:
--------------------------------------------------------------------------------
1 | .portfolio-container {
2 | width: 100%;
3 | height: calc(100vh - 32px);
4 | max-width: 1120px;
5 | margin: auto;
6 |
7 | > h1 {
8 | display: flex;
9 | justify-content: space-between;
10 | align-items: flex-start;
11 | font-size: 32px;
12 | padding: 16px 16px 0;
13 | font-weight: 600;
14 | flex: 1;
15 | overflow: hidden;
16 | > span {
17 | font-size: 16px;
18 | line-height: 16px;
19 | display: flex;
20 | flex-direction: column;
21 | justify-content: space-around;
22 | text-align: end;
23 | }
24 | }
25 | }
26 |
27 | .portfolio-content {
28 | display: flex;
29 | margin: 8px 0 0px 16px;
30 | padding-bottom: 16px;
31 | height: calc(100vh - 128px);
32 | justify-content: space-between;
33 | overflow: hidden;
34 |
35 | > div {
36 | margin: 8px 0;
37 | overflow-x: hidden;
38 | }
39 | > div:first-of-type {
40 | flex: 1 1 0;
41 | overflow-y: scroll;
42 | }
43 |
44 | > div:last-of-type {
45 | flex: 1 1 16px;
46 | display: flex;
47 | flex-direction: column;
48 |
49 | > h1 {
50 | text-align: center;
51 | font-size: 24px;
52 | padding-bottom: 16px;
53 | margin: 0 16px 8px 16px;
54 | border-bottom: var(--text4) solid 2px;
55 | }
56 | }
57 |
58 | > span {
59 | border-right: var(--text4) solid 2px;
60 | }
61 | }
62 |
63 | .portfolio-items-container {
64 | padding-right: 16px;
65 | li {
66 | position: relative;
67 | font-size: 16px;
68 | margin-bottom: 16px;
69 | > div {
70 | display: flex;
71 | justify-content: space-between;
72 | margin: 0 24px 8px 0;
73 | overflow-x: scroll;
74 | overflow-y: hidden;
75 | > span:nth-of-type(2) {
76 | font-weight: 600;
77 | margin-left: 4px;
78 | flex: 1 1;
79 | text-align: end;
80 | white-space: nowrap;
81 | svg {
82 | margin-left: 4px;
83 | }
84 | }
85 | .green {
86 | color: $green;
87 | }
88 | .red {
89 | color: $red;
90 | }
91 | }
92 |
93 | > svg {
94 | position: absolute;
95 | margin-left: 4px;
96 | top: 0;
97 | right: 0;
98 | color: var(--text4);
99 | cursor: pointer;
100 | }
101 |
102 | > svg:hover {
103 | color: $blue;
104 | }
105 |
106 | > span {
107 | font-size: 14px;
108 | margin: 12px 0 0 12px;
109 | display: inline-block;
110 | text-transform: uppercase;
111 | letter-spacing: 1px;
112 | }
113 |
114 | > span:first-of-type {
115 | text-transform: initial;
116 | display: flex;
117 | justify-content: space-between;
118 | margin: 4px 0 0;
119 | font-size: 12px;
120 | letter-spacing: 0px;
121 | overflow-x: scroll;
122 | overflow-y: hidden;
123 | > span:first-of-type {
124 | margin-right: 2px;
125 | }
126 | > span:last-of-type {
127 | margin-left: 2px;
128 | text-align: end;
129 | }
130 | }
131 | .transaction-date {
132 | justify-content: flex-end !important;
133 | text-align: end;
134 | }
135 | }
136 |
137 | li:last-of-type {
138 | margin-bottom: 0;
139 | }
140 |
141 | .spacer {
142 | margin: 16px 0;
143 | display: block;
144 | border-bottom: var(--text4) solid 2px;
145 | }
146 |
147 | .spacer:last-of-type {
148 | margin-bottom: 0px;
149 | }
150 |
151 | p {
152 | display: block;
153 | margin: 12px 12px 0;
154 | font-size: 12px;
155 | line-height: normal;
156 | color: var(--text4);
157 | text-align: center;
158 |
159 | a {
160 | color: $blue;
161 | }
162 |
163 | a:hover {
164 | color: var(--text1);
165 | }
166 | }
167 | }
168 |
169 | .no-trades-message {
170 | display: flex;
171 | flex-direction: column;
172 | justify-content: center;
173 | align-items: center;
174 | text-align: center;
175 | overflow-y: scroll;
176 | line-height: normal;
177 |
178 | h1 {
179 | font-size: 32px;
180 | margin-bottom: 16px;
181 | }
182 |
183 | span {
184 | font-size: 24px;
185 | margin-bottom: 16px;
186 | }
187 |
188 | svg {
189 | font-size: 48px;
190 | }
191 | }
192 |
193 | .portfolio-table-container {
194 | margin-top: 8px !important;
195 | margin-bottom: 0 !important;
196 | width: 100%;
197 | font-size: 12px;
198 | border: var(--text4) solid 2px;
199 | border-radius: 4px;
200 | background-color: var(--bg1);
201 | overflow-x: scroll;;
202 |
203 |
204 | table {
205 | width: 100%;
206 | }
207 |
208 | th, td {
209 | padding: 4px;
210 | line-height: normal;
211 | text-align: center;
212 |
213 | svg {
214 | margin-left: 4px;
215 | }
216 | }
217 |
218 | thead {
219 | font-size: 14px;
220 | border-bottom: var(--text4) solid 2px;
221 | text-transform: uppercase;
222 | color: $blue;
223 | font-weight: 600;
224 | th {
225 | padding-top: 8px;
226 | }
227 | }
228 |
229 | th:first-of-type, td:first-of-type {
230 | padding-left: 8px;
231 | }
232 |
233 | th:last-of-type, td:last-of-type {
234 | padding-right: 8px;
235 | }
236 |
237 | tbody {
238 | > tr:nth-child(odd) {
239 | background-color: var(--bg2);
240 | }
241 |
242 | > tr:last-of-type td {
243 | padding-bottom: 8px;
244 | }
245 | }
246 | }
--------------------------------------------------------------------------------
/frontend/src/components/stock_chart/stock_chart.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { CustomizedXTick, CustomizedYTick, CustomizedTooltip } from "./stock_chart_util";
3 | import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
4 |
5 | class StockChart extends React.Component {
6 |
7 | constructor(props) {
8 | super(props);
9 | this.state = {
10 | color: ""
11 | };
12 | this.setColor = this.setColor.bind(this);
13 | }
14 |
15 | componentDidMount() {
16 | if (this.props.data !== undefined) {
17 | this.setColor();
18 | }
19 | }
20 |
21 | componentDidUpdate(prevProps) {
22 | if ((this.props.data !== prevProps.data) && (this.props.data !== undefined)) {
23 | this.setColor();
24 | }
25 | }
26 |
27 | setColor() {
28 | if (this.props.data.length > 0) {
29 |
30 | let first = 0;
31 | let last = 0;
32 |
33 | for (let i = 0; i < this.props.data.length; i++) {
34 | if (this.props.data[i].close !== null) {
35 | first = this.props.data[i].close;
36 | break;
37 | }
38 | }
39 |
40 | for (let i = this.props.data.length - 1; i >= 0; i--) {
41 | if (this.props.data[i].close !== null) {
42 | last = this.props.data[i].close;
43 | break;
44 | }
45 | }
46 | if (first > last) {
47 | this.setState({ color: "#ab4642" });
48 | } else if (first < last) {
49 | this.setState({ color: "#a1b56c" });
50 | } else {
51 | this.setState({ color: "#7cafc2" });
52 | }
53 | }
54 | }
55 |
56 | render() {
57 | const loadingSpinner = ;
58 |
59 | if (this.props.data === undefined) {
60 | return (
61 |
62 | {this.props.symbol ?
63 | (this.props.error ?
64 | {`Chat Data for ${this.props.symbol} is unavailable through IEX Cloud API due to legal restrictions`}
65 | : loadingSpinner)
66 | : null}
67 |
68 | );
69 | }
70 |
71 | if ((typeof this.props.data === "object") && (this.props.data.length === 0 )) {
72 | return (
73 |
74 | {`IEX Cloud API was unable to retrieve chart data for ${this.props.symbol} over the selected time period`}
75 |
76 | );
77 | }
78 |
79 | return (
80 |
83 | this.state.color === "#7cafc2"
84 | ? null
85 | : this.setState({ color: "#7cafc2" })
86 | }
87 | onMouseOut={() =>
88 | this.state.color === "#7cafc2" ? this.setColor() : null
89 | }
90 | >
91 | {this.props.symbol}
92 | {this.props.data.length > 0 ? (
93 |
94 |
98 |
99 |
106 |
111 |
116 |
117 |
118 |
119 |
128 | }
129 | minTickGap={
130 | this.props.range === "1d" ? 60 : null
131 | }
132 | tickMargin={-4}
133 | tickLine={false}
134 | interval="preserveStart"
135 | />
136 | Math.floor(dataMin * 0.95),
140 | dataMax => Math.ceil(dataMax * 1.05)
141 | ]}
142 | tick={ }
143 | width={30}
144 | orientation="right"
145 | allowDecimals={false}
146 | tickMargin={-4}
147 | tickLine={false}
148 | />
149 |
156 | }
157 | />
158 |
172 |
173 |
174 | ) : (
175 | loadingSpinner
176 | )}
177 |
178 | );
179 |
180 | }
181 | }
182 |
183 |
184 | export default StockChart;
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # InvestChest
6 |
7 | ## Overview
8 |
9 | InvestChest was completed as part of the Winter 2020 assessment for the NYC Tech Talent Pipeline. The given instructions were to build a full-stack web-based stock portfolio application where users can purchase stock based on real-time values fetched from a third-party finance API. The specifics regarding functionality (e.g. user registration, sign-in, purchase forms, portfolio and transaction page, etc.) were detailed ahead of time, as were wireframe styling guidelines. Otherwise, I was free to utilize tools viewed best fit for the task.
10 |
11 | Technologies used include MERN (MongoDB, Express.js, React, Node.js) stack, Redux, Webpack, Sass, CSS3, HTML5, Recharts and the third-party IEX Cloud finance API. InvestChest is deployed within a Docker Container through Amazon Web Services .
12 |
13 | ### [This project is currently hosted at investchest.derekwolpert.com - CLICK HERE to visit the live version of this project](https://investchest.derekwolpert.com)
14 |
15 | 
16 |
17 | ## Architecture & Technologies
18 |
19 | - React , a JavaScript library used to assist with efficient management of rapidly changing data and maintaining a single-page web application structure.
20 | - Redux , a JavaScript library used in coordination with React to create a centralized store for organizing and accessing data.
21 | - Node.js , a runtime environment used to execute JavaScript for server-side scripting.
22 | - Express.js , a web application framework, used with Node.js, to provide server-side structure for querying and retrieval of API data.
23 | - JavaScript , the project's front and backend programing language.
24 | - MongoDB , a document-oriented (NoSQL) database system used for storage and management of information.
25 | - Webpack , a JavaScript bundler to assist with development and production builds.
26 | - Sass, CSS3 and HTML5 , used to manage the presentation and styling of the project.
27 | - Recharts , a JavaScript library built upon D3.js to assist with data visualization management in a React based project.
28 | - IEX Cloud , a third-party finance API used to query and receive real-time stock prices.
29 | - Docker , containerization platform that allows the creation of lightweight/portable environments to run the project.
30 | - Amazon Web Services , an on-demand cloud computing platform utilized to assist with storage and deployment of the project (using ECR, ECS and EC2 along with appropriate network configuration).
31 |
32 | NOTE: In order to avoid exceeding IEX's free-tier API call limit, Sandbox testing mode is utilized for the stock chart data - therefore the information used within the chart is purposely inaccurate. Other financial information (e.g. latest price, last updated, company name etc.) for stocks are unaffected, and should be accurate to within 15 minutes of current status.
33 |
34 | You can read more about Sandbox testing mode in IEX's docs: https://iexcloud.io/docs/api/#testing-sandbox
35 |
36 | ## Functionality
37 |
38 | - Comprehensive registration/authentication behavior for management of user sessions, and keeping track of user information (e.g. available cash, trades association, etc.).
39 | - Error handling for input fields to prevent invalid entries along with appropriate error messages (e.g. prevents users from signing in with incorrect credentials, stops a user from registering an account under a previously used email address, prevent purchase of a stocks that cost more than a user's available cash, etc.).
40 | - Separate Portfolio and Transaction pages. Portfolio page displays an aggregated list of all stocks a user has purchased, and lists in alphabetical order based on stock ticker. If a stock was purchased in two separate transactions then the transactions are grouped together. Transaction page displays a list of each individual trade in reverse-chronological order.
41 | - Color indicators for the pricing information on the Portfolio page to indicate if a stock's value has increased (green) or decreased (red) in price between the most recent opening and closing values.
42 | - An interactive stock chart, with hover effects, to allow users to review a selected stock's history before completing a purchase.
43 | - Dynamic and auto-detecting light/dark mode in conjunction with a theme switch in the webpage's header.
44 | - A polished, intuitive, responsive user interface/experience.
45 |
46 |
47 |
48 |
49 |
50 |
51 | ## Folder Structure
52 |
53 | # Backend Directory
54 |
55 | .
56 | ├── config # includes access keys, and user auth config
57 | ├── frontend # see the frontend directory below
58 | | └── ...
59 | ├── models # defines the structure of db schema
60 | ├── readme_images # images used on this page
61 | ├── routes
62 | | └── api # set connection btw frontend, backend
63 | | # and db interactions
64 | └── validation # checks an instances details before registering
65 | # or modifying a db entry
66 |
67 | # Frontend Directory
68 |
69 | frontend
70 | ├── dist # compiled js and css files
71 | ├── public # publicly accessible files
72 | | # including primary html
73 | ├── src
74 | | |── actions
75 | | |── components # react components
76 | | | |── footer
77 | | | |── header
78 | | | |── modal
79 | | | |── portfolio
80 | | | |── purchase
81 | | | |── session
82 | | | |── splash
83 | | | |── stock_chart
84 | | | |── theme_switch
85 | | | └── transactions
86 | | |── middleware # includes a thunk middleware definition
87 | | |── reducers # organizes information for the
88 | | | # global redux store
89 | | |── store # defines the redux store
90 | | └── util # sets api calls to access backend
91 | └── styles # design/styling files
92 |
93 | ## Wireframe vs. Final Design
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 | ## Known Issues
113 |
114 | - During after-market hours the IEX Cloud API can return inaccurate values for the latest price and previous closing price of a given stock ticker. Many aspects of this web-app rely on these values including the various green/red performance indicators and calculated profit values appearing throughout the site. This issue seems to be most prevalent after the transition into a new day, so it is likely caused by a datetime inconsistency between IEX and the source they use to access their financial data.
115 |
116 | ## Potential Future Features
117 |
118 | - Implement selling of stocks, which would be included as their own separate entries on the Transaction page
119 | - Enhanced responsiveness for various screen sizes, and mobile presentation optimization
120 | - Enhanced ticker symbol field in the purchase form with predictive values based on valid inputs, allow querying based on company name
121 |
122 | ## Resources
123 |
124 | - The background image for the splash page was taken by César Couto, and was feature on Unsplash: https://unsplash.com/photos/TIvFLeqZ4ec
125 | - The basis for the styling of the loading spinner adapted from Loading.io: https://loading.io/css
126 | - The CSS reset used in this project: https://meyerweb.com/eric/tools/css/reset
127 | - Color selection variables inspired by Base16 default colors: http://chriskempson.com/projects/base16/
128 |
--------------------------------------------------------------------------------
/frontend/src/components/portfolio/portfolio.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PortfolioItem from "./portfolio_item";
3 | import PurchaseContainer from "../purchase/purchase_container";
4 | import TransactionItem from "../transactions/transaction_item";
5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
6 | import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
7 |
8 | class Portfolio extends React.Component {
9 | constructor(props) {
10 | super(props);
11 |
12 | this.state = ({
13 | height: null
14 | });
15 |
16 | this.formatTradesForPortfolio = this.formatTradesForPortfolio.bind(
17 | this
18 | );
19 | this.getStocksFromTrades = this.getStocksFromTrades.bind(this);
20 | this.portfolioTotal = this.portfolioTotal.bind(this);
21 | this.formatTransactionItems = this.formatTransactionItems.bind(this);
22 | this._leftscroll = React.createRef();
23 | this.handlePortfolioHeight = this.handlePortfolioHeight.bind(this);
24 | }
25 |
26 | componentDidMount() {
27 |
28 | if (this.state.height === null) {
29 | this.handlePortfolioHeight();
30 | window.addEventListener("resize", this.handlePortfolioHeight);
31 | } else {
32 | if (!this.props.trades) {
33 | this.props.getTrades();
34 | } else if (!this.props.stocks && this.props.trades.length > 0) {
35 | this.getStocksFromTrades();
36 | }
37 |
38 | if (this.props.match.path === "/portfolio") {
39 | document.title = "InvestChest | Your Portfolio";
40 | } else if (this.props.match.path === "/transactions") {
41 | document.title = "InvestChest | Your Transaction History";
42 | }
43 | }
44 | }
45 |
46 | componentDidUpdate(prevProps) {
47 | if (!this.props.trades) {
48 | this.props.getTrades();
49 | } else if (!this.props.stocks && this.props.trades.length > 0) {
50 | this.getStocksFromTrades();
51 | }
52 | if (
53 | this.props.match.path === "/portfolio" &&
54 | prevProps.match.path !== "/portfolio"
55 | ) {
56 | document.title = "InvestChest | Your Portfolio";
57 | this._leftscroll.scrollTop = 0;
58 | } else if (
59 | this.props.match.path === "/transactions" &&
60 | prevProps.match.path !== "/transactions"
61 | ) {
62 | document.title = "InvestChest | Your Transactions";
63 | this._leftscroll.scrollTop = 0;
64 | }
65 | }
66 |
67 | componentWillUnmount() {
68 | window.removeEventListener("resize", this.handlePortfolioHeight);
69 | }
70 |
71 | handlePortfolioHeight() {
72 | this.setState({ height: window.innerHeight });
73 | }
74 |
75 | getStocksFromTrades() {
76 | const symbolsSet = new Set();
77 | for (let trade of this.props.trades) {
78 | symbolsSet.add(trade.symbol);
79 | }
80 | this.props.getStocks([...symbolsSet].join(","));
81 | }
82 |
83 | portfolioTotal() {
84 | let total = 0;
85 | for (let trade of this.props.trades) {
86 | total +=
87 | this.props.stocks[trade.symbol].quote.latestPrice *
88 | trade.numberOfShares;
89 | }
90 | return total.toFixed(2);
91 | }
92 |
93 | formatedPortfolioItems(trades) {
94 | return (
95 |
96 | {Object.keys(trades)
97 | .sort()
98 | .map((symbol, idx) => (
99 |
104 | ))}
105 |
106 | );
107 | }
108 |
109 | formatTradesForPortfolio() {
110 | const combinedTrades = {};
111 |
112 | for (let trade of this.props.trades) {
113 | if (!(trade.symbol in this.props.stocks)) {
114 | this.getStocksFromTrades();
115 | } else {
116 | if (trade.symbol in combinedTrades) {
117 | combinedTrades[trade.symbol].push(trade);
118 | } else {
119 | combinedTrades[trade.symbol] = [trade];
120 | }
121 | }
122 | }
123 | return this.formatedPortfolioItems(combinedTrades);
124 | }
125 |
126 | formatTransactionItems() {
127 | return (
128 |
129 | {this.props.trades.map((trade, idx) => (
130 |
137 | ))}
138 |
139 | );
140 | }
141 |
142 | render() {
143 | const loadingSpinner = (
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 | );
153 |
154 | return (
155 |
156 | {this.props.trades ? (
157 | (this.props.trades && this.props.stocks) ||
158 | this.props.trades.length === 0 ? (
159 | <>
160 |
161 | {this.props.match.path === "/portfolio"
162 | ? `Portfolio ($${this.portfolioTotal()})`
163 | : "Transactions"}
164 |
165 | Logged in as
166 | {this.props.user.name}
167 |
168 |
169 |
170 | {this.props.trades.length > 0 ? (
171 |
(this._leftscroll = l)}>
172 | {this.props.match.path === "/portfolio"
173 | ? this.formatTradesForPortfolio()
174 | : this.formatTransactionItems()}
175 |
176 | ) : (
177 |
178 |
Welcome to InvestChest!
179 |
180 | You do not own any stocks on our
181 | platform. To start your portfolio
182 | enter a stock ticker and a purchase
183 | quantity in the form on the right
184 | side of this page.
185 |
186 |
187 |
188 | )}
189 |
190 |
191 |
192 | Cash – $
193 | {this.props.user.cash.toFixed(2)} USD
194 |
195 |
196 |
197 |
198 | >
199 | ) : (
200 | loadingSpinner
201 | )
202 | ) : (
203 | loadingSpinner
204 | )}
205 |
206 | );
207 | }
208 | }
209 |
210 | export default Portfolio;
--------------------------------------------------------------------------------
/frontend/src/components/portfolio/portfolio_item.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as moment from "moment";
3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4 | import { faChevronDown, faChevronUp, faArrowUp, faArrowDown } from "@fortawesome/free-solid-svg-icons";
5 |
6 | class PortfolioItem extends React.Component {
7 |
8 | constructor(props) {
9 | super(props);
10 | this.state = {
11 | totalShares: null,
12 | totalValue: null,
13 | totalCost: null,
14 | stateIsSet: false,
15 | showDetails: false
16 | };
17 | this.calculateState = this.calculateState.bind(this);
18 | this.formatTradesHistory = this.formatTradesHistory.bind(this);
19 | }
20 |
21 | componentDidMount() {
22 | this.setState({
23 | ...this.calculateState(),
24 | stateIsSet: true
25 | });
26 | }
27 |
28 | componentDidUpdate(prevProps) {
29 | if ((this.props.trades !== prevProps.trades) || (this.props.stock !== prevProps.stock)) {
30 | this.setState({
31 | ...this.calculateState(),
32 | });
33 | }
34 | }
35 |
36 |
37 | calculateState() {
38 |
39 | let totalShares = 0;
40 | let totalValue = 0;
41 | let totalCost = 0;
42 |
43 | for (let trade of this.props.trades) {
44 | totalShares += trade.numberOfShares;
45 | totalValue += trade.numberOfShares * this.props.stock.latestPrice;
46 | totalCost += trade.numberOfShares * trade.purchasePrice;
47 | }
48 | return {
49 | totalShares: totalShares,
50 | totalValue: totalValue,
51 | totalCost: totalCost,
52 | };
53 | }
54 |
55 | formatTradesHistory() {
56 | return (
57 | <>
58 | Transaction History:
59 |
60 |
61 |
62 |
63 | Date
64 | # of Shares
65 | Price-per-Share
66 | Profit %
67 |
68 |
69 |
70 | {[...this.props.trades].reverse().map((trade, idx) => (
71 |
72 | {moment(trade.date).format("M/D/YY h:mm A")}
73 | {trade.numberOfShares}
74 | ${trade.purchasePrice.toFixed(2)}
75 |
79 | trade.purchasePrice
80 | ? "green"
81 | : this.props.stock.latestPrice <
82 | trade.purchasePrice
83 | ? "red"
84 | : ""
85 | : ""
86 | }
87 | >
88 | {(
89 | ((this.props.stock.latestPrice -
90 | trade.purchasePrice) /
91 | trade.purchasePrice) *
92 | 100
93 | ).toFixed(2)}%
94 | {this.props.stock.latestPrice !== null ? (
95 | this.props.stock.latestPrice >
96 | trade.purchasePrice ? (
97 |
98 | ) : this.props.stock.latestPrice <
99 | trade.purchasePrice ? (
100 |
101 | ) : (
102 | null
103 | )
104 | ) : (
105 | null
106 | )}
107 |
108 |
109 | ))}
110 |
111 |
112 |
113 | >
114 | );
115 | }
116 |
117 | render() {
118 | return this.state.stateIsSet ? (
119 | <>
120 |
121 |
122 | {`${this.props.stock.symbol} – ${
123 | this.state.totalShares
124 | } Share${this.state.totalShares > 1 ? "s" : ""}`}
125 |
130 | this.props.stock.previousClose
131 | ? "green"
132 | : this.props.stock.latestPrice <
133 | this.props.stock.previousClose
134 | ? "red"
135 | : ""
136 | : ""
137 | }
138 | >
139 | {`$${this.state.totalValue.toFixed(2)}`}
140 | {this.props.stock.latestPrice !== null &&
141 | this.props.stock.previousClose !== null ? (
142 | this.props.stock.latestPrice >
143 | this.props.stock.previousClose ? (
144 |
145 | ) : this.props.stock.latestPrice <
146 | this.props.stock.previousClose ? (
147 |
148 | ) : null
149 | ) : null}
150 |
151 |
152 |
157 | this.setState({
158 | showDetails: !this.state.showDetails
159 | })
160 | }
161 | />
162 |
163 | ({this.props.stock.companyName})
164 |
165 | Last Updated:{" "}
166 | {moment(this.props.stock.latestUpdate).format(
167 | "l LT"
168 | )}
169 |
170 |
171 | {this.state.showDetails ? (
172 | <>
173 | Daily Trading Data:
174 |
175 |
176 |
177 |
178 | Date
179 | Previous Close
180 | Latest Price
181 | Change %
182 |
183 |
184 |
185 |
186 |
187 | {moment(
188 | this.props.stock
189 | .lastTradeTime
190 | ).format("M/D/YY")}
191 |
192 |
193 | {this.props.stock
194 | .previousClose === null
195 | ? "N/A"
196 | : `$${this.props.stock.previousClose.toFixed(
197 | 2
198 | )}`}
199 |
200 |
201 | {this.props.stock
202 | .latestPrice === null
203 | ? "N/A"
204 | : `$${this.props.stock.latestPrice.toFixed(
205 | 2
206 | )}`}
207 |
208 |
217 | this.props.stock
218 | .previousClose
219 | ? "green"
220 | : this.props.stock
221 | .latestPrice <
222 | this.props.stock
223 | .previousClose
224 | ? "red"
225 | : ""
226 | : ""
227 | }
228 | >
229 | {this.props.stock
230 | .previousClose !== null &&
231 | this.props.stock.latestPrice !==
232 | null
233 | ? `${(
234 | (this.props.stock
235 | .latestPrice -
236 | this.props.stock
237 | .previousClose) *
238 | (100 /
239 | this.props.stock
240 | .previousClose)
241 | ).toFixed(2)}%`
242 | : "N/A"}
243 | {this.props.stock
244 | .latestPrice !== null &&
245 | this.props.stock
246 | .previousClose !== null ? (
247 | this.props.stock
248 | .latestPrice >
249 | this.props.stock
250 | .previousClose ? (
251 |
254 | ) : this.props.stock
255 | .latestPrice <
256 | this.props.stock
257 | .previousClose ? (
258 |
261 | ) : null
262 | ) : null}
263 |
264 |
265 |
266 |
267 |
268 | {this.formatTradesHistory()}
269 |
270 | Note: IEX Cloud API will occasionally return
271 | "null" as a value. If a "null" value occurs
272 | effected data fields are replaced with "N/A",{" "}
273 |
277 | click here to learn more about this
278 | limitation
279 |
280 | .
281 |
282 | >
283 | ) : null}
284 |
285 |
286 | >
287 | ) : null;
288 | }
289 | }
290 |
291 | export default PortfolioItem;
--------------------------------------------------------------------------------
/frontend/src/components/purchase/purchase_form.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import * as moment from "moment";
3 | import StockChart from "../stock_chart/stock_chart";
4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5 | import { faArrowUp, faArrowDown } from "@fortawesome/free-solid-svg-icons";
6 |
7 | class PurchaseForm extends React.Component {
8 | constructor(props) {
9 | super(props);
10 | this.state = {
11 | ticker: "",
12 | quantity: "",
13 | currentStock: "",
14 | currentRange: "1d"
15 | };
16 | this.partOne = this.partOne.bind(this);
17 | this.partTwo = this.partTwo.bind(this);
18 | this.switchToPartTwo = this.switchToPartTwo.bind(this);
19 | this.handleSubmit = this.handleSubmit.bind(this);
20 | this.unaffordableWarning = this.unaffordableWarning.bind(this);
21 | this.setRange = this.setRange.bind(this);
22 | this.handleChart = this.handleChart.bind(this);
23 | }
24 |
25 | componentDidUpdate(prevProps) {
26 | if ((this.state.currentStock in this.props.stocks) && (!this.props.chartError.noChartFound)) {
27 | if (!("chart" in this.props.stocks[this.state.currentStock])) {
28 | this.props.getChart(this.state.currentStock, this.state.currentRange);
29 | } else if (!(this.state.currentRange in this.props.stocks[this.state.currentStock].chart)) {
30 | this.props.getChart(this.state.currentStock, this.state.currentRange);
31 | }
32 | }
33 | }
34 |
35 | handleChart() {
36 | if (!this.state.currentStock) {
37 | return undefined;
38 | }
39 | if (!(this.state.currentStock in this.props.stocks)) {
40 | return undefined;
41 | }
42 | if (!("chart" in this.props.stocks[this.state.currentStock])) {
43 | return undefined;
44 | }
45 | if (!(this.state.currentRange in this.props.stocks[this.state.currentStock].chart)) {
46 | return undefined;
47 | }
48 | return this.props.stocks[this.state.currentStock].chart[this.state.currentRange];
49 | }
50 |
51 | update(field) {
52 | return e => this.setState({
53 | [field]: e.currentTarget.value
54 | });
55 | }
56 |
57 | switchToPartTwo() {
58 | this.setState({
59 | ticker: this.state.ticker.toUpperCase().trim(),
60 | currentStock: this.state.ticker.toUpperCase().trim(),
61 | currentRange: "1d",
62 | quantity: ""
63 | });
64 | if (this.state.ticker.toUpperCase().trim() in this.props.stocks) {
65 | this.props.removeStockError();
66 | }
67 |
68 | if (!(this.state.ticker.toUpperCase().trim() in this.props.stocks)) {
69 | this.props.removeStockError();
70 | this.props.getStock(this.state.ticker.trim());
71 | }
72 | }
73 |
74 | partOne() {
75 | return (
76 | <>
77 |
84 | 0 && (this.state.currentStock !== this.state.ticker.trim().toUpperCase())) ? "" : "disabled" )}
85 | onClick={ (e) => { e.preventDefault();
86 | (this.state.ticker.trim().length > 0 && (this.state.currentStock !== this.state.ticker.trim().toUpperCase())) ? this.switchToPartTwo() : null }}
87 | >
88 | Lookup
89 |
90 | >
91 | );
92 | }
93 |
94 | partTwo() {
95 |
96 | const loadingSpinner = ;
97 |
98 | return (
99 | <>
100 |
101 | {this.state.currentStock ? (
102 | this.state.currentStock in this.props.stocks ? (
103 | <>
104 | {`Selected Company: ${
105 | this.props.stocks[this.state.currentStock].quote
106 | .symbol
107 | } (${
108 | this.props.stocks[this.state.currentStock].quote
109 | .companyName
110 | })`}
111 |
112 | Latest Price:{" "}
113 |
125 | this.props.stocks[
126 | this.state.currentStock
127 | ].quote.previousClose
128 | ? "green"
129 | : this.props.stocks[
130 | this.state.currentStock
131 | ].quote.latestPrice <
132 | this.props.stocks[
133 | this.state.currentStock
134 | ].quote.previousClose
135 | ? "red"
136 | : ""
137 | }
138 | >
139 | {`$${this.props.stocks[
140 | this.state.currentStock
141 | ].quote.latestPrice.toFixed(2)} (${(
142 | (this.props.stocks[
143 | this.state.currentStock
144 | ].quote.latestPrice -
145 | this.props.stocks[
146 | this.state.currentStock
147 | ].quote.previousClose) *
148 | (100 /
149 | this.props.stocks[
150 | this.state.currentStock
151 | ].quote.previousClose)
152 | ).toFixed(2)}%)`}
153 | {(
154 | this.props.stocks[
155 | this.state.currentStock
156 | ].quote.latestPrice === null ||
157 | this.props.stocks[
158 | this.state.currentStock
159 | ].quote.previousClose === null
160 | ) ? null : (
161 | this.props.stocks[
162 | this.state.currentStock
163 | ].quote.latestPrice >
164 | this.props.stocks[
165 | this.state.currentStock
166 | ].quote.previousClose ? (
167 |
168 | ) : this.props.stocks[
169 | this.state.currentStock
170 | ].quote.latestPrice <
171 | this.props.stocks[
172 | this.state.currentStock
173 | ].quote.previousClose ? (
174 |
177 | ) : null
178 | )}
179 |
180 |
181 |
182 | Last Updated:{" "}
183 | {moment(
184 | this.props.stocks[this.state.currentStock]
185 | .quote.latestUpdate
186 | ).format("MMM Do YYYY, h:mm:ss A")}
187 |
188 | >
189 | ) : this.props.stockError.noStockFound ? (
190 | <>
191 |
192 | {`${this.props.stockError.noStockFound} ${this.props.stockError.symbol}`}
193 |
194 |
195 |
196 | >
197 | ) : (
198 |
199 | {loadingSpinner}
200 |
201 | )
202 | ) : (
203 | <>
204 |
205 |
206 |
207 | >
208 | )}
209 |
216 |
226 | {this.unaffordableWarning() ? (
227 |
228 | Cannot afford {this.state.quantity} shares of{" "}
229 | {this.state.currentStock}
230 |
231 | ) : null}
232 | >
233 | );
234 | }
235 |
236 | unaffordableWarning() {
237 | if ((!!this.state.quantity) && (this.state.currentStock in this.props.stocks)) {
238 | if ((this.props.stocks[this.state.currentStock].quote.latestPrice * this.state.quantity) > this.props.user.cash) {
239 | return true;
240 | }
241 | }
242 | return false;
243 | }
244 |
245 | handleSubmit(e) {
246 | e.preventDefault();
247 | let trade = {
248 | symbol: this.state.currentStock,
249 | purchasePrice: this.props.stocks[this.state.currentStock].quote.latestPrice.toFixed(2),
250 | numberOfShares: this.state.quantity
251 | }
252 | this.props.createTrade(trade);
253 | this.setState({
254 | ticker: "",
255 | quantity: "",
256 | currentStock: "",
257 | currentRange: "1d"
258 | })
259 | }
260 |
261 | setRange(range) {
262 | if (this.state.currentRange !== range) {
263 | this.setState({
264 | currentRange: range
265 | })
266 | }
267 | }
268 |
269 | render() {
270 | return (
271 |
272 |
276 |
277 | {(this.state.currentStock in this.props.stocks) && ("chart" in this.props.stocks[this.state.currentStock]) ?
278 | <>
279 | this.setRange("1d")}
282 | >
283 | 1D
284 |
285 |
286 | this.setRange("5dm")}
289 | >
290 | 1W
291 |
292 |
293 | this.setRange("1mm")}
296 | >
297 | 1M
298 |
299 |
300 | this.setRange("3m")}
303 | >
304 | 3M
305 |
306 |
307 | this.setRange("6m")}
310 | >
311 | 6M
312 |
313 |
314 | this.setRange("1y")}
317 | >
318 | 1Y
319 |
320 |
321 | this.setRange("2y")}
324 | >
325 | 2Y
326 |
327 |
328 | this.setRange("5y")}
331 | >
332 | 5Y
333 |
334 | >
335 | :
336 | <>
337 | 1D
338 |
339 | 1W
340 |
341 | 1M
342 |
343 | 3M
344 |
345 | 6M
346 |
347 | 1Y
348 |
349 | 2Y
350 |
351 | 5Y
352 | >
353 | }
354 |
355 |
361 | NOTE: Stock chart data used above utilizes IEX Cloud's Sandbox testing to avoid exceeding IEX's free-tier API call limit, click here to learn more about this limitation .
362 |
363 | );
364 | }
365 |
366 | }
367 |
368 | export default PurchaseForm;
--------------------------------------------------------------------------------