diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..525e668 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/package-lock.json +/client/dist/bundle.js +/client/.DS_Store +/node_modules/ +Archive.zip +.env +.DS_Store + +# Elastic Beanstalk Files +.elasticbeanstalk/* +!.elasticbeanstalk/*.cfg.yml +!.elasticbeanstalk/*.global.yml diff --git a/app.js b/app.js new file mode 100644 index 0000000..b5a7d68 --- /dev/null +++ b/app.js @@ -0,0 +1,41 @@ +// SERVER + +const express = require('express') +const parser = require('body-parser') +const axios = require('axios'); +const app = express() + +const db = require('./database/index.js') + +if (process.env.NODE_ENV !== 'production') { + require('dotenv').config(); +} + +const port = process.env.PORT || 8000; +const apiKey = process.env.API_KEY + +app.use(express.static(__dirname + '/client/dist')); +app.use(parser.json()); + +app.get('/books/top', (req, res) => { + let url = `https://api.nytimes.com/svc/books/v3/lists/current/hardcover-fiction.json?api-key=${apiKey}` + axios.get(url) + .then(response => res.end(JSON.stringify(response.data))) + .catch(err => res.end(err)) +}) + +app.get('/books/wishlist', (req, res) => { + db.getWishlistBooks((err, response) => res.end(JSON.stringify(response))) +}) + +app.post('/books/wishlist', (req, res) => { + db.addToWishlist(req.body.currentBook, (err) => res.end(err)) +}) + +app.delete('/books/wishlist', (req, res) => { + db.removeFromWishlist(req.body.currentBook, (err) => res.end(err)) +}) + +app.listen(port, () => { + console.log(`listening on port ${port}`) +}) \ No newline at end of file diff --git a/client/dist/index.html b/client/dist/index.html new file mode 100644 index 0000000..e309e01 --- /dev/null +++ b/client/dist/index.html @@ -0,0 +1,12 @@ + + + + OkayReads + + + +
+ + + + \ No newline at end of file diff --git a/client/dist/styles.css b/client/dist/styles.css new file mode 100644 index 0000000..00e24e3 --- /dev/null +++ b/client/dist/styles.css @@ -0,0 +1,173 @@ +html, body, #app { + margin: 0; + padding: 0; + font-family: "Roboto", sans-serif; + font-weight: 400; + height: 100%; + width: 100%; +} + +#app { + height: 100%; + width: 100%; + background-color: rgba(231, 76, 60, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.main { + flex-grow: 1; + display: flex; +} + +.wishlist-empty{ + color: white; +} + +.books { + flex-basis:80%; + display: flex; + flex-wrap: wrap; + min-height: 20rem; + margin: 0; + padding: 20px; + overflow-y: scroll; + background-color: #2c3e50; +} + +.book_item { + flex-basis: 22%; + box-sizing: border-box; + margin: 1.5%; + display: flex; + flex-direction: column; + list-style: none; + background-color: #fff; + border: 1px solid #eee; + box-shadow: 0 10px 28px -7px rgba(0,0,0,0.1); + cursor: pointer; +} + +.book_item > div > img { + width: 100%; +} + +.book_description { + display: flex; + flex-grow: 1; + flex-direction: column; + justify-content: space-between; + padding: 10px; +} + +.book_description h2 { + color: #555; + font-weight: bold; + margin-bottom: 20px; +} + +.book_details { + display: flex; + justify-content: space-between; +} + +.book_details span { + color: #555; + font-size: 0.8rem; + font-weight: bold; +} + +.book_year, .book_rating { + display: flex; + flex-direction: column; +} + +.book_year, .book_rating{ + color: #aaa; + margin-bottom: 5px; + font-size: 0.65rem; + font-weight: normal; +} + +.book_rating { + align-items: flex-end; +} + +.app { + height: 10em; + width: 10em; + background: lightblue; + overflow: hidden; +} + +#modal-root { + position: relative; + z-index: 999; +} + +.modal-right { + padding: 2rem; +} + +.modal { + background-color: rgba(248, 247, 247, 0.8); + position: fixed; + height: 100%; + width: 100%; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-left{ + padding-bottom: 1rem; +} + +#modal-x{ + float: right; + cursor: pointer; +} + +.preview { + background-color: white; + padding: 10px; + max-width: 70%; +} + +.right-align { + float: right; + padding: 5px; +} + +.header { + padding: 10px; +} + +button{ + border: 0; + background: #21a8ca; + border-radius: 5px; + padding: 0.5rem 1rem; + font-size: 0.8rem; + line-height: 1; + color: white; + cursor: pointer; +} + +.close-btn { + width: 100%; + border: 0; + background: #21a8ca; + border-radius: 5px; + padding: 0.5rem 1rem; + font-size: 0.8rem; + line-height: 1; + color: white; +} + +#wishlist-notice{ + visibility: hidden; +} \ No newline at end of file diff --git a/client/src/components/books.jsx b/client/src/components/books.jsx new file mode 100644 index 0000000..8bb6ff7 --- /dev/null +++ b/client/src/components/books.jsx @@ -0,0 +1,44 @@ + +import React, { Component } from 'react'; + +class Books extends Component { + + render() { + + if (!this.props.books) { + return ( +
+ Oh no! You haven't added any books to your wishlist. Check out the NYT bestsellers and start adding now! +
+ ) + } + + + return ( + ) + } +} + +export default Books \ No newline at end of file diff --git a/client/src/components/modal.jsx b/client/src/components/modal.jsx new file mode 100644 index 0000000..485fab3 --- /dev/null +++ b/client/src/components/modal.jsx @@ -0,0 +1,31 @@ + +import React, { Component } from 'react'; +import ReactDOM, { createPortal } from 'react-dom'; + +const modalRoot = document.getElementById('modal-root'); + +class Modal extends Component { + + constructor(props) { + super(props) + this.el = document.createElement('div'); + } + + componentDidMount() { + modalRoot.appendChild(this.el); + } + + componentWillUnmount() { + modalRoot.removeChild(this.el); + } + + render() { + return createPortal( + this.props.children, + this.el, + ); + + } +} + +export default Modal \ No newline at end of file diff --git a/client/src/components/selectedBook.jsx b/client/src/components/selectedBook.jsx new file mode 100644 index 0000000..7592879 --- /dev/null +++ b/client/src/components/selectedBook.jsx @@ -0,0 +1,46 @@ +import React, { Fragment } from 'react' + +const SelectedBook = ({ currentBook, handleHideModal, showWishlist, showMsg, addToWishlist, deleteFromWishlist }) => ( +
+
+
+

{currentBook.title} X

+
+ {/* div 1 */} +
+ +
+ {/* div 2 */} +
+
+ Author: {currentBook.author} +
+
+ Rank: {currentBook.rank} +

+
+ {currentBook.description} +

+
+

+ {showWishlist ? + : + }

+
{showMsg}
+
+
+
+
+ +
+
+
+
+); + +const handleOnClick = function (method) { + method() + +} + +export default SelectedBook \ No newline at end of file diff --git a/client/src/index.jsx b/client/src/index.jsx new file mode 100644 index 0000000..f07bcb4 --- /dev/null +++ b/client/src/index.jsx @@ -0,0 +1,133 @@ +// CLIENT + +import React, { Component, Fragment } from 'react'; +import ReactDOM from 'react-dom'; +import axios from 'axios'; + +import Books from './components/books.jsx' +import Modal from './components/modal.jsx' +import SelectedBook from './components/selectedBook.jsx' + +const errorMessage = "Oh no! An error occurred. Please try again later." + +class App extends Component { + constructor(props) { + super(props) + this.state = { + showWishlist: false, + wishlist: [], + books: [], + currentBook: {}, + showError: false, + showModal: false, + showMsg: false, + } + this.getTopBooks = this.getTopBooks.bind(this) + this.handleBookClick = this.handleBookClick.bind(this) + this.handleHideModal = this.handleHideModal.bind(this) + this.addToWishlist = this.addToWishlist.bind(this) + this.getWishlistBooks = this.getWishlistBooks.bind(this) + this.deleteFromWishlist = this.deleteFromWishlist.bind(this) + this.hideBannerAfterDelay = this.hideBannerAfterDelay.bind(this) + } + + componentDidMount() { + this.getTopBooks() + this.getWishlistBooks() + } + + getTopBooks() { + axios.get('/books/top') + .then(({ data }) => this.setState({ books: data.results.books })) + .catch((err) => console.log('An error occurred: ', err)) + } + + getWishlistBooks() { + axios.get('/books/wishlist') + .then(({ data }) => this.setState({ wishlist: data })) + .catch((err) => console.err('An error occurred:', err)) + } + + handleBookClick(e) { + let rank = e.currentTarget.dataset.rank + let book = this.state.books[rank - 1] + this.setState({ showModal: true, currentBook: book }) + } + + handleHideModal() { + this.setState({ showModal: false }) + } + + addToWishlist() { + axios.post('/books/wishlist', { currentBook: this.state.currentBook }) + .then((response) => { + let message = "Good choice! Successfully added to your wishlist :)" + + if (response && response.data) { + if (response.data === 'ER_DUP_ENTRY') message = "This title is already in your wishlist!" + else message = errorMessage + } + + this.setState({ showMsg: message }, () => { + this.hideBannerAfterDelay() + this.getWishlistBooks() + }) + }) + .catch((err) => console.err('An error occurred:', err)) + } + + deleteFromWishlist() { + let { currentBook } = this.state + axios.delete('/books/wishlist', { data: { currentBook, } }) + .then((response) => { + let message = "Successfully deleted from your wishlist."; + if (response.data) message = errorMessage + this.setState({ showMsg: message }, () => { + this.hideBannerAfterDelay() + this.getWishlistBooks() + }) + }) + } + + hideBannerAfterDelay(delay = 2000) { + let self = this; + setTimeout(() => { + self.setState({ showMsg: false }) + }, delay) + } + + + render() { + let modal = this.state.showModal ? + + + + : + + let navButton = this.state.showWishlist ? "See NYT bestsellers" : "See your wishlist"; + return ( + +
+

