├── .gitignore ├── README.md ├── react-coin ├── .gitignore ├── package.json ├── public │ └── index.html └── src │ ├── components │ └── common │ │ ├── logo.png │ │ └── search.png │ └── index.js └── stages ├── 10 └── src │ ├── components │ ├── common │ │ ├── Header.css │ │ ├── Header.js │ │ ├── Loading.css │ │ ├── Loading.js │ │ ├── logo.png │ │ └── search.png │ ├── detail │ │ ├── Detail.css │ │ └── Detail.js │ ├── list │ │ ├── List.js │ │ ├── Pagination.css │ │ ├── Pagination.js │ │ ├── Table.css │ │ └── Table.js │ └── notfound │ │ ├── NotFound.css │ │ └── NotFound.js │ ├── config.js │ ├── helpers.js │ ├── index.css │ └── index.js ├── 11 └── src │ ├── components │ ├── common │ │ ├── Header.css │ │ ├── Header.js │ │ ├── Loading.css │ │ ├── Loading.js │ │ ├── logo.png │ │ └── search.png │ ├── detail │ │ ├── Detail.css │ │ └── Detail.js │ ├── list │ │ ├── List.js │ │ ├── Pagination.css │ │ ├── Pagination.js │ │ ├── Table.css │ │ └── Table.js │ └── notfound │ │ ├── NotFound.css │ │ └── NotFound.js │ ├── config.js │ ├── helpers.js │ ├── index.css │ └── index.js ├── 12 └── src │ ├── components │ ├── common │ │ ├── Header.css │ │ ├── Header.js │ │ ├── Loading.css │ │ ├── Loading.js │ │ ├── Search.css │ │ ├── Search.js │ │ ├── logo.png │ │ └── search.png │ ├── detail │ │ ├── Detail.css │ │ └── Detail.js │ ├── list │ │ ├── List.js │ │ ├── Pagination.css │ │ ├── Pagination.js │ │ ├── Table.css │ │ └── Table.js │ └── notfound │ │ ├── NotFound.css │ │ └── NotFound.js │ ├── config.js │ ├── helpers.js │ ├── index.css │ └── index.js ├── 13 └── src │ ├── components │ ├── common │ │ ├── Header.css │ │ ├── Header.js │ │ ├── Loading.css │ │ ├── Loading.js │ │ ├── Search.css │ │ ├── Search.js │ │ ├── logo.png │ │ └── search.png │ ├── detail │ │ ├── Detail.css │ │ └── Detail.js │ ├── list │ │ ├── List.js │ │ ├── Pagination.css │ │ ├── Pagination.js │ │ ├── Table.css │ │ └── Table.js │ └── notfound │ │ ├── NotFound.css │ │ └── NotFound.js │ ├── config.js │ ├── helpers.js │ ├── index.css │ └── index.js ├── 14 ├── server.js └── src │ ├── components │ ├── common │ │ ├── Header.css │ │ ├── Header.js │ │ ├── Loading.css │ │ ├── Loading.js │ │ ├── Search.css │ │ ├── Search.js │ │ ├── logo.png │ │ └── search.png │ ├── detail │ │ ├── Detail.css │ │ └── Detail.js │ ├── list │ │ ├── List.js │ │ ├── Pagination.css │ │ ├── Pagination.js │ │ ├── Table.css │ │ └── Table.js │ └── notfound │ │ ├── NotFound.css │ │ └── NotFound.js │ ├── config.js │ ├── helpers.js │ ├── index.css │ └── index.js ├── 03 └── src │ ├── components │ └── common │ │ ├── logo.png │ │ └── search.png │ └── index.js ├── 04 └── src │ ├── components │ └── common │ │ ├── Header.js │ │ ├── logo.png │ │ └── search.png │ └── index.js ├── 05 └── src │ ├── components │ └── common │ │ ├── Header.css │ │ ├── Header.js │ │ ├── logo.png │ │ └── search.png │ ├── index.css │ └── index.js ├── 06 └── src │ ├── components │ ├── common │ │ ├── Header.css │ │ ├── Header.js │ │ ├── logo.png │ │ └── search.png │ └── list │ │ └── List.js │ ├── index.css │ └── index.js ├── 07 └── src │ ├── components │ ├── common │ │ ├── Header.css │ │ ├── Header.js │ │ ├── Loading.css │ │ ├── Loading.js │ │ ├── logo.png │ │ └── search.png │ └── list │ │ ├── List.js │ │ └── Table.css │ ├── config.js │ ├── helpers.js │ ├── index.css │ └── index.js ├── 08 └── src │ ├── components │ ├── common │ │ ├── Header.css │ │ ├── Header.js │ │ ├── Loading.css │ │ ├── Loading.js │ │ ├── logo.png │ │ └── search.png │ └── list │ │ ├── List.js │ │ └── Table.css │ ├── config.js │ ├── helpers.js │ ├── index.css │ └── index.js ├── 09 └── src │ ├── components │ ├── common │ │ ├── Header.css │ │ ├── Header.js │ │ ├── Loading.css │ │ ├── Loading.js │ │ ├── logo.png │ │ └── search.png │ └── list │ │ ├── List.js │ │ ├── Pagination.css │ │ ├── Pagination.js │ │ ├── Table.css │ │ └── Table.js │ ├── config.js │ ├── helpers.js │ ├── index.css │ └── index.js └── final ├── .gitignore ├── package.json ├── public └── index.html └── src ├── components ├── common │ ├── Header.css │ ├── Header.js │ ├── Loading.css │ ├── Loading.js │ ├── Search.css │ ├── Search.js │ ├── logo.png │ └── search.png ├── detail │ ├── Detail.css │ └── Detail.js ├── list │ ├── List.js │ ├── Pagination.css │ ├── Pagination.js │ ├── Table.css │ └── Table.js └── notfound │ ├── NotFound.css │ └── NotFound.js ├── config.js ├── helpers.js ├── index.css └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Learn React by building a web app 2 | 3 | Starter files for the Learn React by building and deploying production ready app Course. 4 | 5 | Poster 6 | 7 | # Project structure 8 | 9 | `react-coin` is the folder, where we are going to work for this entire course. 10 | 11 | `stages` is the folder, that contains specific stage of application, corresponding to course video. E.g. if you lose track while watching `5th` video, `stages/05` folder will contain solution for you. It also contains final project. 12 | 13 | # Getting started 14 | 15 | ### Familiar with Git? 16 | 17 | clone this repository and `cd` into `react-coin` folder 18 | 19 | ``` 20 | git clone https://github.com/udilia/learn-react-by-building-a-web-app.git 21 | 22 | cd learn-react-by-building-a-web-app/react-coin 23 | ``` 24 | 25 | ### Not familiar with Git? 26 | 27 | - Download ZIP 28 | - extract the contents of the zip file 29 | - and `cd` into `react-coin` folder `cd learn-react-by-building-a-web-app-master/react-coin` 30 | 31 | ### Install dependencies 32 | 33 | ``` 34 | npm install 35 | ``` 36 | 37 | ### Start development server 38 | 39 | ``` 40 | npm start 41 | ``` 42 | -------------------------------------------------------------------------------- /react-coin/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /react-coin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-coin", 3 | "version": "1.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "react-scripts": "1.1.0" 7 | }, 8 | "dependencies": { 9 | "prop-types": "^15.6.0", 10 | "react": "^16.2.0", 11 | "react-dom": "^16.2.0", 12 | "react-router-dom": "^4.2.2" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test --env=jsdom", 18 | "eject": "react-scripts eject" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /react-coin/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Coin 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /react-coin/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/react-coin/src/components/common/logo.png -------------------------------------------------------------------------------- /react-coin/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/react-coin/src/components/common/search.png -------------------------------------------------------------------------------- /react-coin/src/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/react-coin/src/index.js -------------------------------------------------------------------------------- /stages/03/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/03/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/03/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/03/src/components/common/search.png -------------------------------------------------------------------------------- /stages/03/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | const App = () => { 5 | return

React Coin

; 6 | } 7 | 8 | ReactDOM.render( 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /stages/04/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Header = () => { 4 | return ( 5 |
Header
6 | ); 7 | } 8 | 9 | export default Header; 10 | -------------------------------------------------------------------------------- /stages/04/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/04/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/04/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/04/src/components/common/search.png -------------------------------------------------------------------------------- /stages/04/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Header from './components/common/Header'; 4 | 5 | const App = () => { 6 | const title = 'React Coin'; 7 | 8 | return ( 9 |
10 |
11 | 12 |

{title}

13 | 14 |

Up to date crypto currencies financial data

15 |
16 | ); 17 | } 18 | 19 | ReactDOM.render( 20 | , 21 | document.getElementById('root') 22 | ); 23 | -------------------------------------------------------------------------------- /stages/05/src/components/common/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #0f273d; 7 | width: 100%; 8 | height: 80px; 9 | } 10 | 11 | .Header-logo { 12 | position: absolute; 13 | top: 30px; 14 | left: 20px; 15 | width: 90px; 16 | } 17 | 18 | @media (max-width: 700px) { 19 | .Header-logo { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stages/05/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from './logo.png'; 3 | import './Header.css'; 4 | 5 | const Header = () => { 6 | return ( 7 |
8 | logo 9 |
10 | ); 11 | } 12 | 13 | export default Header; 14 | -------------------------------------------------------------------------------- /stages/05/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/05/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/05/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/05/src/components/common/search.png -------------------------------------------------------------------------------- /stages/05/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Application colors 3 | 4 | text: #fff; 5 | text-secondary: #9cb3c9; 6 | 7 | error-red: #d64d96; 8 | 9 | green: #3cd483; 10 | 11 | indigo-light: #1f364d; 12 | indigo-normal: #0f273d; 13 | indigo-dark: #0c2033; 14 | 15 | blue-normal: #4997e5; 16 | blue-dark: #457cb2; 17 | */ 18 | 19 | /* Load Open Sans font from Google Fonts */ 20 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400'); 21 | 22 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 23 | button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 24 | 25 | body { 26 | background-color: #0c2033; 27 | font-family: 'Open Sans', sans-serif; 28 | color: #fff; 29 | letter-spacing: .5px; 30 | font-size: 16px; 31 | font-weight: 300; 32 | } 33 | 34 | .percent-raised { 35 | color: #3cd483; 36 | } 37 | 38 | .percent-fallen { 39 | color: #d64d96; 40 | } 41 | 42 | .loading-container { 43 | width: 100%; 44 | text-align: center; 45 | margin: 40px auto; 46 | } 47 | 48 | .error { 49 | width: 100%; 50 | margin: 40px 0; 51 | text-align: center; 52 | color: #d64d96; 53 | } 54 | 55 | /* Remove close icon from input on IE */ 56 | input::-ms-clear { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /stages/05/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Header from './components/common/Header'; 4 | import './index.css'; 5 | 6 | const App = () => { 7 | const title = 'React Coin'; 8 | 9 | return ( 10 |
11 |
12 | 13 |

{title}

14 | 15 |

Up to date crypto currencies financial data

