├── .babelrc ├── .editorconfig ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .prettierrc.js ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package.json ├── src ├── actions │ ├── index.js │ └── posts.js ├── api │ └── index.js ├── assets │ └── devfolio.png ├── components │ ├── AddTodo.jsx │ ├── ErrorBoundary.jsx │ ├── Footer.jsx │ ├── Link.jsx │ ├── Todo.jsx │ └── TodoList.jsx ├── constants │ ├── actions.js │ └── index.js ├── helpers │ └── localStorage.js ├── index.html ├── index.jsx ├── reducers │ ├── index.js │ ├── posts.js │ ├── todos.js │ └── visibilityFilter.js ├── routes.jsx ├── store.js └── views │ ├── About.jsx │ ├── App.jsx │ ├── Home.jsx │ ├── NotFound.jsx │ └── Posts.jsx ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react", "stage-1"] 3 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_size = 2 7 | charset = utf-8 8 | end_of_line = lf 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb", "prettier"], 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "prettier/prettier": "error" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - **Clear steps to reproduce the issue**: 4 | - **Relevant error messages and/or screenshots**: 5 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | 6 | 7 | **What's this PR do?** 8 | 9 | **Any background context you want to provide?** 10 | 11 | **Screenshots?** 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store 3 | .env 4 | *.log 5 | build/ 6 | dist/ 7 | flow-typed/ 8 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | printWidth: 120, 4 | singleQuote: true, 5 | tabWidth: 2, 6 | trailingComma: 'es5', 7 | }; 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Devfolio Co. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # devfolio-project-starter 2 | 3 | This starter is aimed at getting you up and running with the latest Frontend technologies on the React-Redux stack, making the development flow easy, optimizing web performance, while maintaining code quality and best practices. 4 | 5 | ## Installation 6 | 7 | Create a new project based on this starter: 8 | 9 | ``` 10 | git clone https://github.com/devfolioco/starter.git 11 | cd 12 | ``` 13 | 14 | Install the project dependencies: 15 | 16 | ``` 17 | yarn install 18 | ``` 19 | 20 | ## Usage 21 | 22 | Start the development server with: 23 | 24 | ### `yarn dev` 25 | 26 | Complete list of scripts: 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 |
yarn <script>Description
buildMake a production build of the app in /dist
devServes the app at localhost:8080 with hot reloading
formatFormat all the files with Prettier
lintRuns ESLint on all the files
50 | 51 | ## Libraries / Tools 52 | 53 | - React + Redux 54 | - React-Router 4 55 | - styled-components 56 | - react-helmet 57 | - redux-thunk middleware 58 | - axios for network requests 59 | - localStorage for persistance 60 | - Webpack 4 with Babel 61 | - webpack-dotenv for .env config 62 | - ESLint + Prettier for code formatting 63 | 64 | ## Contributing 65 | 66 | Feel free to open issues and pull requests! 67 | 68 | ## License 69 | 70 | MIT 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devfolio-project-starter", 3 | "version": "1.0.0", 4 | "description": "Devfolio Project Starter", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --mode production", 8 | "dev": "webpack-dev-server --mode development", 9 | "format": "prettier --write '**/*.{js,jsx}'", 10 | "lint": "eslint '**/*.{js,jsx}' --quiet" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/devfolioco/starter.git" 15 | }, 16 | "author": "Devfolio", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/devfolioco/starter/issues" 20 | }, 21 | "homepage": "https://github.com/devfolioco/starter#readme", 22 | "dependencies": { 23 | "axios": "^0.18.0", 24 | "babel-polyfill": "^6.26.0", 25 | "dotenv-webpack": "^1.5.7", 26 | "normalize.css": "^8.0.0", 27 | "prop-types": "^15.6.1", 28 | "react": "^16.4.1", 29 | "react-dom": "^16.4.1", 30 | "react-helmet": "^5.2.0", 31 | "react-redux": "^5.0.7", 32 | "react-router-dom": "^4.3.1", 33 | "redux": "^4.0.0", 34 | "redux-thunk": "^2.3.0", 35 | "styled-components": "^3.3.2" 36 | }, 37 | "devDependencies": { 38 | "babel-core": "^6.26.3", 39 | "babel-loader": "^7.1.4", 40 | "babel-preset-env": "^1.7.0", 41 | "babel-preset-react": "^6.24.1", 42 | "babel-preset-stage-1": "^6.24.1", 43 | "css-loader": "^0.28.11", 44 | "eslint": "^4.19.1", 45 | "eslint-config-airbnb": "^16.1.0", 46 | "eslint-config-prettier": "^2.9.0", 47 | "eslint-plugin-import": "^2.12.0", 48 | "eslint-plugin-jsx-a11y": "^6.0.3", 49 | "eslint-plugin-prettier": "^2.6.0", 50 | "eslint-plugin-react": "^7.9.1", 51 | "file-loader": "^1.1.11", 52 | "html-webpack-plugin": "^3.2.0", 53 | "prettier": "^1.13.5", 54 | "style-loader": "^0.21.0", 55 | "webpack": "^4.12.0", 56 | "webpack-cli": "^3.0.7", 57 | "webpack-dev-server": "^3.1.4" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | import { ADD_TODO, SET_VISIBILITY_FILTER, TOGGLE_TODO } from '../constants/actions'; 2 | 3 | let nextTodoId = 0; 4 | 5 | export const addTodo = text => ({ 6 | type: ADD_TODO, 7 | id: nextTodoId++, 8 | text, 9 | }); 10 | 11 | export const setVisibilityFilter = filter => ({ 12 | type: SET_VISIBILITY_FILTER, 13 | filter, 14 | }); 15 | 16 | export const toggleTodo = id => ({ 17 | type: TOGGLE_TODO, 18 | id, 19 | }); 20 | -------------------------------------------------------------------------------- /src/actions/posts.js: -------------------------------------------------------------------------------- 1 | import { REQUEST_POSTS, REQUEST_POSTS_SUCCESS, REQUEST_POSTS_FAILURE } from '../constants/actions'; 2 | import API from '../api'; 3 | 4 | const requestPosts = () => ({ 5 | type: REQUEST_POSTS, 6 | }); 7 | 8 | const requestPostsSuccess = items => ({ 9 | type: REQUEST_POSTS_SUCCESS, 10 | payload: items, 11 | }); 12 | 13 | const requestPostsFailure = error => ({ 14 | type: REQUEST_POSTS_FAILURE, 15 | payload: error, 16 | }); 17 | 18 | export const getPosts = () => async dispatch => { 19 | dispatch(requestPosts()); 20 | try { 21 | const { data } = await API.fetchPosts(); 22 | dispatch(requestPostsSuccess(data)); 23 | } catch (error) { 24 | dispatch(requestPostsFailure(error)); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_ROOT = 'https://jsonplaceholder.typicode.com/'; 4 | console.log(process.env.API_ROOT); 5 | 6 | const requests = { 7 | get: (url, config) => axios.get(`${API_ROOT}${url}`, config), 8 | }; 9 | 10 | const API = { 11 | fetchPosts: () => requests.get('posts'), 12 | }; 13 | 14 | export default API; 15 | -------------------------------------------------------------------------------- /src/assets/devfolio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devfolioco/starter/1009d61ad5696ceab270159240770046f9e81b49/src/assets/devfolio.png -------------------------------------------------------------------------------- /src/components/AddTodo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { addTodo } from '../actions'; 4 | 5 | const AddTodo = ({ dispatch }) => { 6 | let input; 7 | return ( 8 |
9 |
{ 11 | e.preventDefault(); 12 | if (!input.value.trim()) { 13 | return; 14 | } 15 | dispatch(addTodo(input.value)); 16 | input.value = ''; 17 | }} 18 | > 19 | { 21 | input = node; 22 | }} 23 | /> 24 | 25 |
26 |
27 | ); 28 | }; 29 | 30 | export default connect()(AddTodo); 31 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class ErrorBoundary extends React.Component { 4 | constructor(props) { 5 | super(props); 6 | this.state = { hasError: false }; 7 | } 8 | 9 | componentDidCatch(error, info) { 10 | this.setState({ hasError: true }); 11 | 12 | console.error(error, info); 13 | } 14 | 15 | render() { 16 | if (this.state.hasError) { 17 | return

Something went wrong.

; 18 | } 19 | 20 | return this.props.children; 21 | } 22 | } 23 | 24 | export default ErrorBoundary; 25 | -------------------------------------------------------------------------------- /src/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from '../components/Link'; 3 | import { visibilityFilters } from '../constants'; 4 | 5 | const Footer = () => ( 6 |

7 | Show: All 8 | {', '} 9 | Active 10 | {', '} 11 | Completed 12 |

13 | ); 14 | 15 | export default Footer; 16 | -------------------------------------------------------------------------------- /src/components/Link.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { setVisibilityFilter } from '../actions'; 6 | 7 | const Link = ({ active, children, onClick }) => { 8 | if (active) { 9 | return {children}; 10 | } 11 | return ( 12 | { 15 | e.preventDefault(); 16 | onClick(); 17 | }} 18 | > 19 | {children} 20 | 21 | ); 22 | }; 23 | 24 | Link.propTypes = { 25 | active: PropTypes.bool.isRequired, 26 | children: PropTypes.node.isRequired, 27 | onClick: PropTypes.func.isRequired, 28 | }; 29 | 30 | const mapStateToProps = (state, ownProps) => ({ 31 | active: ownProps.filter === state.visibilityFilter, 32 | }); 33 | 34 | const mapDispatchToProps = (dispatch, ownProps) => ({ 35 | onClick: () => { 36 | dispatch(setVisibilityFilter(ownProps.filter)); 37 | }, 38 | }); 39 | 40 | export default connect( 41 | mapStateToProps, 42 | mapDispatchToProps 43 | )(Link); 44 | -------------------------------------------------------------------------------- /src/components/Todo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Todo = ({ onClick, completed, text }) => ( 5 |
  • 11 | {text} 12 |
  • 13 | ); 14 | 15 | Todo.propTypes = { 16 | onClick: PropTypes.func.isRequired, 17 | completed: PropTypes.bool.isRequired, 18 | text: PropTypes.string.isRequired, 19 | }; 20 | 21 | export default Todo; 22 | -------------------------------------------------------------------------------- /src/components/TodoList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { toggleTodo } from '../actions'; 5 | 6 | import Todo from './Todo'; 7 | 8 | const getVisibleTodos = (todos, filter) => { 9 | switch (filter) { 10 | case 'SHOW_COMPLETED': 11 | return todos.filter(t => t.completed); 12 | case 'SHOW_ACTIVE': 13 | return todos.filter(t => !t.completed); 14 | case 'SHOW_ALL': 15 | default: 16 | return todos; 17 | } 18 | }; 19 | 20 | const TodoList = ({ todos, onTodoClick }) => ( 21 | 22 | ); 23 | 24 | TodoList.propTypes = { 25 | todos: PropTypes.arrayOf( 26 | PropTypes.shape({ 27 | id: PropTypes.number.isRequired, 28 | completed: PropTypes.bool.isRequired, 29 | text: PropTypes.string.isRequired, 30 | }).isRequired 31 | ).isRequired, 32 | onTodoClick: PropTypes.func.isRequired, 33 | }; 34 | 35 | const mapStateToProps = state => ({ 36 | todos: getVisibleTodos(state.todos, state.visibilityFilter), 37 | }); 38 | 39 | const mapDispatchToProps = dispatch => ({ 40 | onTodoClick: id => { 41 | dispatch(toggleTodo(id)); 42 | }, 43 | }); 44 | 45 | export default connect( 46 | mapStateToProps, 47 | mapDispatchToProps 48 | )(TodoList); 49 | -------------------------------------------------------------------------------- /src/constants/actions.js: -------------------------------------------------------------------------------- 1 | // Todos 2 | export const ADD_TODO = 'ADD_TODO'; 3 | export const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER'; 4 | export const TOGGLE_TODO = 'TOGGLE_TODO'; 5 | 6 | // Posts 7 | export const REQUEST_POSTS = 'REQUEST_POSTS'; 8 | export const REQUEST_POSTS_SUCCESS = 'REQUEST_POSTS_SUCCESS'; 9 | export const REQUEST_POSTS_FAILURE = 'REQUEST_POSTS_FAILURE'; 10 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | export const visibilityFilters = { 2 | SHOW_ALL: 'SHOW_ALL', 3 | SHOW_COMPLETED: 'SHOW_COMPLETED', 4 | SHOW_ACTIVE: 'SHOW_ACTIVE', 5 | }; 6 | -------------------------------------------------------------------------------- /src/helpers/localStorage.js: -------------------------------------------------------------------------------- 1 | export const clearStorage = () => localStorage.clear(); 2 | 3 | export const getItemFromStorage = key => { 4 | try { 5 | const item = JSON.parse(localStorage.getItem(key)); 6 | if (item === null) { 7 | return undefined; 8 | } 9 | return item; 10 | } catch (err) { 11 | console.error(`Error getting item ${key} from localStorage`, err); 12 | } 13 | }; 14 | 15 | export const storeItem = (key, item) => { 16 | try { 17 | return localStorage.setItem(key, JSON.stringify(item)); 18 | } catch (err) { 19 | console.error(`Error storing item ${key} to localStorage`, err); 20 | } 21 | }; 22 | 23 | export const removeItemFromStorage = key => { 24 | try { 25 | return localStorage.removeItem(key); 26 | } catch (err) { 27 | console.error(`Error removing item ${key} from localStorage`, err); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Devfolio Starter 9 | 10 | 11 | 12 |
    13 | 14 | 15 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import 'normalize.css/normalize.css'; 2 | 3 | import React from 'react'; 4 | import { render } from 'react-dom'; 5 | import { Provider } from 'react-redux'; 6 | 7 | import Routes from './routes'; 8 | import store from './store'; 9 | import ErrorBoundary from './components/ErrorBoundary'; 10 | 11 | render( 12 | 13 | 14 | 15 | 16 | , 17 | document.getElementById('app') 18 | ); 19 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import todos from './todos'; 3 | import visibilityFilter from './visibilityFilter'; 4 | import posts from './posts'; 5 | 6 | const rootReducer = combineReducers({ 7 | todos, 8 | posts, 9 | visibilityFilter, 10 | }); 11 | 12 | export default rootReducer; 13 | -------------------------------------------------------------------------------- /src/reducers/posts.js: -------------------------------------------------------------------------------- 1 | import { REQUEST_POSTS, REQUEST_POSTS_SUCCESS, REQUEST_POSTS_FAILURE } from '../constants/actions'; 2 | 3 | const initialState = { 4 | items: [], 5 | isLoading: false, 6 | error: null, 7 | }; 8 | 9 | const posts = (state = initialState, action) => { 10 | switch (action.type) { 11 | case REQUEST_POSTS: 12 | return { 13 | ...state, 14 | isLoading: true, 15 | }; 16 | case REQUEST_POSTS_SUCCESS: 17 | return { 18 | ...state, 19 | isLoading: false, 20 | items: action.payload, 21 | }; 22 | case REQUEST_POSTS_FAILURE: 23 | return { 24 | ...state, 25 | isLoading: false, 26 | error: action.payload, 27 | }; 28 | default: 29 | return state; 30 | } 31 | }; 32 | 33 | export default posts; 34 | -------------------------------------------------------------------------------- /src/reducers/todos.js: -------------------------------------------------------------------------------- 1 | import { ADD_TODO, TOGGLE_TODO } from '../constants/actions'; 2 | 3 | const todos = (state = [], action) => { 4 | switch (action.type) { 5 | case ADD_TODO: 6 | return [ 7 | ...state, 8 | { 9 | id: action.id, 10 | text: action.text, 11 | completed: false, 12 | }, 13 | ]; 14 | case TOGGLE_TODO: 15 | return state.map( 16 | todo => 17 | todo.id === action.id 18 | ? { 19 | ...todo, 20 | completed: !todo.completed, 21 | } 22 | : todo 23 | ); 24 | default: 25 | return state; 26 | } 27 | }; 28 | 29 | export default todos; 30 | -------------------------------------------------------------------------------- /src/reducers/visibilityFilter.js: -------------------------------------------------------------------------------- 1 | import { SET_VISIBILITY_FILTER } from '../constants/actions'; 2 | import { visibilityFilters } from '../constants'; 3 | 4 | const visibilityFilter = (state = visibilityFilters.SHOW_ALL, action) => { 5 | switch (action.type) { 6 | case SET_VISIBILITY_FILTER: 7 | return action.filter; 8 | default: 9 | return state; 10 | } 11 | }; 12 | 13 | export default visibilityFilter; 14 | -------------------------------------------------------------------------------- /src/routes.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; 3 | 4 | import Home from './views/Home'; 5 | import App from './views/App'; 6 | import Posts from './views/Posts'; 7 | import About from './views/About'; 8 | import NotFound from './views/NotFound'; 9 | 10 | const Routes = () => ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | export default Routes; 22 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import rootReducer from './reducers'; 4 | import { getItemFromStorage, storeItem } from './helpers/localStorage'; 5 | 6 | // Get initial state from localStorage 7 | const persistedState = { todos: getItemFromStorage('todos') }; 8 | 9 | const composeEnhancers = 10 | typeof window === 'object' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 11 | ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) 12 | : compose; 13 | 14 | const store = createStore(rootReducer, persistedState, composeEnhancers(applyMiddleware(thunk))); 15 | 16 | // Persist state to localStorage 17 | store.subscribe(() => { 18 | storeItem('todos', store.getState().todos); 19 | }); 20 | 21 | export default store; 22 | -------------------------------------------------------------------------------- /src/views/About.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Helmet } from 'react-helmet'; 3 | 4 | const About = () => ( 5 |
    6 | 7 | About | Devfolio Starter 8 | 9 |

    Devfolio Starter!

    10 |
    11 | ); 12 | 13 | export default About; 14 | -------------------------------------------------------------------------------- /src/views/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Helmet } from 'react-helmet'; 4 | 5 | import Footer from '../components/Footer'; 6 | import AddTodo from '../components/AddTodo'; 7 | import TodoList from '../components/TodoList'; 8 | 9 | const Container = styled.div` 10 | background-color: lightgray; 11 | `; 12 | 13 | const App = () => ( 14 | 15 | 16 | Todos | Devfolio Starter 17 | 18 | 19 | 20 |