Okay Reads

+
A barely okay clone of Good Reads. Click on a book to learn more about that NYT bestseller. + +
+
+ + {modal} +
+ + ) + } +} + +ReactDOM.render(, document.getElementById('root')) \ No newline at end of file diff --git a/database/index.js b/database/index.js new file mode 100644 index 0000000..09853b9 --- /dev/null +++ b/database/index.js @@ -0,0 +1,52 @@ +// DATABASE + +const mysql = require('mysql'); + +if (process.env.NODE_ENV !== 'production') { + require('dotenv').config(); +} + +const connection = mysql.createConnection({ + host: process.env.RDS_HOSTNAME, + user: process.env.RDS_USERNAME, + password: process.env.RDS_PASSWORD, + port: process.env.RDS_PORT, + database: process.env.RDS_DATABASE, +}); + +connection.connect(function (err) { + if (err) { + console.error('Database connection failed: ' + err.stack); + return; + } + console.log('Connected to database.'); +}); + +// connection.end(); + +const getWishlistBooks = (cb) => { + let query = `SELECT * FROM wishlist`; + connection.query(query, (err, results) => { + cb(err, results); + }) +} + +const addToWishlist = ({ title, book_image, amazon_product_url, author, rank, description, primary_isbn10 }, cb) => { + var query = `INSERT INTO wishlist (title, book_image, amazon_product_url, author, rank, description, primary_isbn10) VALUE (?, ?, ?, ?, ?, ?, ?);`; + connection.query(query, [title, book_image, amazon_product_url, author, rank, description, primary_isbn10], (err) => { + if(err) cb(err.code) + else cb(null) + }); +} + +const removeFromWishlist = ({primary_isbn10}, cb) => { + var query = `DELETE FROM wishlist WHERE primary_isbn10 = ?;`; + connection.query(query, [primary_isbn10], (err) => { + cb(err); + }); +} + +// exports.connection = connection; +exports.getWishlistBooks = getWishlistBooks; +exports.addToWishlist = addToWishlist; +exports.removeFromWishlist = removeFromWishlist; diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..15bb28e --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,17 @@ +CREATE DATABASE IF NOT EXISTS okayreads; + +USE okayreads; + +DROP TABLE IF EXISTS wishlist; + +CREATE TABLE IF NOT EXISTS wishlist ( + id INT NOT NULL AUTO_INCREMENT, + title VARCHAR(50) NOT NULL UNIQUE, + book_image VARCHAR(100), + amazon_product_url VARCHAR(250), + author VARCHAR(50) NOT NULL, + rank INT NOT NULL, + description TINYTEXT NOT NULL, + primary_isbn10 VARCHAR(20) NOT NULL UNIQUE, + PRIMARY KEY(id) +) \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..3c0db0c --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "fullstack-developer-challenge", + "version": "1.0.0", + "description": "Bellese technologies coding take home challenge", + "main": "index.js", + "scripts": { + "start": "node app.js", + "build": "webpack --config webpack.config.js", + "react-dev": "webpack -d --watch", + "server-dev": "nodemon app.js" + }, + "author": "WG", + "homepage": "https://github.com/weigao10/fullstack-developer-challenge", + "dependencies": { + "aws-sdk": "^2.562.0", + "axios": "^0.18.0", + "body-parser": "^1.18.2", + "dotenv": "^8.2.0", + "ejs": "^2.7.1", + "express": "^4.16.3", + "mysql": "^2.17.1", + "nodemon": "^1.19.4", + "react": "^16.3.1", + "react-dom": "^16.3.1" + }, + "devDependencies": { + "babel-core": "^6.26.0", + "babel-loader": "^7.1.4", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "webpack": "^2.7.0", + "webpack-cli": "^3.3.9" + } +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..b2942e2 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,23 @@ +const path = require('path'); +const SRC_DIR = path.join(__dirname, '/client/src'); +const DIST_DIR = path.join(__dirname, '/client/dist'); + +module.exports = { + entry: `${SRC_DIR}/index.jsx`, + output: { + filename: 'bundle.js', + path: DIST_DIR + }, + module : { + loaders : [ + { + test : /\.jsx?/, + include : SRC_DIR, + loader : 'babel-loader', + query: { + presets: ['react', 'es2015'] + } + } + ] + } +}; \ No newline at end of file