diff --git a/.babelrc b/.babelrc
new file mode 100644
index 0000000..47c9ace
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,4 @@
+{
+ "presets": ["es2015", "react"],
+ "plugins": ["transform-object-rest-spread"]
+}
diff --git a/.dev.env b/.dev.env
new file mode 100644
index 0000000..13ea3e1
--- /dev/null
+++ b/.dev.env
@@ -0,0 +1 @@
+NODE_ENV='dev'
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 0000000..8dc6807
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,21 @@
+{
+ "rules": {
+ "no-console": "off",
+ "indent": [ "error", 2 ],
+ "quotes": [ "error", "single" ],
+ "semi": ["error", "always"],
+ "linebreak-style": [ "error", "unix" ]
+ },
+ "env": {
+ "es6": true,
+ "node": true,
+ "mocha": true,
+ "jasmine": true
+ },
+ "ecmaFeatures": {
+ "modules": true,
+ "experimentalObjectRestSpread": true,
+ "impliedStrict": true
+ },
+ "extends": "eslint:recommended"
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..345130c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,136 @@
+# Created by https://www.gitignore.io/api/osx,vim,node,macos,windows
+
+### macOS ###
+*.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### Node ###
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Typescript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+
+
+### OSX ###
+
+# Icon must end with two \r
+
+# Thumbnails
+
+# Files that might appear in the root of a volume
+
+# Directories potentially created on remote AFP share
+
+### Vim ###
+# swap
+[._]*.s[a-v][a-z]
+[._]*.sw[a-p]
+[._]s[a-v][a-z]
+[._]sw[a-p]
+# session
+Session.vim
+# temporary
+.netrwhist
+*~
+# auto-generated tag files
+tags
+
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Folder config file
+Desktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# End of https://www.gitignore.io/api/osx,vim,node,macos,windows
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8f963e2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "26-react-redux",
+ "version": "1.0.0",
+ "description": " 26: React & Redux ======",
+ "main": "index.js",
+ "jest": {
+ "globals": {
+ "__DEBUG__": false,
+ "process.env": {
+ "NODE_ENV": "testing"
+ }
+ }
+ },
+ "scripts": {
+ "build": "webpack",
+ "watch": "webpack-dev-server --inline --hot",
+ "test": "jest --coverage",
+ "test-watch": "jest --watchAll"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/bretladenburg/26-react-redux.git"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "bugs": {
+ "url": "https://github.com/bretladenburg/26-react-redux/issues"
+ },
+ "homepage": "https://github.com/bretladenburg/26-react-redux#readme",
+ "dependencies": {
+ "babel-core": "^6.26.0",
+ "babel-loader": "^7.1.2",
+ "babel-plugin-transform-object-rest-spread": "^6.26.0",
+ "babel-preset-es2015": "^6.24.1",
+ "babel-preset-react": "^6.24.1",
+ "clean-webpack-plugin": "^0.1.16",
+ "css-loader": "^0.28.5",
+ "dotenv": "^4.0.0",
+ "extract-text-webpack-plugin": "^3.0.0",
+ "file-loader": "^0.11.2",
+ "html-webpack-plugin": "^2.30.1",
+ "jest": "^20.0.4",
+ "node-sass": "^4.5.3",
+ "react": "^15.6.1",
+ "react-dom": "^15.6.1",
+ "react-redux": "^5.0.6",
+ "react-router-dom": "^4.2.2",
+ "react-test-renderer": "^15.6.1",
+ "redux": "^3.7.2",
+ "sass-loader": "^6.0.6",
+ "superagent": "^3.6.0",
+ "uglifyjs-webpack-plugin": "^0.4.6",
+ "url-loader": "^0.5.9",
+ "uuid": "^3.1.0",
+ "webpack": "^3.5.5",
+ "webpack-dev-server": "^2.7.1"
+ }
+}
diff --git a/src/__test__/category-actions.test.js b/src/__test__/category-actions.test.js
new file mode 100644
index 0000000..e491650
--- /dev/null
+++ b/src/__test__/category-actions.test.js
@@ -0,0 +1,29 @@
+import {categoryCreate, categoryUpdate, categoryDelete} from '../action/category-actions.js';
+
+describe('Category Actions', () => {
+ test('categoryCreate returns a CATEGORY_CREATE action', () => {
+ let action = categoryCreate({name: 'test name'});
+ expect(action.type).toEqual('CATEGORY_CREATE');
+ expect(action.payload.id).toBeTruthy();
+ expect(action.payload.timestamp).toBeTruthy();
+ expect(action.payload.name).toBe('test name');
+ });
+
+ test('categoryDelete returns a CATEGORY_DELETE action', () => {
+ let category = {id: '9876', timestamp: new Date(), name: 'test name'};
+ let action = categoryDelete(category);
+ expect(action).toEqual({
+ type: 'CATEGORY_DELETE',
+ payload: category
+ })
+ });
+
+ test('categoryUpdate returns a CATEGORY_UPDATE action', () => {
+ let category = {id: '9876', timestamp: new Date(), name: 'test name'};
+ let action = categoryUpdate(category);
+ expect(action).toEqual({
+ type: 'CATEGORY_UPDATE',
+ payload: category
+ });
+ });
+});
diff --git a/src/__test__/category-reducer.test.js b/src/__test__/category-reducer.test.js
new file mode 100644
index 0000000..2227f16
--- /dev/null
+++ b/src/__test__/category-reducer.test.js
@@ -0,0 +1,40 @@
+import categoryReducer from '../reducer/category.js';
+
+describe('Category Reducer', () => {
+ test('initial state should be an empty array', () => {
+ let result = categoryReducer(undefined, {type: null});
+ expect(result).toEqual([]);
+ });
+
+ test('if no action type is presented, the state should be returned', () => {
+ let state = [
+ {id:'someid', name: 'some name'},
+ {id: 'anotherid', name: 'another name'}
+ ]
+
+ let result = categoryReducer(state, {type: null});
+ expect(result).toEqual(state);
+ });
+
+ test('CATEGORY_CREATE should append a category to the categories array', () => {
+ let action = {
+ type: 'CATEGORY_CREATE',
+ payload: 'sample payload'
+ }
+
+ let result = categoryReducer([], action);
+ expect(result.length).toBe(1);
+ expect(result[0]).toBe(action.payload);
+ });
+
+ test('CATEGORY_UPDATE should update a category with new information', () => {
+ let action = {
+ type: 'CATEGORY_UPDATE',
+ payload: 'sample payload'
+ }
+
+ let result = categoryReducer([...state], action);
+ expect(result.length).toBe(1);
+ expect(result[0]).toBe(action.payload);
+ });
+});
diff --git a/src/action/category-actions.js b/src/action/category-actions.js
new file mode 100644
index 0000000..0b9eb6b
--- /dev/null
+++ b/src/action/category-actions.js
@@ -0,0 +1,22 @@
+import uuid from 'uuid/v1';
+
+export const categoryCreate = (category) => {
+ category.id = uuid();
+ category.timestamp = new Date();
+ return {
+ type: 'CATEGORY_CREATE',
+ payload: category
+ }
+}
+
+export const categoryUpdate = (category) => ({
+ type: 'CATEGORY_UPDATE',
+ payload: category
+})
+
+export const categoryDelete = (category) => ({
+ type: 'CATEGORY_DELETE',
+ payload: category
+})
+
+export const categoryReset = () => ({type: 'CATEGORY_RESET'})
diff --git a/src/action/expense-actions.js b/src/action/expense-actions.js
new file mode 100644
index 0000000..9ead500
--- /dev/null
+++ b/src/action/expense-actions.js
@@ -0,0 +1,18 @@
+import uuid from 'uuid/v1';
+
+export const expenseCreate = (expense) => ({
+ type: 'EXPENSE_CREATE',
+ payload: {...expense, id: uuid(), timestamp: new Date()}
+})
+
+export const expenseUpdate = (expense) => ({
+ type: 'EXPENSE_UPDATE',
+ payload: {...expense}
+})
+
+export const expenseDelete = (expense) => ({
+ type: 'EXPENSE_DELETE',
+ payload: {...expense}
+})
+
+export const expenseReset = () => ({type: 'EXPENSE_RESET'})
diff --git a/src/component/app/index.js b/src/component/app/index.js
new file mode 100644
index 0000000..2b957a7
--- /dev/null
+++ b/src/component/app/index.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import {Provider} from 'react-redux';
+import {BrowserRouter, Route} from 'react-router-dom';
+import createAppStore from '../../lib/store.js';
+import DashboardContainer from '../dashboard-container';
+
+const store = createAppStore();
+
+class App extends React.Component {
+ componentDidMount() {
+ store.subscribe(() => {
+ console.log('__STATE__', store.getState());
+ });
+
+ store.dispatch({type: null});
+ }
+
+ render() {
+ return (
+
+ )
+ }
+}
+
+export default App;
diff --git a/src/component/category-form/index.js b/src/component/category-form/index.js
new file mode 100644
index 0000000..a7bcaa1
--- /dev/null
+++ b/src/component/category-form/index.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import uuid from 'uuid';
+
+class CategoryForm extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ id: props.category ? props.category.id: uuid.v1(),
+ timestamp: props.category ? props.category.timestamp: new Date(),
+ name: props.category ? props.category.name: '',
+ budget: props.category ? props.category.budget: ''
+ }
+
+ this.handleChange = this.handleChange.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ componentWillReceiveProps(props) {
+ if (props.category) {
+ this.setState(props.category);
+ }
+ }
+
+ handleChange(e) {
+ let {name, value, type} = e.target;
+
+ if (type === 'number') {
+ try {
+ this.setState({
+ [name]: parseInt(value)
+ })
+ } catch(err) {
+ console.error(err);
+ }
+ } else {
+ this.setState({
+ [name]: value
+ })
+ }
+ }
+
+ handleSubmit(e) {
+ e.preventDefault();
+ this.props.onComplete({...this.state});
+ }
+
+ render() {
+ return (
+
+ )
+ }
+}
+
+export default CategoryForm;
diff --git a/src/component/category-item/index.js b/src/component/category-item/index.js
new file mode 100644
index 0000000..d1b0f8d
--- /dev/null
+++ b/src/component/category-item/index.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import {connect} from 'react-redux';
+import CategoryForm from '../category-form';
+import ExpenseForm from '../expense-form';
+import ExpenseItem from '../expense-item'
+import {categoryUpdate, categoryDelete} from '../../action/category-actions.js';
+import {expenseCreate} from '../../action/expense-actions.js';
+
+
+class CategoryItem extends React.Component {
+ render() {
+ let {category, categoryUpdate, categoryDelete, expenses} = this.props;
+
+ return (
+
+
+
+
{category.name}
+ {category.budget}
+
+
+
+
+
+
+
+
+
+
+
+ {expenses.map(expense =>
+
+ )}
+
+
+
+ )
+ }
+}
+
+let mapStateToProps = (state, props) => ({
+ expenses: state.expenses[props.category.id]
+});
+
+
+let mapDispatchToProps = dispatch => ({
+ categoryUpdate: (category) => dispatch(categoryUpdate(category)),
+ categoryDelete: (category) => dispatch(categoryDelete(category)),
+ expenseCreate: (expense) => dispatch(expenseCreate(expense))
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(CategoryItem);
diff --git a/src/component/dashboard-container/index.js b/src/component/dashboard-container/index.js
new file mode 100644
index 0000000..1d3dbea
--- /dev/null
+++ b/src/component/dashboard-container/index.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import {connect} from 'react-redux';
+
+import {categoryCreate as categoryActionCreate} from '../../action/category-actions.js';
+import {expenseCreate as expenseActionCreate} from '../../action/expense-actions.js';
+
+import CategoryForm from '../category-form';
+import CategoryItem from '../category-item';
+import ExpenseForm from '../expense-form';
+import ExpenseItem from '../expense-item';
+
+class DashboardContainer extends React.Component {
+ constructor(props){
+ super(props);
+ }
+ render() {
+ return (
+
+ Dashboard
+
+
+
+ {this.props.categories.map((item) =>
+
+ )}
+
+
+
+ )
+ }
+}
+
+const mapStateToProps = (state) => {
+ return {
+ categories: state.categories,
+ expenses: state.expenses
+ }
+}
+
+const mapDispatchToProps = (dispatch, getState) => {
+ return {
+ categoryCreate: (category) => dispatch(categoryActionCreate(category)),
+ expenseCreate: (expense) => dispatch(expenseActionCreate(expense))
+ }
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(DashboardContainer);
diff --git a/src/component/expense-form/index.js b/src/component/expense-form/index.js
new file mode 100644
index 0000000..0160603
--- /dev/null
+++ b/src/component/expense-form/index.js
@@ -0,0 +1,81 @@
+import React from 'react';
+import uuid from 'uuid';
+
+class ExpenseForm extends React.Component {
+ constructor(props){
+ super(props);
+
+ this.state = {
+ id: props.expense ? props.expense.id: uuid.v1(),
+ timestamp: props.expense ? props.expense.timestamp: new Date(),
+ name: props.expense ? props.expense.name: '',
+ categoryID: props.category ? props.category.id: null,
+ price: props.category ? props.category.budget: ''
+ }
+
+ this.handleChange = this.handleChange.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ componentWillReceiveProps(props) {
+ if (props.expense) {
+ this.setState({...props.expense});
+ }
+
+ if (props.categoryID) {
+ this.setState({categoryID: props.categoryID})
+ }
+ }
+
+ handleChange(e) {
+ let {name, value, type} = e.target;
+
+ if (type === 'number') {
+ try {
+ this.setState({
+ [name]: parseInt(value)
+ })
+ } catch(err) {
+ console.error(err);
+ }
+ } else {
+ this.setState({
+ [name]: value
+ })
+ }
+ }
+
+ handleSubmit(e) {
+ e.preventDefault();
+ this.props.onComplete(this.state);
+ if (!this.props.expense) {
+ this.setState({name: ''});
+ }
+ }
+
+ render() {
+ return (
+
+ )
+ }
+}
+
+export default ExpenseForm;
diff --git a/src/component/expense-item/index.js b/src/component/expense-item/index.js
new file mode 100644
index 0000000..6e34a04
--- /dev/null
+++ b/src/component/expense-item/index.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import {connect} from 'react-redux';
+import ExpenseForm from '../expense-form';
+import {expenseUpdate, expenseDelete} from '../../action/expense-actions.js';
+
+class ExpenseItem extends React.Component {
+ render() {
+ let {category, expense, expenseUpdate, expenseDelete} = this.props;
+
+ return (
+
+
+
+
{expense.name}
+ {expense.price}
+
+
+
+
+
+
+
+ )
+ }
+}
+
+let mapStateToProps = () =>({});
+
+let mapDispatchToProps = (dispatch) => ({
+ expenseUpdate: (expense) => dispatch(expenseUpdate(expense)),
+ expenseDelete: (expense) => dispatch(expenseDelete(expense))
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ExpenseItem);
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 0000000..e28b54a
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,9 @@
+
+
+
+ Budget App
+
+
+
+
+
diff --git a/src/lib/redux-reporter.js b/src/lib/redux-reporter.js
new file mode 100644
index 0000000..5e02f4f
--- /dev/null
+++ b/src/lib/redux-reporter.js
@@ -0,0 +1,15 @@
+let reporter = store => next => action => {
+ console.log('__ACTION__');
+
+ try {
+ let result = next(action);
+ console.log('__STATE__', store.getState());
+ return result;
+ } catch (error) {
+ error.action = action;
+ console.error('__ERROR__', error);
+ return error;
+ }
+};
+
+export default reporter;
diff --git a/src/lib/store.js b/src/lib/store.js
new file mode 100644
index 0000000..177095f
--- /dev/null
+++ b/src/lib/store.js
@@ -0,0 +1,5 @@
+import {createStore, applyMiddleware} from 'redux';
+import reducer from '../reducer';
+import reporter from './redux-reporter.js';
+
+export default () => createStore(reducer, applyMiddleware(reporter));
diff --git a/src/lib/util.js b/src/lib/util.js
new file mode 100644
index 0000000..526f9a5
--- /dev/null
+++ b/src/lib/util.js
@@ -0,0 +1,4 @@
+export const renderIf = (test, component) => test ? component : undefined;
+
+export const classToggler = (options) =>
+ Object.keys(options).filter(key => !!options[keys]).join(' ');
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..faafd74
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,5 @@
+import React from 'react';
+import ReactDom from 'react-dom';
+import App from './component/app';
+
+ReactDom.render(, document.getElementById('root'));
diff --git a/src/reducer/category.js b/src/reducer/category.js
new file mode 100644
index 0000000..46e14fd
--- /dev/null
+++ b/src/reducer/category.js
@@ -0,0 +1,19 @@
+let initialState = [];
+
+export default (state=initialState, action) => {
+ let {type, payload} = action;
+
+ switch(type) {
+ case 'CATEGORY_CREATE':
+ return [...state, payload]
+ case 'CATEGORY_UPDATE':
+ return state.map(category =>
+ category.id === payload.id ? payload : category)
+ case 'CATEGORY_DELETE':
+ return state.filter(category => category.id !== payload.id)
+ case 'CATEGORY_RESET':
+ return initialState
+ default:
+ return state
+ }
+}
diff --git a/src/reducer/expenses.js b/src/reducer/expenses.js
new file mode 100644
index 0000000..3c33d51
--- /dev/null
+++ b/src/reducer/expenses.js
@@ -0,0 +1,61 @@
+let validateCategory = (category) => {
+ if (!category.id || !category.name || !category.timestamp || !category.budget) {
+ throw new Error('VALIDATION ERROR: category must include id, name, timestamp and budget')
+ }
+}
+
+let validateExpense = (expense) => {
+ if (!expense.id || !expense.name || !expense.categoryID) {
+ throw new Error('VALIDATION ERROR: expense must include an id, name, and a categoryID')
+ }
+}
+
+let initialState = {};
+
+export default (state=initialState, action) => {
+ let {type, payload} = action;
+ let categoryID, categoryExpenses;
+
+ switch(type) {
+ case 'CATEGORY_CREATE':
+ console.log('__STATE__!!', state);
+ validateCategory(payload);
+ return {...state, [payload.id] : []};
+ case 'CATEGORY_DELETE':
+ validateCategory(payload);
+ return {...state, [payload.id] : undefined};
+ case 'EXPENSE_CREATE':
+ validateExpense(payload);
+ categoryID = payload.categoryID;
+ categoryExpenses = state[categoryID];
+ return {...state, [categoryID]: [...categoryExpenses, payload]};
+ case 'EXPENSE_UPDATE':
+ validateExpense(payload);
+ categoryID = payload.categoryID;
+ categoryExpenses = state[categoryID];
+
+ return {
+ ...state,
+ [categoryID]: categoryExpenses.map(expense => {
+ console.log('LOGGING EXPENSE', expense);
+ return expense.id === payload.id ? payload : expense;
+ })
+ }
+ case 'EXPENSE_DELETE':
+ console.log('LOGGING THE PAYLOAD', payload);
+ console.log('LOGGING THE STATE', state);
+ console.log('LOGGING CATEGORY ID', categoryID);
+ validateExpense(payload);
+ categoryID = payload.categoryID;
+ categoryExpenses = state[categoryID];
+
+ return {
+ ...state,
+ [categoryID]: categoryExpenses.filter(expense => {
+ return expense.id !== payload.id
+ })
+ }
+ default:
+ return state;
+ }
+}
diff --git a/src/reducer/index.js b/src/reducer/index.js
new file mode 100644
index 0000000..250d838
--- /dev/null
+++ b/src/reducer/index.js
@@ -0,0 +1,8 @@
+import {combineReducers} from 'redux';
+import categoriesReducer from './category.js';
+import expensesReducer from './expenses.js';
+
+export default combineReducers({
+ categories: categoriesReducer,
+ expenses: expensesReducer
+});
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000..84feae2
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,85 @@
+'use strict';
+
+require('dotenv').config({path: `${__dirname}/.dev.env`});
+const production = process.env.NODE_ENV === 'production';
+
+const {DefinePlugin, EnvironmentPlugin} = require('webpack');
+const HtmlPlugin = require('html-webpack-plugin');
+const CleanPlugin = require('clean-webpack-plugin');
+const UglifyPlugin = require('uglifyjs-webpack-plugin');
+const ExtractPlugin = require('extract-text-webpack-plugin');
+
+let plugins = [
+ new EnvironmentPlugin(['NODE_ENV']),
+ new ExtractPlugin('bundle-[hash].css'),
+ new HtmlPlugin({template: `${__dirname}/src/index.html`}),
+ new DefinePlugin({
+ __DEBUG__: JSON.stringify(!production)
+ })
+];
+
+if (production) {
+ plugins = plugins.concat([new CleanPlugin(), new UglifyPlugin()]);
+}
+
+module.exports = {
+ plugins,
+ entry: `${__dirname}/src/main.js`,
+ devServer: {
+ historyApiFallback: true
+ },
+ devtool: production ? undefined : 'eval',
+ output: {
+ path: `${__dirname}/build`,
+ filename: 'bundle-[hash].js',
+ publicPath: process.env.CDN_URL
+ },
+ module: {
+ rules: [
+ {
+ test: /\.js$/,
+ exclude: /node_modules/,
+ loader: 'babel-loader'
+ },
+ {
+ test: /\.scss$/,
+ loader: ExtractPlugin.extract(['css-loader', 'sass-loader'])
+ },
+ {
+ test: /\.(woff|woff2|ttf|eot|glyph|\.svg)$/,
+ use: [
+ {
+ loader: 'url-loader',
+ options: {
+ limit: 10000,
+ name: 'font/[name].[ext]'
+ }
+ }
+ ]
+ },
+ {
+ test: /\.(jpg|jpeg|gif|png|tiff|svg)$/,
+ exclude: /\.glyph.svg/,
+ use: [
+ {
+ loader: 'url-loader',
+ options: {
+ limit: 6000,
+ name: 'image/[name].[ext]'
+ }
+ }
+ ]
+ },
+ {
+ test: /\.(mp3|aac|aiff|wav|flac|m4a|mp4|ogg)$/,
+ exclude: /\.glyph.svg/,
+ use: [
+ {
+ loader: 'file-loader',
+ options: {name: 'audio/[name].[ext]'}
+ }
+ ]
+ }
+ ]
+ }
+};