diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..148b9b6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.idea/ +.git/ +node_modules/ +dist/ +*.iml +webpack-stats.json +webpack-stats.debug.json +npm-debug.log +rethinkdb_data/ +uploads/ +doc/ diff --git a/.eslintrc b/.eslintrc index a0616f5..a051370 100644 --- a/.eslintrc +++ b/.eslintrc @@ -13,6 +13,7 @@ "import/no-duplicates": 0, "import/named": 0, "import/namespace": 0, + "react/no-multi-comp": 0, "import/no-unresolved": 0, "import/no-named-as-default": 2, "jsx-quotes": 2, diff --git a/.travis.yml b/.travis.yml index 8a28b37..e8fcbf3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ language: node_js node_js: - "4" + - "5" sudo: false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b369927 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:latest +MAINTAINER Quentin Jaccarino + +ADD package.json /tmp/package.json +RUN cd /tmp && npm install +RUN mkdir -p /src && cp -a /tmp/node_modules /src/ + +WORKDIR /src +ADD . /src + +EXPOSE 8000 + +CMD npm run build && npm run start diff --git a/api/api.js b/api/api.js index c48d39c..e636d98 100644 --- a/api/api.js +++ b/api/api.js @@ -26,7 +26,7 @@ const staticOptions = {}; if (config.isProduction) { staticOptions.maxAge = '60 days'; } -app.use('/uploads', require('serve-static')('uploads/', staticOptions)); +app.use('/uploads', express.static('uploads/', staticOptions)); app.get('/load', users.load); app.post('/login', users.login); @@ -41,6 +41,25 @@ app.route('/users/:id') .put(users.auth, users.isOwner, users.updateUser) .delete(users.auth, users.isOwner, users.deleteUser); +app.route('/users/:id/cart') + .get(users.auth, users.isOwner, users.getUserCart) + .post(users.auth, users.isOwner, users.addUserProduct) + .delete(users.auth, users.isOwner, users.deleteUserCart); + +app.route('/users/:id/cart/:cartId') + .get(users.auth, users.isOwner, users.getUserCartItem) + .put(users.auth, users.isOwner, users.updateCartItem) + .delete(users.auth, users.isOwner, users.deleteCartItem); + +app.route('/users/:id/orders') + .get(users.auth, users.isOwner, users.getUserOrders) + .post(users.auth, users.isOwner, users.validateCart); + +app.route('/users/:id/orders/:orderId') + .get(users.auth, users.isOwner, users.getUserOrder) + .put(users.auth, users.isAdmin, users.updateOrder) + .delete(users.auth, users.isAdmin, users.deleteOrder); + app.route('/products') .get(users.auth, users.isAdmin, products.getProducts) .post(users.auth, users.isAdmin, products.addProduct); @@ -52,7 +71,7 @@ app.route('/products/:id') app.get('/market', users.auth, products.getMarket); -app.get('/search/:text', products.search); +app.get('/search/:text', users.auth, products.search); app.post('/picture', users.auth, upload.single('picture'), (req, res) => { res.json({url: req.file.path}); @@ -64,7 +83,7 @@ if (config.apiPort) { console.error(err); } console.info('----\n==> 🌎 API is running on port %s', config.apiPort); - console.info('==> 💻 Send requests to http://localhost:%s ', config.apiPort); + console.info('==> 💻 Send requests to http://%s:%s ', config.host, config.apiPort); }); } else { console.error('==> ERROR: No PORT environment variable has been specified'); diff --git a/api/functions/products.js b/api/functions/products.js index 3a9a33c..6df5c22 100644 --- a/api/functions/products.js +++ b/api/functions/products.js @@ -20,7 +20,7 @@ function addProduct(req, res) { title: req.body.title, description: req.body.description, imageUrl: req.body.imageUrl, - price: 0 + price: req.body.price }); product.save().then(() => { diff --git a/api/functions/users.js b/api/functions/users.js index bdec1a6..4eec21b 100644 --- a/api/functions/users.js +++ b/api/functions/users.js @@ -1,4 +1,4 @@ -import { User, Email } from '../models'; +import { User, Email, Product, Cart, Order } from '../models'; import { hash_password, authenticate } from '../utils/auth'; import { generate, verify } from '../utils/token'; import ms from 'ms'; @@ -7,26 +7,25 @@ function checkToken(req, res, cb, error) { const token = req.get('x-api-token') || req.cookies.auth; if (token) { verify(token).then((decoded) => { - User.filter({email: decoded.email}).then((results) => { - if (results.length > 0) { - let user = results[0]; + if (decoded.id) { + User.get(decoded.id).getJoin({orders: {cart: {product: true}}, cart: {product: true}}).then((user) => { if (user.token === token) { cb(user); } else { res.clearCookie('auth'); res.status(error.status).json(error.body); } - } else { + }, (err) => { + console.error(err.message); res.clearCookie('auth'); res.status(error.status).json(error.body); - } - }, (error) => { - console.error(error); + }); + } else { res.clearCookie('auth'); res.status(error.status).json(error.body); - }); - }, (error) => { - console.error(error); + } + }, (err) => { + console.error(err.message); res.clearCookie('auth'); res.status(error.status).json(error.body); }); @@ -49,28 +48,28 @@ function load(req, res) { } function login(req, res) { - User.filter({email: req.body.email}).then((results) => { + User.filter({email: req.body.email}).getJoin().then((results) => { if (results.length > 0) { let user = results[0]; authenticate(req.body.password, user.password).then(() => { - user.token = generate(user.email); + user.token = generate(user.id); user.save().then(() => { res.cookie('auth', user.token, {maxAge: ms('7 days')}); res.json(user.getPublic()); }, (error) => { - console.error(error); - res.status(500).json({msg: 'Contact an administrator', err: error}); + console.error(error.message); + res.status(500).json({msg: 'Contact an administrator', err: error.message}); }); }, (error) => { - console.error(error); - res.status(400).json({msg: 'Bad password', err: error}); + console.error(error.message); + res.status(400).json({msg: 'Bad password', err: error.message}); }); } else { res.status(404).json({msg: 'No user with this email'}); } }, (error) => { - console.error(error); - res.status(500).json({msg: 'Contact an administrator', err: error}); + console.error(error.message); + res.status(500).json({msg: 'Contact an administrator', err: error.message}); }); } @@ -80,10 +79,10 @@ function logout(req, res) { } /** - * @api {get} /users Request All Users - * @apiName GetUsers - * @apiGroup User - */ +* @api {get} /users Request All Users +* @apiName GetUsers +* @apiGroup User +*/ function getUsers(req, res) { User.orderBy('-createdAt').run().then((result) => { res.json(result); @@ -91,29 +90,29 @@ function getUsers(req, res) { } /** - * @api {get} /users/:id Request User Information - * @apiName GetUser - * @apiGroup User - * - * @apiParam {Number} id Users unique ID. - * - * @apiSuccess {String} username The users name. - * @apiSuccess {String} email The users email. - * @apiSuccess {String} token The users token. - * @apiSuccess {String} pictureUrl The users picture url. - * @apiSuccess {Boolean} admin The users right. - * @apiSuccess {Date} createdAt The users creation date. - * - * @apiSuccessExample Example data on success: - * { - * username: 'Kant', - * email: 'Kant@gmail.com', - * token: 'IOEJVofz@fohinsmov24azd5niermogunqeprofinzqoe8297', - * pictureUrl: 'uploads/picture-94305067460.png', - * admin: true, - * createdAt: Wed Oct 21 2015 14:33:53 GMT+00:00 - * } - */ +* @api {get} /users/:id Request User Information +* @apiName GetUser +* @apiGroup User +* +* @apiParam {Number} id Users unique ID. +* +* @apiSuccess {String} username The users name. +* @apiSuccess {String} email The users email. +* @apiSuccess {String} token The users token. +* @apiSuccess {String} pictureUrl The users picture url. +* @apiSuccess {Boolean} admin The users right. +* @apiSuccess {Date} createdAt The users creation date. +* +* @apiSuccessExample Example data on success: +* { +* username: 'Kant', +* email: 'Kant@gmail.com', +* token: 'IOEJVofz@fohinsmov24azd5niermogunqeprofinzqoe8297', +* pictureUrl: 'uploads/picture-94305067460.png', +* admin: true, +* createdAt: Wed Oct 21 2015 14:33:53 GMT+00:00 +* } +*/ function getUser(req, res) { res.json(req.user.getPublic()); } @@ -128,31 +127,36 @@ function addUser(req, res) { const user = new User({ username: req.body.username, email: req.body.email, - password: password, - token: generate(req.body.email) + password: password }); user.save().then(() => { email.done = true; email.save().then(() => { - res.cookie('auth', user.token, {maxAge: ms('7 days')}); - res.json(user); + user.token = generate(user.id); + user.save().then(() => { + res.cookie('auth', user.token, {maxAge: ms('7 days')}); + res.json(user); + }, (error) => { + console.error(error.message); + res.status(500).json({msg: 'Contact an administrator', err: error.message}); + }) }, (error) => { - console.error(error); - res.status(500).json({msg: 'Contact an administrator', err: error}); + console.error(error.message); + res.status(500).json({msg: 'Contact an administrator', err: error.message}); }); }, (error) => { email.delete(); - console.error(error); - res.status(400).json({msg: 'Something went wrong', err: error}); + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); }); }, (error) => { - console.error(error); - res.status(500).json({msg: 'Contact an administrator', err: error}); + console.error(error.message); + res.status(500).json({msg: 'Contact an administrator', err: error.message}); }); }, (error) => { - console.error(error); - res.status(409).json({msg: 'Email already exist', err: error}); + console.error(error.message); + res.status(409).json({msg: 'Email already exist', err: error.message}); }); } @@ -170,17 +174,15 @@ function updateUser(req, res) { email.save().then(() => { Email.get(req.user.email).delete().then(() => { user.email = req.body.email; - user.token = generate(user.email); - res.cookie('auth', user.token, {maxAge: ms('7 days')}); resolve(); }, (error) => { - console.error(error); - res.status(500).json({msg: 'Contact an administrator', err: error}); + console.error(error.message); + res.status(500).json({msg: 'Contact an administrator', err: error.message}); reject(); }); }, (error) => { - console.error(error); - res.status(409).json({msg: 'Email already exist', err: error}); + console.error(error.message); + res.status(409).json({msg: 'Email already exist', err: error.message}); reject(); }); })); @@ -192,13 +194,13 @@ function updateUser(req, res) { user.password = password; resolve(); }, (error) => { - console.error(error); - res.status(500).json({msg: 'Contact an administrator', err: error}); + console.error(error.message); + res.status(500).json({msg: 'Contact an administrator', err: error.message}); reject(); }); }, (error) => { - console.error(error); - res.status(400).json({msg: 'Bad password', err: error}); + console.error(error.message); + res.status(400).json({msg: 'Bad password', err: error.message}); reject(); }); })); @@ -213,23 +215,23 @@ function updateUser(req, res) { req.user.merge(user).save().then((result) => { res.json(req.user.getPublic()); }, (error) => { - console.error(error); - res.status(400).json({msg: 'Something went wrong', err: error}); + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); }); }); } function deleteUser(req, res) { - req.user.delete().then(() => { + req.user.deleteAll({cart: true, orders: {cart: true}}).then(() => { Email.get(req.user.email).delete().then(() => { res.json({msg: 'Account deleted'}); }, (error) => { - console.error(error); - res.status(500).json({msg: 'Contact an administrator', err: error}); + console.error(error.message); + res.status(500).json({msg: 'Contact an administrator', err: error.message}); }); }, (error) => { - console.error(error); - res.status(400).json({msg: 'Something went wrong', err: error}); + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); }); } @@ -249,6 +251,194 @@ function isAdmin(req, res, next) { } } +function getUserCart(req, res) { + res.json(req.user.cart); +} + +function addUserProduct(req, res) { + Product.get(req.body.productId).run().then((product) => { + const cart = new Cart({ + id: product.id + '-' + req.user.id, + nbItem: req.body.nbItem, + product: product, + userId: req.user.id + }); + + cart.saveAll().then((result) => { + req.user.cartTotal += product.price * result.nbItem; + req.user.save().then(() => { + req.user.cart.push(result); + res.json(req.user.getPublic()); + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }); + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }); + }, (error) => { + console.error(error.message); + res.status(404).json({msg: 'Product not found', err: error.message}); + }); +} + +function deleteCart(cart) { + const deletes = [], promises = []; + + cart.forEach((cartItem) => { + deletes.push(cartItem.delete.bind(cartItem)); + }); + + deletes.forEach((del) => { + promises.push(del()); + }); + + return Promise.all(promises); +} + +function deleteUserCart(req, res) { + deleteCart(req.user.cart).then(() => { + req.user.cartTotal = 0; + req.user.save().then(() => { + res.json(req.user.getPublic()); + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }); + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }) +} + +function getUserCartItem(req, res) { + Cart.get(req.params.cartId).getJoin().run().then((cartItem) => { + res.json(cartItem); + }, (error) => { + console.error(error.message); + res.status(404).json({msg: 'Cart Item not found', err: error.message}); + }); +} + +function updateCartItem(req, res) { + Cart.get(req.params.cartId).getJoin().run().then((cartItem) => { + req.user.cartTotal -= cartItem.product.price * cartItem.nbItem; + cartItem.nbItem = req.body.nbItem; + cartItem.save().then(() => { + req.user.cartTotal += cartItem.product.price * cartItem.nbItem; + req.user.save().then(() => { + res.json(req.user.getPublic()); + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }); + }, (error) => { + }); + }, (error) => { + console.error(error.message); + res.status(404).json({msg: 'Cart Item not found', err: error.message}); + }); +} + +function deleteCartItem(req, res) { + Cart.get(req.params.cartId).getJoin().run().then((cartItem) => { + cartItem.delete().then(() => { + req.user.cartTotal -= cartItem.product.price * cartItem.nbItem; + req.user.save().then(() => { + let pos = -1; + req.user.cart.forEach((cartItem2, index) => { + if (cartItem2.id === cartItem.id) pos = index; + }); + if (pos !== -1) req.user.cart.splice(pos, 1); + res.json(req.user.getPublic()); + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }); + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }); + }, (error) => { + console.error(error.message); + res.status(404).json({msg: 'Cart Item not found', err: error.message}); + }); +} + +function getUserOrders(req, res) { + res.json(req.user.orders); +} + +function validateCart(req, res) { + (new Order({ + cartTotal: req.user.cartTotal, + userId: req.user.id + })).save().then((result) => { + req.user.cart.forEach((cartItem) => { + (new Cart({ + nbItem: cartItem.nbItem, + productId: cartItem.productId, + orderId: result.id + })).save(); + }); + + deleteCart(req.user.cart).then(() => { + req.user.cartTotal = 0; + req.user.save().then(() => { + res.json(result); + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }) + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }); + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }); +} + +function getUserOrder(req, res) { + Order.get(req.params.orderId).getJoin().run().then((order) => { + res.json(order); + }, (error) => { + console.error(error.message); + res.status(404).json({msg: 'Order not found', err: error.message}); + }); +} + +function updateOrder(req, res) { + Order.get(req.params.orderId).getJoin().run().then((order) => { + order.status = req.body.status; + order.save().then(() => { + res.json(order); + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }); + }, (error) => { + console.error(error.message); + res.status(404).json({msg: 'Order not found', err: error.message}); + }); +} + +function deleteOrder(req, res) { + Order.get(req.params.orderId).getJoin().run().then((order) => { + order.deleteAll({cart: true}).then(() => { + res.json({msg: 'Order deleted'}); + }, (error) => { + console.error(error.message); + res.status(400).json({msg: 'Something went wrong', err: error.message}); + }); + }, (error) => { + console.error(error.message); + res.status(404).json({msg: 'Order not found', err: error.message}); + }); +} + const users = { auth: auth, load: load, @@ -260,7 +450,18 @@ const users = { updateUser: updateUser, deleteUser: deleteUser, isOwner: isOwner, - isAdmin: isAdmin + isAdmin: isAdmin, + getUserCart: getUserCart, + addUserProduct: addUserProduct, + deleteUserCart: deleteUserCart, + getUserCartItem: getUserCartItem, + updateCartItem: updateCartItem, + deleteCartItem: deleteCartItem, + getUserOrders: getUserOrders, + validateCart: validateCart, + getUserOrder: getUserOrder, + updateOrder: updateOrder, + deleteOrder: deleteOrder }; export default users; diff --git a/api/models/Cart.js b/api/models/Cart.js new file mode 100644 index 0000000..8016925 --- /dev/null +++ b/api/models/Cart.js @@ -0,0 +1,12 @@ +import thinky from '../utils/thinky'; +const type = thinky.type; + +const Cart = thinky.createModel('Cart', { + id: type.string(), + nbItem: type.number().default(1), + userId: type.string(), + orderId: type.string(), + productId: type.string() +}); + +export default Cart; diff --git a/api/models/Order.js b/api/models/Order.js new file mode 100644 index 0000000..87c77e7 --- /dev/null +++ b/api/models/Order.js @@ -0,0 +1,16 @@ +import thinky from '../utils/thinky'; +import Cart from './Cart'; +const type = thinky.type; + +const Order = thinky.createModel('Order', { + id: type.string(), + createdAt: type.date().default(thinky.r.now()), + status: type.string().default('validating'), + userId: type.string(), + cartTotal: type.number().required() +}); + +Order.hasMany(Cart, 'cart', 'id', 'orderId'); +Cart.belongsTo(Order, 'order', 'orderId', 'id'); + +export default Order; diff --git a/api/models/Product.js b/api/models/Product.js index ba8e0f5..e8609de 100644 --- a/api/models/Product.js +++ b/api/models/Product.js @@ -1,4 +1,5 @@ import thinky from '../utils/thinky'; +import Cart from './Cart'; const type = thinky.type; const Product = thinky.createModel('Product', { @@ -10,4 +11,7 @@ const Product = thinky.createModel('Product', { createdAt: type.date().default(thinky.r.now()) }); +Product.hasMany(Cart, 'usersCart', 'id', 'productId'); +Cart.belongsTo(Product, 'product', 'productId', 'id'); + export default Product; diff --git a/api/models/User.js b/api/models/User.js index 29dfefa..345b8a9 100644 --- a/api/models/User.js +++ b/api/models/User.js @@ -1,4 +1,6 @@ import thinky from '../utils/thinky'; +import Cart from './Cart'; +import Order from './Order'; const type = thinky.type; const User = thinky.createModel('User', { @@ -9,7 +11,8 @@ const User = thinky.createModel('User', { admin: type.boolean().default(true), createdAt: type.date().default(thinky.r.now()), token: type.string(), - pictureUrl: type.string() + pictureUrl: type.string(), + cartTotal: type.number().default(0) }); User.define('getPublic', function() { @@ -17,4 +20,9 @@ User.define('getPublic', function() { return this; }); +User.hasMany(Cart, 'cart', 'id', 'userId'); +Cart.belongsTo(User, 'user', 'userId', 'id'); +User.hasMany(Order, 'orders', 'id', 'userId'); +Order.belongsTo(User, 'user', 'userId', 'id'); + export default User; diff --git a/api/models/index.js b/api/models/index.js index 6ab6ea6..53c2b4f 100644 --- a/api/models/index.js +++ b/api/models/index.js @@ -1,3 +1,5 @@ export User from './User'; export Product from './Product'; export Email from './Email'; +export Cart from './Cart'; +export Order from './Order'; diff --git a/api/utils/token.js b/api/utils/token.js index a86428d..73d389a 100644 --- a/api/utils/token.js +++ b/api/utils/token.js @@ -1,8 +1,8 @@ import jwt from 'jsonwebtoken'; import config from '../../src/config'; -export function generate(email) { - return jwt.sign({email: email}, config.secret, { expiresIn: '7 days' }); +export function generate(id) { + return jwt.sign({id: id}, config.secret, { expiresIn: '7 days' }); } export function verify(token) { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..519c93e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +web: + build: . + ports: + - "8000:8000" + links: + - "rdb:webdb" + environment: + - DOCKER_HOST=${DOCKER_HOST} + +rdb: + image: rethinkdb + volumes_from: + - rdbdata + +rdbdata: + image: tianon/true + volumes: + - /data diff --git a/package.json b/package.json index ef526d3..5f9494d 100644 --- a/package.json +++ b/package.json @@ -25,14 +25,19 @@ "main": "bin/server.js", "scripts": { "start": "concurrent --kill-others \"npm run start-prod\" \"npm run start-prod-api\"", - "start-prod": "node ./node_modules/better-npm-run start-prod", - "start-prod-api": "node ./node_modules/better-npm-run start-prod-api", + "compose": "docker-compose build && docker-compose up", + "create-data": "docker run -v /data --name rdbdata tianon/true", + "start-db": "docker run -p 8080:8080 -p 28015:28015 -p 29015:29015 --volumes-from rdbdata --rm --name rethinkdb rethinkdb", + "stop-db": "docker stop rethinkdb", + "rm-db": "docker rm rethinkdb", + "start-prod": "better-npm-run start-prod", + "start-prod-api": "better-npm-run start-prod-api", "build": "webpack --verbose --colors --display-error-details --config webpack/prod.config.js", "postinstall": "webpack --display-error-details --config webpack/prod.config.js", "lint": "eslint -c .eslintrc src", - "start-dev": "node ./node_modules/better-npm-run start-dev", - "start-dev-api": "node ./node_modules/better-npm-run start-dev-api", - "watch-client": "node ./node_modules/better-npm-run watch-client", + "start-dev": "better-npm-run start-dev", + "start-dev-api": "better-npm-run start-dev-api", + "watch-client": "better-npm-run watch-client", "dev": "concurrent --kill-others \"npm run watch-client\" \"npm run start-dev\" \"npm run start-dev-api\"", "test": "karma start", "doc": "apidoc -i api/" @@ -89,81 +94,83 @@ } }, "dependencies": { - "babel": "6.0.14", + "babel": "5.8.29", "babel-plugin-typecheck": "2.0.0", - "bcryptjs": "^2.3.0", + "bcryptjs": "2.3.0", "body-parser": "1.14.1", - "compression": "^1.6.0", - "cookie-parser": "^1.4.0", + "compression": "1.6.0", + "cookie-parser": "1.4.0", "express": "4.13.3", - "file-loader": "0.8.4", - "history": "1.13.0", + "file-loader": "0.8.5", + "history": "1.13.1", + "hoist-non-react-statics": "1.0.3", "http-proxy": "1.12.0", - "jsonwebtoken": "^5.4.1", + "jsonwebtoken": "5.4.1", "lru-memoize": "1.0.0", "map-props": "1.0.0", - "mimetypes": "^0.1.1", - "ms": "^0.7.1", - "multer": "^1.1.0", + "mimetypes": "0.1.1", + "ms": "0.7.1", + "multer": "1.1.0", "multireducer": "1.0.2", "piping": "0.3.0", "pretty-error": "1.2.0", "query-string": "3.0.0", - "react": "^0.14.1", + "react": "0.14.3", "react-document-meta": "2.0.0", - "react-dom": "0.14.1", + "react-dom": "0.14.3", "react-redux": "4.0.0", - "react-router": "1.0.0-rc3", + "react-router": "1.0.0", "redux": "3.0.4", - "redux-router": "^1.0.0-beta3", + "redux-router": "1.0.0-beta4", + "scroll-behavior": "0.3.0", "serialize-javascript": "1.1.2", "serve-favicon": "2.3.0", - "serve-static": "1.10.0", "superagent": "1.4.0", - "thinky": "^2.1.11", - "url-loader": "0.5.6", - "webpack-isomorphic-tools": "2.1.2" + "thinky": "2.2.0", + "url-loader": "0.5.7", + "webpack-isomorphic-tools": "2.2.18" }, "devDependencies": { + "apidoc": "0.13.1", "autoprefixer-stylus": "0.8.1", - "babel-core": "5.8.25", - "babel-eslint": "4.1.3", - "babel-loader": "5.3.2", + "babel-core": "5.8.33", + "babel-eslint": "4.1.4", + "babel-loader": "5.3.3", "babel-plugin-react-transform": "1.1.1", - "babel-runtime": "6.0.12", - "better-npm-run": "0.0.3", - "chai": "3.4.0", + "babel-runtime": "5.8.29", + "better-npm-run": "0.0.4", + "chai": "3.4.1", "clean-webpack-plugin": "0.1.4", - "concurrently": "0.1.1", - "css-loader": "0.21.0", - "eslint": "1.8.0", + "concurrently": "1.0.0", + "css-loader": "0.23.0", + "eslint": "1.10.1", "eslint-config-airbnb": "0.1.0", "eslint-loader": "1.1.1", - "eslint-plugin-import": "^0.8.1", - "eslint-plugin-react": "3.6.3", - "extract-text-webpack-plugin": "^0.8.2", - "json-loader": "0.5.3", - "karma": "0.13.14", + "eslint-plugin-import": "0.10.1", + "eslint-plugin-react": "3.10.0", + "extract-text-webpack-plugin": "0.9.1", + "json-loader": "0.5.4", + "karma": "0.13.15", "karma-chrome-launcher": "0.2.1", "karma-cli": "0.1.1", - "karma-firefox-launcher": "0.1.6", - "karma-mocha": "0.2.0", - "karma-mocha-reporter": "1.1.1", + "karma-firefox-launcher": "0.1.7", + "karma-mocha": "0.2.1", + "karma-mocha-reporter": "1.1.3", "karma-sourcemap-loader": "0.3.6", "karma-webpack": "1.7.0", - "mocha": "2.3.3", + "mocha": "2.3.4", "react-a11y": "0.2.8", - "react-addons-test-utils": "0.14.1", - "react-transform-catch-errors": "^1.0.0", + "react-addons-test-utils": "0.14.3", + "react-transform-catch-errors": "1.0.0", "react-transform-hmr": "1.0.1", - "redbox-react": "^1.1.1", + "redbox-react": "1.2.0", "redux-devtools": "2.1.5", - "strip-loader": "^0.1.0", + "strip-loader": "0.1.0", "style-loader": "0.13.0", - "stylus-loader": "1.4.0", - "webpack": "1.12.2", - "webpack-dev-middleware": "1.2.0", - "webpack-hot-middleware": "2.4.1" + "stylus-loader": "1.4.2", + "webpack": "1.12.9", + "webpack-dev-middleware": "1.4.0", + "webpack-hot-middleware": "2.5.0" }, "engines": { "node": "4.x" diff --git a/src/client.js b/src/client.js index a7b7951..7d8441d 100644 --- a/src/client.js +++ b/src/client.js @@ -1,10 +1,11 @@ /** * THIS IS THE ENTRY POINT FOR THE CLIENT, JUST LIKE server.js IS THE ENTRY POINT FOR THE SERVER. */ -import 'babel-core/polyfill'; +import 'babel/polyfill'; import React from 'react'; import ReactDOM from 'react-dom'; import createHistory from 'history/lib/createBrowserHistory'; +import useScroll from 'scroll-behavior/lib/useStandardScroll'; import createStore from './redux/create'; import ApiClient from './helpers/ApiClient'; import {Provider} from 'react-redux'; @@ -15,8 +16,12 @@ import makeRouteHooksSafe from './helpers/makeRouteHooksSafe'; const client = new ApiClient(); +// Three different types of scroll behavior available. +// Documented here: https://github.com/rackt/scroll-behavior +const scrollablehistory = useScroll(createHistory); + const dest = document.getElementById('content'); -const store = createStore(reduxReactRouter, makeRouteHooksSafe(getRoutes), createHistory, client, window.__data); +const store = createStore(reduxReactRouter, makeRouteHooksSafe(getRoutes), scrollablehistory, client, window.__data); const component = ( diff --git a/src/components/Navbar/Navbar.js b/src/components/Navbar/Navbar.js index 8069599..6d3d0a0 100644 --- a/src/components/Navbar/Navbar.js +++ b/src/components/Navbar/Navbar.js @@ -1,5 +1,5 @@ import React, { PropTypes } from 'react'; -import { IndexLink } from 'react-router'; +import { IndexLink, Link } from 'react-router'; import { connect } from 'react-redux'; import { DropDownButton } from 'components'; import { logout } from 'redux/modules/auth'; @@ -37,6 +37,24 @@ export default class Navbar extends React.Component {
+ {user && user.cart && user.cart.length > 0 && +
+ + shopping_cart + Cart + {user.cartTotal} $ + +
+ {user.cart.map(({product}) => { + return ( + +

{product.title}

+ + ); + })} +
+
+ } ); } diff --git a/src/components/Navbar/Navbar.styl b/src/components/Navbar/Navbar.styl index ea9af42..dbc1851 100644 --- a/src/components/Navbar/Navbar.styl +++ b/src/components/Navbar/Navbar.styl @@ -71,3 +71,55 @@ height: 1px; background-image: linear-gradient(to right,transparent 0,rgba(0,0,0,.07) 15%,rgba(0,0,0,.1) 50%,rgba(0,0,0,.1) 85%,rgba(0,0,0,.1) 100%); } + +.cartContainer { + display: flex; + flex-direction: column; + padding: 0 25px; +} + +.cartTitleContainer { + display: flex; + align-items: center; +} + +.cartTitle { + font-size: 14px; + margin: 10px; + text-decoration: none; + display: flex; + flex: 1; + align-items: center; + color: white; + + &:hover { + color: #dadada; + } + + .cartTitleText { + padding-left: 5px; + flex: 1; + } +} + +.productList { + a { + display: block; + color: white; + border-radius: 4px; + padding: 10px 20px; + cursor: pointer; + text-decoration: none; + + &:hover, &:global(.active) { + background: rgba(255, 255, 255, 0.1); + } + + p { + margin: 0; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + } +} diff --git a/src/components/ProductVignette/ProductVignette.js b/src/components/ProductVignette/ProductVignette.js index 41eaeb9..c184c76 100644 --- a/src/components/ProductVignette/ProductVignette.js +++ b/src/components/ProductVignette/ProductVignette.js @@ -12,11 +12,12 @@ export default class ProductVignette extends Component { return (
- +
-

{product.title}

+ {product.title} +

{product.price} $

); diff --git a/src/components/ProductVignette/ProductVignette.styl b/src/components/ProductVignette/ProductVignette.styl index 93995fe..b9c283b 100644 --- a/src/components/ProductVignette/ProductVignette.styl +++ b/src/components/ProductVignette/ProductVignette.styl @@ -6,15 +6,45 @@ margin: 10px; } -.productContainer img { +.imgContainer { + height: 150px; max-width: 200px; - cursor: pointer; + display: flex; + align-items: center; + overflow: hidden; + + img { + height: auto; + width: 100%; + cursor: pointer; + } } .productInfos { padding: 20px 10px; -} -.productInfos p { - margin: 0; + p { + margin: 0; + } + + .title { + display: block; + margin-bottom: 10px; + color: #3585b5; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + &:hover { + text-decoration: underline; + } + } + + .price { + color: #D64242; + margin-left: 10px; + font-size: 14px; + font-weight: bold; + } } diff --git a/src/config.js b/src/config.js index 0cedeba..bc2ed27 100644 --- a/src/config.js +++ b/src/config.js @@ -1,11 +1,22 @@ -require('babel-core/polyfill'); +require('babel/polyfill'); +const host = (process.env.DOCKER_HOST ? process.env.DOCKER_HOST.match(/([0-9]+\.)+([0-9]+)/g)[0] : 'localhost'); const environment = { development: { - isProduction: false + isProduction: false, + host: 'localhost', + rethinkdb: { + host: host, + port: process.env.DB_PORT + } }, production: { - isProduction: true + isProduction: true, + host: host, + rethinkdb: { + host: process.env.WEBDB_PORT_28015_TCP_ADDR, + port: process.env.WEBDB_PORT_28015_TCP_PORT + } } }[process.env.NODE_ENV || 'development']; @@ -14,9 +25,8 @@ module.exports = Object.assign({ apiPort: process.env.APIPORT, secret: process.env.SECRET, rethinkdb: { - host: process.env.DB_HOST, - port: process.env.DB_PORT, - db: process.env.DB_NAME + db: process.env.DB_NAME, + discovery: false }, app: { title: 'WhatAShop', diff --git a/src/containers/Cart/Cart.js b/src/containers/Cart/Cart.js new file mode 100644 index 0000000..3d4bbbe --- /dev/null +++ b/src/containers/Cart/Cart.js @@ -0,0 +1,52 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { Title } from 'components'; +import { deleteCartItem } from 'redux/modules/auth'; + +@connect(state => ({user: state.auth.user}), { deleteCartItem }) +export default class Cart extends Component { + static propTypes = { + user: PropTypes.object, + deleteCartItem: PropTypes.func + }; + + deleteItem(productId) { + this.props.deleteCartItem(this.props.user.id, productId); + } + + render() { + const styles = require('./Cart.styl'); + const {user} = this.props; + + return ( +
+ + <div className={styles.productContainer}> + {user && user.cart.map(({id, product, nbItem}) => { + return ( + <div className={styles.element} key={product.id}> + <div className={styles.imageContainer}> + <img src={'/api/' + product.imageUrl} className={styles.image}/> + </div> + <div className={styles.infoContainer}> + <p>{product.title}</p> + <p>{product.price} $</p> + <div className={styles.buttonContainer}> + <div className={styles.inputNumber}> + <button><i className="material-icons">remove</i></button> + <input className={styles.number} defaultValue={nbItem}/> + <button><i className="material-icons">add</i></button> + </div> + <button className={styles.deleteButton} onClick={this.deleteItem.bind(this, id)}> + <i className="material-icons">delete</i> + </button> + </div> + </div> + </div> + ); + })} + </div> + </div> + ); + } +} diff --git a/src/containers/Cart/Cart.styl b/src/containers/Cart/Cart.styl new file mode 100644 index 0000000..501706c --- /dev/null +++ b/src/containers/Cart/Cart.styl @@ -0,0 +1,99 @@ +.container { + overflow: auto; + display: flex; + flex-direction: column; + border-bottom: 1px solid #E7E7EC; +} + +.productContainer { + background-color: #f5f5f5; + padding: 20px; + display: flex; + flex-direction: column; + overflow: auto; +} + +.element { + background-color: white; + border-radius: 4px; + margin-bottom: 20px; + box-shadow: 0 1px 2px rgba(0,0,0,.05),0 0 0 1px rgba(63,63,68,.1); + flex-shrink: 0; + padding: 10px 20px; + display: flex; + + .imageContainer { + display: flex; + flex-direction: column; + } + .image { + max-width: 200px; + } + + h4 { + margin-left: 10px; + font-size: 16px; + font-weight: 400; + } +} + +.infoContainer { + margin-left: 20px; + display: flex; + align-items: center; + justify-content: space-between; + flex: 1; +} + +.buttonContainer { + display: flex; + align-items: center; +} + +.inputNumber { + display: flex; + + button { + background: #3585b5; + display: flex; + padding: 0; + border: none; + width: 24px; + height: 24px; + color: white; + justify-content: center; + align-items: center; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + cursor: pointer; + + &:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + } + + .number { + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid #ececec; + text-align: center; + } +} + +.deleteButton { + cursor: pointer; + border: 0; + background: transparent; + margin-left: 20px; + + &:hover { + color: #D64242; + } +} diff --git a/src/containers/Market/Market.js b/src/containers/Market/Market.js index 2690d12..9c77915 100755 --- a/src/containers/Market/Market.js +++ b/src/containers/Market/Market.js @@ -1,20 +1,20 @@ import React, { Component, PropTypes } from 'react'; import { ProductVignette } from 'components'; import { connect } from 'react-redux'; -import { isLoaded as isMarketLoaded, load as loadMarket } from 'redux/modules/product'; +import { load as loadMarket } from 'redux/modules/product'; +import connectData from 'helpers/connectData'; +function fetchDataDeferred(getState, dispatch) { + return dispatch(loadMarket()); +} + +@connectData(null, fetchDataDeferred) @connect(state => ({market: state.product.market})) export default class Market extends Component { static propTypes = { market: PropTypes.array }; - static fetchDataDeferred(getState, dispatch) { - if (!isMarketLoaded(getState())) { - return dispatch(loadMarket()); - } - } - render() { const {market} = this.props; const styles = require('./Market.styl'); diff --git a/src/containers/NotFound/NotFound.js b/src/containers/NotFound/NotFound.js index df4252f..d15347b 100755 --- a/src/containers/NotFound/NotFound.js +++ b/src/containers/NotFound/NotFound.js @@ -1,12 +1,10 @@ -import React, {Component} from 'react'; +import React from 'react'; -export default class NotFound extends Component { - render() { - return ( - <div className="main"> - <h1>Doh! 404!</h1> - <p>These are <em>not</em> the droids you are looking for!</p> - </div> - ); - } +export default function NotFound() { + return ( + <div className="main"> + <h1>Doh! 404!</h1> + <p>These are <em>not</em> the droids you are looking for!</p> + </div> + ); } diff --git a/src/containers/Orders/Orders.js b/src/containers/Orders/Orders.js index c71231a..30b1d55 100644 --- a/src/containers/Orders/Orders.js +++ b/src/containers/Orders/Orders.js @@ -11,7 +11,7 @@ export default class Orders extends Component { return ( <div className={styles.container}> {array.map((value, index) => { - return (<div className={styles.element} key={value + index}><h1>{value}</h1></div>); + return (<div className={styles.element} key={value + index}><h4>{value}</h4></div>); })} </div> ); diff --git a/src/containers/Orders/Orders.styl b/src/containers/Orders/Orders.styl index 39bfc09..2dde5da 100644 --- a/src/containers/Orders/Orders.styl +++ b/src/containers/Orders/Orders.styl @@ -13,13 +13,7 @@ box-shadow: 0 1px 2px rgba(0,0,0,.05),0 0 0 1px rgba(63,63,68,.1); flex-shrink: 0; - img { - height: 40px; - width: auto; - border-radius: 50%; - } - - span { + h4 { margin-left: 10px; font-size: 16px; font-weight: 400; diff --git a/src/containers/Panel/Panel.js b/src/containers/Panel/Panel.js index a89f6d9..3bcde9d 100644 --- a/src/containers/Panel/Panel.js +++ b/src/containers/Panel/Panel.js @@ -6,7 +6,7 @@ export default class Panel extends Component { return ( <div className={styles.container}> - <h4>Panel</h4> + <h4>Panel in construction</h4> </div> ); } diff --git a/src/containers/Panel/Panel.styl b/src/containers/Panel/Panel.styl index e69de29..bc4cb86 100644 --- a/src/containers/Panel/Panel.styl +++ b/src/containers/Panel/Panel.styl @@ -0,0 +1,3 @@ +.container { + padding: 20px; +} diff --git a/src/containers/Product/Product.js b/src/containers/Product/Product.js index 6834b7d..97fdb84 100644 --- a/src/containers/Product/Product.js +++ b/src/containers/Product/Product.js @@ -2,18 +2,32 @@ import React, { Component, PropTypes } from 'react'; import { Title } from 'components'; import { connect } from 'react-redux'; import { isProductLoaded, getById } from 'redux/modules/product'; +import { addToCart } from 'redux/modules/auth'; +import connectData from 'helpers/connectData'; -@connect(state => ({product: state.product.product})) +function fetchDataDeferred(getState, dispatch, location, params) { + if (!isProductLoaded(getState())) { + return dispatch(getById(params.id)); + } +} + +@connectData(null, fetchDataDeferred) +@connect(state => ({user: state.auth.user, product: state.product.product}), {addToCart}) export default class Product extends Component { static propTypes = { + user: PropTypes.object, product: PropTypes.object, - params: PropTypes.object.isRequired + params: PropTypes.object.isRequired, + addToCart: PropTypes.func.isRequired }; - static fetchDataDeferred(getState, dispatch, location, params) { - if (!isProductLoaded(getState())) { - return dispatch(getById(params.id)); - } + constructor() { + super(); + this.addProduct = this.addProduct.bind(this); + } + + addProduct() { + this.props.addToCart(this.props.user.id, this.props.product.id); } render() { @@ -26,24 +40,20 @@ export default class Product extends Component { <div className={styles.productContainer}> - <img src={'/api/' + product.imageUrl}/> + <div className={styles.imgContainer}> + <img src={'/api/' + product.imageUrl}/> + </div> <div className={styles.mainInformations}> - - <div className={styles.mainInformationsLeft}> - <h3>Brand</h3> - <h3>Price</h3> - </div> - - <div className={styles.mainInformationsRight}> - <p>{product.title}</p> - <p>{product.price + ' $'}</p> - </div> - + <p className={styles.price}><b>Price:</b> {product.price + ' $'}</p> + <p className={styles.desc}>{product.description}</p> </div> - <div className={styles.additionalInformations}> - <p>{product.description}</p> + <div className={styles.addContainer}> + <button className={styles.addButton} onClick={this.addProduct}> + <i className="material-icons md-18">add_shopping_cart</i> + <span>Add</span> + </button> </div> </div> </div> diff --git a/src/containers/Product/Product.styl b/src/containers/Product/Product.styl index 9e8b1f1..510acfd 100644 --- a/src/containers/Product/Product.styl +++ b/src/containers/Product/Product.styl @@ -8,55 +8,55 @@ .productContainer { padding: 20px; display: flex; - flex-direction: column; overflow: auto; } -.productContainer img { - padding: 20px; - max-width: 500px; - cursor: pointer; -} - -.mainInformations { +.imgContainer { display: flex; - padding: 20px 0; + flex-basis: 36.5%; - &:not(:last-child) { - border-bottom: 1px solid #E7E7EC; + img { + padding: 20px; + height: 100%; + width: 100%; } } -.mainInformationsLeft { +.mainInformations { display: flex; flex-direction: column; - flex-basis: 100%; - padding: 0 20px; -} + flex: 1; + padding: 20px 0; -.mainInformationsLeft h3 { - color: #3585b5; - font-weight: 300; - margin: 0; - margin-bottom: 10px; -} + .price { + font-size: 16px; + margin-bottom: 10px; + color: #999; + } -.mainInformationsRight { - display: flex; - flex-direction: column; - flex-basis: 70%; - padding-left: 10%; + .desc { + font-size: 16px; + margin-bottom: 10px; + color: #999; + } } -.mainInformationsRight p { - font-size: 12px; - margin-bottom: 10px; - color: #999; -} +.addContainer { + padding: 20px 0; -.additionalInformations { - padding: 20px; - font-weight: 300; - font-size: 12px; - line-height: 24px; + .addButton { + display: flex; + border: 0; + border-radius: 4px; + background: #3585b5; + color: white; + font-size: 14px; + padding: 10px 20px; + cursor: pointer; + align-items: center; + + span { + margin-left: 5px; + } + } } diff --git a/src/containers/Products/Products.js b/src/containers/Products/Products.js index 338cd89..fdb7c62 100644 --- a/src/containers/Products/Products.js +++ b/src/containers/Products/Products.js @@ -2,7 +2,15 @@ import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import { create, isAllLoaded, getAll } from 'redux/modules/product'; import ApiClient from 'helpers/ApiClient'; +import connectData from 'helpers/connectData'; +function fetchDataDeferred(getState, dispatch) { + if (!isAllLoaded(getState())) { + return dispatch(getAll()); + } +} + +@connectData(null, fetchDataDeferred) @connect(state => ({products: state.product.products}), {create}) export default class Products extends Component { static propTypes = { @@ -22,22 +30,17 @@ export default class Products extends Component { this.checkFile = this.checkFile.bind(this); } - static fetchDataDeferred(getState, dispatch) { - if (!isAllLoaded(getState())) { - return dispatch(getAll()); - } - } - addProduct() { const self = this; - const {title, desc} = this.refs; + const {title, desc, price} = this.refs; const client = new ApiClient(); client.post('/picture', { data: this.state.newFile }).then((result) => { const product = { title: title.value, description: desc.value, - imageUrl: result.url + imageUrl: result.url, + price: price.value }; self.props.create(product).then(() => { self.toggleActive(); @@ -93,6 +96,7 @@ export default class Products extends Component { <div className={styles.infosContainer}> <input type="text" ref="title" placeholder="Title"/> <textarea ref="desc" placeholder="Description..."/> + <input type="number" ref="price" placeholder="Price"/> </div> </div> <div className={styles.buttons}> diff --git a/src/containers/Users/Users.js b/src/containers/Users/Users.js index 1476010..bf4d056 100644 --- a/src/containers/Users/Users.js +++ b/src/containers/Users/Users.js @@ -1,19 +1,21 @@ import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import { isUsersLoaded, users as getUsers } from 'redux/modules/auth'; +import connectData from 'helpers/connectData'; +function fetchDataDeferred(getState, dispatch) { + if (!isUsersLoaded(getState())) { + return dispatch(getUsers()); + } +} + +@connectData(null, fetchDataDeferred) @connect(state => ({users: state.auth.users})) export default class Users extends Component { static propTypes = { users: PropTypes.array }; - static fetchDataDeferred(getState, dispatch) { - if (!isUsersLoaded(getState())) { - return dispatch(getUsers()); - } - } - render() { const {users = []} = this.props; const styles = require('./Users.styl'); diff --git a/src/containers/index.js b/src/containers/index.js index ecd30a8..a7abc17 100755 --- a/src/containers/index.js +++ b/src/containers/index.js @@ -11,3 +11,4 @@ export Panel from './Panel/Panel'; export Products from './Products/Products'; export ProfileContainer from './ProfileContainer/ProfileContainer'; export Users from './Users/Users'; +export Cart from './Cart/Cart'; diff --git a/src/helpers/Html.js b/src/helpers/Html.js index 297ea78..1d5bc43 100644 --- a/src/helpers/Html.js +++ b/src/helpers/Html.js @@ -31,6 +31,7 @@ export default class Html extends Component { <link rel="shortcut icon" href="/favicon.ico" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> {/* styles (will be present only in production with webpack extract text plugin) */} {Object.keys(assets.styles).map((style, index) => <link href={assets.styles[style]} key={index} media="screen, projection" diff --git a/src/helpers/__tests__/connectData-test.js b/src/helpers/__tests__/connectData-test.js new file mode 100644 index 0000000..d61d73b --- /dev/null +++ b/src/helpers/__tests__/connectData-test.js @@ -0,0 +1,29 @@ +import { expect } from 'chai'; +import React from 'react'; +import { div } from 'react-dom'; +import connectData from '../connectData'; + +describe('connectData', () => { + let fetchData; + let fetchDataDeferred; + let WrappedComponent; + let DataComponent; + + beforeEach(() => { + fetchData = 'fetchDataFunction'; + fetchDataDeferred = 'fetchDataDeferredFunction'; + + WrappedComponent = () => + <div />; + + DataComponent = connectData(fetchData, fetchDataDeferred)(WrappedComponent); + }); + + it('should set fetchData as a static property of the final component', () => { + expect(DataComponent.fetchData).to.equal(fetchData); + }); + + it('should set fetchDataDeferred as a static property of the final component', () => { + expect(DataComponent.fetchDataDeferred).to.equal(fetchDataDeferred); + }); +}); diff --git a/src/helpers/__tests__/getDataDependencies-test.js b/src/helpers/__tests__/getDataDependencies-test.js index 08ea664..fb2a066 100644 --- a/src/helpers/__tests__/getDataDependencies-test.js +++ b/src/helpers/__tests__/getDataDependencies-test.js @@ -11,8 +11,7 @@ describe('getDataDependencies', () => { let CompWithFetchData; let CompWithNoData; let CompWithFetchDataDeferred; - let ConnectedCompWithFetchData; - let ConnectedCompWithFetchDataDeferred; + const NullComponent = null; beforeEach(() => { getState = 'getState'; @@ -35,43 +34,30 @@ describe('getDataDependencies', () => { CompWithFetchDataDeferred.fetchDataDeferred = (_getState, _dispatch, _location, _params) => { return `fetchDataDeferred ${_getState} ${_dispatch} ${_location} ${_params}`; }; - - ConnectedCompWithFetchData = () => - <div/>; - - ConnectedCompWithFetchData.WrappedComponent = CompWithFetchData; - - ConnectedCompWithFetchDataDeferred = () => - <div/>; - - ConnectedCompWithFetchDataDeferred.WrappedComponent = CompWithFetchDataDeferred; }); it('should get fetchDatas', () => { const deps = getDataDependencies([ + NullComponent, CompWithFetchData, CompWithNoData, - CompWithFetchDataDeferred, - ConnectedCompWithFetchData, - ConnectedCompWithFetchDataDeferred + CompWithFetchDataDeferred ], getState, dispatch, location, params); expect(deps).to.deep.equal([ - 'fetchData getState dispatch location params', 'fetchData getState dispatch location params' ]); }); it('should get fetchDataDeferreds', () => { const deps = getDataDependencies([ + NullComponent, CompWithFetchData, CompWithNoData, - CompWithFetchDataDeferred, - ConnectedCompWithFetchDataDeferred + CompWithFetchDataDeferred ], getState, dispatch, location, params, true); expect(deps).to.deep.equal([ - 'fetchDataDeferred getState dispatch location params', 'fetchDataDeferred getState dispatch location params' ]); }); diff --git a/src/helpers/connectData.js b/src/helpers/connectData.js new file mode 100644 index 0000000..4ad6727 --- /dev/null +++ b/src/helpers/connectData.js @@ -0,0 +1,24 @@ +import React, { Component } from 'react'; +import hoistStatics from 'hoist-non-react-statics'; + +/* + Note: + When this decorator is used, it MUST be the first (outermost) decorator. + Otherwise, we cannot find and call the fetchData and fetchDataDeffered methods. +*/ + +export default function connectData(fetchData, fetchDataDeferred) { + + return function wrapWithFetchData(WrappedComponent) { + class ConnectData extends Component { + render() { + return <WrappedComponent {...this.props} />; + } + } + + ConnectData.fetchData = fetchData; + ConnectData.fetchDataDeferred = fetchDataDeferred; + + return hoistStatics(ConnectData, WrappedComponent); + }; +} diff --git a/src/helpers/getDataDependencies.js b/src/helpers/getDataDependencies.js index 44af9aa..9559c51 100644 --- a/src/helpers/getDataDependencies.js +++ b/src/helpers/getDataDependencies.js @@ -1,15 +1,18 @@ -const getDataDependency = (component = {}, methodName) => { - return component.WrappedComponent ? - getDataDependency(component.WrappedComponent, methodName) : - component[methodName]; -}; - +/** + * 1. Skip holes in route component chain and + * only consider components that implement + * fetchData or fetchDataDeferred + * + * 2. Pull out fetch data methods + * + * 3. Call fetch data methods and gather promises + */ export default (components, getState, dispatch, location, params, deferred) => { const methodName = deferred ? 'fetchDataDeferred' : 'fetchData'; return components - .filter((component) => getDataDependency(component, methodName)) // only look at ones with a static fetchData() - .map((component) => getDataDependency(component, methodName)) // pull out fetch data methods + .filter((component) => component && component[methodName]) // 1 + .map((component) => component[methodName]) // 2 .map(fetchData => - fetchData(getState, dispatch, location, params)); // call fetch data methods and save promises + fetchData(getState, dispatch, location, params)); // 3 }; diff --git a/src/redux/create.js b/src/redux/create.js index b5b8726..85b61d4 100644 --- a/src/redux/create.js +++ b/src/redux/create.js @@ -1,4 +1,4 @@ -import { createStore as _createStore, applyMiddleware } from 'redux'; +import { createStore as _createStore, applyMiddleware, compose } from 'redux'; import createMiddleware from './middleware/clientMiddleware'; import transitionMiddleware from './middleware/transitionMiddleware'; @@ -6,7 +6,14 @@ export default function createStore(reduxReactRouter, getRoutes, createHistory, const middleware = [createMiddleware(client), transitionMiddleware]; let finalCreateStore; - finalCreateStore = applyMiddleware(...middleware)(_createStore); + if (__DEVELOPMENT__ && __CLIENT__) { + finalCreateStore = compose( + applyMiddleware(...middleware), + window.devToolsExtension ? window.devToolsExtension() : funk => funk + )(_createStore); + } else { + finalCreateStore = applyMiddleware(...middleware)(_createStore); + } finalCreateStore = reduxReactRouter({ getRoutes, createHistory })(finalCreateStore); const reducer = require('./modules/reducer'); diff --git a/src/redux/middleware/transitionMiddleware.js b/src/redux/middleware/transitionMiddleware.js index c6dc287..f2fd94f 100644 --- a/src/redux/middleware/transitionMiddleware.js +++ b/src/redux/middleware/transitionMiddleware.js @@ -15,11 +15,21 @@ export default ({getState, dispatch}) => next => action => { const doTransition = () => { next(action); Promise.all(getDataDependencies(components, getState, dispatch, location, params, true)) - .then(resolve, resolve); + .then(resolve) + .catch(error => { + // TODO: You may want to handle errors for fetchDataDeferred here + console.warn('Warning: Error in fetchDataDeferred', error); + return resolve(); + }); }; Promise.all(getDataDependencies(components, getState, dispatch, location, params)) - .then(doTransition, doTransition); + .then(doTransition) + .catch(error => { + // TODO: You may want to handle errors for fetchData here + console.warn('Warning: Error in fetchData', error); + return doTransition(); + }); }); if (__SERVER__) { diff --git a/src/redux/modules/auth.js b/src/redux/modules/auth.js index 588d665..2f708ac 100644 --- a/src/redux/modules/auth.js +++ b/src/redux/modules/auth.js @@ -19,6 +19,12 @@ const DELETE_FAIL = 'auth/DELETE_FAIL'; const USERS = 'auth/USERS'; const USERS_SUCCESS = 'auth/USERS_SUCCESS'; const USERS_FAIL = 'auth/USERS_FAIL'; +const ADDCART = 'auth/ADDCART'; +const ADDCART_SUCCESS = 'auth/ADDCART_SUCCESS'; +const ADDCART_FAIL = 'auth/ADDCART_FAIL'; +const DELCART = 'auth/DELCART'; +const DELCART_SUCCESS = 'auth/DELCART_SUCCESS'; +const DELCART_FAIL = 'auth/DELCART_FAIL'; const initialState = { loaded: false @@ -151,6 +157,40 @@ export default function reducer(state = initialState, action = {}) { usersLoaded: false, usersError: action.error }; + case ADDCART: + return { + ...state, + addingCart: true + }; + case ADDCART_SUCCESS: + return { + ...state, + addingCart: false, + user: action.result + }; + case ADDCART_FAIL: + return { + ...state, + addingCart: false, + cartAddError: action.error + }; + case DELCART: + return { + ...state, + deletingCart: true + }; + case DELCART_SUCCESS: + return { + ...state, + deletingCart: false, + user: action.result + }; + case DELCART_FAIL: + return { + ...state, + deletingCart: false, + cartDelError: action.error + }; default: return state; } @@ -212,3 +252,17 @@ export function logout() { promise: (client) => client.get('/logout') }; } + +export function addToCart(userId, id) { + return { + types: [ADDCART, ADDCART_SUCCESS, ADDCART_FAIL], + promise: (client) => client.post('/users/' + userId + '/cart', {data: {productId: id}}) + }; +} + +export function deleteCartItem(userId, cartId) { + return { + types: [DELCART, DELCART_SUCCESS, DELCART_FAIL], + promise: (client) => client.del('/users/' + userId + '/cart/' + cartId) + }; +} diff --git a/src/redux/modules/product.js b/src/redux/modules/product.js index da6a3ac..c6e7821 100644 --- a/src/redux/modules/product.js +++ b/src/redux/modules/product.js @@ -46,6 +46,7 @@ export default function reducer(state = initialState, action = {}) { ...state, creating: false, created: true, + products: state.products.concat(action.result), createdProduct: action.result }; case CREATE_FAIL: diff --git a/src/routes.js b/src/routes.js index e6c7790..3e69251 100644 --- a/src/routes.js +++ b/src/routes.js @@ -14,7 +14,8 @@ import { Users, Product, Products, - NotFound + NotFound, + Cart } from './containers'; export default function(store) { @@ -73,6 +74,7 @@ export default function(store) { <IndexRoute component={Profile}/> <Route path="orders" component={Orders}/> </Route> + <Route path="cart" component={Cart} onEnter={requireAuth}/> <Route path="product/:id" component={Product} onEnter={requireAuth}/> <Route path="admin" component={Admin} onEnter={requireAdmin}> <IndexRoute component={Panel}/> diff --git a/src/server.js b/src/server.js index 48d67f3..53b100c 100644 --- a/src/server.js +++ b/src/server.js @@ -32,7 +32,7 @@ const staticOptions = {}; if (!__DEVELOPMENT__) { staticOptions.maxAge = '60 days'; } -app.use(require('serve-static')(path.join(__dirname, '..', 'static'), staticOptions)); +app.use(Express.static(path.join(__dirname, '..', 'static'), staticOptions)); // Proxy to API server app.use('/api', (req, res) => { @@ -108,7 +108,7 @@ if (config.port) { console.error(err); } console.info('----\n==> ✅ %s is running, talking to API server on %s.', config.app.title, config.apiPort); - console.info('==> 💻 Open http://localhost:%s in a browser to view the app.', config.port); + console.info('==> 💻 Open http://%s:%s in a browser to view the app.', config.host, config.port); }); } else { console.error('==> ERROR: No PORT environment variable has been specified'); diff --git a/webpack/dev.config.js b/webpack/dev.config.js index a1ffc7f..1e770a8 100644 --- a/webpack/dev.config.js +++ b/webpack/dev.config.js @@ -1,4 +1,4 @@ -require('babel-core/polyfill'); +require('babel/polyfill'); var fs = require('fs'); var path = require('path'); var webpack = require('webpack'); diff --git a/webpack/prod.config.js b/webpack/prod.config.js index 1f7b871..84ce01f 100644 --- a/webpack/prod.config.js +++ b/webpack/prod.config.js @@ -1,6 +1,6 @@ // Webpack config for creating the production bundle. -require('babel-core/polyfill'); +require('babel/polyfill'); var path = require('path'); var webpack = require('webpack'); var CleanPlugin = require('clean-webpack-plugin'); diff --git a/webpack/webpack-isomorphic-tools.js b/webpack/webpack-isomorphic-tools.js index 1ed53cd..464988e 100644 --- a/webpack/webpack-isomorphic-tools.js +++ b/webpack/webpack-isomorphic-tools.js @@ -21,30 +21,34 @@ module.exports = { }, style_modules: { extension: 'styl', - filter: function (m, regex, options) { - if (!options.development) { - return regex.test(m.name); + filter: function(module, regex, options, log) { + if (options.development) { + // in development mode there's webpack "style-loader", + // so the module.name is not equal to module.name + return WebpackIsomorphicToolsPlugin.style_loader_filter(module, regex, options, log); + } else { + // in production mode there's no webpack "style-loader", + // so the module.name will be equal to the asset path + return regex.test(module.name); } - return (regex.test(m.name) && m.name.slice(-4) === 'styl' && m.reasons[0].moduleName.slice(-4) === 'styl'); }, - path: function (m, options) { - //find index of '/src' inside the module name, slice it and resolve path - var srcIndex = m.name.indexOf('/src'); - var name = '.' + m.name.slice(srcIndex); - if (name) { - // Resolve the e.g.: "C:\" issue on windows - const i = name.indexOf(':'); - if (i >= 0) { - name = name.slice(i + 1); - } + path: function(module, options, log) { + if (options.development) { + // in development mode there's webpack "style-loader", + // so the module.name is not equal to module.name + return WebpackIsomorphicToolsPlugin.style_loader_path_extractor(module, options, log); + } else { + // in production mode there's no webpack "style-loader", + // so the module.name will be equal to the asset path + return module.name; } - return name; }, - parser: function (m, options) { - if (m.source) { - var regex = options.development ? /exports\.locals = ((.|\n)+);/ : /module\.exports = ((.|\n)+);/; - var match = m.source.match(regex); - return match ? JSON.parse(match[1]) : {}; + parser: function(module, options, log) { + if (options.development) { + return WebpackIsomorphicToolsPlugin.css_modules_loader_parser(module, options, log); + } else { + // in production mode there's Extract Text Loader which extracts CSS text away + return module.source; } } }