├── .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 |
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 |
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 |
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 |
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 | Cryptocurrency
66 | Price
67 | Market Cap
68 | 24H Change
69 |
70 |
71 |
72 | {currencies.map((currency) => (
73 |
74 |
75 | {currency.rank}
76 | {currency.name}
77 |
78 |
79 | $ {currency.price}
80 |
81 |
82 | $ {currency.marketCap}
83 |
84 |
85 | {this.renderChangePercent(currency.percentChange24h)}
86 |
87 |
88 | ))}
89 |
90 |
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 |
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 | Cryptocurrency
66 | Price
67 | Market Cap
68 | 24H Change
69 |
70 |
71 |
72 | {currencies.map((currency) => (
73 |
74 |
75 | {currency.rank}
76 | {currency.name}
77 |
78 |
79 | $ {currency.price}
80 |
81 |
82 | $ {currency.marketCap}
83 |
84 |
85 | {this.renderChangePercent(currency.percentChange24h)}
86 |
87 |
88 | ))}
89 |
90 |
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 |
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 |
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 | handlePaginationClick('prev')}
13 | disabled={page <= 1}
14 | >
15 | ←
16 |
17 |
18 |
19 | page {page} of {totalPages}
20 |
21 |
22 | handlePaginationClick('next')}
25 | disabled={page >= totalPages}
26 | >
27 | →
28 |
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 | Cryptocurrency
14 | Price
15 | Market Cap
16 | 24H Change
17 |
18 |
19 |
20 | {currencies.map((currency) => (
21 |
22 |
23 | {currency.rank}
24 | {currency.name}
25 |
26 |
27 | $
28 | {currency.price}
29 |
30 |
31 | $
32 | {currency.marketCap}
33 |
34 |
35 | {renderChangePercent(currency.percentChange24h)}
36 |
37 |
38 | ))}
39 |
40 |
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 |
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 |
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 | handlePaginationClick('prev')}
13 | disabled={page <= 1}
14 | >
15 | ←
16 |
17 |
18 |
19 | page {page} of {totalPages}
20 |
21 |
22 | handlePaginationClick('next')}
25 | disabled={page >= totalPages}
26 | >
27 | →
28 |
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 | Cryptocurrency
15 | Price
16 | Market Cap
17 | 24H Change
18 |
19 |
20 |
21 | {currencies.map((currency) => (
22 | history.push(`/currency/${currency.id}`)}
25 | >
26 |
27 | {currency.rank}
28 | {currency.name}
29 |
30 |
31 | $
32 | {currency.price}
33 |
34 |
35 | $
36 | {currency.marketCap}
37 |
38 |
39 | {renderChangePercent(currency.percentChange24h)}
40 |
41 |
42 | ))}
43 |
44 |
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 |
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 |
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 | handlePaginationClick('prev')}
13 | disabled={page <= 1}
14 | >
15 | ←
16 |
17 |
18 |
19 | page {page} of {totalPages}
20 |
21 |
22 | handlePaginationClick('next')}
25 | disabled={page >= totalPages}
26 | >
27 | →
28 |
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 | Cryptocurrency
16 | Price
17 | Market Cap
18 | 24H Change
19 |
20 |
21 |
22 | {currencies.map((currency) => (
23 | history.push(`/currency/${currency.id}`)}
26 | >
27 |
28 | {currency.rank}
29 | {currency.name}
30 |
31 |
32 | $
33 | {currency.price}
34 |
35 |
36 | $
37 | {currency.marketCap}
38 |
39 |
40 | {renderChangePercent(currency.percentChange24h)}
41 |
42 |
43 | ))}
44 |
45 |
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 |
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 |
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 | handlePaginationClick('prev')}
13 | disabled={page <= 1}
14 | >
15 | ←
16 |
17 |
18 |
19 | page {page} of {totalPages}
20 |
21 |
22 | handlePaginationClick('next')}
25 | disabled={page >= totalPages}
26 | >
27 | →
28 |
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 | Cryptocurrency
16 | Price
17 | Market Cap
18 | 24H Change
19 |
20 |
21 |
22 | {currencies.map((currency) => (
23 | history.push(`/currency/${currency.id}`)}
26 | >
27 |
28 | {currency.rank}
29 | {currency.name}
30 |
31 |
32 | $
33 | {currency.price}
34 |
35 |
36 | $
37 | {currency.marketCap}
38 |
39 |
40 | {renderChangePercent(currency.percentChange24h)}
41 |
42 |
43 | ))}
44 |
45 |
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 |
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 |
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 | handlePaginationClick('prev')}
13 | disabled={page <= 1}
14 | >
15 | ←
16 |
17 |
18 |
19 | page {page} of {totalPages}
20 |
21 |
22 | handlePaginationClick('next')}
25 | disabled={page >= totalPages}
26 | >
27 | →
28 |
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 | Cryptocurrency
16 | Price
17 | Market Cap
18 | 24H Change
19 |
20 |
21 |
22 | {currencies.map((currency) => (
23 | history.push(`/currency/${currency.id}`)}
26 | >
27 |
28 | {currency.rank}
29 | {currency.name}
30 |
31 |
32 | $
33 | {currency.price}
34 |
35 |
36 | $
37 | {currency.marketCap}
38 |
39 |
40 | {renderChangePercent(currency.percentChange24h)}
41 |
42 |
43 | ))}
44 |
45 |
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 |
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 |
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 | handlePaginationClick('prev')}
13 | disabled={page <= 1}
14 | >
15 | ←
16 |
17 |
18 |
19 | page {page} of {totalPages}
20 |
21 |
22 | handlePaginationClick('next')}
25 | disabled={page >= totalPages}
26 | >
27 | →
28 |
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 | Cryptocurrency
16 | Price
17 | Market Cap
18 | 24H Change
19 |
20 |
21 |
22 | {currencies.map((currency) => (
23 | history.push(`/currency/${currency.id}`)}
26 | >
27 |
28 | {currency.rank}
29 | {currency.name}
30 |
31 |
32 | $
33 | {currency.price}
34 |
35 |
36 | $
37 | {currency.marketCap}
38 |
39 |
40 | {renderChangePercent(currency.percentChange24h)}
41 |
42 |
43 | ))}
44 |
45 |
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 |
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 |
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 | handlePaginationClick('prev')}
14 | >
15 | ←
16 |
17 |
18 |
19 | Page {page} of {totalPages}
20 |
21 |
22 | handlePaginationClick('next')}
26 | >
27 | →
28 |
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 | Cryptocurrency
16 | Price
17 | Market Cap
18 | 24H Change
19 |
20 |
21 |
22 | {currencies.map(currency =>
23 | history.push(`/currency/${currency.id}`)}
26 | >
27 |
28 | {currency.rank}
29 | {currency.name}
30 |
31 |
32 | $
33 | {currency.price}
34 |
35 |
36 | $
37 | {currency.marketCap}
38 |
39 | {renderChangePercent(currency.percentChange24h)}
40 | )}
41 |
42 |
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 |
--------------------------------------------------------------------------------