diff --git a/.eslintrc.json b/.eslintrc.json
index df40ff8..402a1dc 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -5,6 +5,11 @@
"prettier/prettier": "error"
},
"env": {
- "browser": true
+ "browser": true,
+ "es6": true,
+ "jest": true
+ },
+ "parserOptions": {
+ "ecmaVersion": 9
}
}
diff --git a/package-lock.json b/package-lock.json
index c1cedf5..31635f4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3685,6 +3685,15 @@
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz",
"integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg=="
},
+ "connected-react-router": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.3.2.tgz",
+ "integrity": "sha512-YxrAfMExl/OBsi+ojA4ywZeC7cmQ52MnZ4bhzqLhhjuOiXcQogC4kW0kodouXAXrXDovb2l3yEhDfpH99/wYcw==",
+ "requires": {
+ "immutable": "^3.8.1",
+ "seamless-immutable": "^7.1.3"
+ }
+ },
"console-browserify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz",
@@ -7933,6 +7942,11 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz",
"integrity": "sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg=="
},
+ "immutable": {
+ "version": "3.8.2",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz",
+ "integrity": "sha1-wkOZUUVbs5kT2vKBN28VMOEErfM="
+ },
"import-cwd": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
@@ -10285,6 +10299,11 @@
}
}
},
+ "moment": {
+ "version": "2.24.0",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
+ "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
+ },
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@@ -13882,10 +13901,18 @@
}
}
},
- "react-router-redux": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-4.0.8.tgz",
- "integrity": "sha1-InQDWWtRUeGCN32rg1tdRfD4BU4="
+ "react-router-dom": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.3.1.tgz",
+ "integrity": "sha512-c/MlywfxDdCp7EnB7YfPMOfMD3tOtIjrQlj/CKfNMBxdmpJP8xcz5P/UAFn3JbnQCNUxsHyVVqllF9LhgVyFCA==",
+ "requires": {
+ "history": "^4.7.2",
+ "invariant": "^2.2.4",
+ "loose-envify": "^1.3.1",
+ "prop-types": "^15.6.1",
+ "react-router": "^4.3.1",
+ "warning": "^4.0.1"
+ }
},
"react-scripts": {
"version": "2.1.8",
@@ -15047,6 +15074,11 @@
"ajv-keywords": "^3.1.0"
}
},
+ "seamless-immutable": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/seamless-immutable/-/seamless-immutable-7.1.4.tgz",
+ "integrity": "sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A=="
+ },
"select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
diff --git a/package.json b/package.json
index 792553e..5cedd82 100644
--- a/package.json
+++ b/package.json
@@ -3,11 +3,15 @@
"version": "0.1.0",
"private": true,
"dependencies": {
+ "connected-react-router": "^6.3.2",
+ "history": "^4.7.2",
+ "moment": "^2.24.0",
+ "prop-types": "^15.7.2",
"react": "^16.8.4",
"react-dom": "^16.8.4",
"react-redux": "^6.0.1",
"react-router": "^4.3.1",
- "react-router-redux": "^4.0.8",
+ "react-router-dom": "^4.3.1",
"react-scripts": "2.1.8",
"redux": "^4.0.1",
"reselect": "^4.0.0"
diff --git a/src/common/.gitkeep b/src/common/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/common/components/DappList/DappList.jsx b/src/common/components/DappList/DappList.jsx
new file mode 100644
index 0000000..afc81d3
--- /dev/null
+++ b/src/common/components/DappList/DappList.jsx
@@ -0,0 +1,14 @@
+import React from 'react'
+import { DappListModel } from '../../utils/models'
+import DappListItem from '../DappListItem'
+
+const DappList = props => {
+ const { dapps } = props
+ return dapps && dapps.map(dapp => )
+}
+
+DappList.propTypes = {
+ dapps: DappListModel.isRequired,
+}
+
+export default DappList
diff --git a/src/common/components/DappList/index.js b/src/common/components/DappList/index.js
new file mode 100644
index 0000000..c43cdb9
--- /dev/null
+++ b/src/common/components/DappList/index.js
@@ -0,0 +1,3 @@
+import DappList from './DappList'
+
+export default DappList
diff --git a/src/common/components/DappListItem/DappListItem.jsx b/src/common/components/DappListItem/DappListItem.jsx
new file mode 100644
index 0000000..1e84c89
--- /dev/null
+++ b/src/common/components/DappListItem/DappListItem.jsx
@@ -0,0 +1,11 @@
+import React from 'react'
+import { DappModel } from '../../utils/models'
+
+const DappListItem = props => {
+ const { name } = props
+ return
{name}
+}
+
+DappListItem.propTypes = DappModel
+
+export default DappListItem
diff --git a/src/common/components/DappListItem/index.js b/src/common/components/DappListItem/index.js
new file mode 100644
index 0000000..4bfc56d
--- /dev/null
+++ b/src/common/components/DappListItem/index.js
@@ -0,0 +1,3 @@
+import DappListItem from './DappListItem'
+
+export default DappListItem
diff --git a/src/common/data/categories.js b/src/common/data/categories.js
new file mode 100644
index 0000000..b42eae1
--- /dev/null
+++ b/src/common/data/categories.js
@@ -0,0 +1,6 @@
+export const EXCHANGES = 'EXCHANGES'
+export const MARKETPLACES = 'MARKETPLACES'
+export const GAMES = 'GAMES'
+export const SOCIAL_NETWORKS = 'SOCIAL_NETWORKS'
+export const MEDIA = 'MEDIA'
+export const SOCIAL_UTILITIES = 'SOCIAL_UTILITIES'
diff --git a/src/common/data/dapps.js b/src/common/data/dapps.js
new file mode 100644
index 0000000..4a75c5a
--- /dev/null
+++ b/src/common/data/dapps.js
@@ -0,0 +1,369 @@
+import * as Categories from './categories'
+
+const Dapps = [
+ {
+ name: 'Airswap',
+ url: 'https://instant.airswap.io/',
+ image: null,
+ description: 'Meet the future of trading.',
+ category: Categories.EXCHANGES,
+ dateAdded: null,
+ },
+ {
+ name: 'Bancor',
+ url: 'https://www.bancor.network/',
+ image: null,
+ description: 'Bancor is a decentralized liquidity network',
+ category: Categories.EXCHANGES,
+ dateAdded: null,
+ },
+ {
+ name: 'ERC dEX',
+ url: 'https://app.ercdex.com/',
+ description: 'Trustless trading has arrived on Ethereum',
+ image: null,
+ category: Categories.EXCHANGES,
+ dateAdded: null,
+ },
+ {
+ name: 'Kyber',
+ url: 'https://web3.kyber.network',
+ description:
+ 'On-chain, instant and liquid platform for exchange and payment service',
+ image: null,
+ category: Categories.EXCHANGES,
+ dateAdded: null,
+ },
+ {
+ name: 'Oasis Direct',
+ url: 'https://oasis.direct/',
+ description: 'The first decentralized instant exchange',
+ image: null,
+ category: Categories.EXCHANGES,
+ dateAdded: null,
+ },
+ {
+ name: 'DAI by MakerDao',
+ url: 'https://dai.makerdao.com',
+ description: 'Stability for the blockchain',
+ image: null,
+ category: Categories.EXCHANGES,
+ dateAdded: null,
+ },
+ {
+ name: 'LocalEthereum',
+ url: 'https://localethereum.com/',
+ description: 'The smartest way to buy and sell Ether',
+ image: null,
+ category: Categories.EXCHANGES,
+ dateAdded: null,
+ },
+ {
+ name: 'Eth2phone',
+ url: 'https://eth2.io',
+ description: 'Send Ether by phone number',
+ image: null,
+ category: Categories.EXCHANGES,
+ dateAdded: null,
+ },
+ {
+ name: 'DDEX',
+ url: 'https://ddex.io/',
+ description:
+ 'Instant, real-time order matching with secure on-chain settlement',
+ image: null,
+ category: Categories.EXCHANGES,
+ dateAdded: null,
+ },
+ {
+ name: 'EasyTrade',
+ url: 'https://easytrade.io',
+ description: 'One exchange for every token',
+ image: null,
+ category: Categories.EXCHANGES,
+ dateAdded: null,
+ },
+ {
+ name: 'slow.trade',
+ url: 'https://slow.trade/',
+ description:
+ 'Trade fairly priced crypto assets on the first platform built with the DutchX protocol.',
+ image: null,
+ category: Categories.EXCHANGES,
+ dateAdded: null,
+ },
+ {
+ name: 'blockimmo',
+ url: 'https://blockimmo.ch',
+ description:
+ 'blockimmo is a blockchain powered, regulated platform enabling shared property investments and ownership.',
+ image: null,
+ category: Categories.MARKETPLACES,
+ dateAdded: null,
+ },
+ {
+ name: 'CryptoCribs',
+ url: 'https://cryptocribs.com',
+ description: 'Travel the globe. Pay in crypto.',
+ image: null,
+ category: Categories.MARKETPLACES,
+ dateAdded: null,
+ },
+ {
+ name: 'Ethlance',
+ url: 'https://ethlance.com',
+ description:
+ 'The future of work is now. Hire people or work yourself in return for ETH.',
+ image: null,
+ category: Categories.MARKETPLACES,
+ dateAdded: null,
+ },
+ {
+ name: 'OpenSea',
+ url: 'https://opensea.io',
+ description: 'The largest decentralized marketplace for cryptogoods',
+ image: null,
+ category: Categories.MARKETPLACES,
+ dateAdded: null,
+ },
+ {
+ name: 'Name Bazaar',
+ url: 'https://namebazaar.io',
+ description: 'ENS name marketplace',
+ image: null,
+ category: Categories.MARKETPLACES,
+ dateAdded: null,
+ },
+ {
+ name: 'The Bounties Network',
+ url: 'https://bounties.network/',
+ description: 'Bounties on any task, paid in any token',
+ image: null,
+ category: Categories.MARKETPLACES,
+ dateAdded: null,
+ },
+ {
+ name: 'Emoon',
+ url: 'https://www.emoon.io/',
+ description:
+ 'A decentralized marketplace for buying & selling crypto assets',
+ image: null,
+ category: Categories.MARKETPLACES,
+ dateAdded: null,
+ },
+ {
+ name: 'SuperRare',
+ url: 'https://superrare.co/market',
+ description:
+ 'Buy, sell and collect unique digital creations by artists around the world',
+ image: null,
+ category: Categories.MARKETPLACES,
+ dateAdded: null,
+ },
+ {
+ name: 'CryptoKitties',
+ url: 'https://www.cryptokitties.co',
+ description: 'Collect and breed adorable digital cats.',
+ image: null,
+ category: Categories.GAMES,
+ dateAdded: null,
+ },
+ {
+ name: 'CryptoFighters',
+ url: 'https://cryptofighters.io',
+ description: 'Collect train and fight digital fighters.',
+ image: null,
+ category: Categories.GAMES,
+ dateAdded: null,
+ },
+ {
+ name: 'Cryptographics',
+ url: 'https://cryptographics.app/',
+ description:
+ 'Cryptographics is a digital art hub where artists, creators and collectors can submit asset packs, create unique cryptographics and trade them.',
+ image: null,
+ category: Categories.GAMES,
+ dateAdded: null,
+ },
+ {
+ name: 'CryptoPunks',
+ url: 'https://www.larvalabs.com/cryptopunks',
+ description: '10,000 unique collectible punks',
+ image: null,
+ category: Categories.GAMES,
+ dateAdded: null,
+ },
+ {
+ name: 'Crypto Takeovers',
+ url: 'https://cryptotakeovers.com/',
+ description: 'Predict and conquer the world. Make a crypto fortune.',
+ image: null,
+ category: Categories.GAMES,
+ dateAdded: null,
+ },
+ {
+ name: 'Decentraland',
+ url: 'https://market.decentraland.org/',
+ description:
+ 'Decentraland is a virtual reality platform powered by the Ethereum blockchain.',
+ image: null,
+ category: Categories.GAMES,
+ dateAdded: null,
+ },
+ {
+ name: 'Dragonereum',
+ url: 'https://dapp.dragonereum.io',
+ description: 'Own and trade dragons, fight with other players.',
+ image: null,
+ category: Categories.GAMES,
+ dateAdded: null,
+ },
+ {
+ name: 'Etherbots',
+ url: 'https://etherbots.io/',
+ description: 'Robot wars on the Ethereum Platform',
+ image: null,
+ category: Categories.GAMES,
+ dateAdded: null,
+ },
+ {
+ name: 'Etheremon',
+ url: 'https://www.etheremon.com/',
+ description: 'Decentralized World of Ether Monsters',
+ image: null,
+ category: Categories.GAMES,
+ dateAdded: null,
+ },
+ {
+ name: 'CryptoStrikers',
+ url: 'https://www.cryptostrikers.com/',
+ description: 'The Beautiful (card) Game',
+ image: null,
+ category: Categories.GAMES,
+ dateAdded: null,
+ },
+ {
+ name: 'Cent',
+ url: 'https://beta.cent.co/',
+ description: 'Get wisdom, get money',
+ image: null,
+ category: Categories.SOCIAL_NETWORKS,
+ dateAdded: null,
+ },
+ {
+ name: 'Kickback',
+ url: 'https://kickback.events/',
+ description:
+ 'Event no shows? No problem. Kickback asks event attendees to put skin in the game with Ethereum.',
+ image: null,
+ category: Categories.SOCIAL_NETWORKS,
+ dateAdded: null,
+ },
+ {
+ name: 'Peepeth',
+ url: 'https://peepeth.com/',
+ description: 'Blockchain-powered microblogging',
+ image: null,
+ category: Categories.SOCIAL_NETWORKS,
+ dateAdded: null,
+ },
+ {
+ name: 'Purrbook',
+ url: 'https://cryptopurr.co/',
+ description: 'A social network for CryptoKitties',
+ image: null,
+ category: Categories.SOCIAL_NETWORKS,
+ dateAdded: null,
+ },
+ {
+ name: 'livepeer.tv',
+ url: 'http://livepeer.tv/',
+ description: 'Decentralized video broadcasting',
+ image: null,
+ category: Categories.MEDIA,
+ dateAdded: null,
+ },
+ {
+ name: '3Box',
+ url: 'https://3box.io/',
+ description: 'Create and manage your Ethereum Profile.',
+ image: null,
+ category: Categories.SOCIAL_UTILITIES,
+ dateAdded: null,
+ },
+
+ {
+ name: 'Aragon',
+ url: 'https://mainnet.aragon.org/',
+ description: 'Build unstoppable organizations on Ethereum.',
+ image: null,
+ category: Categories.SOCIAL_UTILITIES,
+ dateAdded: null,
+ },
+
+ {
+ name: 'Civitas',
+ url: 'https://communities.colu.com/',
+ description: 'Blockchain-powered local communities',
+ image: null,
+ category: Categories.SOCIAL_UTILITIES,
+ dateAdded: null,
+ },
+
+ {
+ name: 'ETHLend',
+ url: 'https://app.ethlend.io',
+ description: 'Decentralized lending on Ethereum',
+ image: null,
+ category: Categories.SOCIAL_UTILITIES,
+ dateAdded: null,
+ },
+
+ {
+ name: 'Hexel',
+ url: 'https://www.onhexel.com/',
+ description: 'Create your own cryptocurrency',
+ image: null,
+ category: Categories.SOCIAL_UTILITIES,
+ dateAdded: null,
+ },
+
+ {
+ name: 'Livepeer',
+ url: 'https://explorer.livepeer.org/',
+ description: 'Decentralized video broadcasting',
+ image: null,
+ category: Categories.SOCIAL_UTILITIES,
+ dateAdded: null,
+ },
+
+ {
+ name: 'Smartz',
+ url: 'https://smartz.io',
+ description: 'Easy smart contract management',
+ image: null,
+ category: Categories.SOCIAL_UTILITIES,
+ dateAdded: null,
+ },
+
+ {
+ name: 'SNT Voting DApp',
+ url: 'https://vote.status.im',
+ description:
+ 'Let your SNT be heard! Vote on decisions exclusive to SNT holders, or create a poll of your own.',
+ image: null,
+ category: Categories.SOCIAL_UTILITIES,
+ dateAdded: null,
+ },
+
+ {
+ name: 'Status Test DApp',
+ url: 'simpledapp.eth',
+ description: 'Request test assets and test basic web3 functionality.',
+ image: null,
+ category: Categories.SOCIAL_UTILITIES,
+ dateAdded: null,
+ },
+]
+
+export default Dapps
diff --git a/src/common/redux/reducers.js b/src/common/redux/reducers.js
new file mode 100644
index 0000000..d9c0024
--- /dev/null
+++ b/src/common/redux/reducers.js
@@ -0,0 +1,11 @@
+import { combineReducers } from 'redux'
+import { connectRouter } from 'connected-react-router'
+import dapps from '../../modules/Dapps/Dapps.reducer'
+import selectedCategory from '../../modules/CategorySelector/CategorySelector.reducer'
+
+export default history =>
+ combineReducers({
+ router: connectRouter(history),
+ dapps,
+ selectedCategory,
+ })
diff --git a/src/common/redux/store.js b/src/common/redux/store.js
new file mode 100644
index 0000000..73cfa7a
--- /dev/null
+++ b/src/common/redux/store.js
@@ -0,0 +1,19 @@
+import { compose, createStore, applyMiddleware } from 'redux'
+import { routerMiddleware } from 'connected-react-router'
+import { createBrowserHistory } from 'history'
+import reducer from './reducers'
+
+export const history = createBrowserHistory()
+
+const composeWithDevTools =
+ /* eslint-disable-next-line no-underscore-dangle */
+ window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
+
+const configureStore = () =>
+ createStore(
+ reducer(history),
+ {},
+ composeWithDevTools(applyMiddleware(routerMiddleware(history))),
+ )
+
+export default configureStore
diff --git a/src/common/utils/categories.js b/src/common/utils/categories.js
new file mode 100644
index 0000000..b109ebc
--- /dev/null
+++ b/src/common/utils/categories.js
@@ -0,0 +1,7 @@
+import * as Categories from '../data/categories'
+import humanise from './humanise'
+
+export default Object.entries(Categories).map(entry => ({
+ key: entry[1],
+ value: humanise(entry[1]),
+}))
diff --git a/src/common/utils/categories.test.js b/src/common/utils/categories.test.js
new file mode 100644
index 0000000..968d17d
--- /dev/null
+++ b/src/common/utils/categories.test.js
@@ -0,0 +1,32 @@
+import categories from './categories'
+
+describe('categories', () => {
+ test('it should return the correct data structure of categories', () => {
+ expect(categories).toEqual([
+ {
+ key: 'EXCHANGES',
+ value: 'Exchanges',
+ },
+ {
+ key: 'MARKETPLACES',
+ value: 'Marketplaces',
+ },
+ {
+ key: 'GAMES',
+ value: 'Games',
+ },
+ {
+ key: 'SOCIAL_NETWORKS',
+ value: 'Social Networks',
+ },
+ {
+ key: 'MEDIA',
+ value: 'Media',
+ },
+ {
+ key: 'SOCIAL_UTILITIES',
+ value: 'Social Utilities',
+ },
+ ])
+ })
+})
diff --git a/src/common/utils/humanise.js b/src/common/utils/humanise.js
new file mode 100644
index 0000000..f304016
--- /dev/null
+++ b/src/common/utils/humanise.js
@@ -0,0 +1,7 @@
+const humanise = value =>
+ value
+ .split('_')
+ .map(word => `${word[0]}${word.slice(1).toLowerCase()}`)
+ .join(' ')
+
+export default humanise
diff --git a/src/common/utils/humanise.test.js b/src/common/utils/humanise.test.js
new file mode 100644
index 0000000..c46866a
--- /dev/null
+++ b/src/common/utils/humanise.test.js
@@ -0,0 +1,11 @@
+import humanise from './humanise'
+
+describe('humanise', () => {
+ test('it should transform a constant string for human reading', () => {
+ const first = 'TEST'
+ const second = 'ANOTHER_TEST'
+
+ expect(humanise(first)).toEqual('Test')
+ expect(humanise(second)).toEqual('Another Test')
+ })
+})
diff --git a/src/common/utils/models.js b/src/common/utils/models.js
new file mode 100644
index 0000000..d832b4c
--- /dev/null
+++ b/src/common/utils/models.js
@@ -0,0 +1,12 @@
+import PropTypes from 'prop-types'
+
+export const DappModel = {
+ name: PropTypes.string,
+ url: PropTypes.string,
+ image: PropTypes.string,
+ description: PropTypes.string,
+ category: PropTypes.string,
+ dateAdded: PropTypes.string,
+}
+
+export const DappListModel = PropTypes.arrayOf(PropTypes.shape(DappModel))
diff --git a/src/common/utils/reducer.js b/src/common/utils/reducer.js
new file mode 100644
index 0000000..e0c165b
--- /dev/null
+++ b/src/common/utils/reducer.js
@@ -0,0 +1,11 @@
+export default (map, defaultState) => (currentState, action) => {
+ const state = !currentState ? defaultState : currentState
+
+ if (!action) {
+ return state
+ }
+
+ return Object.keys(map).includes(action.type)
+ ? map[action.type](state, action.payload)
+ : state
+}
diff --git a/src/common/utils/reducer.test.js b/src/common/utils/reducer.test.js
new file mode 100644
index 0000000..ef5d06b
--- /dev/null
+++ b/src/common/utils/reducer.test.js
@@ -0,0 +1,50 @@
+import util from './reducer'
+
+describe('reducer utility', () => {
+ const reducers = {
+ TEST_ACTION: (state, payload) => ({
+ ...state,
+ ...payload,
+ }),
+ }
+
+ const state = { foo: 'bar' }
+ const action = { type: 'TEST_ACTION', payload: { baz: true } }
+ const missingAction = { type: 'MISSING_ACTION' }
+ const reducer = util(reducers, state)
+
+ test("it should return the existing state when the action doesn't exist", () => {
+ // Given an existing state
+ // And an action we can't reduce
+
+ // We should expect the function to pass through the existing state
+ expect(reducer(state, missingAction)).toEqual({ foo: 'bar' })
+ })
+
+ test('it should call the correct reducer when the action exists', () => {
+ // Given an existing state
+ // And an action we can reduce
+
+ // We expect the function to return the correctly reduced new state
+ expect(reducer(state, action)).toEqual({
+ foo: 'bar',
+ baz: true,
+ })
+ })
+
+ test('it should return the default state if the existing state is null', () => {
+ // Given a null state
+ // And an action we can't reduce
+
+ // We expect the default state
+ expect(reducer(null, missingAction)).toEqual({ foo: 'bar' })
+ })
+
+ test('it should return the existing state if the action is null', () => {
+ // Given a null state
+ // And an action that is undefined
+
+ // We expect the default state
+ expect(reducer(state, undefined)).toEqual({ foo: 'bar' })
+ })
+})
diff --git a/src/index.jsx b/src/index.jsx
index 0ae6a24..1c2dbb3 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -1,5 +1,17 @@
import React from 'react'
import ReactDOM from 'react-dom'
+import { Provider } from 'react-redux'
+import { ConnectedRouter } from 'connected-react-router'
import App from './modules/App'
+import configureStore, { history } from './common/redux/store'
-ReactDOM.render(, document.getElementById('root'))
+const store = configureStore()
+
+ReactDOM.render(
+
+
+
+
+ ,
+ document.getElementById('root'),
+)
diff --git a/src/modules/App/Router.jsx b/src/modules/App/Router.jsx
new file mode 100644
index 0000000..8977975
--- /dev/null
+++ b/src/modules/App/Router.jsx
@@ -0,0 +1,15 @@
+import React from 'react'
+import { Route, Switch } from 'react-router-dom'
+import Home from '../Home'
+import Filtered from '../Filtered'
+import RecentlyAdded from '../RecentlyAdded'
+import Dapps from '../Dapps'
+
+export default () => (
+
+
+
+
+
+
+)
diff --git a/src/modules/App/index.js b/src/modules/App/index.js
new file mode 100644
index 0000000..d58f88a
--- /dev/null
+++ b/src/modules/App/index.js
@@ -0,0 +1,3 @@
+import Router from './Router'
+
+export default Router
diff --git a/src/modules/App/index.jsx b/src/modules/App/index.jsx
deleted file mode 100644
index 311b334..0000000
--- a/src/modules/App/index.jsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import React from 'react'
-
-export default () => The app
diff --git a/src/modules/Categories/Categories.container.js b/src/modules/Categories/Categories.container.js
new file mode 100644
index 0000000..0bc5191
--- /dev/null
+++ b/src/modules/Categories/Categories.container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux'
+import { push } from 'connected-react-router'
+import { selectCategory } from '../CategorySelector/CategorySelector.reducer'
+import Categories from './Categories'
+
+const mapDispatchToProps = dispatch => ({
+ select: category => {
+ dispatch(push('/categories'))
+ dispatch(selectCategory(category))
+ },
+})
+
+export default connect(
+ null,
+ mapDispatchToProps,
+)(Categories)
diff --git a/src/modules/Categories/Categories.jsx b/src/modules/Categories/Categories.jsx
new file mode 100644
index 0000000..c8b2b40
--- /dev/null
+++ b/src/modules/Categories/Categories.jsx
@@ -0,0 +1,28 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import categories from '../../common/utils/categories'
+
+const Categories = props => {
+ const { select } = props
+ const handleClick = category => select(category)
+
+ return (
+ <>
+ {categories.map(category => (
+
+ ))}
+ >
+ )
+}
+
+Categories.propTypes = {
+ select: PropTypes.func.isRequired,
+}
+
+export default Categories
diff --git a/src/modules/Categories/index.js b/src/modules/Categories/index.js
new file mode 100644
index 0000000..7505d3b
--- /dev/null
+++ b/src/modules/Categories/index.js
@@ -0,0 +1,3 @@
+import Categories from './Categories.container'
+
+export default Categories
diff --git a/src/modules/CategorySelector/CategorySelecter.container.js b/src/modules/CategorySelector/CategorySelecter.container.js
new file mode 100644
index 0000000..1beba8c
--- /dev/null
+++ b/src/modules/CategorySelector/CategorySelecter.container.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import CategorySelector from './CategorySelector'
+import { selectCategory } from './CategorySelector.reducer'
+
+const mapStateToProps = state => ({ category: state.selectedCategory })
+const mapDispatchToProps = dispatch => ({
+ select: category => dispatch(selectCategory(category)),
+})
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(CategorySelector)
diff --git a/src/modules/CategorySelector/CategorySelector.jsx b/src/modules/CategorySelector/CategorySelector.jsx
new file mode 100644
index 0000000..ba0bc20
--- /dev/null
+++ b/src/modules/CategorySelector/CategorySelector.jsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import categories from '../../common/utils/categories'
+
+const CategorySelector = props => {
+ const { category, select } = props
+ const handleChange = event => select(event.target.value)
+
+ return (
+
+ )
+}
+
+CategorySelector.propTypes = {
+ category: PropTypes.string,
+ select: PropTypes.func.isRequired,
+}
+
+CategorySelector.defaultProps = {
+ category: null,
+}
+
+export default CategorySelector
diff --git a/src/modules/CategorySelector/CategorySelector.reducer.js b/src/modules/CategorySelector/CategorySelector.reducer.js
new file mode 100644
index 0000000..3e752f8
--- /dev/null
+++ b/src/modules/CategorySelector/CategorySelector.reducer.js
@@ -0,0 +1,18 @@
+import reducerUtil from '../../common/utils/reducer'
+
+const UPDATE_CATEGORY = 'UPDATE_CATEGORY'
+
+export const selectCategory = category => ({
+ type: UPDATE_CATEGORY,
+ payload: category,
+})
+
+const initialState = null
+
+const categoryChange = (_, category) => category
+
+const map = {
+ [UPDATE_CATEGORY]: categoryChange,
+}
+
+export default reducerUtil(map, initialState)
diff --git a/src/modules/CategorySelector/index.js b/src/modules/CategorySelector/index.js
new file mode 100644
index 0000000..81cb535
--- /dev/null
+++ b/src/modules/CategorySelector/index.js
@@ -0,0 +1,3 @@
+import CategorySelector from './CategorySelecter.container'
+
+export default CategorySelector
diff --git a/src/modules/Dapps/Dapps.container.js b/src/modules/Dapps/Dapps.container.js
new file mode 100644
index 0000000..cf36ecd
--- /dev/null
+++ b/src/modules/Dapps/Dapps.container.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux'
+import Dapps from './Dapps'
+import selector from './Dapps.selector'
+
+const mapStateToProps = state => ({
+ categories: selector(state),
+})
+
+export default connect(mapStateToProps)(Dapps)
diff --git a/src/modules/Dapps/Dapps.jsx b/src/modules/Dapps/Dapps.jsx
new file mode 100644
index 0000000..5dd5da7
--- /dev/null
+++ b/src/modules/Dapps/Dapps.jsx
@@ -0,0 +1,29 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { DappListModel } from '../../common/utils/models'
+import DappList from '../../common/components/DappList'
+import humanise from '../../common/utils/humanise'
+
+const Dapps = props => {
+ const { categories } = props
+
+ return (
+ <>
+ All Dapps
+ {categories.map(category => (
+
+
{humanise(category.category)}
+
+
+ ))}
+ >
+ )
+}
+
+Dapps.propTypes = {
+ categories: PropTypes.arrayOf(
+ PropTypes.shape({ category: PropTypes.string, dapps: DappListModel }),
+ ).isRequired,
+}
+
+export default Dapps
diff --git a/src/modules/Dapps/Dapps.reducer.js b/src/modules/Dapps/Dapps.reducer.js
new file mode 100644
index 0000000..ebd2e03
--- /dev/null
+++ b/src/modules/Dapps/Dapps.reducer.js
@@ -0,0 +1,3 @@
+import dapps from '../../common/data/dapps'
+
+export default (state = dapps) => state
diff --git a/src/modules/Dapps/Dapps.selector.js b/src/modules/Dapps/Dapps.selector.js
new file mode 100644
index 0000000..6860551
--- /dev/null
+++ b/src/modules/Dapps/Dapps.selector.js
@@ -0,0 +1,24 @@
+import { createSelector } from 'reselect'
+
+const getDapps = state => state.dapps
+
+const categorisedDapps = createSelector(
+ [getDapps],
+ dapps =>
+ dapps.reduce((acc, current) => {
+ if (acc.some(i => i.category === current.category)) {
+ return acc.map(n =>
+ n.category === current.category
+ ? {
+ ...n,
+ dapps: [...n.dapps, current],
+ }
+ : n,
+ )
+ }
+
+ return [...acc, { category: current.category, dapps: [current] }]
+ }, []),
+)
+
+export default categorisedDapps
diff --git a/src/modules/Dapps/Dapps.selector.test.js b/src/modules/Dapps/Dapps.selector.test.js
new file mode 100644
index 0000000..5ff596e
--- /dev/null
+++ b/src/modules/Dapps/Dapps.selector.test.js
@@ -0,0 +1,46 @@
+import selector from './Dapps.selector'
+
+describe('Dapp selector', () => {
+ test('it should group the dapps by categories', () => {
+ // Given state with dapps
+ const state = {
+ dapps: [
+ {
+ name: 'DAPP_1',
+ category: 'CATEGORY_1',
+ },
+ {
+ name: 'DAPP_2',
+ category: 'CATEGORY_1',
+ },
+ {
+ name: 'DAPP_3',
+ category: 'CATEGORY_2',
+ },
+ {
+ name: 'DAPP_4',
+ category: 'CATEGORY_3',
+ },
+ ],
+ }
+
+ // We should get back the dapps organised by category
+ expect(selector(state)).toEqual([
+ {
+ category: 'CATEGORY_1',
+ dapps: [
+ { name: 'DAPP_1', category: 'CATEGORY_1' },
+ { name: 'DAPP_2', category: 'CATEGORY_1' },
+ ],
+ },
+ {
+ category: 'CATEGORY_2',
+ dapps: [{ name: 'DAPP_3', category: 'CATEGORY_2' }],
+ },
+ {
+ category: 'CATEGORY_3',
+ dapps: [{ name: 'DAPP_4', category: 'CATEGORY_3' }],
+ },
+ ])
+ })
+})
diff --git a/src/modules/Dapps/index.js b/src/modules/Dapps/index.js
new file mode 100644
index 0000000..76da169
--- /dev/null
+++ b/src/modules/Dapps/index.js
@@ -0,0 +1,3 @@
+import Dapps from './Dapps.container'
+
+export default Dapps
diff --git a/src/modules/Filtered/Filtered.container.js b/src/modules/Filtered/Filtered.container.js
new file mode 100644
index 0000000..76d3bb0
--- /dev/null
+++ b/src/modules/Filtered/Filtered.container.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux'
+import Filtered from './Filtered'
+import filteredDapps from './Filtered.selector'
+
+const mapStateToProps = state => ({
+ dapps: filteredDapps(state),
+})
+
+export default connect(mapStateToProps)(Filtered)
diff --git a/src/modules/Filtered/Filtered.jsx b/src/modules/Filtered/Filtered.jsx
new file mode 100644
index 0000000..74a06a3
--- /dev/null
+++ b/src/modules/Filtered/Filtered.jsx
@@ -0,0 +1,24 @@
+import React from 'react'
+import { Link } from 'react-router-dom'
+import { DappListModel } from '../../common/utils/models'
+import CategorySelector from '../CategorySelector'
+import DappList from '../../common/components/DappList'
+
+const Filtered = props => {
+ const { dapps } = props
+
+ return (
+ <>
+ Filtered
+
+ View all
+
+ >
+ )
+}
+
+Filtered.propTypes = {
+ dapps: DappListModel.isRequired,
+}
+
+export default Filtered
diff --git a/src/modules/Filtered/Filtered.selector.js b/src/modules/Filtered/Filtered.selector.js
new file mode 100644
index 0000000..9b375aa
--- /dev/null
+++ b/src/modules/Filtered/Filtered.selector.js
@@ -0,0 +1,10 @@
+import { createSelector } from 'reselect'
+
+const getCategory = state => state.selectedCategory
+const getDapps = state => state.dapps
+
+export default createSelector(
+ [getCategory, getDapps],
+ (category, dapps) =>
+ category ? dapps.filter(dapp => dapp.category === category) : dapps,
+)
diff --git a/src/modules/Filtered/Filtered.selector.test.js b/src/modules/Filtered/Filtered.selector.test.js
new file mode 100644
index 0000000..f7a0cab
--- /dev/null
+++ b/src/modules/Filtered/Filtered.selector.test.js
@@ -0,0 +1,38 @@
+import filteredDapps from './Filtered.selector'
+
+describe('filteredDapps', () => {
+ const dapps = [
+ {
+ name: 'DAPP_1',
+ category: 'CATEGORY_1',
+ },
+ {
+ name: 'DAPP_2',
+ category: 'CATEGORY_2',
+ },
+ ]
+
+ test('it should return all the dapps when the category is not set', () => {
+ // Given a state where the selected category is null
+ const state = {
+ dapps,
+ selectedCategory: null,
+ }
+
+ // We expect to get back all the dapps
+ expect(filteredDapps(state)).toEqual(dapps)
+ })
+
+ test('it should return only the matching dapps when the category is set', () => {
+ // Given a state where the selected category is set
+ const state = {
+ dapps,
+ selectedCategory: 'CATEGORY_1',
+ }
+
+ // We expect to get back only the matching dapps
+ expect(filteredDapps(state)).toEqual([
+ { name: 'DAPP_1', category: 'CATEGORY_1' },
+ ])
+ })
+})
diff --git a/src/modules/Filtered/index.js b/src/modules/Filtered/index.js
new file mode 100644
index 0000000..05197fe
--- /dev/null
+++ b/src/modules/Filtered/index.js
@@ -0,0 +1,3 @@
+import Filtered from './Filtered.container'
+
+export default Filtered
diff --git a/src/modules/Home/Home.container.js b/src/modules/Home/Home.container.js
new file mode 100644
index 0000000..ec93b99
--- /dev/null
+++ b/src/modules/Home/Home.container.js
@@ -0,0 +1,6 @@
+import { connect } from 'react-redux'
+import Home from './Home'
+
+const mapStateToProps = state => state
+
+export default connect(mapStateToProps)(Home)
diff --git a/src/modules/Home/Home.jsx b/src/modules/Home/Home.jsx
new file mode 100644
index 0000000..40d1b62
--- /dev/null
+++ b/src/modules/Home/Home.jsx
@@ -0,0 +1,11 @@
+import React from 'react'
+import RecentlyAdded from '../RecentlyAdded'
+import Categories from '../Categories'
+
+export default () => (
+ <>
+ Home
+
+
+ >
+)
diff --git a/src/modules/Home/index.js b/src/modules/Home/index.js
new file mode 100644
index 0000000..565aed9
--- /dev/null
+++ b/src/modules/Home/index.js
@@ -0,0 +1,3 @@
+import Home from './Home.container'
+
+export default Home
diff --git a/src/modules/RecentlyAdded/RecentlyAdded.container.js b/src/modules/RecentlyAdded/RecentlyAdded.container.js
new file mode 100644
index 0000000..d8dbb2d
--- /dev/null
+++ b/src/modules/RecentlyAdded/RecentlyAdded.container.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux'
+import RecentlyAdded from './RecentlyAdded'
+import recentDapps from './RecentlyAdded.selector'
+
+const mapStateToProps = state => ({
+ dapps: recentDapps(state),
+})
+
+export default connect(mapStateToProps)(RecentlyAdded)
diff --git a/src/modules/RecentlyAdded/RecentlyAdded.jsx b/src/modules/RecentlyAdded/RecentlyAdded.jsx
new file mode 100644
index 0000000..994867d
--- /dev/null
+++ b/src/modules/RecentlyAdded/RecentlyAdded.jsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import { Link } from 'react-router-dom'
+import { DappListModel } from '../../common/utils/models'
+import DappList from '../../common/components/DappList'
+
+const RecentlyAdded = props => {
+ const { dapps } = props
+
+ return (
+ <>
+ Recently Added
+ View all
+
+ >
+ )
+}
+
+RecentlyAdded.propTypes = {
+ dapps: DappListModel.isRequired,
+}
+
+export default RecentlyAdded
diff --git a/src/modules/RecentlyAdded/RecentlyAdded.selector.js b/src/modules/RecentlyAdded/RecentlyAdded.selector.js
new file mode 100644
index 0000000..1f0173c
--- /dev/null
+++ b/src/modules/RecentlyAdded/RecentlyAdded.selector.js
@@ -0,0 +1,11 @@
+import { createSelector } from 'reselect'
+import moment from 'moment'
+
+const getDapps = state => state.dapps
+
+const recentDapps = createSelector(
+ [getDapps],
+ dapps => [...dapps].sort((a, b) => moment(b.dateAdded).diff(a.dateAdded)),
+)
+
+export default recentDapps
diff --git a/src/modules/RecentlyAdded/RecentlyAdded.selector.test.js b/src/modules/RecentlyAdded/RecentlyAdded.selector.test.js
new file mode 100644
index 0000000..0956d1a
--- /dev/null
+++ b/src/modules/RecentlyAdded/RecentlyAdded.selector.test.js
@@ -0,0 +1,37 @@
+import moment from 'moment'
+import recentDapps from './RecentlyAdded.selector'
+
+describe('recentDapps', () => {
+ test('it should return the dapps ordered by most recently added', () => {
+ // Given a state with multiple dapps
+ const today = moment().format()
+ const yesterday = moment()
+ .subtract(1, 'days')
+ .format()
+
+ const state = {
+ dapps: [
+ {
+ name: 'DAPP_1',
+ dateAdded: yesterday,
+ },
+ {
+ name: 'DAPP_2',
+ dateAdded: today,
+ },
+ ],
+ }
+
+ // We expect to get the apps ordered by the dateAdded
+ expect(recentDapps(state)).toEqual([
+ {
+ name: 'DAPP_2',
+ dateAdded: today,
+ },
+ {
+ name: 'DAPP_1',
+ dateAdded: yesterday,
+ },
+ ])
+ })
+})
diff --git a/src/modules/RecentlyAdded/index.js b/src/modules/RecentlyAdded/index.js
new file mode 100644
index 0000000..b06074f
--- /dev/null
+++ b/src/modules/RecentlyAdded/index.js
@@ -0,0 +1,3 @@
+import RecentlyAdded from './RecentlyAdded.container'
+
+export default RecentlyAdded