16 |
17 | ); 18 | } 19 | 20 | ReactDOM.render( 21 | , 22 | document.getElementById('root') 23 | ); 24 | -------------------------------------------------------------------------------- /stages/06/src/components/common/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #0f273d; 7 | width: 100%; 8 | height: 80px; 9 | } 10 | 11 | .Header-logo { 12 | position: absolute; 13 | top: 30px; 14 | left: 20px; 15 | width: 90px; 16 | } 17 | 18 | @media (max-width: 700px) { 19 | .Header-logo { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stages/06/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from './logo.png'; 3 | import './Header.css'; 4 | 5 | const Header = () => { 6 | return ( 7 |
8 | logo 9 |
10 | ); 11 | } 12 | 13 | export default Header; 14 | -------------------------------------------------------------------------------- /stages/06/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/06/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/06/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/06/src/components/common/search.png -------------------------------------------------------------------------------- /stages/06/src/components/list/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class List extends React.Component { 4 | constructor() { 5 | super(); 6 | 7 | this.state = { 8 | loading: false, 9 | currencies: [], 10 | error: null, 11 | }; 12 | } 13 | 14 | componentDidMount() { 15 | this.setState({ loading: true }); 16 | 17 | fetch('https://api.udilia.com/coins/v1/cryptocurrencies?page=1&perPage=20') 18 | .then(response => { 19 | return response.json().then(json => { 20 | return response.ok ? json : Promise.reject(json); 21 | }); 22 | }) 23 | .then((data) => { 24 | this.setState({ 25 | currencies: data.currencies, 26 | loading: false, 27 | }); 28 | }) 29 | .catch((error) => { 30 | this.setState({ 31 | error: error.errorMessage, 32 | loading: false, 33 | }); 34 | }); 35 | } 36 | 37 | render() { 38 | console.log(this.state); 39 | 40 | if (this.state.loading) { 41 | return
Loading...
42 | } 43 | 44 | return ( 45 |
text
46 | ); 47 | } 48 | } 49 | 50 | export default List; 51 | -------------------------------------------------------------------------------- /stages/06/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Application colors 3 | 4 | text: #fff; 5 | text-secondary: #9cb3c9; 6 | 7 | error-red: #d64d96; 8 | 9 | green: #3cd483; 10 | 11 | indigo-light: #1f364d; 12 | indigo-normal: #0f273d; 13 | indigo-dark: #0c2033; 14 | 15 | blue-normal: #4997e5; 16 | blue-dark: #457cb2; 17 | */ 18 | 19 | /* Load Open Sans font from Google Fonts */ 20 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400'); 21 | 22 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 23 | button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 24 | 25 | body { 26 | background-color: #0c2033; 27 | font-family: 'Open Sans', sans-serif; 28 | color: #fff; 29 | letter-spacing: .5px; 30 | font-size: 16px; 31 | font-weight: 300; 32 | } 33 | 34 | .percent-raised { 35 | color: #3cd483; 36 | } 37 | 38 | .percent-fallen { 39 | color: #d64d96; 40 | } 41 | 42 | .loading-container { 43 | width: 100%; 44 | text-align: center; 45 | margin: 40px auto; 46 | } 47 | 48 | .error { 49 | width: 100%; 50 | margin: 40px 0; 51 | text-align: center; 52 | color: #d64d96; 53 | } 54 | 55 | /* Remove close icon from input on IE */ 56 | input::-ms-clear { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /stages/06/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Header from './components/common/Header'; 4 | import List from './components/list/List'; 5 | import './index.css'; 6 | 7 | const App = () => { 8 | return ( 9 |
10 |
11 | 12 | 13 |
14 | ); 15 | } 16 | 17 | ReactDOM.render( 18 | , 19 | document.getElementById('root') 20 | ); 21 | -------------------------------------------------------------------------------- /stages/07/src/components/common/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #0f273d; 7 | width: 100%; 8 | height: 80px; 9 | } 10 | 11 | .Header-logo { 12 | position: absolute; 13 | top: 30px; 14 | left: 20px; 15 | width: 90px; 16 | } 17 | 18 | @media (max-width: 700px) { 19 | .Header-logo { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stages/07/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from './logo.png'; 3 | import './Header.css'; 4 | 5 | const Header = () => { 6 | return ( 7 |
8 | logo 9 |
10 | ); 11 | } 12 | 13 | export default Header; 14 | -------------------------------------------------------------------------------- /stages/07/src/components/common/Loading.css: -------------------------------------------------------------------------------- 1 | .Loading { 2 | width: 28px; 3 | height: 28px; 4 | display: inline-block; 5 | border: 2px solid #fff; 6 | border-right-color: transparent; 7 | border-radius: 50%; 8 | animation: rotate 1s infinite linear; 9 | } 10 | 11 | @keyframes rotate { 12 | 0% { 13 | transform: rotate(0deg); 14 | } 15 | 16 | 100% { 17 | transform: rotate(360deg); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /stages/07/src/components/common/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Loading.css'; 3 | 4 | const Loading = () => { 5 | return
; 6 | } 7 | 8 | export default Loading; 9 | -------------------------------------------------------------------------------- /stages/07/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/07/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/07/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/07/src/components/common/search.png -------------------------------------------------------------------------------- /stages/07/src/components/list/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { handleResponse } from '../../helpers'; 3 | import { API_URL } from '../../config'; 4 | import Loading from '../common/Loading'; 5 | import './Table.css'; 6 | 7 | class List extends React.Component { 8 | constructor() { 9 | super(); 10 | 11 | this.state = { 12 | loading: false, 13 | currencies: [], 14 | error: null, 15 | }; 16 | } 17 | 18 | componentDidMount() { 19 | this.setState({ loading: true }); 20 | 21 | fetch(`${API_URL}/cryptocurrencies?page=1&perPage=20`) 22 | .then(handleResponse) 23 | .then((data) => { 24 | this.setState({ 25 | currencies: data.currencies, 26 | loading: false, 27 | }); 28 | }) 29 | .catch((error) => { 30 | this.setState({ 31 | error: error.errorMessage, 32 | loading: false, 33 | }); 34 | }); 35 | } 36 | 37 | renderChangePercent(percent) { 38 | if (percent > 0) { 39 | return {percent}% ↑ 40 | } else if (percent < 0) { 41 | return {percent}% ↓ 42 | } else { 43 | return {percent} 44 | } 45 | } 46 | 47 | render() { 48 | const { loading, error, currencies } = this.state; 49 | 50 | // render only loading component, if loading state is set to true 51 | if (loading) { 52 | return
53 | } 54 | 55 | // render only error message, if error occurred while fetching data 56 | if (error) { 57 | return
{error}
58 | } 59 | 60 | return ( 61 |
62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | {currencies.map((currency) => ( 73 | 74 | 78 | 81 | 84 | 87 | 88 | ))} 89 | 90 |
CryptocurrencyPriceMarket Cap24H Change
75 | {currency.rank} 76 | {currency.name} 77 | 79 | $ {currency.price} 80 | 82 | $ {currency.marketCap} 83 | 85 | {this.renderChangePercent(currency.percentChange24h)} 86 |
91 |
92 | ); 93 | } 94 | } 95 | 96 | export default List; 97 | -------------------------------------------------------------------------------- /stages/07/src/components/list/Table.css: -------------------------------------------------------------------------------- 1 | .Table-container { 2 | overflow-x: auto; /* Needed for table to be responsive */ 3 | } 4 | 5 | .Table { 6 | width: 100%; 7 | border-collapse: collapse; 8 | border-spacing: 0; 9 | } 10 | 11 | .Table-head { 12 | background-color: #0c2033; 13 | } 14 | 15 | .Table-head tr th { 16 | padding: 10px 20px; 17 | color: #9cb3c9; 18 | text-align: left; 19 | font-size: 14px; 20 | font-weight: 400; 21 | } 22 | 23 | .Table-body { 24 | text-align: left; 25 | background-color: #0f273d; 26 | } 27 | 28 | .Table-body tr td { 29 | padding: 24px 20px; 30 | border-bottom: 2px solid #0c2033; 31 | color: #fff; 32 | cursor: pointer; 33 | } 34 | 35 | .Table-rank { 36 | color: #9cb3c9; 37 | margin-right: 18px; 38 | font-size: 12px; 39 | } 40 | 41 | .Table-dollar { 42 | color: #9cb3c9; 43 | margin-right: 6px; 44 | } 45 | -------------------------------------------------------------------------------- /stages/07/src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API root url 3 | */ 4 | export const API_URL = 'https://api.udilia.com/coins/v1'; 5 | -------------------------------------------------------------------------------- /stages/07/src/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fetch response helper 3 | * 4 | * @param {object} response 5 | */ 6 | export const handleResponse = (response) => { 7 | return response.json().then(json => { 8 | return response.ok ? json : Promise.reject(json); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /stages/07/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Application colors 3 | 4 | text: #fff; 5 | text-secondary: #9cb3c9; 6 | 7 | error-red: #d64d96; 8 | 9 | green: #3cd483; 10 | 11 | indigo-light: #1f364d; 12 | indigo-normal: #0f273d; 13 | indigo-dark: #0c2033; 14 | 15 | blue-normal: #4997e5; 16 | blue-dark: #457cb2; 17 | */ 18 | 19 | /* Load Open Sans font from Google Fonts */ 20 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400'); 21 | 22 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 23 | button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 24 | 25 | body { 26 | background-color: #0c2033; 27 | font-family: 'Open Sans', sans-serif; 28 | color: #fff; 29 | letter-spacing: .5px; 30 | font-size: 16px; 31 | font-weight: 300; 32 | } 33 | 34 | .percent-raised { 35 | color: #3cd483; 36 | } 37 | 38 | .percent-fallen { 39 | color: #d64d96; 40 | } 41 | 42 | .loading-container { 43 | width: 100%; 44 | text-align: center; 45 | margin: 40px auto; 46 | } 47 | 48 | .error { 49 | width: 100%; 50 | margin: 40px 0; 51 | text-align: center; 52 | color: #d64d96; 53 | } 54 | 55 | /* Remove close icon from input on IE */ 56 | input::-ms-clear { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /stages/07/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Header from './components/common/Header'; 4 | import List from './components/list/List'; 5 | import './index.css'; 6 | 7 | const App = () => { 8 | return ( 9 |
10 |
11 | 12 | 13 |
14 | ); 15 | } 16 | 17 | ReactDOM.render( 18 | , 19 | document.getElementById('root') 20 | ); 21 | -------------------------------------------------------------------------------- /stages/08/src/components/common/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #0f273d; 7 | width: 100%; 8 | height: 80px; 9 | } 10 | 11 | .Header-logo { 12 | position: absolute; 13 | top: 30px; 14 | left: 20px; 15 | width: 90px; 16 | } 17 | 18 | @media (max-width: 700px) { 19 | .Header-logo { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stages/08/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from './logo.png'; 3 | import './Header.css'; 4 | 5 | const Header = () => { 6 | return ( 7 |
8 | logo 9 |
10 | ); 11 | } 12 | 13 | export default Header; 14 | -------------------------------------------------------------------------------- /stages/08/src/components/common/Loading.css: -------------------------------------------------------------------------------- 1 | .Loading { 2 | width: 28px; 3 | height: 28px; 4 | display: inline-block; 5 | border: 2px solid #fff; 6 | border-right-color: transparent; 7 | border-radius: 50%; 8 | animation: rotate 1s infinite linear; 9 | } 10 | 11 | @keyframes rotate { 12 | 0% { 13 | transform: rotate(0deg); 14 | } 15 | 16 | 100% { 17 | transform: rotate(360deg); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /stages/08/src/components/common/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Loading.css'; 3 | 4 | const Loading = () => { 5 | return
; 6 | } 7 | 8 | export default Loading; 9 | -------------------------------------------------------------------------------- /stages/08/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/08/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/08/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/08/src/components/common/search.png -------------------------------------------------------------------------------- /stages/08/src/components/list/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { handleResponse } from '../../helpers'; 3 | import { API_URL } from '../../config'; 4 | import Loading from '../common/Loading'; 5 | import './Table.css'; 6 | 7 | class List extends React.Component { 8 | constructor() { 9 | super(); 10 | 11 | this.state = { 12 | loading: false, 13 | currencies: [], 14 | error: null, 15 | }; 16 | } 17 | 18 | componentDidMount() { 19 | this.setState({ loading: true }); 20 | 21 | fetch(`${API_URL}/cryptocurrencies?page=1&perPage=20`) 22 | .then(handleResponse) 23 | .then((data) => { 24 | this.setState({ 25 | currencies: data.currencies, 26 | loading: false, 27 | }); 28 | }) 29 | .catch((error) => { 30 | this.setState({ 31 | error: error.errorMessage, 32 | loading: false, 33 | }); 34 | }); 35 | } 36 | 37 | renderChangePercent(percent) { 38 | if (percent > 0) { 39 | return {percent}% ↑ 40 | } else if (percent < 0) { 41 | return {percent}% ↓ 42 | } else { 43 | return {percent} 44 | } 45 | } 46 | 47 | render() { 48 | const { loading, error, currencies } = this.state; 49 | 50 | // render only loading component, if loading state is set to true 51 | if (loading) { 52 | return
53 | } 54 | 55 | // render only error message, if error occurred while fetching data 56 | if (error) { 57 | return
{error}
58 | } 59 | 60 | return ( 61 |
62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | {currencies.map((currency) => ( 73 | 74 | 78 | 81 | 84 | 87 | 88 | ))} 89 | 90 |
CryptocurrencyPriceMarket Cap24H Change
75 | {currency.rank} 76 | {currency.name} 77 | 79 | $ {currency.price} 80 | 82 | $ {currency.marketCap} 83 | 85 | {this.renderChangePercent(currency.percentChange24h)} 86 |
91 |
92 | ); 93 | } 94 | } 95 | 96 | export default List; 97 | -------------------------------------------------------------------------------- /stages/08/src/components/list/Table.css: -------------------------------------------------------------------------------- 1 | .Table-container { 2 | overflow-x: auto; /* Needed for table to be responsive */ 3 | } 4 | 5 | .Table { 6 | width: 100%; 7 | border-collapse: collapse; 8 | border-spacing: 0; 9 | } 10 | 11 | .Table-head { 12 | background-color: #0c2033; 13 | } 14 | 15 | .Table-head tr th { 16 | padding: 10px 20px; 17 | color: #9cb3c9; 18 | text-align: left; 19 | font-size: 14px; 20 | font-weight: 400; 21 | } 22 | 23 | .Table-body { 24 | text-align: left; 25 | background-color: #0f273d; 26 | } 27 | 28 | .Table-body tr td { 29 | padding: 24px 20px; 30 | border-bottom: 2px solid #0c2033; 31 | color: #fff; 32 | cursor: pointer; 33 | } 34 | 35 | .Table-rank { 36 | color: #9cb3c9; 37 | margin-right: 18px; 38 | font-size: 12px; 39 | } 40 | 41 | .Table-dollar { 42 | color: #9cb3c9; 43 | margin-right: 6px; 44 | } 45 | -------------------------------------------------------------------------------- /stages/08/src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API root url 3 | */ 4 | export const API_URL = 'https://api.udilia.com/coins/v1'; 5 | -------------------------------------------------------------------------------- /stages/08/src/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fetch response helper 3 | * 4 | * @param {object} response 5 | */ 6 | export const handleResponse = (response) => { 7 | return response.json().then(json => { 8 | return response.ok ? json : Promise.reject(json); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /stages/08/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Application colors 3 | 4 | text: #fff; 5 | text-secondary: #9cb3c9; 6 | 7 | error-red: #d64d96; 8 | 9 | green: #3cd483; 10 | 11 | indigo-light: #1f364d; 12 | indigo-normal: #0f273d; 13 | indigo-dark: #0c2033; 14 | 15 | blue-normal: #4997e5; 16 | blue-dark: #457cb2; 17 | */ 18 | 19 | /* Load Open Sans font from Google Fonts */ 20 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400'); 21 | 22 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 23 | button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 24 | 25 | body { 26 | background-color: #0c2033; 27 | font-family: 'Open Sans', sans-serif; 28 | color: #fff; 29 | letter-spacing: .5px; 30 | font-size: 16px; 31 | font-weight: 300; 32 | } 33 | 34 | .percent-raised { 35 | color: #3cd483; 36 | } 37 | 38 | .percent-fallen { 39 | color: #d64d96; 40 | } 41 | 42 | .loading-container { 43 | width: 100%; 44 | text-align: center; 45 | margin: 40px auto; 46 | } 47 | 48 | .error { 49 | width: 100%; 50 | margin: 40px 0; 51 | text-align: center; 52 | color: #d64d96; 53 | } 54 | 55 | /* Remove close icon from input on IE */ 56 | input::-ms-clear { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /stages/08/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Header from './components/common/Header'; 4 | import List from './components/list/List'; 5 | import './index.css'; 6 | 7 | const App = () => { 8 | return ( 9 |
10 |
11 | 12 | 13 |
14 | ); 15 | } 16 | 17 | ReactDOM.render( 18 | , 19 | document.getElementById('root') 20 | ); 21 | -------------------------------------------------------------------------------- /stages/09/src/components/common/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #0f273d; 7 | width: 100%; 8 | height: 80px; 9 | } 10 | 11 | .Header-logo { 12 | position: absolute; 13 | top: 30px; 14 | left: 20px; 15 | width: 90px; 16 | } 17 | 18 | @media (max-width: 700px) { 19 | .Header-logo { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stages/09/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from './logo.png'; 3 | import './Header.css'; 4 | 5 | const Header = () => { 6 | return ( 7 |
8 | logo 9 |
10 | ); 11 | } 12 | 13 | export default Header; 14 | -------------------------------------------------------------------------------- /stages/09/src/components/common/Loading.css: -------------------------------------------------------------------------------- 1 | .Loading { 2 | width: 28px; 3 | height: 28px; 4 | display: inline-block; 5 | border: 2px solid #fff; 6 | border-right-color: transparent; 7 | border-radius: 50%; 8 | animation: rotate 1s infinite linear; 9 | } 10 | 11 | @keyframes rotate { 12 | 0% { 13 | transform: rotate(0deg); 14 | } 15 | 16 | 100% { 17 | transform: rotate(360deg); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /stages/09/src/components/common/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Loading.css'; 3 | 4 | const Loading = () => { 5 | return
; 6 | } 7 | 8 | export default Loading; 9 | -------------------------------------------------------------------------------- /stages/09/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/09/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/09/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/09/src/components/common/search.png -------------------------------------------------------------------------------- /stages/09/src/components/list/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { handleResponse } from '../../helpers'; 3 | import { API_URL } from '../../config'; 4 | import Loading from '../common/Loading'; 5 | import Table from './Table'; 6 | import Pagination from './Pagination'; 7 | 8 | class List extends React.Component { 9 | constructor() { 10 | super(); 11 | 12 | this.state = { 13 | loading: false, 14 | currencies: [], 15 | error: null, 16 | totalPages: 0, 17 | page: 1, 18 | }; 19 | 20 | this.handlePaginationClick = this.handlePaginationClick.bind(this); 21 | } 22 | 23 | componentDidMount() { 24 | this.fetchCurrencies(); 25 | } 26 | 27 | fetchCurrencies() { 28 | this.setState({ loading: true }); 29 | 30 | const { page } = this.state; 31 | 32 | fetch(`${API_URL}/cryptocurrencies?page=${page}&perPage=20`) 33 | .then(handleResponse) 34 | .then((data) => { 35 | const { currencies, totalPages } = data; 36 | 37 | this.setState({ 38 | currencies, 39 | totalPages, 40 | loading: false, 41 | }); 42 | }) 43 | .catch((error) => { 44 | this.setState({ 45 | error: error.errorMessage, 46 | loading: false, 47 | }); 48 | }); 49 | } 50 | 51 | renderChangePercent(percent) { 52 | if (percent > 0) { 53 | return {percent}% ↑ 54 | } else if (percent < 0) { 55 | return {percent}% ↓ 56 | } else { 57 | return {percent} 58 | } 59 | } 60 | 61 | handlePaginationClick(direction) { 62 | let nextPage = this.state.page; 63 | 64 | // Increment nextPage if direction variable is next, otherwise decrement 65 | nextPage = direction === 'next' ? nextPage + 1 : nextPage - 1; 66 | 67 | this.setState({ page: nextPage }, () => { 68 | // call fetchCurrencies function inside setState's callback 69 | // because we have to make sure first page state is updated 70 | this.fetchCurrencies(); 71 | }); 72 | } 73 | 74 | render() { 75 | const { loading, error, currencies, page, totalPages } = this.state; 76 | 77 | // render only loading component, if loading state is set to true 78 | if (loading) { 79 | return
80 | } 81 | 82 | // render only error message, if error occurred while fetching data 83 | if (error) { 84 | return
{error}
85 | } 86 | 87 | return ( 88 |
89 | 93 | 94 | 99 | 100 | ); 101 | } 102 | } 103 | 104 | export default List; 105 | -------------------------------------------------------------------------------- /stages/09/src/components/list/Pagination.css: -------------------------------------------------------------------------------- 1 | .Pagination { 2 | margin: 50px auto; 3 | text-align: center; 4 | } 5 | 6 | .Pagination-button { 7 | text-align: center; 8 | border: none; 9 | border-radius: 16px; 10 | background-color: #4997e5; 11 | transition: background-color .2s; 12 | color: white; 13 | cursor: pointer; 14 | margin: 10px; 15 | width: 44px; 16 | height: 34px; 17 | } 18 | 19 | .Pagination-button:hover { 20 | background-color: #457cb2; 21 | } 22 | 23 | .Pagination-button:focus { 24 | outline: none; 25 | } 26 | 27 | .Pagination-button:disabled { 28 | background-color: #1f364d; 29 | cursor: not-allowed; 30 | } 31 | 32 | .Pagination-info { 33 | font-size: 12px; 34 | } 35 | -------------------------------------------------------------------------------- /stages/09/src/components/list/Pagination.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Pagination.css'; 4 | 5 | const Pagination = (props) => { 6 | const { page, totalPages, handlePaginationClick } = props; 7 | 8 | return ( 9 |
10 | 17 | 18 | 19 | page {page} of {totalPages} 20 | 21 | 22 | 29 |
30 | ); 31 | } 32 | 33 | Pagination.propTypes = { 34 | totalPages: PropTypes.number.isRequired, 35 | page: PropTypes.number.isRequired, 36 | handlePaginationClick: PropTypes.func.isRequired, 37 | }; 38 | 39 | export default Pagination; 40 | -------------------------------------------------------------------------------- /stages/09/src/components/list/Table.css: -------------------------------------------------------------------------------- 1 | .Table-container { 2 | overflow-x: auto; /* Needed for table to be responsive */ 3 | } 4 | 5 | .Table { 6 | width: 100%; 7 | border-collapse: collapse; 8 | border-spacing: 0; 9 | } 10 | 11 | .Table-head { 12 | background-color: #0c2033; 13 | } 14 | 15 | .Table-head tr th { 16 | padding: 10px 20px; 17 | color: #9cb3c9; 18 | text-align: left; 19 | font-size: 14px; 20 | font-weight: 400; 21 | } 22 | 23 | .Table-body { 24 | text-align: left; 25 | background-color: #0f273d; 26 | } 27 | 28 | .Table-body tr td { 29 | padding: 24px 20px; 30 | border-bottom: 2px solid #0c2033; 31 | color: #fff; 32 | cursor: pointer; 33 | } 34 | 35 | .Table-rank { 36 | color: #9cb3c9; 37 | margin-right: 18px; 38 | font-size: 12px; 39 | } 40 | 41 | .Table-dollar { 42 | color: #9cb3c9; 43 | margin-right: 6px; 44 | } 45 | -------------------------------------------------------------------------------- /stages/09/src/components/list/Table.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Table.css'; 4 | 5 | const Table = (props) => { 6 | const { currencies, renderChangePercent } = props; 7 | 8 | return ( 9 |
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {currencies.map((currency) => ( 21 | 22 | 26 | 30 | 34 | 37 | 38 | ))} 39 | 40 |
CryptocurrencyPriceMarket Cap24H Change
23 | {currency.rank} 24 | {currency.name} 25 | 27 | $ 28 | {currency.price} 29 | 31 | $ 32 | {currency.marketCap} 33 | 35 | {renderChangePercent(currency.percentChange24h)} 36 |
41 |
42 | ); 43 | } 44 | 45 | Table.propTypes = { 46 | currencies: PropTypes.array.isRequired, 47 | renderChangePercent: PropTypes.func.isRequired, 48 | }; 49 | 50 | export default Table; 51 | -------------------------------------------------------------------------------- /stages/09/src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API root url 3 | */ 4 | export const API_URL = 'https://api.udilia.com/coins/v1'; 5 | -------------------------------------------------------------------------------- /stages/09/src/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fetch response helper 3 | * 4 | * @param {object} response 5 | */ 6 | export const handleResponse = (response) => { 7 | return response.json().then(json => { 8 | return response.ok ? json : Promise.reject(json); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /stages/09/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Application colors 3 | 4 | text: #fff; 5 | text-secondary: #9cb3c9; 6 | 7 | error-red: #d64d96; 8 | 9 | green: #3cd483; 10 | 11 | indigo-light: #1f364d; 12 | indigo-normal: #0f273d; 13 | indigo-dark: #0c2033; 14 | 15 | blue-normal: #4997e5; 16 | blue-dark: #457cb2; 17 | */ 18 | 19 | /* Load Open Sans font from Google Fonts */ 20 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400'); 21 | 22 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 23 | button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 24 | 25 | body { 26 | background-color: #0c2033; 27 | font-family: 'Open Sans', sans-serif; 28 | color: #fff; 29 | letter-spacing: .5px; 30 | font-size: 16px; 31 | font-weight: 300; 32 | } 33 | 34 | .percent-raised { 35 | color: #3cd483; 36 | } 37 | 38 | .percent-fallen { 39 | color: #d64d96; 40 | } 41 | 42 | .loading-container { 43 | width: 100%; 44 | text-align: center; 45 | margin: 40px auto; 46 | } 47 | 48 | .error { 49 | width: 100%; 50 | margin: 40px 0; 51 | text-align: center; 52 | color: #d64d96; 53 | } 54 | 55 | /* Remove close icon from input on IE */ 56 | input::-ms-clear { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /stages/09/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Header from './components/common/Header'; 4 | import List from './components/list/List'; 5 | import './index.css'; 6 | 7 | const App = () => { 8 | return ( 9 |
10 |
11 | 12 | 13 |
14 | ); 15 | } 16 | 17 | ReactDOM.render( 18 | , 19 | document.getElementById('root') 20 | ); 21 | -------------------------------------------------------------------------------- /stages/10/src/components/common/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #0f273d; 7 | width: 100%; 8 | height: 80px; 9 | } 10 | 11 | .Header-logo { 12 | position: absolute; 13 | top: 30px; 14 | left: 20px; 15 | width: 90px; 16 | } 17 | 18 | @media (max-width: 700px) { 19 | .Header-logo { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stages/10/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from './logo.png'; 3 | import './Header.css'; 4 | 5 | const Header = () => { 6 | return ( 7 |
8 | logo 9 |
10 | ); 11 | } 12 | 13 | export default Header; 14 | -------------------------------------------------------------------------------- /stages/10/src/components/common/Loading.css: -------------------------------------------------------------------------------- 1 | .Loading { 2 | width: 28px; 3 | height: 28px; 4 | display: inline-block; 5 | border: 2px solid #fff; 6 | border-right-color: transparent; 7 | border-radius: 50%; 8 | animation: rotate 1s infinite linear; 9 | } 10 | 11 | @keyframes rotate { 12 | 0% { 13 | transform: rotate(0deg); 14 | } 15 | 16 | 100% { 17 | transform: rotate(360deg); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /stages/10/src/components/common/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Loading.css'; 3 | 4 | const Loading = () => { 5 | return
; 6 | } 7 | 8 | export default Loading; 9 | -------------------------------------------------------------------------------- /stages/10/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/10/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/10/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/10/src/components/common/search.png -------------------------------------------------------------------------------- /stages/10/src/components/detail/Detail.css: -------------------------------------------------------------------------------- 1 | .Detail { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | margin-top: 30px; 6 | margin-bottom: 40px; 7 | padding: 0 60px; 8 | } 9 | 10 | .Detail-heading { 11 | font-size: 24px; 12 | font-weight: 300; 13 | } 14 | 15 | .Detail-container { 16 | width: 100%; 17 | max-width: 400px; 18 | margin-top: 30px; 19 | padding: 40px 40px 0; 20 | border-radius: 4px; 21 | box-shadow: 0px 0px 40px 0px#1f364d; 22 | } 23 | 24 | .Detail-item { 25 | margin-bottom: 50px; 26 | } 27 | 28 | .Detail-value { 29 | border-radius: 20px; 30 | background-color: #1f364d; 31 | font-size: 14px; 32 | padding: 8px 12px; 33 | margin-left: 10px; 34 | } 35 | 36 | .Detail-title { 37 | display: block; 38 | color: #9cb3c9; 39 | font-size: 12px; 40 | font-weight: bold; 41 | margin-bottom: 10px; 42 | } 43 | 44 | .Detail-dollar { 45 | color: #9cb3c9; 46 | margin-right: 6px; 47 | } 48 | -------------------------------------------------------------------------------- /stages/10/src/components/detail/Detail.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Detail.css'; 3 | 4 | class Detail extends React.Component { 5 | render() { 6 | return ( 7 |
Detail
8 | ); 9 | } 10 | } 11 | 12 | export default Detail; 13 | -------------------------------------------------------------------------------- /stages/10/src/components/list/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { handleResponse } from '../../helpers'; 3 | import { API_URL } from '../../config'; 4 | import Loading from '../common/Loading'; 5 | import Table from './Table'; 6 | import Pagination from './Pagination'; 7 | 8 | class List extends React.Component { 9 | constructor() { 10 | super(); 11 | 12 | this.state = { 13 | loading: false, 14 | currencies: [], 15 | error: null, 16 | totalPages: 0, 17 | page: 1, 18 | }; 19 | 20 | this.handlePaginationClick = this.handlePaginationClick.bind(this); 21 | } 22 | 23 | componentDidMount() { 24 | this.fetchCurrencies(); 25 | } 26 | 27 | fetchCurrencies() { 28 | this.setState({ loading: true }); 29 | 30 | const { page } = this.state; 31 | 32 | fetch(`${API_URL}/cryptocurrencies?page=${page}&perPage=20`) 33 | .then(handleResponse) 34 | .then((data) => { 35 | const { currencies, totalPages } = data; 36 | 37 | this.setState({ 38 | currencies, 39 | totalPages, 40 | loading: false, 41 | }); 42 | }) 43 | .catch((error) => { 44 | this.setState({ 45 | error: error.errorMessage, 46 | loading: false, 47 | }); 48 | }); 49 | } 50 | 51 | renderChangePercent(percent) { 52 | if (percent > 0) { 53 | return {percent}% ↑ 54 | } else if (percent < 0) { 55 | return {percent}% ↓ 56 | } else { 57 | return {percent} 58 | } 59 | } 60 | 61 | handlePaginationClick(direction) { 62 | let nextPage = this.state.page; 63 | 64 | // Increment nextPage if direction variable is next, otherwise decrement 65 | nextPage = direction === 'next' ? nextPage + 1 : nextPage - 1; 66 | 67 | this.setState({ page: nextPage }, () => { 68 | // call fetchCurrencies function inside setState's callback 69 | // because we have to make sure first page state is updated 70 | this.fetchCurrencies(); 71 | }); 72 | } 73 | 74 | render() { 75 | const { loading, error, currencies, page, totalPages } = this.state; 76 | 77 | // render only loading component, if loading state is set to true 78 | if (loading) { 79 | return
80 | } 81 | 82 | // render only error message, if error occurred while fetching data 83 | if (error) { 84 | return
{error}
85 | } 86 | 87 | return ( 88 |
89 | 93 | 94 | 99 | 100 | ); 101 | } 102 | } 103 | 104 | export default List; 105 | -------------------------------------------------------------------------------- /stages/10/src/components/list/Pagination.css: -------------------------------------------------------------------------------- 1 | .Pagination { 2 | margin: 50px auto; 3 | text-align: center; 4 | } 5 | 6 | .Pagination-button { 7 | text-align: center; 8 | border: none; 9 | border-radius: 16px; 10 | background-color: #4997e5; 11 | transition: background-color .2s; 12 | color: white; 13 | cursor: pointer; 14 | margin: 10px; 15 | width: 44px; 16 | height: 34px; 17 | } 18 | 19 | .Pagination-button:hover { 20 | background-color: #457cb2; 21 | } 22 | 23 | .Pagination-button:focus { 24 | outline: none; 25 | } 26 | 27 | .Pagination-button:disabled { 28 | background-color: #1f364d; 29 | cursor: not-allowed; 30 | } 31 | 32 | .Pagination-info { 33 | font-size: 12px; 34 | } 35 | -------------------------------------------------------------------------------- /stages/10/src/components/list/Pagination.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Pagination.css'; 4 | 5 | const Pagination = (props) => { 6 | const { page, totalPages, handlePaginationClick } = props; 7 | 8 | return ( 9 |
10 | 17 | 18 | 19 | page {page} of {totalPages} 20 | 21 | 22 | 29 |
30 | ); 31 | } 32 | 33 | Pagination.propTypes = { 34 | totalPages: PropTypes.number.isRequired, 35 | page: PropTypes.number.isRequired, 36 | handlePaginationClick: PropTypes.func.isRequired, 37 | }; 38 | 39 | export default Pagination; 40 | -------------------------------------------------------------------------------- /stages/10/src/components/list/Table.css: -------------------------------------------------------------------------------- 1 | .Table-container { 2 | overflow-x: auto; /* Needed for table to be responsive */ 3 | } 4 | 5 | .Table { 6 | width: 100%; 7 | border-collapse: collapse; 8 | border-spacing: 0; 9 | } 10 | 11 | .Table-head { 12 | background-color: #0c2033; 13 | } 14 | 15 | .Table-head tr th { 16 | padding: 10px 20px; 17 | color: #9cb3c9; 18 | text-align: left; 19 | font-size: 14px; 20 | font-weight: 400; 21 | } 22 | 23 | .Table-body { 24 | text-align: left; 25 | background-color: #0f273d; 26 | } 27 | 28 | .Table-body tr td { 29 | padding: 24px 20px; 30 | border-bottom: 2px solid #0c2033; 31 | color: #fff; 32 | cursor: pointer; 33 | } 34 | 35 | .Table-rank { 36 | color: #9cb3c9; 37 | margin-right: 18px; 38 | font-size: 12px; 39 | } 40 | 41 | .Table-dollar { 42 | color: #9cb3c9; 43 | margin-right: 6px; 44 | } 45 | -------------------------------------------------------------------------------- /stages/10/src/components/list/Table.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import PropTypes from 'prop-types'; 4 | import './Table.css'; 5 | 6 | const Table = (props) => { 7 | const { currencies, renderChangePercent, history } = props; 8 | 9 | return ( 10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {currencies.map((currency) => ( 22 | history.push(`/currency/${currency.id}`)} 25 | > 26 | 30 | 34 | 38 | 41 | 42 | ))} 43 | 44 |
CryptocurrencyPriceMarket Cap24H Change
27 | {currency.rank} 28 | {currency.name} 29 | 31 | $ 32 | {currency.price} 33 | 35 | $ 36 | {currency.marketCap} 37 | 39 | {renderChangePercent(currency.percentChange24h)} 40 |
45 |
46 | ); 47 | } 48 | 49 | Table.propTypes = { 50 | currencies: PropTypes.array.isRequired, 51 | renderChangePercent: PropTypes.func.isRequired, 52 | history: PropTypes.object.isRequired, 53 | }; 54 | 55 | export default withRouter(Table); 56 | -------------------------------------------------------------------------------- /stages/10/src/components/notfound/NotFound.css: -------------------------------------------------------------------------------- 1 | .NotFound { 2 | width: 100%; 3 | text-align: center; 4 | margin-top: 60px; 5 | } 6 | 7 | .NotFound-title { 8 | font-weight: 400; 9 | color: #9cb3c9; 10 | } 11 | 12 | .NotFound-link { 13 | display: inline-block; 14 | margin-top: 40px; 15 | color: #fff; 16 | text-decoration: none; 17 | border: 1px solid #9cb3c9; 18 | border-radius: 4px; 19 | padding: 18px; 20 | transition: border .2s; 21 | } 22 | 23 | .NotFound-link:hover { 24 | border: 1px solid #fff; 25 | } 26 | -------------------------------------------------------------------------------- /stages/10/src/components/notfound/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import './NotFound.css'; 4 | 5 | const NotFound = () => { 6 | return ( 7 |
8 |

Oops! Page not found

9 | 10 | Go to homepage 11 |
12 | ); 13 | } 14 | 15 | export default NotFound; 16 | -------------------------------------------------------------------------------- /stages/10/src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API root url 3 | */ 4 | export const API_URL = 'https://api.udilia.com/coins/v1'; 5 | -------------------------------------------------------------------------------- /stages/10/src/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fetch response helper 3 | * 4 | * @param {object} response 5 | */ 6 | export const handleResponse = (response) => { 7 | return response.json().then(json => { 8 | return response.ok ? json : Promise.reject(json); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /stages/10/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Application colors 3 | 4 | text: #fff; 5 | text-secondary: #9cb3c9; 6 | 7 | error-red: #d64d96; 8 | 9 | green: #3cd483; 10 | 11 | indigo-light: #1f364d; 12 | indigo-normal: #0f273d; 13 | indigo-dark: #0c2033; 14 | 15 | blue-normal: #4997e5; 16 | blue-dark: #457cb2; 17 | */ 18 | 19 | /* Load Open Sans font from Google Fonts */ 20 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400'); 21 | 22 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 23 | button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 24 | 25 | body { 26 | background-color: #0c2033; 27 | font-family: 'Open Sans', sans-serif; 28 | color: #fff; 29 | letter-spacing: .5px; 30 | font-size: 16px; 31 | font-weight: 300; 32 | } 33 | 34 | .percent-raised { 35 | color: #3cd483; 36 | } 37 | 38 | .percent-fallen { 39 | color: #d64d96; 40 | } 41 | 42 | .loading-container { 43 | width: 100%; 44 | text-align: center; 45 | margin: 40px auto; 46 | } 47 | 48 | .error { 49 | width: 100%; 50 | margin: 40px 0; 51 | text-align: center; 52 | color: #d64d96; 53 | } 54 | 55 | /* Remove close icon from input on IE */ 56 | input::-ms-clear { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /stages/10/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 4 | import Header from './components/common/Header'; 5 | import List from './components/list/List'; 6 | import NotFound from './components/notfound/NotFound'; 7 | import Detail from './components/detail/Detail'; 8 | import './index.css'; 9 | 10 | const App = () => { 11 | return ( 12 | 13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | ); 24 | } 25 | 26 | ReactDOM.render( 27 | , 28 | document.getElementById('root') 29 | ); 30 | -------------------------------------------------------------------------------- /stages/11/src/components/common/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #0f273d; 7 | width: 100%; 8 | height: 80px; 9 | } 10 | 11 | .Header-logo { 12 | position: absolute; 13 | top: 30px; 14 | left: 20px; 15 | width: 90px; 16 | } 17 | 18 | @media (max-width: 700px) { 19 | .Header-logo { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stages/11/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import logo from './logo.png'; 4 | import './Header.css'; 5 | 6 | const Header = () => { 7 | return ( 8 |
9 | 10 | logo 11 | 12 |
13 | ); 14 | } 15 | 16 | export default Header; 17 | -------------------------------------------------------------------------------- /stages/11/src/components/common/Loading.css: -------------------------------------------------------------------------------- 1 | .Loading { 2 | width: 28px; 3 | height: 28px; 4 | display: inline-block; 5 | border: 2px solid #fff; 6 | border-right-color: transparent; 7 | border-radius: 50%; 8 | animation: rotate 1s infinite linear; 9 | } 10 | 11 | @keyframes rotate { 12 | 0% { 13 | transform: rotate(0deg); 14 | } 15 | 16 | 100% { 17 | transform: rotate(360deg); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /stages/11/src/components/common/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Loading.css'; 3 | 4 | const Loading = () => { 5 | return
; 6 | } 7 | 8 | export default Loading; 9 | -------------------------------------------------------------------------------- /stages/11/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/11/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/11/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/11/src/components/common/search.png -------------------------------------------------------------------------------- /stages/11/src/components/detail/Detail.css: -------------------------------------------------------------------------------- 1 | .Detail { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | margin-top: 30px; 6 | margin-bottom: 40px; 7 | padding: 0 60px; 8 | } 9 | 10 | .Detail-heading { 11 | font-size: 24px; 12 | font-weight: 300; 13 | } 14 | 15 | .Detail-container { 16 | width: 100%; 17 | max-width: 400px; 18 | margin-top: 30px; 19 | padding: 40px 40px 0; 20 | border-radius: 4px; 21 | box-shadow: 0px 0px 40px 0px#1f364d; 22 | } 23 | 24 | .Detail-item { 25 | margin-bottom: 50px; 26 | } 27 | 28 | .Detail-value { 29 | border-radius: 20px; 30 | background-color: #1f364d; 31 | font-size: 14px; 32 | padding: 8px 12px; 33 | margin-left: 10px; 34 | } 35 | 36 | .Detail-title { 37 | display: block; 38 | color: #9cb3c9; 39 | font-size: 12px; 40 | font-weight: bold; 41 | margin-bottom: 10px; 42 | } 43 | 44 | .Detail-dollar { 45 | color: #9cb3c9; 46 | margin-right: 6px; 47 | } 48 | -------------------------------------------------------------------------------- /stages/11/src/components/detail/Detail.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { API_URL } from '../../config'; 3 | import Loading from '../common/Loading'; 4 | import { handleResponse, renderChangePercent } from '../../helpers'; 5 | import './Detail.css'; 6 | 7 | class Detail extends React.Component { 8 | constructor() { 9 | super(); 10 | 11 | this.state = { 12 | currency: {}, 13 | loading: false, 14 | error: null, 15 | }; 16 | } 17 | 18 | componentDidMount() { 19 | const currencyId = this.props.match.params.id; 20 | 21 | this.setState({ loading: true }); 22 | 23 | fetch(`${API_URL}/cryptocurrencies/${currencyId}`) 24 | .then(handleResponse) 25 | .then((currency) => { 26 | this.setState({ 27 | loading: false, 28 | error: null, 29 | currency, 30 | }); 31 | }) 32 | .catch((error) => { 33 | this.setState({ 34 | loading: false, 35 | error: error.errorMessage, 36 | }); 37 | }); 38 | } 39 | 40 | render() { 41 | const { loading, error, currency } = this.state; 42 | 43 | // Render only loading component if loading state is set to true 44 | if (loading) { 45 | return
46 | } 47 | 48 | // Render only error message, if error occurred while fetching data 49 | if (error) { 50 | return
{error}
51 | } 52 | 53 | return ( 54 |
55 |

56 | {currency.name} ({currency.symbol}) 57 |

58 | 59 |
60 |
61 | Price $ {currency.price} 62 |
63 |
64 | Rank {currency.rank} 65 |
66 |
67 | 24H Change 68 | {renderChangePercent(currency.percentChange24h)} 69 |
70 |
71 | Market cap 72 | $ 73 | {currency.marketCap} 74 |
75 |
76 | 24H Volume 77 | $ 78 | {currency.volume24h} 79 |
80 |
81 | Total supply 82 | {currency.totalSupply} 83 |
84 |
85 |
86 | ); 87 | } 88 | } 89 | 90 | export default Detail; 91 | -------------------------------------------------------------------------------- /stages/11/src/components/list/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { handleResponse } from '../../helpers'; 3 | import { API_URL } from '../../config'; 4 | import Loading from '../common/Loading'; 5 | import Table from './Table'; 6 | import Pagination from './Pagination'; 7 | 8 | class List extends React.Component { 9 | constructor() { 10 | super(); 11 | 12 | this.state = { 13 | loading: false, 14 | currencies: [], 15 | error: null, 16 | totalPages: 0, 17 | page: 1, 18 | }; 19 | 20 | this.handlePaginationClick = this.handlePaginationClick.bind(this); 21 | } 22 | 23 | componentDidMount() { 24 | this.fetchCurrencies(); 25 | } 26 | 27 | fetchCurrencies() { 28 | this.setState({ loading: true }); 29 | 30 | const { page } = this.state; 31 | 32 | fetch(`${API_URL}/cryptocurrencies?page=${page}&perPage=20`) 33 | .then(handleResponse) 34 | .then((data) => { 35 | const { currencies, totalPages } = data; 36 | 37 | this.setState({ 38 | currencies, 39 | totalPages, 40 | loading: false, 41 | }); 42 | }) 43 | .catch((error) => { 44 | this.setState({ 45 | error: error.errorMessage, 46 | loading: false, 47 | }); 48 | }); 49 | } 50 | 51 | handlePaginationClick(direction) { 52 | let nextPage = this.state.page; 53 | 54 | // Increment nextPage if direction variable is next, otherwise decrement 55 | nextPage = direction === 'next' ? nextPage + 1 : nextPage - 1; 56 | 57 | this.setState({ page: nextPage }, () => { 58 | // call fetchCurrencies function inside setState's callback 59 | // because we have to make sure first page state is updated 60 | this.fetchCurrencies(); 61 | }); 62 | } 63 | 64 | render() { 65 | const { loading, error, currencies, page, totalPages } = this.state; 66 | 67 | // render only loading component, if loading state is set to true 68 | if (loading) { 69 | return
70 | } 71 | 72 | // render only error message, if error occurred while fetching data 73 | if (error) { 74 | return
{error}
75 | } 76 | 77 | return ( 78 |
79 | 82 | 83 | 88 | 89 | ); 90 | } 91 | } 92 | 93 | export default List; 94 | -------------------------------------------------------------------------------- /stages/11/src/components/list/Pagination.css: -------------------------------------------------------------------------------- 1 | .Pagination { 2 | margin: 50px auto; 3 | text-align: center; 4 | } 5 | 6 | .Pagination-button { 7 | text-align: center; 8 | border: none; 9 | border-radius: 16px; 10 | background-color: #4997e5; 11 | transition: background-color .2s; 12 | color: white; 13 | cursor: pointer; 14 | margin: 10px; 15 | width: 44px; 16 | height: 34px; 17 | } 18 | 19 | .Pagination-button:hover { 20 | background-color: #457cb2; 21 | } 22 | 23 | .Pagination-button:focus { 24 | outline: none; 25 | } 26 | 27 | .Pagination-button:disabled { 28 | background-color: #1f364d; 29 | cursor: not-allowed; 30 | } 31 | 32 | .Pagination-info { 33 | font-size: 12px; 34 | } 35 | -------------------------------------------------------------------------------- /stages/11/src/components/list/Pagination.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Pagination.css'; 4 | 5 | const Pagination = (props) => { 6 | const { page, totalPages, handlePaginationClick } = props; 7 | 8 | return ( 9 |
10 | 17 | 18 | 19 | page {page} of {totalPages} 20 | 21 | 22 | 29 |
30 | ); 31 | } 32 | 33 | Pagination.propTypes = { 34 | totalPages: PropTypes.number.isRequired, 35 | page: PropTypes.number.isRequired, 36 | handlePaginationClick: PropTypes.func.isRequired, 37 | }; 38 | 39 | export default Pagination; 40 | -------------------------------------------------------------------------------- /stages/11/src/components/list/Table.css: -------------------------------------------------------------------------------- 1 | .Table-container { 2 | overflow-x: auto; /* Needed for table to be responsive */ 3 | } 4 | 5 | .Table { 6 | width: 100%; 7 | border-collapse: collapse; 8 | border-spacing: 0; 9 | } 10 | 11 | .Table-head { 12 | background-color: #0c2033; 13 | } 14 | 15 | .Table-head tr th { 16 | padding: 10px 20px; 17 | color: #9cb3c9; 18 | text-align: left; 19 | font-size: 14px; 20 | font-weight: 400; 21 | } 22 | 23 | .Table-body { 24 | text-align: left; 25 | background-color: #0f273d; 26 | } 27 | 28 | .Table-body tr td { 29 | padding: 24px 20px; 30 | border-bottom: 2px solid #0c2033; 31 | color: #fff; 32 | cursor: pointer; 33 | } 34 | 35 | .Table-rank { 36 | color: #9cb3c9; 37 | margin-right: 18px; 38 | font-size: 12px; 39 | } 40 | 41 | .Table-dollar { 42 | color: #9cb3c9; 43 | margin-right: 6px; 44 | } 45 | -------------------------------------------------------------------------------- /stages/11/src/components/list/Table.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import { renderChangePercent } from '../../helpers'; 4 | import PropTypes from 'prop-types'; 5 | import './Table.css'; 6 | 7 | const Table = (props) => { 8 | const { currencies, history } = props; 9 | 10 | return ( 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {currencies.map((currency) => ( 23 | history.push(`/currency/${currency.id}`)} 26 | > 27 | 31 | 35 | 39 | 42 | 43 | ))} 44 | 45 |
CryptocurrencyPriceMarket Cap24H Change
28 | {currency.rank} 29 | {currency.name} 30 | 32 | $ 33 | {currency.price} 34 | 36 | $ 37 | {currency.marketCap} 38 | 40 | {renderChangePercent(currency.percentChange24h)} 41 |
46 |
47 | ); 48 | } 49 | 50 | Table.propTypes = { 51 | currencies: PropTypes.array.isRequired, 52 | history: PropTypes.object.isRequired, 53 | }; 54 | 55 | export default withRouter(Table); 56 | -------------------------------------------------------------------------------- /stages/11/src/components/notfound/NotFound.css: -------------------------------------------------------------------------------- 1 | .NotFound { 2 | width: 100%; 3 | text-align: center; 4 | margin-top: 60px; 5 | } 6 | 7 | .NotFound-title { 8 | font-weight: 400; 9 | color: #9cb3c9; 10 | } 11 | 12 | .NotFound-link { 13 | display: inline-block; 14 | margin-top: 40px; 15 | color: #fff; 16 | text-decoration: none; 17 | border: 1px solid #9cb3c9; 18 | border-radius: 4px; 19 | padding: 18px; 20 | transition: border .2s; 21 | } 22 | 23 | .NotFound-link:hover { 24 | border: 1px solid #fff; 25 | } 26 | -------------------------------------------------------------------------------- /stages/11/src/components/notfound/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import './NotFound.css'; 4 | 5 | const NotFound = () => { 6 | return ( 7 |
8 |

Oops! Page not found

9 | 10 | Go to homepage 11 |
12 | ); 13 | } 14 | 15 | export default NotFound; 16 | -------------------------------------------------------------------------------- /stages/11/src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API root url 3 | */ 4 | export const API_URL = 'https://api.udilia.com/coins/v1'; 5 | -------------------------------------------------------------------------------- /stages/11/src/helpers.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * Fetch response helper 5 | * 6 | * @param {object} response 7 | */ 8 | export const handleResponse = (response) => { 9 | return response.json().then(json => { 10 | return response.ok ? json : Promise.reject(json); 11 | }); 12 | } 13 | 14 | /** 15 | * Render change percent helper 16 | * 17 | * @param {string} percent 18 | */ 19 | export const renderChangePercent = (percent) => { 20 | if (percent > 0) { 21 | return {percent}% ↑ 22 | } else if (percent < 0) { 23 | return {percent}% ↓ 24 | } else { 25 | return {percent} 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /stages/11/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Application colors 3 | 4 | text: #fff; 5 | text-secondary: #9cb3c9; 6 | 7 | error-red: #d64d96; 8 | 9 | green: #3cd483; 10 | 11 | indigo-light: #1f364d; 12 | indigo-normal: #0f273d; 13 | indigo-dark: #0c2033; 14 | 15 | blue-normal: #4997e5; 16 | blue-dark: #457cb2; 17 | */ 18 | 19 | /* Load Open Sans font from Google Fonts */ 20 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400'); 21 | 22 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 23 | button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 24 | 25 | body { 26 | background-color: #0c2033; 27 | font-family: 'Open Sans', sans-serif; 28 | color: #fff; 29 | letter-spacing: .5px; 30 | font-size: 16px; 31 | font-weight: 300; 32 | } 33 | 34 | .percent-raised { 35 | color: #3cd483; 36 | } 37 | 38 | .percent-fallen { 39 | color: #d64d96; 40 | } 41 | 42 | .loading-container { 43 | width: 100%; 44 | text-align: center; 45 | margin: 40px auto; 46 | } 47 | 48 | .error { 49 | width: 100%; 50 | margin: 40px 0; 51 | text-align: center; 52 | color: #d64d96; 53 | } 54 | 55 | /* Remove close icon from input on IE */ 56 | input::-ms-clear { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /stages/11/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 4 | import Header from './components/common/Header'; 5 | import List from './components/list/List'; 6 | import NotFound from './components/notfound/NotFound'; 7 | import Detail from './components/detail/Detail'; 8 | import './index.css'; 9 | 10 | const App = () => { 11 | return ( 12 | 13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | ); 24 | } 25 | 26 | ReactDOM.render( 27 | , 28 | document.getElementById('root') 29 | ); 30 | -------------------------------------------------------------------------------- /stages/12/src/components/common/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #0f273d; 7 | width: 100%; 8 | height: 80px; 9 | } 10 | 11 | .Header-logo { 12 | position: absolute; 13 | top: 30px; 14 | left: 20px; 15 | width: 90px; 16 | } 17 | 18 | @media (max-width: 700px) { 19 | .Header-logo { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stages/12/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import Search from './Search'; 4 | import logo from './logo.png'; 5 | import './Header.css'; 6 | 7 | const Header = () => { 8 | return ( 9 |
10 | 11 | logo 12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | 19 | export default Header; 20 | -------------------------------------------------------------------------------- /stages/12/src/components/common/Loading.css: -------------------------------------------------------------------------------- 1 | .Loading { 2 | display: inline-block; 3 | border: 2px solid #fff; 4 | border-right-color: transparent; 5 | border-radius: 50%; 6 | animation: rotate 1s infinite linear; 7 | } 8 | 9 | @keyframes rotate { 10 | 0% { 11 | transform: rotate(0deg); 12 | } 13 | 14 | 100% { 15 | transform: rotate(360deg); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /stages/12/src/components/common/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Loading.css'; 4 | 5 | const Loading = (props) => { 6 | const { width, height } = props; 7 | 8 | return
; 9 | } 10 | 11 | Loading.defaultProps = { 12 | width: '28px', 13 | height: '28px', 14 | }; 15 | 16 | Loading.propTypes = { 17 | width: PropTypes.string, 18 | height: PropTypes.string, 19 | }; 20 | 21 | export default Loading; 22 | -------------------------------------------------------------------------------- /stages/12/src/components/common/Search.css: -------------------------------------------------------------------------------- 1 | .Search { 2 | position: relative; 3 | width: 30%; 4 | height: 35px; 5 | margin: 0 auto; 6 | padding: 0 20px; 7 | } 8 | 9 | @media (max-width: 700px) { 10 | .Search { 11 | width: 100%; 12 | } 13 | } 14 | 15 | .Search-icon { 16 | z-index: 1; 17 | position: absolute; 18 | top: 9px; 19 | left: 28px; 20 | background-image: url('./search.png'); 21 | background-repeat: no-repeat; 22 | background-position: center; 23 | background-size: cover; 24 | width: 18px; 25 | height: 18px; 26 | } 27 | 28 | .Search-input { 29 | box-sizing: border-box; 30 | background-color: #1f364d; 31 | border-radius: 4px; 32 | border: 0; 33 | padding-left: 35px; 34 | color: white; 35 | opacity: .8; 36 | transition: opacity .2s; 37 | width: 100%; 38 | height: 35px; 39 | } 40 | 41 | .Search-input:focus { 42 | outline: none; 43 | opacity: 1; 44 | } 45 | 46 | .Search ::placeholder { 47 | color: #9cb3c9; 48 | opacity: 1; 49 | } 50 | 51 | .Search-loading { 52 | position: absolute; 53 | top: 9px; 54 | right: 28px; 55 | } 56 | 57 | .Search-result-container { 58 | position: relative; 59 | width: 100%; 60 | max-height: 299px; 61 | overflow-y: auto; 62 | background-color: #0f273d; 63 | border: 1px solid #0c2033; 64 | border-radius: 4px; 65 | box-shadow: 0px 0px 40px 0px#1f364d; 66 | margin-top: 10px; 67 | } 68 | 69 | .Search-result { 70 | color: #9cb3c9; 71 | padding: 15px 0 15px 35px; 72 | border-bottom: 2px solid #0c2033; 73 | cursor: pointer; 74 | } 75 | 76 | .Search-result:hover { 77 | color: #fff; 78 | } 79 | 80 | .Search-no-result { 81 | color: #9cb3c9; 82 | padding: 15px 0 15px 35px; 83 | border-bottom: 1px solid #0f273d; 84 | } 85 | -------------------------------------------------------------------------------- /stages/12/src/components/common/Search.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Loading from './Loading'; 3 | import { API_URL } from '../../config'; 4 | import { handleResponse } from '../../helpers'; 5 | import './Search.css'; 6 | 7 | class Search extends React.Component { 8 | constructor() { 9 | super(); 10 | 11 | this.state = { 12 | searchQuery: '', 13 | loading: false, 14 | }; 15 | 16 | this.handleChange = this.handleChange.bind(this); 17 | } 18 | 19 | handleChange(event) { 20 | const searchQuery = event.target.value; 21 | 22 | this.setState({ searchQuery }); 23 | 24 | // If searchQuery isn't present, don't send request to server 25 | if (!searchQuery) { 26 | return ''; 27 | } 28 | 29 | this.setState({ loading: true }); 30 | 31 | fetch(`${API_URL}/autocomplete?searchQuery=${searchQuery}`) 32 | .then(handleResponse) 33 | .then((result) => { 34 | console.log(result); 35 | 36 | this.setState({ loading: false }); 37 | }); 38 | } 39 | 40 | render() { 41 | const { loading } = this.state; 42 | 43 | return ( 44 |
45 | 46 | 47 | 53 | 54 | {loading && 55 |
56 | 60 |
} 61 |
62 | ); 63 | } 64 | } 65 | 66 | export default Search; 67 | -------------------------------------------------------------------------------- /stages/12/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/12/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/12/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/12/src/components/common/search.png -------------------------------------------------------------------------------- /stages/12/src/components/detail/Detail.css: -------------------------------------------------------------------------------- 1 | .Detail { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | margin-top: 30px; 6 | margin-bottom: 40px; 7 | padding: 0 60px; 8 | } 9 | 10 | .Detail-heading { 11 | font-size: 24px; 12 | font-weight: 300; 13 | } 14 | 15 | .Detail-container { 16 | width: 100%; 17 | max-width: 400px; 18 | margin-top: 30px; 19 | padding: 40px 40px 0; 20 | border-radius: 4px; 21 | box-shadow: 0px 0px 40px 0px#1f364d; 22 | } 23 | 24 | .Detail-item { 25 | margin-bottom: 50px; 26 | } 27 | 28 | .Detail-value { 29 | border-radius: 20px; 30 | background-color: #1f364d; 31 | font-size: 14px; 32 | padding: 8px 12px; 33 | margin-left: 10px; 34 | } 35 | 36 | .Detail-title { 37 | display: block; 38 | color: #9cb3c9; 39 | font-size: 12px; 40 | font-weight: bold; 41 | margin-bottom: 10px; 42 | } 43 | 44 | .Detail-dollar { 45 | color: #9cb3c9; 46 | margin-right: 6px; 47 | } 48 | -------------------------------------------------------------------------------- /stages/12/src/components/detail/Detail.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { API_URL } from '../../config'; 3 | import Loading from '../common/Loading'; 4 | import { handleResponse, renderChangePercent } from '../../helpers'; 5 | import './Detail.css'; 6 | 7 | class Detail extends React.Component { 8 | constructor() { 9 | super(); 10 | 11 | this.state = { 12 | currency: {}, 13 | loading: false, 14 | error: null, 15 | }; 16 | } 17 | 18 | componentDidMount() { 19 | const currencyId = this.props.match.params.id; 20 | 21 | this.setState({ loading: true }); 22 | 23 | fetch(`${API_URL}/cryptocurrencies/${currencyId}`) 24 | .then(handleResponse) 25 | .then((currency) => { 26 | this.setState({ 27 | loading: false, 28 | error: null, 29 | currency, 30 | }); 31 | }) 32 | .catch((error) => { 33 | this.setState({ 34 | loading: false, 35 | error: error.errorMessage, 36 | }); 37 | }); 38 | } 39 | 40 | render() { 41 | const { loading, error, currency } = this.state; 42 | 43 | // Render only loading component if loading state is set to true 44 | if (loading) { 45 | return
46 | } 47 | 48 | // Render only error message, if error occurred while fetching data 49 | if (error) { 50 | return
{error}
51 | } 52 | 53 | return ( 54 |
55 |

56 | {currency.name} ({currency.symbol}) 57 |

58 | 59 |
60 |
61 | Price $ {currency.price} 62 |
63 |
64 | Rank {currency.rank} 65 |
66 |
67 | 24H Change 68 | {renderChangePercent(currency.percentChange24h)} 69 |
70 |
71 | Market cap 72 | $ 73 | {currency.marketCap} 74 |
75 |
76 | 24H Volume 77 | $ 78 | {currency.volume24h} 79 |
80 |
81 | Total supply 82 | {currency.totalSupply} 83 |
84 |
85 |
86 | ); 87 | } 88 | } 89 | 90 | export default Detail; 91 | -------------------------------------------------------------------------------- /stages/12/src/components/list/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { handleResponse } from '../../helpers'; 3 | import { API_URL } from '../../config'; 4 | import Loading from '../common/Loading'; 5 | import Table from './Table'; 6 | import Pagination from './Pagination'; 7 | 8 | class List extends React.Component { 9 | constructor() { 10 | super(); 11 | 12 | this.state = { 13 | loading: false, 14 | currencies: [], 15 | error: null, 16 | totalPages: 0, 17 | page: 1, 18 | }; 19 | 20 | this.handlePaginationClick = this.handlePaginationClick.bind(this); 21 | } 22 | 23 | componentDidMount() { 24 | this.fetchCurrencies(); 25 | } 26 | 27 | fetchCurrencies() { 28 | this.setState({ loading: true }); 29 | 30 | const { page } = this.state; 31 | 32 | fetch(`${API_URL}/cryptocurrencies?page=${page}&perPage=20`) 33 | .then(handleResponse) 34 | .then((data) => { 35 | const { currencies, totalPages } = data; 36 | 37 | this.setState({ 38 | currencies, 39 | totalPages, 40 | loading: false, 41 | }); 42 | }) 43 | .catch((error) => { 44 | this.setState({ 45 | error: error.errorMessage, 46 | loading: false, 47 | }); 48 | }); 49 | } 50 | 51 | handlePaginationClick(direction) { 52 | let nextPage = this.state.page; 53 | 54 | // Increment nextPage if direction variable is next, otherwise decrement 55 | nextPage = direction === 'next' ? nextPage + 1 : nextPage - 1; 56 | 57 | this.setState({ page: nextPage }, () => { 58 | // call fetchCurrencies function inside setState's callback 59 | // because we have to make sure first page state is updated 60 | this.fetchCurrencies(); 61 | }); 62 | } 63 | 64 | render() { 65 | const { loading, error, currencies, page, totalPages } = this.state; 66 | 67 | // render only loading component, if loading state is set to true 68 | if (loading) { 69 | return
70 | } 71 | 72 | // render only error message, if error occurred while fetching data 73 | if (error) { 74 | return
{error}
75 | } 76 | 77 | return ( 78 |
79 | 82 | 83 | 88 | 89 | ); 90 | } 91 | } 92 | 93 | export default List; 94 | -------------------------------------------------------------------------------- /stages/12/src/components/list/Pagination.css: -------------------------------------------------------------------------------- 1 | .Pagination { 2 | margin: 50px auto; 3 | text-align: center; 4 | } 5 | 6 | .Pagination-button { 7 | text-align: center; 8 | border: none; 9 | border-radius: 16px; 10 | background-color: #4997e5; 11 | transition: background-color .2s; 12 | color: white; 13 | cursor: pointer; 14 | margin: 10px; 15 | width: 44px; 16 | height: 34px; 17 | } 18 | 19 | .Pagination-button:hover { 20 | background-color: #457cb2; 21 | } 22 | 23 | .Pagination-button:focus { 24 | outline: none; 25 | } 26 | 27 | .Pagination-button:disabled { 28 | background-color: #1f364d; 29 | cursor: not-allowed; 30 | } 31 | 32 | .Pagination-info { 33 | font-size: 12px; 34 | } 35 | -------------------------------------------------------------------------------- /stages/12/src/components/list/Pagination.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Pagination.css'; 4 | 5 | const Pagination = (props) => { 6 | const { page, totalPages, handlePaginationClick } = props; 7 | 8 | return ( 9 |
10 | 17 | 18 | 19 | page {page} of {totalPages} 20 | 21 | 22 | 29 |
30 | ); 31 | } 32 | 33 | Pagination.propTypes = { 34 | totalPages: PropTypes.number.isRequired, 35 | page: PropTypes.number.isRequired, 36 | handlePaginationClick: PropTypes.func.isRequired, 37 | }; 38 | 39 | export default Pagination; 40 | -------------------------------------------------------------------------------- /stages/12/src/components/list/Table.css: -------------------------------------------------------------------------------- 1 | .Table-container { 2 | overflow-x: auto; /* Needed for table to be responsive */ 3 | } 4 | 5 | .Table { 6 | width: 100%; 7 | border-collapse: collapse; 8 | border-spacing: 0; 9 | } 10 | 11 | .Table-head { 12 | background-color: #0c2033; 13 | } 14 | 15 | .Table-head tr th { 16 | padding: 10px 20px; 17 | color: #9cb3c9; 18 | text-align: left; 19 | font-size: 14px; 20 | font-weight: 400; 21 | } 22 | 23 | .Table-body { 24 | text-align: left; 25 | background-color: #0f273d; 26 | } 27 | 28 | .Table-body tr td { 29 | padding: 24px 20px; 30 | border-bottom: 2px solid #0c2033; 31 | color: #fff; 32 | cursor: pointer; 33 | } 34 | 35 | .Table-rank { 36 | color: #9cb3c9; 37 | margin-right: 18px; 38 | font-size: 12px; 39 | } 40 | 41 | .Table-dollar { 42 | color: #9cb3c9; 43 | margin-right: 6px; 44 | } 45 | -------------------------------------------------------------------------------- /stages/12/src/components/list/Table.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import { renderChangePercent } from '../../helpers'; 4 | import PropTypes from 'prop-types'; 5 | import './Table.css'; 6 | 7 | const Table = (props) => { 8 | const { currencies, history } = props; 9 | 10 | return ( 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {currencies.map((currency) => ( 23 | history.push(`/currency/${currency.id}`)} 26 | > 27 | 31 | 35 | 39 | 42 | 43 | ))} 44 | 45 |
CryptocurrencyPriceMarket Cap24H Change
28 | {currency.rank} 29 | {currency.name} 30 | 32 | $ 33 | {currency.price} 34 | 36 | $ 37 | {currency.marketCap} 38 | 40 | {renderChangePercent(currency.percentChange24h)} 41 |
46 |
47 | ); 48 | } 49 | 50 | Table.propTypes = { 51 | currencies: PropTypes.array.isRequired, 52 | history: PropTypes.object.isRequired, 53 | }; 54 | 55 | export default withRouter(Table); 56 | -------------------------------------------------------------------------------- /stages/12/src/components/notfound/NotFound.css: -------------------------------------------------------------------------------- 1 | .NotFound { 2 | width: 100%; 3 | text-align: center; 4 | margin-top: 60px; 5 | } 6 | 7 | .NotFound-title { 8 | font-weight: 400; 9 | color: #9cb3c9; 10 | } 11 | 12 | .NotFound-link { 13 | display: inline-block; 14 | margin-top: 40px; 15 | color: #fff; 16 | text-decoration: none; 17 | border: 1px solid #9cb3c9; 18 | border-radius: 4px; 19 | padding: 18px; 20 | transition: border .2s; 21 | } 22 | 23 | .NotFound-link:hover { 24 | border: 1px solid #fff; 25 | } 26 | -------------------------------------------------------------------------------- /stages/12/src/components/notfound/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import './NotFound.css'; 4 | 5 | const NotFound = () => { 6 | return ( 7 |
8 |

Oops! Page not found

9 | 10 | Go to homepage 11 |
12 | ); 13 | } 14 | 15 | export default NotFound; 16 | -------------------------------------------------------------------------------- /stages/12/src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API root url 3 | */ 4 | export const API_URL = 'https://api.udilia.com/coins/v1'; 5 | -------------------------------------------------------------------------------- /stages/12/src/helpers.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * Fetch response helper 5 | * 6 | * @param {object} response 7 | */ 8 | export const handleResponse = (response) => { 9 | return response.json().then(json => { 10 | return response.ok ? json : Promise.reject(json); 11 | }); 12 | } 13 | 14 | /** 15 | * Render change percent helper 16 | * 17 | * @param {string} percent 18 | */ 19 | export const renderChangePercent = (percent) => { 20 | if (percent > 0) { 21 | return {percent}% ↑ 22 | } else if (percent < 0) { 23 | return {percent}% ↓ 24 | } else { 25 | return {percent} 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /stages/12/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Application colors 3 | 4 | text: #fff; 5 | text-secondary: #9cb3c9; 6 | 7 | error-red: #d64d96; 8 | 9 | green: #3cd483; 10 | 11 | indigo-light: #1f364d; 12 | indigo-normal: #0f273d; 13 | indigo-dark: #0c2033; 14 | 15 | blue-normal: #4997e5; 16 | blue-dark: #457cb2; 17 | */ 18 | 19 | /* Load Open Sans font from Google Fonts */ 20 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400'); 21 | 22 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 23 | button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 24 | 25 | body { 26 | background-color: #0c2033; 27 | font-family: 'Open Sans', sans-serif; 28 | color: #fff; 29 | letter-spacing: .5px; 30 | font-size: 16px; 31 | font-weight: 300; 32 | } 33 | 34 | .percent-raised { 35 | color: #3cd483; 36 | } 37 | 38 | .percent-fallen { 39 | color: #d64d96; 40 | } 41 | 42 | .loading-container { 43 | width: 100%; 44 | text-align: center; 45 | margin: 40px auto; 46 | } 47 | 48 | .error { 49 | width: 100%; 50 | margin: 40px 0; 51 | text-align: center; 52 | color: #d64d96; 53 | } 54 | 55 | /* Remove close icon from input on IE */ 56 | input::-ms-clear { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /stages/12/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 4 | import Header from './components/common/Header'; 5 | import List from './components/list/List'; 6 | import NotFound from './components/notfound/NotFound'; 7 | import Detail from './components/detail/Detail'; 8 | import './index.css'; 9 | 10 | const App = () => { 11 | return ( 12 | 13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | ); 24 | } 25 | 26 | ReactDOM.render( 27 | , 28 | document.getElementById('root') 29 | ); 30 | -------------------------------------------------------------------------------- /stages/13/src/components/common/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #0f273d; 7 | width: 100%; 8 | height: 80px; 9 | } 10 | 11 | .Header-logo { 12 | position: absolute; 13 | top: 30px; 14 | left: 20px; 15 | width: 90px; 16 | } 17 | 18 | @media (max-width: 700px) { 19 | .Header-logo { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stages/13/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import Search from './Search'; 4 | import logo from './logo.png'; 5 | import './Header.css'; 6 | 7 | const Header = () => { 8 | return ( 9 |
10 | 11 | logo 12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | 19 | export default Header; 20 | -------------------------------------------------------------------------------- /stages/13/src/components/common/Loading.css: -------------------------------------------------------------------------------- 1 | .Loading { 2 | display: inline-block; 3 | border: 2px solid #fff; 4 | border-right-color: transparent; 5 | border-radius: 50%; 6 | animation: rotate 1s infinite linear; 7 | } 8 | 9 | @keyframes rotate { 10 | 0% { 11 | transform: rotate(0deg); 12 | } 13 | 14 | 100% { 15 | transform: rotate(360deg); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /stages/13/src/components/common/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Loading.css'; 4 | 5 | const Loading = (props) => { 6 | const { width, height } = props; 7 | 8 | return
; 9 | } 10 | 11 | Loading.defaultProps = { 12 | width: '28px', 13 | height: '28px', 14 | }; 15 | 16 | Loading.propTypes = { 17 | width: PropTypes.string, 18 | height: PropTypes.string, 19 | }; 20 | 21 | export default Loading; 22 | -------------------------------------------------------------------------------- /stages/13/src/components/common/Search.css: -------------------------------------------------------------------------------- 1 | .Search { 2 | position: relative; 3 | width: 30%; 4 | height: 35px; 5 | margin: 0 auto; 6 | padding: 0 20px; 7 | } 8 | 9 | @media (max-width: 700px) { 10 | .Search { 11 | width: 100%; 12 | } 13 | } 14 | 15 | .Search-icon { 16 | z-index: 1; 17 | position: absolute; 18 | top: 9px; 19 | left: 28px; 20 | background-image: url('./search.png'); 21 | background-repeat: no-repeat; 22 | background-position: center; 23 | background-size: cover; 24 | width: 18px; 25 | height: 18px; 26 | } 27 | 28 | .Search-input { 29 | box-sizing: border-box; 30 | background-color: #1f364d; 31 | border-radius: 4px; 32 | border: 0; 33 | padding-left: 35px; 34 | color: white; 35 | opacity: .8; 36 | transition: opacity .2s; 37 | width: 100%; 38 | height: 35px; 39 | } 40 | 41 | .Search-input:focus { 42 | outline: none; 43 | opacity: 1; 44 | } 45 | 46 | .Search ::placeholder { 47 | color: #9cb3c9; 48 | opacity: 1; 49 | } 50 | 51 | .Search-loading { 52 | position: absolute; 53 | top: 9px; 54 | right: 28px; 55 | } 56 | 57 | .Search-result-container { 58 | position: relative; 59 | width: 100%; 60 | max-height: 299px; 61 | overflow-y: auto; 62 | background-color: #0f273d; 63 | border: 1px solid #0c2033; 64 | border-radius: 4px; 65 | box-shadow: 0px 0px 40px 0px#1f364d; 66 | margin-top: 10px; 67 | } 68 | 69 | .Search-result { 70 | color: #9cb3c9; 71 | padding: 15px 0 15px 35px; 72 | border-bottom: 2px solid #0c2033; 73 | cursor: pointer; 74 | } 75 | 76 | .Search-result:hover { 77 | color: #fff; 78 | } 79 | 80 | .Search-no-result { 81 | color: #9cb3c9; 82 | padding: 15px 0 15px 35px; 83 | border-bottom: 1px solid #0f273d; 84 | } 85 | -------------------------------------------------------------------------------- /stages/13/src/components/common/Search.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import Loading from './Loading'; 4 | import { API_URL } from '../../config'; 5 | import { handleResponse } from '../../helpers'; 6 | import './Search.css'; 7 | 8 | class Search extends React.Component { 9 | constructor() { 10 | super(); 11 | 12 | this.state = { 13 | searchResults: [], 14 | searchQuery: '', 15 | loading: false, 16 | }; 17 | 18 | this.handleChange = this.handleChange.bind(this); 19 | this.handleRedirect = this.handleRedirect.bind(this); 20 | } 21 | 22 | handleChange(event) { 23 | const searchQuery = event.target.value; 24 | 25 | this.setState({ searchQuery }); 26 | 27 | // If searchQuery isn't present, don't send request to server 28 | if (!searchQuery) { 29 | return ''; 30 | } 31 | 32 | this.setState({ loading: true }); 33 | 34 | fetch(`${API_URL}/autocomplete?searchQuery=${searchQuery}`) 35 | .then(handleResponse) 36 | .then((result) => { 37 | this.setState({ 38 | loading: false, 39 | searchResults: result, 40 | }); 41 | }); 42 | } 43 | 44 | handleRedirect(currencyId) { 45 | // Clear input value and close autocomplete container, 46 | // By clearing searchQuery state 47 | this.setState({ 48 | searchQuery: '', 49 | searchResults: [], 50 | }); 51 | 52 | this.props.history.push(`/currency/${currencyId}`); 53 | } 54 | 55 | renderSearchResults() { 56 | const { searchResults, searchQuery, loading } = this.state; 57 | 58 | if (!searchQuery) { 59 | return ''; 60 | } 61 | 62 | if (searchResults.length > 0) { 63 | return ( 64 |
65 | {searchResults.map(result => ( 66 |
this.handleRedirect(result.id)} 70 | > 71 | {result.name} ({result.symbol}) 72 |
73 | ))} 74 |
75 | ); 76 | } 77 | 78 | if (!loading) { 79 | return ( 80 |
81 |
82 | No results found. 83 |
84 |
85 | ); 86 | } 87 | } 88 | 89 | render() { 90 | const { loading, searchQuery } = this.state; 91 | 92 | return ( 93 |
94 | 95 | 96 | 103 | 104 | {loading && 105 |
106 | 110 |
} 111 | 112 | {this.renderSearchResults()} 113 |
114 | ); 115 | } 116 | } 117 | 118 | export default withRouter(Search); 119 | -------------------------------------------------------------------------------- /stages/13/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/13/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/13/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/13/src/components/common/search.png -------------------------------------------------------------------------------- /stages/13/src/components/detail/Detail.css: -------------------------------------------------------------------------------- 1 | .Detail { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | margin-top: 30px; 6 | margin-bottom: 40px; 7 | padding: 0 60px; 8 | } 9 | 10 | .Detail-heading { 11 | font-size: 24px; 12 | font-weight: 300; 13 | } 14 | 15 | .Detail-container { 16 | width: 100%; 17 | max-width: 400px; 18 | margin-top: 30px; 19 | padding: 40px 40px 0; 20 | border-radius: 4px; 21 | box-shadow: 0px 0px 40px 0px#1f364d; 22 | } 23 | 24 | .Detail-item { 25 | margin-bottom: 50px; 26 | } 27 | 28 | .Detail-value { 29 | border-radius: 20px; 30 | background-color: #1f364d; 31 | font-size: 14px; 32 | padding: 8px 12px; 33 | margin-left: 10px; 34 | } 35 | 36 | .Detail-title { 37 | display: block; 38 | color: #9cb3c9; 39 | font-size: 12px; 40 | font-weight: bold; 41 | margin-bottom: 10px; 42 | } 43 | 44 | .Detail-dollar { 45 | color: #9cb3c9; 46 | margin-right: 6px; 47 | } 48 | -------------------------------------------------------------------------------- /stages/13/src/components/detail/Detail.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { API_URL } from '../../config'; 3 | import Loading from '../common/Loading'; 4 | import { handleResponse, renderChangePercent } from '../../helpers'; 5 | import './Detail.css'; 6 | 7 | class Detail extends React.Component { 8 | constructor() { 9 | super(); 10 | 11 | this.state = { 12 | currency: {}, 13 | loading: false, 14 | error: null, 15 | }; 16 | } 17 | 18 | componentDidMount() { 19 | const currencyId = this.props.match.params.id; 20 | 21 | this.fetchCurrency(currencyId); 22 | } 23 | 24 | componentWillReceiveProps(nextProps) { 25 | if (this.props.location.pathname !== nextProps.location.pathname) { 26 | // Get new currency id from url 27 | const newCurrencyId = nextProps.match.params.id; 28 | 29 | this.fetchCurrency(newCurrencyId); 30 | } 31 | } 32 | 33 | fetchCurrency(currencyId) { 34 | this.setState({ loading: true }); 35 | 36 | fetch(`${API_URL}/cryptocurrencies/${currencyId}`) 37 | .then(handleResponse) 38 | .then((currency) => { 39 | this.setState({ 40 | loading: false, 41 | error: null, 42 | currency, 43 | }); 44 | }) 45 | .catch((error) => { 46 | this.setState({ 47 | loading: false, 48 | error: error.errorMessage, 49 | }); 50 | }); 51 | } 52 | 53 | render() { 54 | const { loading, error, currency } = this.state; 55 | 56 | // Render only loading component if loading state is set to true 57 | if (loading) { 58 | return
59 | } 60 | 61 | // Render only error message, if error occurred while fetching data 62 | if (error) { 63 | return
{error}
64 | } 65 | 66 | return ( 67 |
68 |

69 | {currency.name} ({currency.symbol}) 70 |

71 | 72 |
73 |
74 | Price $ {currency.price} 75 |
76 |
77 | Rank {currency.rank} 78 |
79 |
80 | 24H Change 81 | {renderChangePercent(currency.percentChange24h)} 82 |
83 |
84 | Market cap 85 | $ 86 | {currency.marketCap} 87 |
88 |
89 | 24H Volume 90 | $ 91 | {currency.volume24h} 92 |
93 |
94 | Total supply 95 | {currency.totalSupply} 96 |
97 |
98 |
99 | ); 100 | } 101 | } 102 | 103 | export default Detail; 104 | -------------------------------------------------------------------------------- /stages/13/src/components/list/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { handleResponse } from '../../helpers'; 3 | import { API_URL } from '../../config'; 4 | import Loading from '../common/Loading'; 5 | import Table from './Table'; 6 | import Pagination from './Pagination'; 7 | 8 | class List extends React.Component { 9 | constructor() { 10 | super(); 11 | 12 | this.state = { 13 | loading: false, 14 | currencies: [], 15 | error: null, 16 | totalPages: 0, 17 | page: 1, 18 | }; 19 | 20 | this.handlePaginationClick = this.handlePaginationClick.bind(this); 21 | } 22 | 23 | componentDidMount() { 24 | this.fetchCurrencies(); 25 | } 26 | 27 | fetchCurrencies() { 28 | this.setState({ loading: true }); 29 | 30 | const { page } = this.state; 31 | 32 | fetch(`${API_URL}/cryptocurrencies?page=${page}&perPage=20`) 33 | .then(handleResponse) 34 | .then((data) => { 35 | const { currencies, totalPages } = data; 36 | 37 | this.setState({ 38 | currencies, 39 | totalPages, 40 | loading: false, 41 | }); 42 | }) 43 | .catch((error) => { 44 | this.setState({ 45 | error: error.errorMessage, 46 | loading: false, 47 | }); 48 | }); 49 | } 50 | 51 | handlePaginationClick(direction) { 52 | let nextPage = this.state.page; 53 | 54 | // Increment nextPage if direction variable is next, otherwise decrement 55 | nextPage = direction === 'next' ? nextPage + 1 : nextPage - 1; 56 | 57 | this.setState({ page: nextPage }, () => { 58 | // call fetchCurrencies function inside setState's callback 59 | // because we have to make sure first page state is updated 60 | this.fetchCurrencies(); 61 | }); 62 | } 63 | 64 | render() { 65 | const { loading, error, currencies, page, totalPages } = this.state; 66 | 67 | // render only loading component, if loading state is set to true 68 | if (loading) { 69 | return
70 | } 71 | 72 | // render only error message, if error occurred while fetching data 73 | if (error) { 74 | return
{error}
75 | } 76 | 77 | return ( 78 |
79 | 82 | 83 | 88 | 89 | ); 90 | } 91 | } 92 | 93 | export default List; 94 | -------------------------------------------------------------------------------- /stages/13/src/components/list/Pagination.css: -------------------------------------------------------------------------------- 1 | .Pagination { 2 | margin: 50px auto; 3 | text-align: center; 4 | } 5 | 6 | .Pagination-button { 7 | text-align: center; 8 | border: none; 9 | border-radius: 16px; 10 | background-color: #4997e5; 11 | transition: background-color .2s; 12 | color: white; 13 | cursor: pointer; 14 | margin: 10px; 15 | width: 44px; 16 | height: 34px; 17 | } 18 | 19 | .Pagination-button:hover { 20 | background-color: #457cb2; 21 | } 22 | 23 | .Pagination-button:focus { 24 | outline: none; 25 | } 26 | 27 | .Pagination-button:disabled { 28 | background-color: #1f364d; 29 | cursor: not-allowed; 30 | } 31 | 32 | .Pagination-info { 33 | font-size: 12px; 34 | } 35 | -------------------------------------------------------------------------------- /stages/13/src/components/list/Pagination.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Pagination.css'; 4 | 5 | const Pagination = (props) => { 6 | const { page, totalPages, handlePaginationClick } = props; 7 | 8 | return ( 9 |
10 | 17 | 18 | 19 | page {page} of {totalPages} 20 | 21 | 22 | 29 |
30 | ); 31 | } 32 | 33 | Pagination.propTypes = { 34 | totalPages: PropTypes.number.isRequired, 35 | page: PropTypes.number.isRequired, 36 | handlePaginationClick: PropTypes.func.isRequired, 37 | }; 38 | 39 | export default Pagination; 40 | -------------------------------------------------------------------------------- /stages/13/src/components/list/Table.css: -------------------------------------------------------------------------------- 1 | .Table-container { 2 | overflow-x: auto; /* Needed for table to be responsive */ 3 | } 4 | 5 | .Table { 6 | width: 100%; 7 | border-collapse: collapse; 8 | border-spacing: 0; 9 | } 10 | 11 | .Table-head { 12 | background-color: #0c2033; 13 | } 14 | 15 | .Table-head tr th { 16 | padding: 10px 20px; 17 | color: #9cb3c9; 18 | text-align: left; 19 | font-size: 14px; 20 | font-weight: 400; 21 | } 22 | 23 | .Table-body { 24 | text-align: left; 25 | background-color: #0f273d; 26 | } 27 | 28 | .Table-body tr td { 29 | padding: 24px 20px; 30 | border-bottom: 2px solid #0c2033; 31 | color: #fff; 32 | cursor: pointer; 33 | } 34 | 35 | .Table-rank { 36 | color: #9cb3c9; 37 | margin-right: 18px; 38 | font-size: 12px; 39 | } 40 | 41 | .Table-dollar { 42 | color: #9cb3c9; 43 | margin-right: 6px; 44 | } 45 | -------------------------------------------------------------------------------- /stages/13/src/components/list/Table.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import { renderChangePercent } from '../../helpers'; 4 | import PropTypes from 'prop-types'; 5 | import './Table.css'; 6 | 7 | const Table = (props) => { 8 | const { currencies, history } = props; 9 | 10 | return ( 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {currencies.map((currency) => ( 23 | history.push(`/currency/${currency.id}`)} 26 | > 27 | 31 | 35 | 39 | 42 | 43 | ))} 44 | 45 |
CryptocurrencyPriceMarket Cap24H Change
28 | {currency.rank} 29 | {currency.name} 30 | 32 | $ 33 | {currency.price} 34 | 36 | $ 37 | {currency.marketCap} 38 | 40 | {renderChangePercent(currency.percentChange24h)} 41 |
46 |
47 | ); 48 | } 49 | 50 | Table.propTypes = { 51 | currencies: PropTypes.array.isRequired, 52 | history: PropTypes.object.isRequired, 53 | }; 54 | 55 | export default withRouter(Table); 56 | -------------------------------------------------------------------------------- /stages/13/src/components/notfound/NotFound.css: -------------------------------------------------------------------------------- 1 | .NotFound { 2 | width: 100%; 3 | text-align: center; 4 | margin-top: 60px; 5 | } 6 | 7 | .NotFound-title { 8 | font-weight: 400; 9 | color: #9cb3c9; 10 | } 11 | 12 | .NotFound-link { 13 | display: inline-block; 14 | margin-top: 40px; 15 | color: #fff; 16 | text-decoration: none; 17 | border: 1px solid #9cb3c9; 18 | border-radius: 4px; 19 | padding: 18px; 20 | transition: border .2s; 21 | } 22 | 23 | .NotFound-link:hover { 24 | border: 1px solid #fff; 25 | } 26 | -------------------------------------------------------------------------------- /stages/13/src/components/notfound/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import './NotFound.css'; 4 | 5 | const NotFound = () => { 6 | return ( 7 |
8 |

Oops! Page not found

9 | 10 | Go to homepage 11 |
12 | ); 13 | } 14 | 15 | export default NotFound; 16 | -------------------------------------------------------------------------------- /stages/13/src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API root url 3 | */ 4 | export const API_URL = 'https://api.udilia.com/coins/v1'; 5 | -------------------------------------------------------------------------------- /stages/13/src/helpers.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * Fetch response helper 5 | * 6 | * @param {object} response 7 | */ 8 | export const handleResponse = (response) => { 9 | return response.json().then(json => { 10 | return response.ok ? json : Promise.reject(json); 11 | }); 12 | } 13 | 14 | /** 15 | * Render change percent helper 16 | * 17 | * @param {string} percent 18 | */ 19 | export const renderChangePercent = (percent) => { 20 | if (percent > 0) { 21 | return {percent}% ↑ 22 | } else if (percent < 0) { 23 | return {percent}% ↓ 24 | } else { 25 | return {percent} 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /stages/13/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Application colors 3 | 4 | text: #fff; 5 | text-secondary: #9cb3c9; 6 | 7 | error-red: #d64d96; 8 | 9 | green: #3cd483; 10 | 11 | indigo-light: #1f364d; 12 | indigo-normal: #0f273d; 13 | indigo-dark: #0c2033; 14 | 15 | blue-normal: #4997e5; 16 | blue-dark: #457cb2; 17 | */ 18 | 19 | /* Load Open Sans font from Google Fonts */ 20 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400'); 21 | 22 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 23 | button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 24 | 25 | body { 26 | background-color: #0c2033; 27 | font-family: 'Open Sans', sans-serif; 28 | color: #fff; 29 | letter-spacing: .5px; 30 | font-size: 16px; 31 | font-weight: 300; 32 | } 33 | 34 | .percent-raised { 35 | color: #3cd483; 36 | } 37 | 38 | .percent-fallen { 39 | color: #d64d96; 40 | } 41 | 42 | .loading-container { 43 | width: 100%; 44 | text-align: center; 45 | margin: 40px auto; 46 | } 47 | 48 | .error { 49 | width: 100%; 50 | margin: 40px 0; 51 | text-align: center; 52 | color: #d64d96; 53 | } 54 | 55 | /* Remove close icon from input on IE */ 56 | input::-ms-clear { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /stages/13/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 4 | import Header from './components/common/Header'; 5 | import List from './components/list/List'; 6 | import NotFound from './components/notfound/NotFound'; 7 | import Detail from './components/detail/Detail'; 8 | import './index.css'; 9 | 10 | const App = () => { 11 | return ( 12 | 13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | ); 24 | } 25 | 26 | ReactDOM.render( 27 | , 28 | document.getElementById('root') 29 | ); 30 | -------------------------------------------------------------------------------- /stages/14/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | 4 | app.use(express.static('build')); 5 | 6 | app.get('*', (req, res) => { 7 | res.sendFile(`${__dirname}/build/index.html`); 8 | }); 9 | 10 | const port = process.env.PORT || 9000; 11 | 12 | app.listen(port, () => { 13 | console.log('server listening on port: ', port); 14 | }); 15 | -------------------------------------------------------------------------------- /stages/14/src/components/common/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #0f273d; 7 | width: 100%; 8 | height: 80px; 9 | } 10 | 11 | .Header-logo { 12 | position: absolute; 13 | top: 30px; 14 | left: 20px; 15 | width: 90px; 16 | } 17 | 18 | @media (max-width: 700px) { 19 | .Header-logo { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stages/14/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import Search from './Search'; 4 | import logo from './logo.png'; 5 | import './Header.css'; 6 | 7 | const Header = () => { 8 | return ( 9 |
10 | 11 | logo 12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | 19 | export default Header; 20 | -------------------------------------------------------------------------------- /stages/14/src/components/common/Loading.css: -------------------------------------------------------------------------------- 1 | .Loading { 2 | display: inline-block; 3 | border: 2px solid #fff; 4 | border-right-color: transparent; 5 | border-radius: 50%; 6 | animation: rotate 1s infinite linear; 7 | } 8 | 9 | @keyframes rotate { 10 | 0% { 11 | transform: rotate(0deg); 12 | } 13 | 14 | 100% { 15 | transform: rotate(360deg); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /stages/14/src/components/common/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Loading.css'; 4 | 5 | const Loading = (props) => { 6 | const { width, height } = props; 7 | 8 | return
; 9 | } 10 | 11 | Loading.defaultProps = { 12 | width: '28px', 13 | height: '28px', 14 | }; 15 | 16 | Loading.propTypes = { 17 | width: PropTypes.string, 18 | height: PropTypes.string, 19 | }; 20 | 21 | export default Loading; 22 | -------------------------------------------------------------------------------- /stages/14/src/components/common/Search.css: -------------------------------------------------------------------------------- 1 | .Search { 2 | position: relative; 3 | width: 30%; 4 | height: 35px; 5 | margin: 0 auto; 6 | padding: 0 20px; 7 | } 8 | 9 | @media (max-width: 700px) { 10 | .Search { 11 | width: 100%; 12 | } 13 | } 14 | 15 | .Search-icon { 16 | z-index: 1; 17 | position: absolute; 18 | top: 9px; 19 | left: 28px; 20 | background-image: url('./search.png'); 21 | background-repeat: no-repeat; 22 | background-position: center; 23 | background-size: cover; 24 | width: 18px; 25 | height: 18px; 26 | } 27 | 28 | .Search-input { 29 | box-sizing: border-box; 30 | background-color: #1f364d; 31 | border-radius: 4px; 32 | border: 0; 33 | padding-left: 35px; 34 | color: white; 35 | opacity: .8; 36 | transition: opacity .2s; 37 | width: 100%; 38 | height: 35px; 39 | } 40 | 41 | .Search-input:focus { 42 | outline: none; 43 | opacity: 1; 44 | } 45 | 46 | .Search ::placeholder { 47 | color: #9cb3c9; 48 | opacity: 1; 49 | } 50 | 51 | .Search-loading { 52 | position: absolute; 53 | top: 9px; 54 | right: 28px; 55 | } 56 | 57 | .Search-result-container { 58 | position: relative; 59 | width: 100%; 60 | max-height: 299px; 61 | overflow-y: auto; 62 | background-color: #0f273d; 63 | border: 1px solid #0c2033; 64 | border-radius: 4px; 65 | box-shadow: 0px 0px 40px 0px#1f364d; 66 | margin-top: 10px; 67 | } 68 | 69 | .Search-result { 70 | color: #9cb3c9; 71 | padding: 15px 0 15px 35px; 72 | border-bottom: 2px solid #0c2033; 73 | cursor: pointer; 74 | } 75 | 76 | .Search-result:hover { 77 | color: #fff; 78 | } 79 | 80 | .Search-no-result { 81 | color: #9cb3c9; 82 | padding: 15px 0 15px 35px; 83 | border-bottom: 1px solid #0f273d; 84 | } 85 | -------------------------------------------------------------------------------- /stages/14/src/components/common/Search.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import Loading from './Loading'; 4 | import { API_URL } from '../../config'; 5 | import { handleResponse } from '../../helpers'; 6 | import './Search.css'; 7 | 8 | class Search extends React.Component { 9 | constructor() { 10 | super(); 11 | 12 | this.state = { 13 | searchResults: [], 14 | searchQuery: '', 15 | loading: false, 16 | }; 17 | 18 | this.handleChange = this.handleChange.bind(this); 19 | this.handleRedirect = this.handleRedirect.bind(this); 20 | } 21 | 22 | handleChange(event) { 23 | const searchQuery = event.target.value; 24 | 25 | this.setState({ searchQuery }); 26 | 27 | // If searchQuery isn't present, don't send request to server 28 | if (!searchQuery) { 29 | return ''; 30 | } 31 | 32 | this.setState({ loading: true }); 33 | 34 | fetch(`${API_URL}/autocomplete?searchQuery=${searchQuery}`) 35 | .then(handleResponse) 36 | .then((result) => { 37 | this.setState({ 38 | loading: false, 39 | searchResults: result, 40 | }); 41 | }); 42 | } 43 | 44 | handleRedirect(currencyId) { 45 | // Clear input value and close autocomplete container, 46 | // By clearing searchQuery state 47 | this.setState({ 48 | searchQuery: '', 49 | searchResults: [], 50 | }); 51 | 52 | this.props.history.push(`/currency/${currencyId}`); 53 | } 54 | 55 | renderSearchResults() { 56 | const { searchResults, searchQuery, loading } = this.state; 57 | 58 | if (!searchQuery) { 59 | return ''; 60 | } 61 | 62 | if (searchResults.length > 0) { 63 | return ( 64 |
65 | {searchResults.map(result => ( 66 |
this.handleRedirect(result.id)} 70 | > 71 | {result.name} ({result.symbol}) 72 |
73 | ))} 74 |
75 | ); 76 | } 77 | 78 | if (!loading) { 79 | return ( 80 |
81 |
82 | No results found. 83 |
84 |
85 | ); 86 | } 87 | } 88 | 89 | render() { 90 | const { loading, searchQuery } = this.state; 91 | 92 | return ( 93 |
94 | 95 | 96 | 103 | 104 | {loading && 105 |
106 | 110 |
} 111 | 112 | {this.renderSearchResults()} 113 |
114 | ); 115 | } 116 | } 117 | 118 | export default withRouter(Search); 119 | -------------------------------------------------------------------------------- /stages/14/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/14/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/14/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/14/src/components/common/search.png -------------------------------------------------------------------------------- /stages/14/src/components/detail/Detail.css: -------------------------------------------------------------------------------- 1 | .Detail { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | margin-top: 30px; 6 | margin-bottom: 40px; 7 | padding: 0 60px; 8 | } 9 | 10 | .Detail-heading { 11 | font-size: 24px; 12 | font-weight: 300; 13 | } 14 | 15 | .Detail-container { 16 | width: 100%; 17 | max-width: 400px; 18 | margin-top: 30px; 19 | padding: 40px 40px 0; 20 | border-radius: 4px; 21 | box-shadow: 0px 0px 40px 0px#1f364d; 22 | } 23 | 24 | .Detail-item { 25 | margin-bottom: 50px; 26 | } 27 | 28 | .Detail-value { 29 | border-radius: 20px; 30 | background-color: #1f364d; 31 | font-size: 14px; 32 | padding: 8px 12px; 33 | margin-left: 10px; 34 | } 35 | 36 | .Detail-title { 37 | display: block; 38 | color: #9cb3c9; 39 | font-size: 12px; 40 | font-weight: bold; 41 | margin-bottom: 10px; 42 | } 43 | 44 | .Detail-dollar { 45 | color: #9cb3c9; 46 | margin-right: 6px; 47 | } 48 | -------------------------------------------------------------------------------- /stages/14/src/components/detail/Detail.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { API_URL } from '../../config'; 3 | import Loading from '../common/Loading'; 4 | import { handleResponse, renderChangePercent } from '../../helpers'; 5 | import './Detail.css'; 6 | 7 | class Detail extends React.Component { 8 | constructor() { 9 | super(); 10 | 11 | this.state = { 12 | currency: {}, 13 | loading: false, 14 | error: null, 15 | }; 16 | } 17 | 18 | componentDidMount() { 19 | const currencyId = this.props.match.params.id; 20 | 21 | this.fetchCurrency(currencyId); 22 | } 23 | 24 | componentWillReceiveProps(nextProps) { 25 | if (this.props.location.pathname !== nextProps.location.pathname) { 26 | // Get new currency id from url 27 | const newCurrencyId = nextProps.match.params.id; 28 | 29 | this.fetchCurrency(newCurrencyId); 30 | } 31 | } 32 | 33 | fetchCurrency(currencyId) { 34 | this.setState({ loading: true }); 35 | 36 | fetch(`${API_URL}/cryptocurrencies/${currencyId}`) 37 | .then(handleResponse) 38 | .then((currency) => { 39 | this.setState({ 40 | loading: false, 41 | error: null, 42 | currency, 43 | }); 44 | }) 45 | .catch((error) => { 46 | this.setState({ 47 | loading: false, 48 | error: error.errorMessage, 49 | }); 50 | }); 51 | } 52 | 53 | render() { 54 | const { loading, error, currency } = this.state; 55 | 56 | // Render only loading component if loading state is set to true 57 | if (loading) { 58 | return
59 | } 60 | 61 | // Render only error message, if error occurred while fetching data 62 | if (error) { 63 | return
{error}
64 | } 65 | 66 | return ( 67 |
68 |

69 | {currency.name} ({currency.symbol}) 70 |

71 | 72 |
73 |
74 | Price $ {currency.price} 75 |
76 |
77 | Rank {currency.rank} 78 |
79 |
80 | 24H Change 81 | {renderChangePercent(currency.percentChange24h)} 82 |
83 |
84 | Market cap 85 | $ 86 | {currency.marketCap} 87 |
88 |
89 | 24H Volume 90 | $ 91 | {currency.volume24h} 92 |
93 |
94 | Total supply 95 | {currency.totalSupply} 96 |
97 |
98 |
99 | ); 100 | } 101 | } 102 | 103 | export default Detail; 104 | -------------------------------------------------------------------------------- /stages/14/src/components/list/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { handleResponse } from '../../helpers'; 3 | import { API_URL } from '../../config'; 4 | import Loading from '../common/Loading'; 5 | import Table from './Table'; 6 | import Pagination from './Pagination'; 7 | 8 | class List extends React.Component { 9 | constructor() { 10 | super(); 11 | 12 | this.state = { 13 | loading: false, 14 | currencies: [], 15 | error: null, 16 | totalPages: 0, 17 | page: 1, 18 | }; 19 | 20 | this.handlePaginationClick = this.handlePaginationClick.bind(this); 21 | } 22 | 23 | componentDidMount() { 24 | this.fetchCurrencies(); 25 | } 26 | 27 | fetchCurrencies() { 28 | this.setState({ loading: true }); 29 | 30 | const { page } = this.state; 31 | 32 | fetch(`${API_URL}/cryptocurrencies?page=${page}&perPage=20`) 33 | .then(handleResponse) 34 | .then((data) => { 35 | const { currencies, totalPages } = data; 36 | 37 | this.setState({ 38 | currencies, 39 | totalPages, 40 | loading: false, 41 | }); 42 | }) 43 | .catch((error) => { 44 | this.setState({ 45 | error: error.errorMessage, 46 | loading: false, 47 | }); 48 | }); 49 | } 50 | 51 | handlePaginationClick(direction) { 52 | let nextPage = this.state.page; 53 | 54 | // Increment nextPage if direction variable is next, otherwise decrement 55 | nextPage = direction === 'next' ? nextPage + 1 : nextPage - 1; 56 | 57 | this.setState({ page: nextPage }, () => { 58 | // call fetchCurrencies function inside setState's callback 59 | // because we have to make sure first page state is updated 60 | this.fetchCurrencies(); 61 | }); 62 | } 63 | 64 | render() { 65 | const { loading, error, currencies, page, totalPages } = this.state; 66 | 67 | // render only loading component, if loading state is set to true 68 | if (loading) { 69 | return
70 | } 71 | 72 | // render only error message, if error occurred while fetching data 73 | if (error) { 74 | return
{error}
75 | } 76 | 77 | return ( 78 |
79 | 82 | 83 | 88 | 89 | ); 90 | } 91 | } 92 | 93 | export default List; 94 | -------------------------------------------------------------------------------- /stages/14/src/components/list/Pagination.css: -------------------------------------------------------------------------------- 1 | .Pagination { 2 | margin: 50px auto; 3 | text-align: center; 4 | } 5 | 6 | .Pagination-button { 7 | text-align: center; 8 | border: none; 9 | border-radius: 16px; 10 | background-color: #4997e5; 11 | transition: background-color .2s; 12 | color: white; 13 | cursor: pointer; 14 | margin: 10px; 15 | width: 44px; 16 | height: 34px; 17 | } 18 | 19 | .Pagination-button:hover { 20 | background-color: #457cb2; 21 | } 22 | 23 | .Pagination-button:focus { 24 | outline: none; 25 | } 26 | 27 | .Pagination-button:disabled { 28 | background-color: #1f364d; 29 | cursor: not-allowed; 30 | } 31 | 32 | .Pagination-info { 33 | font-size: 12px; 34 | } 35 | -------------------------------------------------------------------------------- /stages/14/src/components/list/Pagination.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Pagination.css'; 4 | 5 | const Pagination = (props) => { 6 | const { page, totalPages, handlePaginationClick } = props; 7 | 8 | return ( 9 |
10 | 17 | 18 | 19 | page {page} of {totalPages} 20 | 21 | 22 | 29 |
30 | ); 31 | } 32 | 33 | Pagination.propTypes = { 34 | totalPages: PropTypes.number.isRequired, 35 | page: PropTypes.number.isRequired, 36 | handlePaginationClick: PropTypes.func.isRequired, 37 | }; 38 | 39 | export default Pagination; 40 | -------------------------------------------------------------------------------- /stages/14/src/components/list/Table.css: -------------------------------------------------------------------------------- 1 | .Table-container { 2 | overflow-x: auto; /* Needed for table to be responsive */ 3 | } 4 | 5 | .Table { 6 | width: 100%; 7 | border-collapse: collapse; 8 | border-spacing: 0; 9 | } 10 | 11 | .Table-head { 12 | background-color: #0c2033; 13 | } 14 | 15 | .Table-head tr th { 16 | padding: 10px 20px; 17 | color: #9cb3c9; 18 | text-align: left; 19 | font-size: 14px; 20 | font-weight: 400; 21 | } 22 | 23 | .Table-body { 24 | text-align: left; 25 | background-color: #0f273d; 26 | } 27 | 28 | .Table-body tr td { 29 | padding: 24px 20px; 30 | border-bottom: 2px solid #0c2033; 31 | color: #fff; 32 | cursor: pointer; 33 | } 34 | 35 | .Table-rank { 36 | color: #9cb3c9; 37 | margin-right: 18px; 38 | font-size: 12px; 39 | } 40 | 41 | .Table-dollar { 42 | color: #9cb3c9; 43 | margin-right: 6px; 44 | } 45 | -------------------------------------------------------------------------------- /stages/14/src/components/list/Table.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import { renderChangePercent } from '../../helpers'; 4 | import PropTypes from 'prop-types'; 5 | import './Table.css'; 6 | 7 | const Table = (props) => { 8 | const { currencies, history } = props; 9 | 10 | return ( 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {currencies.map((currency) => ( 23 | history.push(`/currency/${currency.id}`)} 26 | > 27 | 31 | 35 | 39 | 42 | 43 | ))} 44 | 45 |
CryptocurrencyPriceMarket Cap24H Change
28 | {currency.rank} 29 | {currency.name} 30 | 32 | $ 33 | {currency.price} 34 | 36 | $ 37 | {currency.marketCap} 38 | 40 | {renderChangePercent(currency.percentChange24h)} 41 |
46 |
47 | ); 48 | } 49 | 50 | Table.propTypes = { 51 | currencies: PropTypes.array.isRequired, 52 | history: PropTypes.object.isRequired, 53 | }; 54 | 55 | export default withRouter(Table); 56 | -------------------------------------------------------------------------------- /stages/14/src/components/notfound/NotFound.css: -------------------------------------------------------------------------------- 1 | .NotFound { 2 | width: 100%; 3 | text-align: center; 4 | margin-top: 60px; 5 | } 6 | 7 | .NotFound-title { 8 | font-weight: 400; 9 | color: #9cb3c9; 10 | } 11 | 12 | .NotFound-link { 13 | display: inline-block; 14 | margin-top: 40px; 15 | color: #fff; 16 | text-decoration: none; 17 | border: 1px solid #9cb3c9; 18 | border-radius: 4px; 19 | padding: 18px; 20 | transition: border .2s; 21 | } 22 | 23 | .NotFound-link:hover { 24 | border: 1px solid #fff; 25 | } 26 | -------------------------------------------------------------------------------- /stages/14/src/components/notfound/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import './NotFound.css'; 4 | 5 | const NotFound = () => { 6 | return ( 7 |
8 |

Oops! Page not found

9 | 10 | Go to homepage 11 |
12 | ); 13 | } 14 | 15 | export default NotFound; 16 | -------------------------------------------------------------------------------- /stages/14/src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API root url 3 | */ 4 | export const API_URL = 'https://api.udilia.com/coins/v1'; 5 | -------------------------------------------------------------------------------- /stages/14/src/helpers.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * Fetch response helper 5 | * 6 | * @param {object} response 7 | */ 8 | export const handleResponse = (response) => { 9 | return response.json().then(json => { 10 | return response.ok ? json : Promise.reject(json); 11 | }); 12 | } 13 | 14 | /** 15 | * Render change percent helper 16 | * 17 | * @param {string} percent 18 | */ 19 | export const renderChangePercent = (percent) => { 20 | if (percent > 0) { 21 | return {percent}% ↑ 22 | } else if (percent < 0) { 23 | return {percent}% ↓ 24 | } else { 25 | return {percent} 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /stages/14/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Application colors 3 | 4 | text: #fff; 5 | text-secondary: #9cb3c9; 6 | 7 | error-red: #d64d96; 8 | 9 | green: #3cd483; 10 | 11 | indigo-light: #1f364d; 12 | indigo-normal: #0f273d; 13 | indigo-dark: #0c2033; 14 | 15 | blue-normal: #4997e5; 16 | blue-dark: #457cb2; 17 | */ 18 | 19 | /* Load Open Sans font from Google Fonts */ 20 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400'); 21 | 22 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 23 | button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 24 | 25 | body { 26 | background-color: #0c2033; 27 | font-family: 'Open Sans', sans-serif; 28 | color: #fff; 29 | letter-spacing: .5px; 30 | font-size: 16px; 31 | font-weight: 300; 32 | } 33 | 34 | .percent-raised { 35 | color: #3cd483; 36 | } 37 | 38 | .percent-fallen { 39 | color: #d64d96; 40 | } 41 | 42 | .loading-container { 43 | width: 100%; 44 | text-align: center; 45 | margin: 40px auto; 46 | } 47 | 48 | .error { 49 | width: 100%; 50 | margin: 40px 0; 51 | text-align: center; 52 | color: #d64d96; 53 | } 54 | 55 | /* Remove close icon from input on IE */ 56 | input::-ms-clear { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /stages/14/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 4 | import Header from './components/common/Header'; 5 | import List from './components/list/List'; 6 | import NotFound from './components/notfound/NotFound'; 7 | import Detail from './components/detail/Detail'; 8 | import './index.css'; 9 | 10 | const App = () => { 11 | return ( 12 | 13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | ); 24 | } 25 | 26 | ReactDOM.render( 27 | , 28 | document.getElementById('root') 29 | ); 30 | -------------------------------------------------------------------------------- /stages/final/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /stages/final/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-coin", 3 | "version": "1.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "react-scripts": "1.1.0" 7 | }, 8 | "dependencies": { 9 | "prop-types": "^15.6.0", 10 | "react": "^16.2.0", 11 | "react-dom": "^16.2.0", 12 | "react-router-dom": "^4.2.2" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test --env=jsdom", 18 | "eject": "react-scripts eject" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /stages/final/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Coin 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /stages/final/src/components/common/Header.css: -------------------------------------------------------------------------------- 1 | .Header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | background-color: #0f273d; 7 | width: 100%; 8 | height: 80px; 9 | } 10 | 11 | .Header-logo { 12 | position: absolute; 13 | top: 30px; 14 | left: 20px; 15 | width: 90px; 16 | } 17 | 18 | @media (max-width: 700px) { 19 | .Header-logo { 20 | display: none; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stages/final/src/components/common/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import Search from './Search'; 4 | import logo from './logo.png'; 5 | import './Header.css'; 6 | 7 | const Header = () => { 8 | return ( 9 |
10 | 11 | logo 12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | 19 | export default Header; 20 | -------------------------------------------------------------------------------- /stages/final/src/components/common/Loading.css: -------------------------------------------------------------------------------- 1 | .Loading { 2 | display: inline-block; 3 | border: 2px solid #fff; 4 | border-right-color: transparent; 5 | border-radius: 50%; 6 | animation: rotate 1s infinite linear; 7 | } 8 | 9 | @keyframes rotate { 10 | 0% { 11 | transform: rotate(0deg); 12 | } 13 | 14 | 100% { 15 | transform: rotate(360deg); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /stages/final/src/components/common/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Loading.css'; 4 | 5 | const Loading = (props) => { 6 | const { width, height } = props; 7 | 8 | return ( 9 |
13 | ); 14 | }; 15 | 16 | Loading.propTypes = { 17 | width: PropTypes.string, 18 | height: PropTypes.string, 19 | }; 20 | 21 | Loading.defaultProps = { 22 | width: '28px', 23 | height: '28px', 24 | }; 25 | 26 | export default Loading; 27 | -------------------------------------------------------------------------------- /stages/final/src/components/common/Search.css: -------------------------------------------------------------------------------- 1 | .Search { 2 | position: relative; 3 | width: 30%; 4 | height: 35px; 5 | margin: 0 auto; 6 | padding: 0 20px; 7 | } 8 | 9 | @media (max-width: 700px) { 10 | .Search { 11 | width: 100%; 12 | } 13 | } 14 | 15 | .Search-icon { 16 | z-index: 1; 17 | position: absolute; 18 | top: 9px; 19 | left: 28px; 20 | background-image: url('./search.png'); 21 | background-repeat: no-repeat; 22 | background-position: center; 23 | background-size: cover; 24 | width: 18px; 25 | height: 18px; 26 | } 27 | 28 | .Search-input { 29 | box-sizing: border-box; 30 | background-color: #1f364d; 31 | border-radius: 4px; 32 | border: 0; 33 | padding-left: 35px; 34 | color: white; 35 | opacity: .8; 36 | transition: opacity .2s; 37 | width: 100%; 38 | height: 35px; 39 | } 40 | 41 | .Search-input:focus { 42 | outline: none; 43 | opacity: 1; 44 | } 45 | 46 | .Search ::placeholder { 47 | color: #9cb3c9; 48 | opacity: 1; 49 | } 50 | 51 | .Search-loading { 52 | position: absolute; 53 | top: 9px; 54 | right: 28px; 55 | } 56 | 57 | .Search-result-container { 58 | position: relative; 59 | width: 100%; 60 | max-height: 299px; 61 | overflow-y: auto; 62 | background-color: #0f273d; 63 | border: 1px solid #0c2033; 64 | border-radius: 4px; 65 | box-shadow: 0px 0px 40px 0px#1f364d; 66 | margin-top: 10px; 67 | } 68 | 69 | .Search-result { 70 | color: #9cb3c9; 71 | padding: 15px 0 15px 35px; 72 | border-bottom: 2px solid #0c2033; 73 | cursor: pointer; 74 | } 75 | 76 | .Search-result:hover { 77 | color: #fff; 78 | } 79 | 80 | .Search-no-result { 81 | color: #9cb3c9; 82 | padding: 15px 0 15px 35px; 83 | border-bottom: 1px solid #0f273d; 84 | } 85 | -------------------------------------------------------------------------------- /stages/final/src/components/common/Search.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withRouter } from 'react-router-dom'; 4 | import { handleResponse } from '../../helpers.js'; 5 | import Loading from '../common/Loading'; 6 | import { API_URL } from '../../config'; 7 | import './Search.css'; 8 | 9 | class Search extends React.Component { 10 | constructor() { 11 | super(); 12 | 13 | this.state = { 14 | searchResults: [], 15 | searchQuery: '', 16 | loading: false, 17 | } 18 | 19 | this.handleRedirect = this.handleRedirect.bind(this); 20 | this.handleChange = this.handleChange.bind(this); 21 | } 22 | 23 | handleChange(e) { 24 | const searchQuery = e.target.value; 25 | 26 | this.setState({ searchQuery }); 27 | 28 | // If searchQuery isn't present, don't send request to server 29 | if (!searchQuery) { 30 | return false; 31 | } 32 | 33 | // Set loading to true, while we are fetching data from server 34 | this.setState({ loading: true }); 35 | 36 | fetch(`${API_URL}/autocomplete?searchQuery=${searchQuery}`) 37 | .then(handleResponse) 38 | .then((result) => { 39 | this.setState({ 40 | searchResults: result, 41 | loading: false, 42 | }); 43 | }); 44 | } 45 | 46 | handleRedirect(currencyId) { 47 | // Clear input value and close autocomplete container, 48 | // by clearing searchQuery state 49 | this.setState({ 50 | searchQuery: '', 51 | searchResults: [], 52 | }); 53 | 54 | // Redirect to currency page 55 | this.props.history.push(`/currency/${currencyId}`); 56 | } 57 | 58 | renderSearchResults() { 59 | const { searchResults, searchQuery, loading } = this.state; 60 | 61 | if (!searchQuery) { 62 | return ''; 63 | } 64 | 65 | if (searchResults.length > 0) { 66 | return ( 67 |
68 | {searchResults.map(result => 69 |
this.handleRedirect(result.id)} 73 | > 74 | {result.name} ({result.symbol}) 75 |
76 | )} 77 |
78 | ) 79 | } 80 | 81 | // Send no result, only if loading is set to false 82 | // To avoid showing no result, when actually there are ones 83 | if (!loading) { 84 | return ( 85 |
86 |
87 | No results found. 88 |
89 |
90 | ) 91 | } 92 | } 93 | 94 | render() { 95 | const { searchQuery, loading } = this.state; 96 | 97 | return ( 98 |
99 |
100 | 101 | 108 | 109 | {loading && 110 |
111 | 115 |
} 116 |
117 | 118 | {this.renderSearchResults()} 119 |
120 | ); 121 | } 122 | } 123 | 124 | Search.propTypes = { 125 | history: PropTypes.object.isRequired, 126 | } 127 | 128 | export default withRouter(Search); 129 | -------------------------------------------------------------------------------- /stages/final/src/components/common/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/final/src/components/common/logo.png -------------------------------------------------------------------------------- /stages/final/src/components/common/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udilia/learn-react-by-building-a-web-app/d8ead336edb6c68e444337d5725bacca89c6ef35/stages/final/src/components/common/search.png -------------------------------------------------------------------------------- /stages/final/src/components/detail/Detail.css: -------------------------------------------------------------------------------- 1 | .Detail { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | margin-top: 30px; 6 | margin-bottom: 40px; 7 | padding: 0 60px; 8 | } 9 | 10 | .Detail-heading { 11 | font-size: 24px; 12 | font-weight: 300; 13 | } 14 | 15 | .Detail-container { 16 | width: 100%; 17 | max-width: 400px; 18 | margin-top: 30px; 19 | padding: 40px 40px 0; 20 | border-radius: 4px; 21 | box-shadow: 0px 0px 40px 0px#1f364d; 22 | } 23 | 24 | .Detail-item { 25 | margin-bottom: 50px; 26 | } 27 | 28 | .Detail-value { 29 | border-radius: 20px; 30 | background-color: #1f364d; 31 | font-size: 14px; 32 | padding: 8px 12px; 33 | margin-left: 10px; 34 | } 35 | 36 | .Detail-title { 37 | display: block; 38 | color: #9cb3c9; 39 | font-size: 12px; 40 | font-weight: bold; 41 | margin-bottom: 10px; 42 | } 43 | 44 | .Detail-dollar { 45 | color: #9cb3c9; 46 | margin-right: 6px; 47 | } 48 | -------------------------------------------------------------------------------- /stages/final/src/components/detail/Detail.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { API_URL } from '../../config'; 3 | import { handleResponse, renderChangePercent } from '../../helpers.js'; 4 | import Loading from '../common/Loading'; 5 | import './Detail.css'; 6 | 7 | class Detail extends React.Component { 8 | constructor() { 9 | super(); 10 | 11 | this.state = { 12 | currency: {}, 13 | error: '', 14 | loading: false, 15 | } 16 | } 17 | 18 | componentWillMount() { 19 | // Get id from url params 20 | const currencyId = this.props.match.params.id; 21 | 22 | // Fetch currency 23 | this.fetchCurrency(currencyId); 24 | } 25 | 26 | componentWillReceiveProps(nextProps) { 27 | if (this.props.location.pathname !== nextProps.location.pathname) { 28 | // Get id from new url params 29 | const currencyId = nextProps.match.params.id; 30 | 31 | // Fetch currency 32 | this.fetchCurrency(currencyId); 33 | } 34 | } 35 | 36 | fetchCurrency(currencyId) { 37 | // Set loading to true, while we are fetching data from server 38 | this.setState({ loading: true }); 39 | 40 | fetch(`${API_URL}/cryptocurrencies/${currencyId}`) 41 | .then(handleResponse) 42 | .then((currency) => { 43 | // Set received data in components state 44 | // Clear error if any and set loading to false 45 | this.setState({ 46 | currency, 47 | error: '', 48 | loading: false, 49 | }); 50 | }) 51 | .catch((error) => { 52 | // Show error message, if request fails and set loading to false 53 | this.setState({ 54 | error: error.errorMessage, 55 | loading: false, 56 | }); 57 | }); 58 | } 59 | 60 | render() { 61 | const { currency, loading, error } = this.state; 62 | 63 | // Render only loading component, if loading state is set to true 64 | if (loading) { 65 | return
66 | } 67 | 68 | // Render only error message, if error occured while fetching data 69 | if (error) { 70 | return
{error}
71 | } 72 | 73 | return ( 74 |
75 |

76 | {currency.name} ({currency.symbol}) 77 |

78 | 79 |
80 |
81 | Price $ {currency.price} 82 |
83 |
84 | Rank {currency.rank} 85 |
86 |
87 | 24H change 88 | 89 | {renderChangePercent(currency.percentChange24h)} 90 | 91 |
92 |
93 | Market cap 94 | $ 95 | {currency.marketCap} 96 |
97 |
98 | 24H Volume 99 | $ 100 | {currency.volume24h} 101 |
102 |
103 | Total supply 104 | {currency.totalSupply} 105 |
106 |
107 |
108 | ); 109 | } 110 | } 111 | 112 | export default Detail; 113 | -------------------------------------------------------------------------------- /stages/final/src/components/list/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { API_URL } from '../../config'; 3 | import { handleResponse } from '../../helpers.js'; 4 | import Pagination from './Pagination'; 5 | import Loading from '../common/Loading'; 6 | import Table from './Table'; 7 | 8 | class List extends React.Component { 9 | constructor() { 10 | super(); 11 | 12 | this.state = { 13 | page: 1, 14 | totalPages: 0, 15 | // NOTE: Don't set it greater than 50, because maximum perPage for API is 50 16 | perPage: 20, 17 | currencies: [], 18 | loading: false, 19 | error: '', 20 | }; 21 | 22 | this.handlePaginationClick = this.handlePaginationClick.bind(this); 23 | } 24 | 25 | componentWillMount() { 26 | this.fetchCurrencies(); 27 | } 28 | 29 | fetchCurrencies() { 30 | const { page, perPage } = this.state; 31 | 32 | // Set loading to true, while we are fetching data from server 33 | this.setState({ loading: true }); 34 | 35 | // Fetch crypto currency data from API with page and perPage parameters 36 | fetch(`${API_URL}/cryptocurrencies/?page=${page}&perPage=${perPage}`) 37 | .then(handleResponse) 38 | .then((data) => { 39 | // Set received data in components state 40 | // Clear error if any and set loading to false 41 | const { totalPages, currencies } = data; 42 | 43 | this.setState({ 44 | currencies, 45 | totalPages, 46 | error: '', 47 | loading: false, 48 | }); 49 | }) 50 | .catch((error) => { 51 | // Show error message, if request fails and set loading to false 52 | this.setState({ 53 | error: error.errorMessage, 54 | loading: false, 55 | }); 56 | }); 57 | } 58 | 59 | handlePaginationClick(direction) { 60 | let nextPage = this.state.page; 61 | 62 | // Increment nextPage if direction variable is next, otherwise decrement it 63 | nextPage = direction === 'next' ? nextPage + 1 : nextPage - 1; 64 | 65 | // Call fetchCurrencies function inside setState's callback 66 | // Because we have to make sure first page state is updated 67 | this.setState({ page: nextPage }, () => { 68 | this.fetchCurrencies(); 69 | }); 70 | } 71 | 72 | render() { 73 | const { currencies, loading, error, page, totalPages } = this.state; 74 | 75 | // Render only loading component, if it's set to true 76 | if (loading) { 77 | return
78 | } 79 | 80 | // Render only error message, if error occured while fetching data 81 | if (error) { 82 | return
{error}
83 | } 84 | 85 | return ( 86 |
87 | 88 | 89 | 94 | 95 | ); 96 | } 97 | } 98 | 99 | export default List; 100 | -------------------------------------------------------------------------------- /stages/final/src/components/list/Pagination.css: -------------------------------------------------------------------------------- 1 | .Pagination { 2 | margin: 50px auto; 3 | text-align: center; 4 | } 5 | 6 | .Pagination-button { 7 | text-align: center; 8 | border: none; 9 | border-radius: 16px; 10 | background-color: #4997e5; 11 | transition: background-color .2s; 12 | color: white; 13 | cursor: pointer; 14 | margin: 10px; 15 | width: 44px; 16 | height: 34px; 17 | } 18 | 19 | .Pagination-button:hover { 20 | background-color: #457cb2; 21 | } 22 | 23 | .Pagination-button:focus { 24 | outline: none; 25 | } 26 | 27 | .Pagination-button:disabled { 28 | background-color: #1f364d; 29 | cursor: not-allowed; 30 | } 31 | 32 | .Pagination-info { 33 | font-size: 12px; 34 | } 35 | -------------------------------------------------------------------------------- /stages/final/src/components/list/Pagination.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Pagination.css'; 4 | 5 | const Pagination = (props) => { 6 | const { totalPages, page, handlePaginationClick } = props; 7 | 8 | return ( 9 |
10 | 17 | 18 | 19 | Page {page} of {totalPages} 20 | 21 | 22 | 29 |
30 | ); 31 | } 32 | 33 | Pagination.propTypes = { 34 | totalPages: PropTypes.number.isRequired, 35 | page: PropTypes.number.isRequired, 36 | handlePaginationClick: PropTypes.func.isRequired, 37 | }; 38 | 39 | export default Pagination; 40 | -------------------------------------------------------------------------------- /stages/final/src/components/list/Table.css: -------------------------------------------------------------------------------- 1 | .Table-container { 2 | overflow-x: auto; /* Needed for table to be responsive */ 3 | } 4 | 5 | .Table { 6 | width: 100%; 7 | border-collapse: collapse; 8 | border-spacing: 0; 9 | } 10 | 11 | .Table-head { 12 | background-color: #0c2033; 13 | } 14 | 15 | .Table-head tr th { 16 | padding: 10px 20px; 17 | color: #9cb3c9; 18 | text-align: left; 19 | font-size: 14px; 20 | font-weight: 400; 21 | } 22 | 23 | .Table-body { 24 | text-align: left; 25 | background-color: #0f273d; 26 | } 27 | 28 | .Table-body tr td { 29 | padding: 24px 20px; 30 | border-bottom: 2px solid #0c2033; 31 | color: #fff; 32 | cursor: pointer; 33 | } 34 | 35 | .Table-rank { 36 | color: #9cb3c9; 37 | margin-right: 18px; 38 | font-size: 12px; 39 | } 40 | 41 | .Table-dollar { 42 | color: #9cb3c9; 43 | margin-right: 6px; 44 | } 45 | -------------------------------------------------------------------------------- /stages/final/src/components/list/Table.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withRouter } from 'react-router-dom'; 4 | import { renderChangePercent } from '../../helpers'; 5 | import './Table.css'; 6 | 7 | const Table = (props) => { 8 | const { history, currencies } = props; 9 | 10 | return ( 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {currencies.map(currency => 23 | history.push(`/currency/${currency.id}`)} 26 | > 27 | 31 | 35 | 39 | 40 | )} 41 | 42 |
CryptocurrencyPriceMarket Cap24H Change
28 | {currency.rank} 29 | {currency.name} 30 | 32 | $ 33 | {currency.price} 34 | 36 | $ 37 | {currency.marketCap} 38 | {renderChangePercent(currency.percentChange24h)}
43 |
44 | ); 45 | } 46 | 47 | Table.propTypes = { 48 | currencies: PropTypes.array.isRequired, 49 | history: PropTypes.object.isRequired, 50 | } 51 | 52 | export default withRouter(Table); 53 | -------------------------------------------------------------------------------- /stages/final/src/components/notfound/NotFound.css: -------------------------------------------------------------------------------- 1 | .NotFound { 2 | width: 100%; 3 | text-align: center; 4 | margin-top: 60px; 5 | } 6 | 7 | .NotFound-title { 8 | font-weight: 400; 9 | color: #9cb3c9; 10 | } 11 | 12 | .NotFound-link { 13 | display: inline-block; 14 | margin-top: 40px; 15 | color: #fff; 16 | text-decoration: none; 17 | border: 1px solid #9cb3c9; 18 | border-radius: 4px; 19 | padding: 18px; 20 | transition: border .2s; 21 | } 22 | 23 | .NotFound-link:hover { 24 | border: 1px solid #fff; 25 | } 26 | -------------------------------------------------------------------------------- /stages/final/src/components/notfound/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom' 3 | import './NotFound.css'; 4 | 5 | const NotFound = () => { 6 | return ( 7 |
8 |

Oops! Page not found

9 | Go to home page 10 |
11 | ); 12 | } 13 | 14 | export default NotFound; 15 | -------------------------------------------------------------------------------- /stages/final/src/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API url 3 | */ 4 | export const API_URL = 'https://api.udilia.com/coins/v1'; 5 | -------------------------------------------------------------------------------- /stages/final/src/helpers.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * Fetch response helper 5 | * 6 | * @param {object} response 7 | */ 8 | export const handleResponse = (response) => { 9 | return response.json() 10 | .then(json => { 11 | if (response.ok) { 12 | return json 13 | } else { 14 | return Promise.reject(json) 15 | } 16 | }) 17 | } 18 | 19 | /** 20 | * Render change percent 21 | * 22 | * Show green text and up arrow if 24h percentage change has been raised 23 | * Red text and down arrow if it has fallen 24 | * Default text color without arrow, if it's zero 25 | * 26 | * @param {number} changePercent 27 | */ 28 | export const renderChangePercent = (changePercent) => { 29 | if (changePercent > 0) { 30 | return {changePercent}% ↑ 31 | } else if (changePercent < 0) { 32 | return {changePercent}% ↓ 33 | } else { 34 | return {changePercent} 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /stages/final/src/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Application colors 3 | 4 | text: #fff; 5 | text-secondary: #9cb3c9; 6 | 7 | error-red: #d64d96; 8 | 9 | green: #3cd483; 10 | 11 | indigo-light: #1f364d; 12 | indigo-normal: #0f273d; 13 | indigo-dark: #0c2033; 14 | 15 | blue-normal: #4997e5; 16 | blue-dark: #457cb2; 17 | */ 18 | 19 | /* Load Open Sans font from Google Fonts */ 20 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:300,400'); 21 | 22 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 23 | button,hr,input{overflow:visible}audio,canvas,progress,video{display:inline-block}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} 24 | 25 | body { 26 | background-color: #0c2033; 27 | font-family: 'Open Sans', sans-serif; 28 | color: #fff; 29 | letter-spacing: .5px; 30 | font-size: 16px; 31 | font-weight: 300; 32 | } 33 | 34 | .percent-raised { 35 | color: #3cd483; 36 | } 37 | 38 | .percent-fallen { 39 | color: #d64d96; 40 | } 41 | 42 | .loading-container { 43 | width: 100%; 44 | text-align: center; 45 | margin: 40px auto; 46 | } 47 | 48 | .error { 49 | width: 100%; 50 | margin: 40px 0; 51 | text-align: center; 52 | color: #d64d96; 53 | } 54 | 55 | /* Remove close icon from input on IE */ 56 | input::-ms-clear { 57 | display: none; 58 | } 59 | -------------------------------------------------------------------------------- /stages/final/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 3 | import ReactDOM from 'react-dom'; 4 | import Header from './components/common/Header'; 5 | import List from './components/list/List'; 6 | import Detail from './components/detail/Detail'; 7 | import NotFound from './components/notfound/NotFound'; 8 | import './index.css'; 9 | 10 | const App = () => { 11 | return ( 12 | 13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | ); 24 | } 25 | 26 | ReactDOM.render(, document.getElementById('root')); 27 | --------------------------------------------------------------------------------