├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── README.md ├── dist └── favicon.ico ├── mochaSetup.js ├── nodemon.json ├── package.json ├── runner.js ├── src ├── app │ ├── routes │ │ └── index.js │ ├── state │ │ ├── ducks │ │ │ ├── busy │ │ │ │ ├── index.js │ │ │ │ ├── reducers.js │ │ │ │ ├── tests.js │ │ │ │ └── utils.js │ │ │ ├── cart │ │ │ │ ├── actions.js │ │ │ │ ├── index.js │ │ │ │ ├── operations.js │ │ │ │ ├── reducers.js │ │ │ │ ├── selectors.js │ │ │ │ ├── tests.js │ │ │ │ ├── types.js │ │ │ │ └── utils.js │ │ │ ├── index.js │ │ │ ├── product │ │ │ │ ├── actions.js │ │ │ │ ├── index.js │ │ │ │ ├── operations.js │ │ │ │ ├── reducers.js │ │ │ │ ├── tests.js │ │ │ │ └── types.js │ │ │ └── session │ │ │ │ ├── actions.js │ │ │ │ ├── index.js │ │ │ │ ├── operations.js │ │ │ │ ├── reducers.js │ │ │ │ ├── tests.js │ │ │ │ └── types.js │ │ ├── middlewares │ │ │ ├── apiService.js │ │ │ ├── index.js │ │ │ └── logger.js │ │ ├── store.js │ │ └── utils │ │ │ ├── createReducer.js │ │ │ ├── fetch.js │ │ │ └── index.js │ ├── utilities │ │ ├── dictionary.js │ │ └── index.js │ └── views │ │ ├── enhancers │ │ ├── fetchBefore.js │ │ ├── index.js │ │ ├── lazyLoad.js │ │ └── withAuthentication.js │ │ ├── layouts │ │ ├── app.js │ │ ├── css.js │ │ └── index.js │ │ ├── pages │ │ ├── cart.js │ │ ├── home.js │ │ ├── index.js │ │ ├── login.js │ │ ├── myAccount.js │ │ ├── productDetails.js │ │ └── productList.js │ │ └── propTypes │ │ ├── index.js │ │ └── productShape.js ├── client │ └── index.js └── server │ ├── apiData.json │ ├── apiRoutes.js │ └── index.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ "env", { "modules": false } ], 4 | "react" 5 | ], 6 | "plugins": [ "syntax-dynamic-import" ] 7 | } 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "fortech-react", 3 | "env": { 4 | "browser": true, 5 | "mocha": true 6 | }, 7 | "rules": { 8 | "prefer-promise-reject-errors": 1, 9 | "react/forbid-prop-types": 1, 10 | "react/no-typos": 0 11 | } 12 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/*.js 3 | dist/*.js.map 4 | dist/*.css 5 | dist/*.css.map -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | script: npm run ci -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WIP - Complete react-redux example project 2 | An example project based on the folder structure proposed here: https://github.com/alexnm/re-ducks 3 | 4 | [![Build Status](https://travis-ci.org/FortechRomania/react-redux-complete-example.svg?branch=master)](https://travis-ci.org/FortechRomania/react-redux-complete-example) 5 | 6 | ## Usage 7 | 8 | **Clone the repo** 9 | ``` 10 | git clone git@github.com:FortechRomania/react-redux-complete-example.git 11 | ``` 12 | 13 | **Install dependencies** 14 | ``` 15 | npm i 16 | ``` 17 | or with [yarn](https://yarnpkg.com/), which I highly recommend 18 | ``` 19 | yarn 20 | ``` 21 | 22 | **Run project** 23 | ``` 24 | npm run compile 25 | npm run dev-server 26 | ``` 27 | or both tasks in parallel in a single terminal 28 | ``` 29 | npm start 30 | ``` 31 | 32 | Access `localhost:7777` to see the magic. 33 | 34 | Running the tests 35 | ``` 36 | npm run test 37 | ``` 38 | 39 | Running eslint 40 | ``` 41 | npm run linter 42 | ``` 43 | 44 | ## Todos 45 | - [x] Ducks modular approach 46 | - [x] Server side rendering with prefetching 47 | - [x] Redux Dev Tools / HMR 48 | - [x] Styling Setup 49 | - [x] Codesplitting 50 | -------------------------------------------------------------------------------- /dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FortechRomania/react-redux-complete-example/bb52a0974c24f59ad8d4190f69680c26aefde85e/dist/favicon.ico -------------------------------------------------------------------------------- /mochaSetup.js: -------------------------------------------------------------------------------- 1 | require( "babel-register" )( { 2 | presets: [ "env" ], 3 | plugins: [ "dynamic-import-node" ], 4 | } ); 5 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": [ 4 | ".git", 5 | "node_modules/**/node_modules" 6 | ], 7 | "verbose": true, 8 | "watch": [ 9 | "src" 10 | ], 11 | "env": { 12 | "NODE_ENV": "development" 13 | }, 14 | "ext": "js json" 15 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-complete-example", 3 | "version": "1.0.0", 4 | "description": "A react-redux example project based on the folder structure proposed here: https://github.com/alexnm/re-ducks", 5 | "main": "index.js", 6 | "repository": { 7 | "url": "https://github.com/FortechRomania/react-redux-complete-example", 8 | "type": "git" 9 | }, 10 | "author": "alexnm ", 11 | "license": "MIT", 12 | "scripts": { 13 | "dev-server": "nodemon runner.js ./src/server", 14 | "compile": "webpack --progress --colors --watch", 15 | "start": "npm run dev-server & npm run compile", 16 | "build": "NODE_ENV=production webpack", 17 | "linter": "eslint src --quiet", 18 | "linter-with-warnings": "eslint src", 19 | "test": "mocha --require mochaSetup src/app --recursive", 20 | "ci": "npm run test & npm run linter" 21 | }, 22 | "dependencies": { 23 | "isomorphic-fetch": "^2.2.1", 24 | "preact": "^8.1.0", 25 | "preact-compat": "^3.16.0", 26 | "prop-types": "^15.5.10", 27 | "react": "^16.0.0", 28 | "react-dom": "^16.0.1", 29 | "react-helmet": "^5.1.3", 30 | "react-redux": "^5.0.5", 31 | "react-router-dom": "^4.1.2", 32 | "redux": "^3.7.2", 33 | "redux-thunk": "^2.2.0", 34 | "styled-components": "^2.1.1" 35 | }, 36 | "devDependencies": { 37 | "babel": "^6.5.2", 38 | "babel-core": "^6.25.0", 39 | "babel-eslint": "^7.1.1", 40 | "babel-loader": "^7.1.1", 41 | "babel-plugin-dynamic-import-node": "^1.0.2", 42 | "babel-plugin-styled-components": "^1.1.7", 43 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 44 | "babel-preset-env": "^1.6.0", 45 | "babel-preset-react": "^6.16.0", 46 | "babel-register": "^6.18.0", 47 | "body-parser": "^1.17.2", 48 | "cookie-parser": "^1.4.3", 49 | "eslint": "^4.8.0", 50 | "eslint-config-fortech-react": "^1.0.1", 51 | "eslint-loader": "^1.9.0", 52 | "eslint-plugin-import": "^2.7.0", 53 | "eslint-plugin-jsx-a11y": "^6.0.2", 54 | "eslint-plugin-react": "^7.4.0", 55 | "expect.js": "^0.3.1", 56 | "express": "^4.15.3", 57 | "extract-text-webpack-plugin": "^3.0.0", 58 | "isomorphic-fetch": "^2.2.1", 59 | "mocha": "^3.4.2", 60 | "nodemon": "^1.11.0", 61 | "webpack": "^3.4.1", 62 | "webpack-bundle-analyzer": "^2.8.3" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /runner.js: -------------------------------------------------------------------------------- 1 | require( "babel-register" )( { 2 | presets: [ "env" ], 3 | plugins: [ 4 | "dynamic-import-node", 5 | [ 6 | "styled-components", 7 | { 8 | ssr: true, 9 | displayName: true, 10 | preprocess: false, 11 | }, 12 | ], 13 | ], 14 | } ); 15 | 16 | const path = process.argv[ 2 ]; 17 | 18 | require( path ); // eslint-disable-line import/no-dynamic-require 19 | -------------------------------------------------------------------------------- /src/app/routes/index.js: -------------------------------------------------------------------------------- 1 | import { Home, Login, ProductDetails, ProductList } from "../views/pages"; 2 | import { withAuthentication, lazyLoad } from "../views/enhancers"; 3 | 4 | const routes = [ 5 | { 6 | path: "/", 7 | component: Home, 8 | exact: true, 9 | }, 10 | { 11 | path: "/products", 12 | component: ProductList, 13 | exact: true, 14 | }, 15 | { 16 | path: "/products/:permalink", 17 | example: "/products/apple", 18 | component: ProductDetails, 19 | exact: true, 20 | }, 21 | { 22 | path: "/cart", 23 | component: lazyLoad( ( ) => import( "../views/pages/cart" ) ), 24 | exact: true, 25 | }, 26 | { 27 | path: "/myaccount", 28 | component: withAuthentication( lazyLoad( ( ) => import( "../views/pages/myAccount" ) ) ), 29 | exact: true, 30 | }, 31 | { 32 | path: "/login", 33 | component: Login, 34 | exact: true, 35 | }, 36 | ]; 37 | 38 | export default routes; 39 | -------------------------------------------------------------------------------- /src/app/state/ducks/busy/index.js: -------------------------------------------------------------------------------- 1 | import reducer from "./reducers"; 2 | 3 | export default reducer; 4 | -------------------------------------------------------------------------------- /src/app/state/ducks/busy/reducers.js: -------------------------------------------------------------------------------- 1 | import * as utils from "./utils"; 2 | 3 | const busyReducer = ( state = 0, action ) => { 4 | if ( utils.actionShouldBlock( action.meta ) ) { 5 | return state; 6 | } 7 | if ( utils.actionFinished( action.type ) ) { 8 | return state - 1; 9 | } 10 | return state + 1; 11 | }; 12 | 13 | export default busyReducer; 14 | -------------------------------------------------------------------------------- /src/app/state/ducks/busy/tests.js: -------------------------------------------------------------------------------- 1 | import expect from "expect.js"; 2 | import busyReducer from "./reducers"; 3 | 4 | const noCallInProgress = 0; 5 | const oneCallInProgress = 1; 6 | const twoCallsInProgress = 2; 7 | 8 | const blocking = { 9 | type: "TEST", 10 | meta: { 11 | async: true, 12 | blocking: true, 13 | }, 14 | }; 15 | 16 | const blockingCompleted = { 17 | type: "TEST_COMPLETED", 18 | meta: { 19 | async: true, 20 | blocking: true, 21 | }, 22 | }; 23 | 24 | const blockingFailed = { 25 | type: "TEST_FAILED", 26 | meta: { 27 | async: true, 28 | blocking: true, 29 | }, 30 | }; 31 | 32 | const nonBlocking = { 33 | type: "TEST", 34 | meta: { 35 | async: true, 36 | blocking: false, 37 | }, 38 | }; 39 | 40 | const nonBlockingCompleted = { 41 | type: "TEST_COMPLETED", 42 | meta: { 43 | async: true, 44 | blocking: false, 45 | }, 46 | }; 47 | 48 | const nonBlockingFailed = { 49 | type: "TEST_FAILED", 50 | meta: { 51 | async: true, 52 | blocking: false, 53 | }, 54 | }; 55 | 56 | /* eslint-disable func-names */ 57 | describe( "busy reducer", function( ) { 58 | describe( "initial action", function( ) { 59 | context( "on general action", function( ) { 60 | context( "no api call running", function( ) { 61 | const result = busyReducer( noCallInProgress, blocking ); 62 | 63 | it( "should increment the busy state", function( ) { 64 | expect( result ).to.be( 1 ); 65 | } ); 66 | } ); 67 | 68 | context( "another api call running", function( ) { 69 | const result = busyReducer( oneCallInProgress, blocking ); 70 | 71 | it( "should increment the busy state", function( ) { 72 | expect( result ).to.be( 2 ); 73 | } ); 74 | } ); 75 | } ); 76 | 77 | context( "on non blocking action", function( ) { 78 | context( "no api call running", function( ) { 79 | const result = busyReducer( noCallInProgress, nonBlocking ); 80 | 81 | it( "should not increment the busy state", function( ) { 82 | expect( result ).to.be( 0 ); 83 | } ); 84 | } ); 85 | 86 | context( "another api call running", function( ) { 87 | const result = busyReducer( oneCallInProgress, nonBlocking ); 88 | 89 | it( "should not increment the busy state", function( ) { 90 | expect( result ).to.be( 1 ); 91 | } ); 92 | } ); 93 | } ); 94 | } ); 95 | 96 | describe( "completed action", function( ) { 97 | context( "on general action", function( ) { 98 | context( "no api call running", function( ) { 99 | const result = busyReducer( oneCallInProgress, blockingCompleted ); 100 | 101 | it( "should increment the busy state", function( ) { 102 | expect( result ).to.be( 0 ); 103 | } ); 104 | } ); 105 | 106 | context( "another api call running", function( ) { 107 | const result = busyReducer( twoCallsInProgress, blockingCompleted ); 108 | 109 | it( "should increment the busy state", function( ) { 110 | expect( result ).to.be( 1 ); 111 | } ); 112 | } ); 113 | } ); 114 | 115 | context( "on general blocking action", function( ) { 116 | context( "no api call running", function( ) { 117 | const result = busyReducer( noCallInProgress, nonBlockingCompleted ); 118 | 119 | it( "should not increment the busy state", function( ) { 120 | expect( result ).to.be( 0 ); 121 | } ); 122 | } ); 123 | 124 | context( "another api call running", function( ) { 125 | const result = busyReducer( oneCallInProgress, nonBlockingCompleted ); 126 | 127 | it( "should not increment the busy state", function( ) { 128 | expect( result ).to.be( 1 ); 129 | } ); 130 | } ); 131 | } ); 132 | } ); 133 | 134 | describe( "failed action", function( ) { 135 | context( "on general action", function( ) { 136 | context( "no api call running", function( ) { 137 | const result = busyReducer( oneCallInProgress, blockingFailed ); 138 | 139 | it( "should increment the busy state", function( ) { 140 | expect( result ).to.be( 0 ); 141 | } ); 142 | } ); 143 | 144 | context( "another api call running", function( ) { 145 | const result = busyReducer( twoCallsInProgress, blockingFailed ); 146 | 147 | it( "should increment the busy state", function( ) { 148 | expect( result ).to.be( 1 ); 149 | } ); 150 | } ); 151 | } ); 152 | 153 | context( "on non blocking action", function( ) { 154 | context( "no api call running", function( ) { 155 | const result = busyReducer( noCallInProgress, nonBlockingFailed ); 156 | 157 | it( "should not increment the busy state", function( ) { 158 | expect( result ).to.be( 0 ); 159 | } ); 160 | } ); 161 | 162 | context( "another api call running", function( ) { 163 | const result = busyReducer( oneCallInProgress, nonBlockingFailed ); 164 | 165 | it( "should not increment the busy state", function( ) { 166 | expect( result ).to.be( 1 ); 167 | } ); 168 | } ); 169 | } ); 170 | } ); 171 | } ); 172 | -------------------------------------------------------------------------------- /src/app/state/ducks/busy/utils.js: -------------------------------------------------------------------------------- 1 | export function actionShouldBlock( meta ) { 2 | return !meta || !meta.async || !meta.blocking; 3 | } 4 | 5 | export function actionFinished ( type ) { 6 | return type.includes( "_COMPLETED" ) || type.includes( "_FAILED" ); 7 | } 8 | -------------------------------------------------------------------------------- /src/app/state/ducks/cart/actions.js: -------------------------------------------------------------------------------- 1 | import * as types from "./types"; 2 | 3 | export const addToCart = ( product, quantity ) => ( { 4 | type: types.ADD, 5 | payload: { 6 | product, 7 | quantity, 8 | }, 9 | } ); 10 | 11 | export const changeQuantity = ( product, quantity ) => ( { 12 | type: types.CHANGE_QUANTITY, 13 | payload: { 14 | product, 15 | quantity, 16 | }, 17 | } ); 18 | 19 | export const removeFromCart = ( index ) => ( { 20 | type: types.REMOVE, 21 | payload: { 22 | index, 23 | }, 24 | } ); 25 | 26 | export const clearCart = ( ) => ( { 27 | type: types.CLEAR, 28 | } ); 29 | 30 | export const setCart = ( cart ) => ( { 31 | type: types.SET_CART, 32 | payload: { 33 | cart, 34 | }, 35 | } ); 36 | -------------------------------------------------------------------------------- /src/app/state/ducks/cart/index.js: -------------------------------------------------------------------------------- 1 | import reducer from "./reducers"; 2 | 3 | import * as cartOperations from "./operations"; 4 | import * as cartSelectors from "./selectors"; 5 | 6 | export { 7 | cartOperations, 8 | cartSelectors, 9 | }; 10 | 11 | export default reducer; 12 | -------------------------------------------------------------------------------- /src/app/state/ducks/cart/operations.js: -------------------------------------------------------------------------------- 1 | import { addToCart, changeQuantity, removeFromCart, clearCart, setCart } from "./actions"; 2 | 3 | export { 4 | addToCart, 5 | changeQuantity, 6 | removeFromCart, 7 | clearCart, 8 | setCart, 9 | }; 10 | -------------------------------------------------------------------------------- /src/app/state/ducks/cart/reducers.js: -------------------------------------------------------------------------------- 1 | import * as types from "./types"; 2 | import * as utils from "./utils"; 3 | import { createReducer } from "../../utils"; 4 | 5 | /* State shape 6 | [ 7 | { 8 | product, 9 | quantity, 10 | } 11 | ] 12 | */ 13 | 14 | const initialState = [ ]; 15 | 16 | const cartReducer = createReducer( initialState )( { 17 | [ types.ADD ]: ( state, action ) => { 18 | const { product, quantity } = action.payload; 19 | const index = utils.productPositionInCart( state, product ); 20 | if ( index === -1 ) { 21 | return [ utils.newCartItem( product, quantity ), ...state ]; 22 | } 23 | 24 | const currentItem = state[ index ]; 25 | const updatedItem = Object.assign( { }, currentItem, { quantity: currentItem.quantity + quantity } ); 26 | return [ 27 | ...state.slice( 0, index ), 28 | updatedItem, 29 | ...state.slice( index + 1 ), 30 | ]; 31 | }, 32 | [ types.CHANGE_QUANTITY ]: ( state, action ) => { 33 | const { product, quantity } = action.payload; 34 | const index = utils.productPositionInCart( state, product ); 35 | 36 | const updatedItem = Object.assign( { }, state[ index ], { quantity } ); 37 | return [ 38 | ...state.slice( 0, index ), 39 | updatedItem, 40 | ...state.slice( index + 1 ), 41 | ]; 42 | }, 43 | [ types.REMOVE ]: ( state, action ) => { 44 | const { product } = action.payload; 45 | const index = utils.productPositionInCart( state, product ); 46 | return [ 47 | ...state.slice( 0, index ), 48 | ...state.slice( index + 1 ), 49 | ]; 50 | }, 51 | [ types.CLEAR ]: ( ) => [ ], 52 | } ); 53 | 54 | export default cartReducer; 55 | -------------------------------------------------------------------------------- /src/app/state/ducks/cart/selectors.js: -------------------------------------------------------------------------------- 1 | export function getCartItemQuantity( cart, id ) { 2 | return cart.find( item => item.product.id === id ).quantity; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/state/ducks/cart/tests.js: -------------------------------------------------------------------------------- 1 | import expect from "expect.js"; 2 | import reducer from "./reducers"; 3 | import * as types from "./types"; 4 | 5 | const product = { 6 | id: 1, 7 | name: "Test", 8 | permalink: "test", 9 | }; 10 | 11 | /* eslint-disable func-names */ 12 | describe( "cart reducer", function( ) { 13 | describe( "add to cart", function( ) { 14 | const action = { 15 | type: types.ADD, 16 | payload: { 17 | product, 18 | quantity: 10, 19 | }, 20 | }; 21 | 22 | context( "empty cart", function( ) { 23 | const initialState = [ ]; 24 | 25 | const result = reducer( initialState, action ); 26 | 27 | it( "should add the product in the cart", function( ) { 28 | expect( result.length ).to.be( 1 ); 29 | expect( result[ 0 ].product.id ).to.be( product.id ); 30 | expect( result[ 0 ].quantity ).to.be( 10 ); 31 | } ); 32 | } ); 33 | 34 | context( "cart has one item", function( ) { 35 | const initialState = [ { 36 | product: { 37 | id: 2, 38 | name: "Existing product", 39 | }, 40 | quantity: 4, 41 | } ]; 42 | 43 | const result = reducer( initialState, action ); 44 | 45 | it( "should add the product in the cart", function( ) { 46 | expect( result.length ).to.be( 2 ); 47 | } ); 48 | 49 | it( "should add the product in the first position", function( ) { 50 | expect( result[ 0 ].product.id ).to.be( product.id ); 51 | expect( result[ 0 ].quantity ).to.be( 10 ); 52 | } ); 53 | } ); 54 | 55 | context( "cart has the same product already", function( ) { 56 | const initialState = [ { 57 | product: { 58 | id: 1, 59 | name: "Test", 60 | }, 61 | quantity: 10, 62 | } ]; 63 | 64 | const result = reducer( initialState, action ); 65 | 66 | it( "should not add the same product in the cart", function( ) { 67 | expect( result.length ).to.be( 1 ); 68 | } ); 69 | 70 | it( "should increase the quantity", function( ) { 71 | expect( result[ 0 ].product.id ).to.be( product.id ); 72 | expect( result[ 0 ].quantity ).to.be( 20 ); 73 | } ); 74 | } ); 75 | } ); 76 | } ); 77 | -------------------------------------------------------------------------------- /src/app/state/ducks/cart/types.js: -------------------------------------------------------------------------------- 1 | export const SET_CART = "cart/SET"; 2 | export const ADD = "cart/ADD"; 3 | export const CHANGE_QUANTITY = "cart/CHANGE_QUANTITY"; 4 | export const REMOVE = "cart/REMOVE"; 5 | export const CLEAR = "cart/CLEAR"; 6 | -------------------------------------------------------------------------------- /src/app/state/ducks/cart/utils.js: -------------------------------------------------------------------------------- 1 | export function productPositionInCart( cart, product ) { 2 | return cart.map( item => item.product.id ).indexOf( product.id ); 3 | } 4 | 5 | export function newCartItem( product, quantity ) { 6 | return { 7 | product, 8 | quantity, 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/state/ducks/index.js: -------------------------------------------------------------------------------- 1 | export { default as busy } from "./busy"; 2 | export { default as cart } from "./cart"; 3 | export { default as product } from "./product"; 4 | export { default as session } from "./session"; 5 | -------------------------------------------------------------------------------- /src/app/state/ducks/product/actions.js: -------------------------------------------------------------------------------- 1 | import * as types from "./types"; 2 | 3 | export const fetchDetails = ( permalink ) => ( { 4 | type: types.FETCH_DETAILS, 5 | meta: { 6 | async: true, 7 | blocking: true, 8 | path: `/products/${ permalink }`, 9 | method: "GET", 10 | }, 11 | } ); 12 | 13 | export const fetchList = ( ) => ( { 14 | type: types.FETCH_LIST, 15 | meta: { 16 | async: true, 17 | blocking: true, 18 | path: "/products", 19 | method: "GET", 20 | }, 21 | } ); 22 | -------------------------------------------------------------------------------- /src/app/state/ducks/product/index.js: -------------------------------------------------------------------------------- 1 | import reducer from "./reducers"; 2 | 3 | import * as productOperations from "./operations"; 4 | 5 | export { 6 | productOperations, 7 | }; 8 | 9 | export default reducer; 10 | -------------------------------------------------------------------------------- /src/app/state/ducks/product/operations.js: -------------------------------------------------------------------------------- 1 | import { fetchDetails, fetchList } from "./actions"; 2 | 3 | export { 4 | fetchDetails, 5 | fetchList, 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/state/ducks/product/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import * as types from "./types"; 3 | import { createReducer } from "../../utils"; 4 | 5 | /* State shape 6 | { 7 | details: product, 8 | list: [ product ], 9 | } 10 | */ 11 | 12 | const detailsReducer = createReducer( null )( { 13 | [ types.FETCH_DETAILS_COMPLETED ]: ( state, action ) => action.payload.product, 14 | } ); 15 | 16 | const listReducer = createReducer( [ ] )( { 17 | [ types.FETCH_LIST_COMPLETED ]: ( state, action ) => action.payload.products, 18 | } ); 19 | 20 | export default combineReducers( { 21 | details: detailsReducer, 22 | list: listReducer, 23 | } ); 24 | -------------------------------------------------------------------------------- /src/app/state/ducks/product/tests.js: -------------------------------------------------------------------------------- 1 | import expect from "expect.js"; 2 | import reducer from "./reducers"; 3 | import * as types from "./types"; 4 | 5 | /* eslint-disable func-names */ 6 | describe( "product reducer", function( ) { 7 | describe( "fetch product", function( ) { 8 | const action = { 9 | type: types.FETCH_DETAILS_COMPLETED, 10 | payload: { 11 | product: { 12 | id: 1, 13 | name: "Test", 14 | permalink: "test", 15 | }, 16 | }, 17 | }; 18 | 19 | const initialState = { 20 | list: [ ], 21 | details: null, 22 | }; 23 | 24 | const result = reducer( initialState, action ); 25 | 26 | it( "should set the product in the state", function( ) { 27 | expect( result.details.id ).to.be( 1 ); 28 | expect( result.details.name ).to.be( "Test" ); 29 | expect( result.details.permalink ).to.be( "test" ); 30 | } ); 31 | } ); 32 | } ); 33 | -------------------------------------------------------------------------------- /src/app/state/ducks/product/types.js: -------------------------------------------------------------------------------- 1 | export const FETCH_DETAILS = "product/FETCH_DETAILS"; 2 | export const FETCH_DETAILS_COMPLETED = "product/FETCH_DETAILS_COMPLETED"; 3 | export const FETCH_DETAILS_FAILED = "product/FETCH_DETAILS_FAILED"; 4 | 5 | export const FETCH_LIST = "product/FETCH_LIST"; 6 | export const FETCH_LIST_COMPLETED = "product/FETCH_LIST_COMPLETED"; 7 | export const FETCH_LIST_FAILED = "product/FETCH_LIST_FAILED"; 8 | -------------------------------------------------------------------------------- /src/app/state/ducks/session/actions.js: -------------------------------------------------------------------------------- 1 | import * as types from "./types"; 2 | 3 | export const login = ( ) => ( { 4 | type: types.LOGIN, 5 | } ); 6 | 7 | export const logout = ( ) => ( { 8 | type: types.LOGOUT, 9 | } ); 10 | 11 | export const initializeSession = ( ) => ( { 12 | type: types.INITIALIZE, 13 | } ); 14 | 15 | export const setRedirectAfterLogin = ( ) => ( { 16 | type: types.SET_REDIRECT_AFTER_LOGIN, 17 | } ); 18 | -------------------------------------------------------------------------------- /src/app/state/ducks/session/index.js: -------------------------------------------------------------------------------- 1 | import reducer from "./reducers"; 2 | 3 | import * as sessionOperations from "./operations"; 4 | 5 | export { 6 | sessionOperations, 7 | }; 8 | 9 | export default reducer; 10 | -------------------------------------------------------------------------------- /src/app/state/ducks/session/operations.js: -------------------------------------------------------------------------------- 1 | import { login, logout, initializeSession, setRedirectAfterLogin } from "./actions"; 2 | 3 | export { 4 | login, 5 | logout, 6 | initializeSession, 7 | setRedirectAfterLogin, 8 | }; 9 | -------------------------------------------------------------------------------- /src/app/state/ducks/session/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import * as types from "./types"; 3 | import { createReducer } from "../../utils"; 4 | 5 | /* State shape 6 | { 7 | isAuthenticated: bool, 8 | redirectAfterLogin: string 9 | } 10 | */ 11 | 12 | const authReducer = createReducer( false )( { 13 | [ types.LOGIN ]: ( ) => true, 14 | [ types.LOGOUT ]: ( ) => false, 15 | } ); 16 | 17 | const redirectAfterLoginReducer = createReducer( null )( { 18 | [ types.SET_REDIRECT_AFTER_LOGIN ]: ( state, action ) => action.payload.redirectUrl, 19 | } ); 20 | 21 | export default combineReducers( { 22 | isAuthenticated: authReducer, 23 | redirectAfterLogin: redirectAfterLoginReducer, 24 | } ); 25 | -------------------------------------------------------------------------------- /src/app/state/ducks/session/tests.js: -------------------------------------------------------------------------------- 1 | import expect from "expect.js"; 2 | import reducer from "./reducers"; 3 | import * as types from "./types"; 4 | 5 | /* eslint-disable func-names */ 6 | describe( "session reducer", function( ) { 7 | describe( "login", function( ) { 8 | const action = { 9 | type: types.LOGIN, 10 | }; 11 | 12 | const initialState = { 13 | isAuthenticated: false, 14 | redirectAfterLogin: "/products", 15 | }; 16 | 17 | const result = reducer( initialState, action ); 18 | 19 | it( "should authenticate the user", function( ) { 20 | expect( result.isAuthenticated ).to.be( true ); 21 | } ); 22 | 23 | it( "should not change the redirect after login url", function( ) { 24 | expect( result.redirectAfterLogin ).to.be( initialState.redirectAfterLogin ); 25 | } ); 26 | } ); 27 | } ); 28 | -------------------------------------------------------------------------------- /src/app/state/ducks/session/types.js: -------------------------------------------------------------------------------- 1 | export const LOGIN = "session/LOGIN"; 2 | export const LOGIN_COMPLETED = "session/LOGIN_COMPLETED"; 3 | export const LOGIN_FAILED = "session/LOGIN_FAILED"; 4 | export const LOGOUT = "session/LOGOUT"; 5 | export const INITIALIZE = "session/INITIALIZE_SESSION"; 6 | export const SET_REDIRECT_AFTER_LOGIN = "session/SET_REDIRECT_AFTER_LOGIN"; 7 | -------------------------------------------------------------------------------- /src/app/state/middlewares/apiService.js: -------------------------------------------------------------------------------- 1 | import { fetch } from "../utils"; 2 | 3 | const baseUrl = typeof document === "undefined" ? "http://localhost:7777/api" : "/api"; 4 | 5 | const apiService = ( ) => ( next ) => ( action ) => { 6 | const result = next( action ); 7 | if ( !action.meta || !action.meta.async ) { 8 | return result; 9 | } 10 | 11 | const { path, method = "GET", body } = action.meta; 12 | 13 | if ( !path ) { 14 | throw new Error( `'path' not specified for async action ${ action.type }` ); 15 | } 16 | 17 | const url = `${ baseUrl }${ path }`; 18 | 19 | return fetch( url, method, body ).then( 20 | res => handleResponse( res, action, next ), 21 | err => handleErrors( err, action, next ), 22 | ); 23 | }; 24 | 25 | export default apiService; 26 | 27 | function handleErrors( err, action, next ) { 28 | next( { 29 | type: `${ action.type }_FAILED`, 30 | payload: err, 31 | meta: action.meta, 32 | } ); 33 | 34 | return Promise.reject( err ); 35 | } 36 | 37 | function handleResponse( res, action, next ) { 38 | next( { 39 | type: `${ action.type }_COMPLETED`, 40 | payload: res, 41 | meta: action.meta, 42 | } ); 43 | 44 | return res; 45 | } 46 | -------------------------------------------------------------------------------- /src/app/state/middlewares/index.js: -------------------------------------------------------------------------------- 1 | export { default as apiService } from "./apiService"; 2 | export { default as createLogger } from "./logger"; 3 | -------------------------------------------------------------------------------- /src/app/state/middlewares/logger.js: -------------------------------------------------------------------------------- 1 | const REGULAR = [ 2 | "background: blue", 3 | "color: white", 4 | ].join( ";" ); 5 | 6 | const SUCCESS = [ 7 | "background: green", 8 | "color: white", 9 | ].join( ";" ); 10 | 11 | const STARTED = [ 12 | "background: darkorange", 13 | "color: white", 14 | ].join( ";" ); 15 | 16 | const FAILURE = [ 17 | "background: red", 18 | "color: white", 19 | ].join( ";" ); 20 | 21 | const createLogger = ( active = true ) => ( store ) => ( next ) => ( action ) => { 22 | if ( !active ) { 23 | return next( action ); 24 | } 25 | 26 | const prevState = store.getState( ); 27 | const result = next( action ); 28 | const nextState = store.getState( ); 29 | logGroupCollapsed( `%c ${ action.type } `, determineStyle( action ) ); 30 | logInfo( "%cprev state", "color: darkorange", prevState ); 31 | logInfo( "%caction payload", "color: blue", action.payload ); 32 | logInfo( "%cnext state", "color: darkgreen", nextState ); 33 | logGroupEnd( ); 34 | return result; 35 | }; 36 | 37 | export default createLogger; 38 | 39 | function logGroupCollapsed( ...args ) { 40 | const logFunction = typeof console.groupCollapsed === "function" ? console.groupCollapsed : console.info; 41 | logFunction( ...args ); 42 | } 43 | 44 | function logGroupEnd( ...args ) { 45 | const logFunction = typeof console.groupEnd === "function" ? console.groupEnd : console.info; 46 | logFunction( ...args ); 47 | } 48 | 49 | function logInfo( ...args ) { 50 | console.info( ...args ); 51 | } 52 | 53 | function determineStyle( action ) { 54 | if ( !action.meta || !action.meta.async ) { 55 | return REGULAR; 56 | } 57 | 58 | if ( action.type.indexOf( "_COMPLETED" ) > -1 ) { 59 | return SUCCESS; 60 | } 61 | 62 | if ( action.type.indexOf( "_FAILED" ) > -1 ) { 63 | return FAILURE; 64 | } 65 | 66 | return STARTED; 67 | } 68 | -------------------------------------------------------------------------------- /src/app/state/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, combineReducers } from "redux"; 2 | import thunkMiddleware from "redux-thunk"; 3 | import * as reducers from "./ducks"; 4 | import { apiService, createLogger } from "./middlewares"; 5 | 6 | export default function configureStore( initialState ) { 7 | const rootReducer = combineReducers( reducers ); 8 | 9 | return createStore( 10 | rootReducer, 11 | initialState, 12 | applyMiddleware( 13 | apiService, 14 | thunkMiddleware, 15 | createLogger( true ), 16 | ), 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/state/utils/createReducer.js: -------------------------------------------------------------------------------- 1 | export default ( initialState ) => ( reducerMap ) => ( state = initialState, action ) => { 2 | const reducer = reducerMap[ action.type ]; 3 | return reducer ? reducer( state, action ) : state; 4 | }; 5 | -------------------------------------------------------------------------------- /src/app/state/utils/fetch.js: -------------------------------------------------------------------------------- 1 | import isomorphicFetch from "isomorphic-fetch"; 2 | 3 | export default ( url, method, body ) => { 4 | const options = { 5 | method, 6 | headers: requestHeaders( ), 7 | body: method !== "GET" ? JSON.stringify( body ) : null, 8 | }; 9 | 10 | return isomorphicFetch( url, options ) 11 | .then( res => parseStatus( res.status, res.json() ) ); 12 | }; 13 | 14 | function parseStatus( status, res ) { 15 | return new Promise( ( resolve, reject ) => { 16 | if ( status >= 200 && status < 300 ) { 17 | res.then( response => resolve( response ) ); 18 | } else { 19 | res.then( response => reject( { status, response } ) ); 20 | } 21 | } ); 22 | } 23 | 24 | function requestHeaders( ) { 25 | return { 26 | Accept: "application/json", 27 | "Content-Type": "application/json", 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/app/state/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as createReducer } from "./createReducer"; 2 | export { default as fetch } from "./fetch"; 3 | -------------------------------------------------------------------------------- /src/app/utilities/dictionary.js: -------------------------------------------------------------------------------- 1 | export default { 2 | title: "Welcome to the e-commerce example project", 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/utilities/index.js: -------------------------------------------------------------------------------- 1 | export { default as dictionary } from "./dictionary"; 2 | -------------------------------------------------------------------------------- /src/app/views/enhancers/fetchBefore.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const fetchBefore = ( dispatch, Component ) => ( matchProps ) => { 4 | dispatch( Component.prefetch( ) ); 5 | return ( ); 6 | }; 7 | 8 | export default fetchBefore; 9 | -------------------------------------------------------------------------------- /src/app/views/enhancers/index.js: -------------------------------------------------------------------------------- 1 | export { default as fetchBefore } from "./fetchBefore"; 2 | export { default as lazyLoad } from "./lazyLoad"; 3 | export { default as withAuthentication } from "./withAuthentication"; 4 | -------------------------------------------------------------------------------- /src/app/views/enhancers/lazyLoad.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { object } from "prop-types"; 3 | 4 | class LazyLoad extends Component { 5 | componentWillMount() { 6 | this.props.load.then( comp => { 7 | this.comp = comp; 8 | this.forceUpdate(); 9 | } ); 10 | } 11 | render() { 12 | return this.comp ? : null; 13 | } 14 | } 15 | 16 | LazyLoad.propTypes = { 17 | load: object, // eslint-disable-line 18 | }; 19 | 20 | export default ( dynamicImport ) => ( ) => ( 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /src/app/views/enhancers/withAuthentication.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { Redirect } from "react-router-dom"; 5 | 6 | export default function withAuthentication( WrappedComponent ) { 7 | const WithAuthentication = ( props ) => { 8 | if ( !props.isAuthenticated ) { 9 | return ; 10 | } 11 | 12 | return ( ); 13 | }; 14 | 15 | const { bool } = PropTypes; 16 | WithAuthentication.propTypes = { 17 | isAuthenticated: bool.isRequired, 18 | }; 19 | 20 | const mapStateToProps = ( state ) => ( { 21 | isAuthenticated: state.session.isAuthenticated, 22 | } ); 23 | 24 | return connect( mapStateToProps )( WithAuthentication ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/views/layouts/app.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link, Route } from "react-router-dom"; 3 | import routes from "../../routes"; 4 | import Styles from "./css"; 5 | 6 | const App = ( ) => ( 7 |
8 |
9 | Home 10 | Products 11 | Cart 12 | My Account 13 |
14 | 15 | { routes.map( route => ( 16 | 17 | ) ) } 18 | 19 |
20 | I`m the footer, I am on every page. 21 |
22 | 23 | 24 |
25 | ); 26 | 27 | export default App; 28 | -------------------------------------------------------------------------------- /src/app/views/layouts/css.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { injectGlobal } from "styled-components"; 3 | 4 | export default () => { 5 | injectGlobal` 6 | body { 7 | background-color: levander; 8 | color: navy-blue; 9 | div { 10 | padding: 4px; 11 | } 12 | 13 | a { 14 | padding: 0 10px; 15 | text-decoration: none; 16 | } 17 | 18 | a:hover { 19 | color: blue; 20 | } 21 | } 22 | `; 23 | 24 | return null; 25 | }; 26 | -------------------------------------------------------------------------------- /src/app/views/layouts/index.js: -------------------------------------------------------------------------------- 1 | export { default as App } from "./app"; 2 | -------------------------------------------------------------------------------- /src/app/views/pages/cart.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import PropTypes from "prop-types"; 4 | import styled from "styled-components"; 5 | 6 | const CartItem = styled.div` 7 | margin: 10px; 8 | 9 | &:hover { 10 | background-color: grey; 11 | } 12 | `; 13 | 14 | const Cart = ( { cartItems } ) => { 15 | if ( cartItems.length === 0 ) { 16 | return (
You have no items in the cart
); 17 | } 18 | const items = cartItems.map( item => ( 19 | 20 | { item.product.name } - { item.quantity } { item.quantity > 1 ? "items" : "item" } 21 | ) ); 22 | return ( 23 |
24 | { items } 25 |
26 | ); 27 | }; 28 | 29 | const { arrayOf, object } = PropTypes; 30 | 31 | Cart.propTypes = { 32 | cartItems: arrayOf( object ), 33 | }; 34 | 35 | Cart.defaultProps = { 36 | cartItems: [ ], 37 | }; 38 | 39 | const mapStateToProps = ( state ) => ( { 40 | cartItems: state.cart, 41 | } ); 42 | 43 | export default connect( mapStateToProps, null )( Cart ); 44 | -------------------------------------------------------------------------------- /src/app/views/pages/home.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { dictionary } from "../../utilities"; 3 | 4 | export default ( ) => (
{ dictionary.title }
); 5 | -------------------------------------------------------------------------------- /src/app/views/pages/index.js: -------------------------------------------------------------------------------- 1 | export { default as Home } from "./home"; 2 | export { default as Login } from "./login"; 3 | export { default as ProductDetails } from "./productDetails"; 4 | export { default as ProductList } from "./productList"; 5 | -------------------------------------------------------------------------------- /src/app/views/pages/login.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default ( ) => ( 4 |
5 |

Login

6 | 10 | 11 | 15 | 16 |
17 | ); 18 | -------------------------------------------------------------------------------- /src/app/views/pages/myAccount.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default ( ) => (
My Account Page
); 4 | -------------------------------------------------------------------------------- /src/app/views/pages/productDetails.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { productOperations } from "../../state/ducks/product"; 5 | import { cartOperations } from "../../state/ducks/cart"; 6 | import { productShape } from "../propTypes"; 7 | 8 | class ProductDetails extends Component { 9 | componentDidMount( ) { 10 | const { product, match, fetchProduct } = this.props; 11 | const loadedProductPermalink = product ? product.permalink : ""; 12 | if ( match.params.permalink !== loadedProductPermalink ) { 13 | fetchProduct( match.params.permalink ); 14 | } 15 | } 16 | 17 | componentWillReceiveProps( nextProps ) { 18 | if ( this.props.match.params.permalink !== nextProps.match.params.permalink ) { 19 | this.props.fetchProduct( nextProps.match.params.permalink ); 20 | } 21 | } 22 | 23 | render( ) { 24 | const { product } = this.props; 25 | if ( !product ) { 26 | return false; 27 | } 28 | 29 | return ( 30 |
31 |
{ product.name }
32 |
Price: ${ product.price }
33 |
Description: { product.description }
34 |
35 | 42 |
43 |
44 | ); 45 | } 46 | } 47 | 48 | const { object, func } = PropTypes; 49 | 50 | ProductDetails.propTypes = { 51 | addToCart: func.isRequired, 52 | product: productShape, 53 | fetchProduct: func.isRequired, 54 | match: object.isRequired, 55 | }; 56 | 57 | ProductDetails.prefetch = ( { params } ) => productOperations.fetchDetails( params.permalink ); 58 | 59 | ProductDetails.defaultProps = { 60 | product: null, 61 | }; 62 | 63 | const mapStateToProps = ( state ) => ( { 64 | product: state.product.details, 65 | } ); 66 | 67 | const mapDispatchToProps = { 68 | fetchProduct: productOperations.fetchDetails, 69 | addToCart: cartOperations.addToCart, 70 | }; 71 | 72 | export default connect( mapStateToProps, mapDispatchToProps )( ProductDetails ); 73 | -------------------------------------------------------------------------------- /src/app/views/pages/productList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Link } from "react-router-dom"; 4 | import { connect } from "react-redux"; 5 | import { productOperations } from "../../state/ducks/product"; 6 | import { productShape } from "../propTypes"; 7 | 8 | class ProductList extends Component { 9 | componentDidMount( ) { 10 | if ( this.props.products.length === 0 ) { 11 | this.props.fetchList( ); 12 | } 13 | } 14 | 15 | render( ) { 16 | const productList = this.props.products 17 | .map( p => { p.name } ); 18 | 19 | return ( 20 |
21 | { productList } 22 |
23 | ); 24 | } 25 | } 26 | 27 | const { arrayOf, func } = PropTypes; 28 | 29 | ProductList.propTypes = { 30 | products: arrayOf( productShape ), 31 | fetchList: func.isRequired, 32 | }; 33 | 34 | ProductList.defaultProps = { 35 | products: [ ], 36 | }; 37 | 38 | ProductList.prefetch = productOperations.fetchList; 39 | 40 | const mapStateToProps = ( state ) => ( { 41 | products: state.product.list, 42 | } ); 43 | 44 | const mapDispatchToProps = { 45 | fetchList: productOperations.fetchList, 46 | }; 47 | 48 | export default connect( mapStateToProps, mapDispatchToProps )( ProductList ); 49 | -------------------------------------------------------------------------------- /src/app/views/propTypes/index.js: -------------------------------------------------------------------------------- 1 | export { default as productShape } from "./productShape"; 2 | -------------------------------------------------------------------------------- /src/app/views/propTypes/productShape.js: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types"; 2 | 3 | const { number, shape, string } = PropTypes; 4 | 5 | export default shape( { 6 | id: number.isRequired, 7 | name: string.isRequired, 8 | permalink: string.isRequired, 9 | price: number.isRequired, 10 | stock: number.isRequired, 11 | } ); 12 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { render } from "react-dom"; 4 | import { BrowserRouter as Router } from "react-router-dom"; 5 | import { Provider as ReduxProvider } from "react-redux"; 6 | 7 | import App from "../app/views/layouts/app"; 8 | import configureStore from "../app/state/store"; 9 | 10 | const reduxStore = configureStore( window.REDUX_INITIAL_DATA ); 11 | 12 | const RootHtml = ( ) => ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | 20 | render( , document.getElementById( "react-root" ) ); 21 | -------------------------------------------------------------------------------- /src/server/apiData.json: -------------------------------------------------------------------------------- 1 | { 2 | "products": [ 3 | { 4 | "id": 1, 5 | "name": "Banana", 6 | "permalink": "banana", 7 | "price": 12.99, 8 | "description": "Banana!", 9 | "stock": 100, 10 | "imageUrl": "/dist/images/banana.jpg" 11 | }, 12 | { 13 | "id": 2, 14 | "name": "Apple", 15 | "permalink": "apple", 16 | "price": 9.99, 17 | "description": "Apple!", 18 | "stock": 100, 19 | "imageUrl": "/dist/images/apple.jpg" 20 | }, 21 | { 22 | "id": 3, 23 | "name": "Orange", 24 | "permalink": "orange", 25 | "price": 2.99, 26 | "description": "Orange!", 27 | "stock": 100, 28 | "imageUrl": "/dist/images/orange.jpg" 29 | }, 30 | { 31 | "id": 4, 32 | "name": "Watermelon", 33 | "permalink": "watermelon", 34 | "price": 10.49, 35 | "description": "Watermelon!", 36 | "stock": 100, 37 | "imageUrl": "/dist/images/watermelon.jpg" 38 | }, 39 | { 40 | "id": 5, 41 | "name": "Plum", 42 | "permalink": "plum", 43 | "price": 12.99, 44 | "description": "Plum!", 45 | "stock": 100, 46 | "imageUrl": "/dist/images/plum.png" 47 | } 48 | ] 49 | } -------------------------------------------------------------------------------- /src/server/apiRoutes.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import data from "./apiData.json"; 3 | 4 | const router = new Router(); 5 | 6 | router.get( "/products", ( req, res ) => { 7 | setTimeout( ( ) => res.json( { products: data.products.map( productOverview ) } ), 500 ); 8 | } ); 9 | 10 | router.get( "/products/:permalink", ( req, res ) => { 11 | const product = data.products.find( p => p.permalink === req.params.permalink ); 12 | setTimeout( ( ) => res.json( { product } ), 500 ); 13 | } ); 14 | 15 | router.post( "/login", ( req, res ) => { 16 | setTimeout( ( ) => { 17 | if ( req.body.username === "user42" && req.body.password === "secret" ) { 18 | return res.json( { success: true, token: "1111-2222-3333-4444" } ); 19 | } 20 | 21 | return res.status( 401 ).json( { success: false, error: "Invalid credentials" } ); 22 | }, 500 ); 23 | } ); 24 | 25 | function productOverview( product ) { 26 | return { 27 | id: product.id, 28 | name: product.name, 29 | permalink: product.permalink, 30 | price: product.price, 31 | imageUrl: product.imageUrl, 32 | stock: product.stock, 33 | }; 34 | } 35 | 36 | export default router; 37 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import path from "path"; 3 | import bodyParser from "body-parser"; 4 | import cookieParser from "cookie-parser"; 5 | 6 | import React from "react"; 7 | import { renderToString } from "react-dom/server"; 8 | import { ServerStyleSheet } from "styled-components"; 9 | import { matchPath, StaticRouter } from "react-router-dom"; 10 | import Helmet from "react-helmet"; 11 | import { Provider as ReduxProvider } from "react-redux"; 12 | 13 | import App from "../app/views/layouts/app"; 14 | import apiRoutes from "./apiRoutes"; 15 | import configureStore from "../app/state/store"; 16 | import routes from "../app/routes"; 17 | 18 | const app = express( ); 19 | 20 | const DEFAULT_PORT = 7777; 21 | app.use( bodyParser.json( ) ); 22 | app.use( cookieParser( ) ); 23 | app.use( express.static( path.resolve( __dirname, "../../dist" ) ) ); 24 | app.use( "/api", apiRoutes ); 25 | 26 | app.use( ( req, res ) => { 27 | const reduxStore = configureStore( ); 28 | const sheet = new ServerStyleSheet(); 29 | 30 | reduxStore.dispatch( { type: "SERVER_READY" } ); // will be replaced later with a init session 31 | 32 | prefetchData( req.url, reduxStore.dispatch ).then( ( ) => { 33 | const head = Helmet.rewind( ); 34 | const reduxState = reduxStore.getState( ); 35 | const context = { }; 36 | const jsx = ( 37 | 38 | 42 | 43 | 44 | 45 | ); 46 | const reactDom = renderToString( sheet.collectStyles( jsx ) ); 47 | 48 | const styles = sheet.getStyleTags(); 49 | 50 | res.writeHead( 200, { "Content-Type": "text/html" } ); 51 | res.end( templateHtml( head, reactDom, reduxState, styles ) ); 52 | } ).catch( err => console.log( err ) ); 53 | } ); 54 | 55 | function prefetchData( url, dispatch ) { 56 | const promises = 57 | routes 58 | .map( ( route ) => ( { route, match: matchPath( url, route ) } ) ) 59 | .filter( ( { route, match } ) => match && route.component.prefetch ) 60 | .map( ( { route, match } ) => dispatch( route.component.prefetch( match ) ) ); 61 | 62 | return Promise.all( promises ); 63 | } 64 | 65 | function templateHtml( head, reactDom, reduxState, styles ) { 66 | return ` 67 | 68 | 69 | 70 | ${ head.title.toString( ) } 71 | ${ head.meta.toString( ) } 72 | ${ head.link.toString( ) } 73 | ${ styles } 74 | 75 | 76 | 77 | 78 |
${ reactDom }
79 | 80 | 83 | 84 | 85 | 86 | 87 | 88 | `; 89 | } 90 | 91 | app.listen( process.env.NODE_PORT || DEFAULT_PORT ); 92 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require( "path" ); 2 | const webpack = require( "webpack" ); 3 | const ExtractTextPlugin = require( "extract-text-webpack-plugin" ); 4 | const BundleAnalyzerPlugin = require( "webpack-bundle-analyzer" ).BundleAnalyzerPlugin; 5 | 6 | const productionEnv = process.env.NODE_ENV === "production"; 7 | 8 | const plugins = [ 9 | new webpack.optimize.CommonsChunkPlugin( { 10 | name: "lib", 11 | minChunks: Infinity, 12 | filename: "lib.bundle.js", 13 | } ), 14 | new ExtractTextPlugin( { 15 | filename: "[name].bundle.css", 16 | allChunks: true, 17 | } ), 18 | new webpack.DefinePlugin( { "process.env.NODE_ENV": JSON.stringify( process.env.NODE_ENV ) } ), 19 | new BundleAnalyzerPlugin( ), 20 | ]; 21 | 22 | if ( productionEnv ) { 23 | plugins.push( 24 | new webpack.optimize.OccurrenceOrderPlugin(), 25 | new webpack.LoaderOptionsPlugin( { minimize: true, debug: false } ), 26 | new webpack.optimize.UglifyJsPlugin( { sourcemap: true } ) ); 27 | } 28 | 29 | module.exports = { 30 | context: path.resolve( __dirname, "src" ), 31 | 32 | devtool: productionEnv ? "source-map" : "cheap-module-source-map", 33 | 34 | entry: { 35 | app: "./client/index.js", 36 | lib: [ "react", "react-dom" ], 37 | }, 38 | 39 | output: { 40 | path: path.resolve( __dirname, "dist" ), 41 | filename: "[name].bundle.js", 42 | }, 43 | 44 | resolve: { 45 | alias: { 46 | // react: "preact-compat", 47 | // "react-dom": "preact-compat", 48 | }, 49 | modules: [ 50 | path.resolve( "./src" ), 51 | "node_modules", 52 | ], 53 | }, 54 | 55 | module: { 56 | rules: [ 57 | { 58 | test: /(\.jsx|\.js)$/, 59 | enforce: "pre", 60 | exclude: /node_modules/, 61 | use: [ 62 | { 63 | loader: "eslint-loader", 64 | options: { 65 | failOnWarning: false, 66 | failOnError: true, 67 | quiet: true, 68 | }, 69 | }, 70 | ], 71 | }, 72 | { 73 | test: /(\.jsx|\.js)$/, 74 | exclude: /node_modules/, 75 | use: "babel-loader", 76 | }, 77 | ], 78 | }, 79 | 80 | plugins, 81 | }; 82 | --------------------------------------------------------------------------------