├── public
├── favicon.png
├── robots.txt
├── blueLogo.png
├── whiteLogo.png
└── index.html
├── src
├── styles
│ ├── fonts
│ │ ├── NotoSansKR-Black.otf
│ │ ├── NotoSansKR-Black.woff
│ │ ├── NotoSansKR-Bold.otf
│ │ ├── NotoSansKR-Bold.woff
│ │ ├── NotoSansKR-Bold.woff2
│ │ ├── NotoSansKR-Light.otf
│ │ ├── NotoSansKR-Light.woff
│ │ ├── NotoSansKR-Medium.otf
│ │ ├── NotoSansKR-Thin.otf
│ │ ├── NotoSansKR-Thin.woff
│ │ ├── NotoSansKR-Thin.woff2
│ │ ├── NotoSansKR-Black.woff2
│ │ ├── NotoSansKR-Light.woff2
│ │ ├── NotoSansKR-Medium.woff
│ │ ├── NotoSansKR-Medium.woff2
│ │ ├── NotoSansKR-Regular.otf
│ │ ├── NotoSansKR-Regular.woff
│ │ ├── NotoSansKR-DemiLight.otf
│ │ ├── NotoSansKR-DemiLight.woff
│ │ ├── NotoSansKR-DemiLight.woff2
│ │ └── NotoSansKR-Regular.woff2
│ ├── GlobalStyle.js
│ └── theme.js
├── setupTests.js
├── Container
│ ├── withThemeData.js
│ ├── withTradeListData.js
│ ├── withOHLCData.js
│ ├── withSize.js
│ ├── withSelectedCoinName.js
│ ├── withLoadingData.js
│ ├── withLatestCoinData.js
│ ├── withSelectedOption.js
│ ├── withMarketNames.js
│ ├── withOrderbookData.js
│ └── withSelectedCoinPrice.js
├── Router
│ └── MainRouter.js
├── App.js
├── Reducer
│ ├── index.js
│ ├── loadingReducer.js
│ └── coinReducer.js
├── Components
│ ├── Main
│ │ ├── OrderInfoTradeList.js
│ │ ├── OrderbookCoinInfo.js
│ │ ├── MainChart-d3fc.js
│ │ ├── TradeListItem.js
│ │ ├── OrderInfo.js
│ │ ├── ChartDataConsole.js
│ │ ├── Orderbook.js
│ │ ├── TradeList.js
│ │ ├── OrderbookItem.js
│ │ ├── MainChart-old.js
│ │ ├── CoinListItem.js
│ │ ├── CoinInfoHeader.js
│ │ ├── CoinList.js
│ │ ├── OrderInfoAskBid.js
│ │ └── MainChart.js
│ └── Global
│ │ ├── Loading.js
│ │ ├── Header.js
│ │ └── Footer.js
├── index.css
├── index.js
├── Api
│ └── api.js
├── Pages
│ └── Main.js
├── serviceWorker.js
└── Lib
│ ├── asyncUtil.js
│ └── utils.js
├── LICENSE
├── package.json
├── .gitignore
└── README.md
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/blueLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/public/blueLogo.png
--------------------------------------------------------------------------------
/public/whiteLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/public/whiteLogo.png
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Black.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Black.otf
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Black.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Black.woff
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Bold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Bold.otf
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Bold.woff
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Bold.woff2
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Light.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Light.otf
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Light.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Light.woff
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Medium.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Medium.otf
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Thin.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Thin.otf
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Thin.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Thin.woff
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Thin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Thin.woff2
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Black.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Black.woff2
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Light.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Light.woff2
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Medium.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Medium.woff
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Medium.woff2
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Regular.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Regular.otf
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Regular.woff
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-DemiLight.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-DemiLight.otf
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-DemiLight.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-DemiLight.woff
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-DemiLight.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-DemiLight.woff2
--------------------------------------------------------------------------------
/src/styles/fonts/NotoSansKR-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Seongkyun-Yu/upbit-downbit/HEAD/src/styles/fonts/NotoSansKR-Regular.woff2
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/src/Container/withThemeData.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import { ThemeContext } from "styled-components";
3 |
4 | const withThemeData = () => (OriginalComponent) => (props) => {
5 | const theme = useContext(ThemeContext); // 테마 정보
6 | return ;
7 | };
8 |
9 | export default withThemeData;
10 |
--------------------------------------------------------------------------------
/src/Router/MainRouter.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Switch, Route } from "react-router-dom";
3 | import Main from "../Pages/Main";
4 |
5 | const MainRouter = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | );
12 | };
13 |
14 | export default MainRouter;
15 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { useDispatch } from "react-redux";
3 | import { startInit } from "./Reducer/coinReducer";
4 | import MainRouter from "./Router/MainRouter";
5 |
6 | function App() {
7 | const dispatch = useDispatch();
8 | useEffect(() => {
9 | dispatch(startInit());
10 | }, [dispatch]);
11 |
12 | return ;
13 | }
14 |
15 | export default App;
16 |
--------------------------------------------------------------------------------
/src/Reducer/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux";
2 | import { coinReducer, coinSaga } from "./coinReducer";
3 | import { loadingReducer } from "./loadingReducer";
4 | import { all } from "redux-saga/effects";
5 |
6 | const rootReducer = combineReducers({
7 | Coin: coinReducer,
8 | Loading: loadingReducer,
9 | });
10 |
11 | function* rootSaga() {
12 | yield all([coinSaga()]);
13 | }
14 |
15 | export { rootReducer, rootSaga };
16 |
--------------------------------------------------------------------------------
/src/styles/GlobalStyle.js:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from "styled-components";
2 | import normalize from "styled-normalize";
3 | import reset from "styled-reset";
4 |
5 | const GlobalStyle = createGlobalStyle`
6 | ${normalize}
7 | ${reset}
8 | * {
9 | box-sizing: border-box;
10 | }
11 |
12 | body {
13 | background-color: rgb(231, 234, 239);
14 | /* height: 100%; */
15 | }
16 | `;
17 |
18 | export default GlobalStyle;
19 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 | Downbit
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/Components/Main/OrderInfoTradeList.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 |
4 | const St = {
5 | Container: styled.div`
6 | display: flex;
7 | justify-content: center;
8 | align-items: center;
9 | width: 100%;
10 | height: 212px;
11 | background-color: white;
12 | font-size: 0.8rem;
13 | color: #666;
14 | `,
15 | };
16 |
17 | const OrderInfoTradeList = ({ theme }) => {
18 | return 로그인 후 사용 가능합니다.;
19 | };
20 |
21 | export default React.memo(OrderInfoTradeList);
22 |
--------------------------------------------------------------------------------
/src/Container/withTradeListData.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector } from "react-redux";
3 |
4 | const withTradeListData = () => (OriginalComponent) => (props) => {
5 | const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
6 | const selectedTradeListData = useSelector(
7 | (state) => state.Coin.tradeList.data[selectedMarket]
8 | );
9 |
10 | return (
11 |
15 | );
16 | };
17 |
18 | export default withTradeListData;
19 |
--------------------------------------------------------------------------------
/src/Container/withOHLCData.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector } from "react-redux";
3 |
4 | const withOHLCData = () => (OriginalComponent) => () => {
5 | const selectedMarket = useSelector((state) => state.Coin.selectedMarket); // 선택된 코인/마켓
6 | const selectedCandles = useSelector(
7 | (state) => state.Coin.candle.data[selectedMarket].candles
8 | ); // 선택된 코인/마켓 캔들 정보
9 |
10 | // return selectedCandles.length ? (
11 | //
12 | // ) : (
13 | // Chart Loading
14 | // );
15 | return ;
16 | };
17 |
18 | export default withOHLCData;
19 |
--------------------------------------------------------------------------------
/src/Components/Global/Loading.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactLoading from "react-loading";
3 | import styled, { css } from "styled-components";
4 |
5 | const St = {
6 | Container: styled.div`
7 | display: flex;
8 | justify-content: center;
9 | align-items: center;
10 | width: 100%;
11 | height: 100%;
12 | ${({ isCenter }) =>
13 | !isCenter &&
14 | css`
15 | align-items: stretch;
16 | margin-top: 200px;
17 | `}
18 | `,
19 | };
20 |
21 | const Loading = ({ center = true }) => {
22 | return (
23 |
24 |
30 |
31 | );
32 | };
33 |
34 | export default Loading;
35 |
--------------------------------------------------------------------------------
/src/Container/withSize.js:
--------------------------------------------------------------------------------
1 | import { throttle } from "lodash";
2 | import React, { useCallback, useEffect, useState } from "react";
3 |
4 | const withSize = () => (OriginalComponent) => (props) => {
5 | const [widthSize, setWidthSize] = useState(window.innerWidth);
6 | const [heightSize, setHeightSize] = useState(window.innerHeight);
7 |
8 | const handleSize = useCallback(() => {
9 | setWidthSize(window.innerWidth);
10 | setHeightSize(window.innerHeight);
11 | }, []);
12 |
13 | useEffect(() => {
14 | window.addEventListener("resize", throttle(handleSize, 200));
15 | return () => {
16 | window.removeEventListener("resize", handleSize);
17 | };
18 | }, [handleSize]);
19 |
20 | return (
21 |
26 | );
27 | };
28 |
29 | export default withSize;
30 |
--------------------------------------------------------------------------------
/src/Container/withSelectedCoinName.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector } from "react-redux";
3 |
4 | const withSelectedCoinName = () => (OriginalComponent) => (props) => {
5 | const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
6 | const coinNameKor = useSelector(
7 | (state) => state.Coin.marketNames.data[selectedMarket].korean
8 | );
9 | const coinNameEng = useSelector(
10 | (state) => state.Coin.marketNames.data[selectedMarket].english
11 | );
12 |
13 | const splitedName = selectedMarket.split("-");
14 | const coinSymbol = splitedName[1];
15 | const coinNameAndMarketEng = splitedName[1] + "/" + splitedName[0];
16 |
17 | return (
18 |
25 | );
26 | };
27 |
28 | export default withSelectedCoinName;
29 |
--------------------------------------------------------------------------------
/src/styles/theme.js:
--------------------------------------------------------------------------------
1 | const viewSize = {
2 | mobileS: 480,
3 | mobileM: 770,
4 | tablet: 1279,
5 | desktop: 1280,
6 | };
7 |
8 | const theme = {
9 | deepBlue: "#093687",
10 | skyBlue1: "rgba(0,98,223,.03)",
11 | skyBlue2: "rgba(0,98,223,.09)",
12 | lightPink1: "rgba(216,14,53,.03);",
13 | lightPink2: "rgba(216,14,53,.09);",
14 | strongRed: "#d80e35",
15 | strongBlue: "#115DCB",
16 | priceUp: "rgb(210, 79, 69)",
17 | priceDown: "rgb(18, 97, 196)",
18 | priceUpTrans: "rgba(210, 79, 69, 0.5)",
19 | priceDownTrans: "rgba(18, 97, 196, 0.5)",
20 | middleGray: "#00000033",
21 | lightGray: "rgb(244, 245, 248)",
22 | lightGray1: "rgb(249, 250, 252)",
23 | lightGray2: "rgb(212, 214, 220)",
24 | mobileS: `(max-width: ${viewSize.mobileS}px)`,
25 | mobileM: `(max-width: ${viewSize.mobileM}px)`,
26 | tablet: `(max-width: ${viewSize.tablet}px)`,
27 | desktop: `(min-width: ${viewSize.desktop}px)`,
28 | };
29 |
30 | export { viewSize };
31 | export default theme;
32 |
--------------------------------------------------------------------------------
/src/Reducer/loadingReducer.js:
--------------------------------------------------------------------------------
1 | const START_LOADING = "loading/START_LOADING";
2 | const FINISH_LOADING = "loading/FINISH_LOADING";
3 |
4 | const startLoading = (payload) => ({ type: START_LOADING, payload });
5 | const finishLoading = (payload) => ({ type: FINISH_LOADING, payload });
6 |
7 | const initialState = {
8 | "coin/GET_ONE_COIN_CANDLES": true,
9 | "coin/GET_INIT_ORDERBOOKS": true,
10 | "coin/GET_ONE_COIN_TRADELISTS": true,
11 | "coin/GET_INIT_CANDLES": true,
12 | "coin/GET_MARKET_NAMES": true,
13 | "coin/GET_ADDITIONAL_COIN_CANDLES": false,
14 | };
15 |
16 | const loadingReducer = (state = initialState, action) => {
17 | switch (action.type) {
18 | case START_LOADING:
19 | return {
20 | ...state,
21 | [action.payload]: true,
22 | };
23 | case FINISH_LOADING:
24 | return {
25 | ...state,
26 | [action.payload]: false,
27 | };
28 | default:
29 | return state;
30 | }
31 | };
32 |
33 | export { startLoading, finishLoading, loadingReducer };
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Seongkyun Yu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Container/withLoadingData.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector } from "react-redux";
3 |
4 | const withLoadingData = () => (OriginalComponent) => (props) => {
5 | const isCandleLoading = useSelector(
6 | (state) => state.Loading["coin/GET_ONE_COIN_CANDLES"]
7 | );
8 | const isOrderbookLoading = useSelector(
9 | (state) => state.Loading["coin/GET_INIT_ORDERBOOKS"]
10 | );
11 | const isTradeListLoading = useSelector(
12 | (state) => state.Loading["coin/GET_ONE_COIN_TRADELISTS"]
13 | );
14 | const isInitCandleLoading = useSelector(
15 | (state) => state.Loading["coin/GET_INIT_CANDLES"]
16 | );
17 | const isMarketNamesLoading = useSelector(
18 | (state) => state.Loading["coin/GET_MARKET_NAMES"]
19 | );
20 |
21 | return (
22 |
30 | );
31 | };
32 |
33 | export default withLoadingData;
34 |
--------------------------------------------------------------------------------
/src/Container/withLatestCoinData.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector } from "react-redux";
3 |
4 | const withLatestCoinData = () => (OriginalComponent) => (props) => {
5 | const coinListDatas = useSelector((state) => state.Coin.candle.data); // 코인들 데이터
6 |
7 | const latestCoinData = {};
8 |
9 | if (Object.keys(coinListDatas).length > 2) {
10 | Object.keys(coinListDatas).forEach((marketName) => {
11 | latestCoinData[marketName] = {};
12 | latestCoinData[marketName].price =
13 | coinListDatas[marketName].candles[
14 | coinListDatas[marketName].candles.length - 1
15 | ].close;
16 |
17 | latestCoinData[marketName].changeRate24Hour = (
18 | Math.round(coinListDatas[marketName].changeRate24Hour * 10000) / 100
19 | ).toFixed(2);
20 |
21 | latestCoinData[marketName].changePrice24Hour =
22 | coinListDatas[marketName].changePrice24Hour;
23 |
24 | latestCoinData[marketName].tradePrice24Hour = Math.floor(
25 | coinListDatas[marketName].tradePrice24Hour / 1000000
26 | );
27 | });
28 | }
29 |
30 | return ;
31 | };
32 |
33 | export default withLatestCoinData;
34 |
--------------------------------------------------------------------------------
/src/Container/withSelectedOption.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector } from "react-redux";
3 |
4 | const withSelectedOption = () => (OriginalComponent) => (props) => {
5 | const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
6 | const selectedTimeType = useSelector((state) => state.Coin.selectedTimeType);
7 | const selectedTimeCount = useSelector(
8 | (state) => state.Coin.selectedTimeCount
9 | );
10 | const selectedAskBidOrder = useSelector(
11 | (state) => state.Coin.selectedAskBidOrder
12 | );
13 | const searchCoin = useSelector((state) => state.Coin.searchCoin);
14 | const orderPrice = useSelector((state) => state.Coin.orderPrice);
15 | const orderAmount = useSelector((state) => state.Coin.orderAmount);
16 | const orderTotalPrice = useSelector((state) => state.Coin.orderTotalPrice);
17 |
18 | return (
19 |
30 | );
31 | };
32 |
33 | export default withSelectedOption;
34 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | html {
2 | font-family: "Noto Sans CJK KR", sans-serif;
3 | }
4 |
5 | /*
6 | @font-face {
7 | font-family: "Noto Sans CJK KR";
8 | font-style: normal;
9 | font-weight: 100;
10 | src: url("/src/styles/fonts/NotoSansKR-Light.woff2") format("woff2"),
11 | url("/src/styles/fonts/NotoSansKR-Light.woff") format("woff"),
12 | url("/src/styles/fonts/NotoSansKR-Light.otf") format("truetype");
13 | }
14 |
15 | @font-face {
16 | font-family: "Noto Sans CJK KR";
17 | font-style: normal;
18 | font-weight: normal;
19 | src: url("/src/styles/fonts/NotoSansKR-Regular.woff2") format("woff2"),
20 | url("/src/styles/fonts/NotoSansKR-Regular.woff") format("woff"),
21 | url("/src/styles/fonts/NotoSansKR-Regular.otf") format("truetype");
22 | }
23 |
24 | @font-face {
25 | font-family: "Noto Sans CJK KR";
26 | font-style: normal;
27 | font-weight: 500;
28 | src: url("/src/styles/fonts/NotoSansKR-Medium.woff2") format("woff2"),
29 | url("/src/styles/fonts/NotoSansKR-Medium.woff") format("woff"),
30 | url("/src/styles/fonts/NotoSansKR-Medium.otf") format("truetype");
31 | }
32 |
33 | @font-face {
34 | font-family: "Noto Sans CJK KR";
35 | font-style: normal;
36 | font-weight: 800;
37 | src: url("/src/styles/fonts/NotoSansKR-Bold.woff2") format("woff2"),
38 | url("/src/styles/fonts/NotoSansKR-Bold.woff") format("woff"),
39 | url("/src/styles/fonts/NotoSansKR-Bold.otf") format("truetype");
40 | } */
41 |
--------------------------------------------------------------------------------
/src/Components/Global/Header.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 |
4 | const St = {
5 | Header: styled.header`
6 | position: sticky;
7 | top: 0;
8 | z-index: 100;
9 | width: 100%;
10 | height: 60px;
11 | background-color: rgb(9, 54, 135);
12 | `,
13 | Container: styled.div`
14 | display: flex;
15 | align-items: center;
16 | width: 100%;
17 | height: 100%;
18 | max-width: 1360px;
19 | margin: 0 auto;
20 |
21 | @media ${({ theme, isRootURL }) => (!isRootURL ? theme.tablet : true)} {
22 | max-width: 950px;
23 | }
24 |
25 | @media ${({ theme, isRootURL }) => (isRootURL ? theme.tablet : true)} {
26 | max-width: 100%;
27 | }
28 | `,
29 | SiteHeading: styled.h1`
30 | padding: 0 20px;
31 | width: 150px;
32 | height: 100%;
33 | `,
34 | MainLink: styled.a`
35 | display: block;
36 | background-image: ${({ logo }) => `url(${logo})`};
37 | background-repeat: no-repeat;
38 | background-position: center;
39 | background-size: contain;
40 | color: transparent;
41 | width: 100%;
42 | height: 100%;
43 | `,
44 | };
45 |
46 | const Header = ({ isRootURL }) => {
47 | return (
48 |
49 |
50 |
51 |
56 | 업비트
57 |
58 |
59 |
60 |
61 | );
62 | };
63 |
64 | export default Header;
65 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import "core-js/stable";
2 | import "core-js/es/set";
3 | import "core-js/es/map";
4 | import "regenerator-runtime/runtime";
5 | import "raf/polyfill";
6 | import "react-app-polyfill/ie9";
7 | import "react-app-polyfill/stable";
8 |
9 | import React from "react";
10 | import ReactDOM from "react-dom";
11 | import App from "./App";
12 | import * as serviceWorker from "./serviceWorker";
13 |
14 | import createSagaMiddleware from "redux-saga";
15 | import ReduxThunk from "redux-thunk";
16 | import { createStore, applyMiddleware } from "redux";
17 | import { rootReducer, rootSaga } from "./Reducer";
18 | import { composeWithDevTools } from "redux-devtools-extension";
19 | import { Provider } from "react-redux";
20 | import { BrowserRouter } from "react-router-dom";
21 |
22 | import { ThemeProvider } from "styled-components";
23 | import theme from "./styles/theme";
24 | import GlobalStyle from "./styles/GlobalStyle";
25 |
26 | // import "./index.css";
27 |
28 | const sagaMiddleware = createSagaMiddleware();
29 | const store = createStore(
30 | rootReducer,
31 | composeWithDevTools(applyMiddleware(ReduxThunk, sagaMiddleware))
32 | );
33 |
34 | sagaMiddleware.run(rootSaga);
35 |
36 | ReactDOM.render(
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | ,
45 | document.getElementById("root")
46 | );
47 |
48 | // If you want your app to work offline and load faster, you can change
49 | // unregister() to register() below. Note this comes with some pitfalls.
50 | // Learn more about service workers: https://bit.ly/CRA-PWA
51 | serviceWorker.unregister();
52 |
--------------------------------------------------------------------------------
/src/Components/Main/OrderbookCoinInfo.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 |
4 | const St = {
5 | CandleInfoContainer: styled.div`
6 | display: flex;
7 | flex-direction: column;
8 | justify-content: flex-end;
9 | width: 33.3333%;
10 | border: 1px solid gray;
11 | margin-top: -1px;
12 | margin-left: -1px;
13 | `,
14 |
15 | InfoContainer: styled.div`
16 | display: flex;
17 | width: 90%;
18 | font-size: 0.8rem;
19 | padding: 5px;
20 | `,
21 |
22 | InfoTxt: styled.span`
23 | display: block;
24 | width: 50%;
25 | `,
26 |
27 | InfoValue: styled.span`
28 | display: block;
29 | width: 50%;
30 | text-align: right;
31 | `,
32 | };
33 |
34 | const OrderbookCoinInfo = ({
35 | volume24,
36 | tradePrice24,
37 | highestPrice52Week,
38 | highestDate52Week,
39 | lowestPrice52Week,
40 | lowestDate52Week,
41 | }) => {
42 | return (
43 |
44 |
45 | 거래량
46 | {volume24}
47 |
48 |
49 | 거래대금
50 | {`${tradePrice24}백만`}
51 |
52 |
53 | 52주 최고
54 | {`${highestPrice52Week} (${highestDate52Week})`}
55 |
56 |
57 | 52주 최저
58 | {`${lowestPrice52Week} (${lowestDate52Week})`}
59 |
60 |
61 | );
62 | };
63 |
64 | export default React.memo(OrderbookCoinInfo);
65 |
--------------------------------------------------------------------------------
/src/Container/withMarketNames.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector } from "react-redux";
3 | import * as Hangul from "hangul-js";
4 | import { choHangul } from "../Lib/utils";
5 |
6 | const withMarketNames = () => (OriginalComponent) => (props) => {
7 | const marketNames = useSelector((state) => state.Coin.marketNames.data); // 코인 마켓 이름들(객체)
8 | let marketNamesArr = Object.keys(marketNames); // 코인 마켓 이름 배열화
9 |
10 | const coinListDatas = useSelector((state) => state.Coin.candle.data); // 코인들 데이터
11 | const coinSearchInputData = useSelector((state) => state.Coin.searchCoin); // 검색한 코인 이름
12 |
13 | // 데이터 받는 데 성공하면 필터링 및 정렬한다
14 | if (Object.keys(coinListDatas).length > 1) {
15 | // 검색 기준 필터링
16 | marketNamesArr = marketNamesArr.filter(
17 | (coin) =>
18 | // 영어 검색
19 | marketNames[coin].english
20 | .toLowerCase()
21 | .includes(coinSearchInputData.toLowerCase()) ||
22 | // 코인 심볼 검색
23 | coin
24 | .split("-")[1]
25 | .toLowerCase()
26 | .includes(coinSearchInputData.toLowerCase()) ||
27 | // 한글 검색
28 | Hangul.disassembleToString(marketNames[coin].korean).includes(
29 | Hangul.disassembleToString(coinSearchInputData)
30 | ) ||
31 | // 초성 검색
32 | choHangul(marketNames[coin].korean).includes(coinSearchInputData)
33 | );
34 |
35 | // 정렬
36 | marketNamesArr = marketNamesArr.sort((coin1, coin2) => {
37 | return (
38 | +coinListDatas[coin2].tradePrice24Hour -
39 | +coinListDatas[coin1].tradePrice24Hour
40 | );
41 | });
42 | }
43 | return (
44 |
49 | );
50 | };
51 |
52 | export default withMarketNames;
53 |
--------------------------------------------------------------------------------
/src/Container/withOrderbookData.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector } from "react-redux";
3 |
4 | const withOrderbookData = () => (OriginalComponent) => (props) => {
5 | const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
6 | const orderbook = useSelector(
7 | (state) => state.Coin.orderbook.data[selectedMarket]
8 | );
9 |
10 | let totalData;
11 | let bidOrderbookData;
12 | let askOrderbookData;
13 | let orderbookData;
14 | let maxOrderSize = 0;
15 |
16 | if (orderbook) {
17 | totalData = {
18 | totalBidSize: orderbook.total_bid_size,
19 | totalAskSize: orderbook.total_ask_size,
20 | };
21 |
22 | bidOrderbookData = [];
23 | askOrderbookData = [];
24 |
25 | // let maxOrderSize = 0;
26 | // 호가 데이터 분리 정렬
27 | orderbook.orderbook_units.forEach((orderbook, i) => {
28 | const bidSize = orderbook.bid_size.toFixed(3);
29 | const askSize = orderbook.ask_size.toFixed(3);
30 |
31 | bidOrderbookData.push({
32 | bidPrice: orderbook.bid_price,
33 | bidSize: orderbook.bid_size.toFixed(3),
34 | });
35 | askOrderbookData.push({
36 | askPrice: orderbook.ask_price,
37 | askSize: orderbook.ask_size.toFixed(3),
38 | });
39 | maxOrderSize = Math.max(maxOrderSize, bidSize, askSize);
40 | });
41 |
42 | orderbookData = [...askOrderbookData, ...bidOrderbookData];
43 | // 매도 호가창은 가격 내림차순으로 정렬해줌 (매수는 원래 가격 내림차순임)
44 | askOrderbookData.sort((book1, book2) => +book2.askPrice - +book1.askPrice);
45 | }
46 |
47 | return (
48 |
56 | );
57 | };
58 |
59 | export default withOrderbookData;
60 |
--------------------------------------------------------------------------------
/src/Api/api.js:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | export const coinApi = {
4 | getMarketCodes: () =>
5 | axios.get("https://api.upbit.com/v1/market/all?isDetails=false"),
6 | getInitCanldes: (coins) =>
7 | axios.get(`https://api.upbit.com/v1/ticker?markets=${coins}`),
8 | getInitOrderbooks: (coins) =>
9 | axios.get(`https://api.upbit.com/v1/orderbook?markets=${coins}`),
10 | getOneCoinCandles: ({ coin, timeType, timeCount }) => {
11 | if (timeType === "minutes")
12 | return axios
13 | .get(
14 | `https://api.upbit.com/v1/candles/${timeType}/${timeCount}?market=${coin}&count=200`
15 | )
16 | .then((res) => {
17 | return {
18 | ...res,
19 | data: res.data.sort((a, b) => a.timestamp - b.timestamp),
20 | };
21 | });
22 | else
23 | return axios
24 | .get(
25 | `https://api.upbit.com/v1/candles/${timeType}?market=${coin}&count=200`
26 | )
27 | .then((res) => {
28 | return {
29 | ...res,
30 | data: res.data.sort((a, b) => a.timestamp - b.timestamp),
31 | };
32 | });
33 | },
34 | getAdditionalCoinCandles: ({ coin, timeType, timeCount, datetime }) => {
35 | if (timeType === "minutes")
36 | return axios
37 | .get(
38 | `https://api.upbit.com/v1/candles/${timeType}/${timeCount}?market=${coin}&to=${datetime}&count=200`
39 | )
40 | .then((res) => {
41 | return {
42 | ...res,
43 | data: res.data.sort((a, b) => a.timestamp - b.timestamp),
44 | };
45 | });
46 | else
47 | return axios
48 | .get(
49 | `https://api.upbit.com/v1/candles/${timeType}?market=${coin}&to=${datetime}&count=200`
50 | )
51 | .then((res) => {
52 | return {
53 | ...res,
54 | data: res.data.sort((a, b) => a.timestamp - b.timestamp),
55 | };
56 | });
57 | },
58 | getOneCoinTradeLists: (coin) =>
59 | axios.get(`https://api.upbit.com/v1/trades/ticks?market=${coin}&count=50`),
60 | };
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "upbit-clone",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@babel/polyfill": "^7.12.1",
7 | "@fortawesome/fontawesome-svg-core": "^1.2.32",
8 | "@fortawesome/free-brands-svg-icons": "^5.15.1",
9 | "@fortawesome/free-solid-svg-icons": "^5.15.1",
10 | "@fortawesome/react-fontawesome": "^0.1.12",
11 | "@testing-library/jest-dom": "^4.2.4",
12 | "@testing-library/react": "^9.3.2",
13 | "@testing-library/user-event": "^7.1.2",
14 | "axios": "^0.20.0",
15 | "core-js": "^3.8.3",
16 | "d3": "^5.15.1",
17 | "d3-selection": "^1.1.0",
18 | "d3-transition": "^1.3.2",
19 | "d3fc": "^15.0.16",
20 | "decimal.js": "^10.2.1",
21 | "hangul-js": "^0.2.6",
22 | "install": "^0.13.0",
23 | "lodash": "^4.17.20",
24 | "moment-timezone": "^0.5.31",
25 | "npm": "^6.14.9",
26 | "raf": "^3.4.1",
27 | "react": "^16.13.1",
28 | "react-app-polyfill": "^2.0.0",
29 | "react-dom": "^16.13.1",
30 | "react-fast-compare": "^3.2.0",
31 | "react-financial-charts": "1.0.0-alpha.16",
32 | "react-loading": "^2.0.3",
33 | "react-redux": "^7.2.1",
34 | "react-router-dom": "^5.2.0",
35 | "react-scripts": "3.2.0",
36 | "redux": "^4.0.5",
37 | "redux-devtools-extension": "^2.13.8",
38 | "redux-saga": "^1.1.3",
39 | "redux-thunk": "^2.3.0",
40 | "regenerator-runtime": "^0.13.7",
41 | "styled-components": "^5.2.0",
42 | "styled-normalize": "^8.0.7",
43 | "styled-reset": "^4.3.0",
44 | "text-encoding": "^0.7.0",
45 | "websocket": "^1.0.32"
46 | },
47 | "scripts": {
48 | "start": "react-scripts start",
49 | "build": "react-scripts build",
50 | "test": "react-scripts test",
51 | "eject": "react-scripts eject"
52 | },
53 | "eslintConfig": {
54 | "extends": "react-app"
55 | },
56 | "browserslist": {
57 | "production": [
58 | ">0.2%",
59 | "not dead",
60 | "not op_mini all"
61 | ],
62 | "development": [
63 | ">0.2%",
64 | "ie 9",
65 | "last 1 chrome version",
66 | "last 1 firefox version",
67 | "last 1 safari version",
68 | "not dead"
69 | ]
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 |
78 | # Next.js build output
79 | .next
80 |
81 | # Nuxt.js build / generate output
82 | .nuxt
83 | dist
84 |
85 | # Gatsby files
86 | .cache/
87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
88 | # https://nextjs.org/blog/next-9-1#public-directory-support
89 | # public
90 |
91 | # vuepress build output
92 | .vuepress/dist
93 |
94 | # Serverless directories
95 | .serverless/
96 |
97 | # FuseBox cache
98 | .fusebox/
99 |
100 | # DynamoDB Local files
101 | .dynamodb/
102 |
103 | # TernJS port file
104 | .tern-port
105 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
106 |
107 | # dependencies
108 | /node_modules
109 | /.pnp
110 | .pnp.js
111 |
112 | # testing
113 | /coverage
114 |
115 | # production
116 | /build
117 |
118 | # misc
119 | .DS_Store
120 | .env.local
121 | .env.development.local
122 | .env.test.local
123 | .env.production.local
124 |
125 | npm-debug.log*
126 | yarn-debug.log*
127 | yarn-error.log*
128 |
--------------------------------------------------------------------------------
/src/Components/Main/MainChart-d3fc.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 | import * as d3 from "d3";
3 | import * as fc from "d3fc";
4 | import { useSelector } from "react-redux";
5 |
6 | const MainChart = () => {
7 | const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
8 | // const selectedCandles = useSelector(
9 | // (state) => state.Coin.candle.data[selectedMarket].candles
10 | // );
11 | // console.log(test[0].date);
12 |
13 | const selectedCandles = fc.randomFinancial()(200);
14 | let updated = false;
15 |
16 | if (selectedCandles.length > 2) {
17 | let updated = true;
18 |
19 | // console.log("여기야", selectedCandles);
20 | const xExtent = fc.extentDate().accessors([(d) => d.date]);
21 | const yExtent = fc.extentLinear().accessors([(d) => d.high, (d) => d.low]);
22 |
23 | const gridlines = fc.annotationSvgGridline();
24 |
25 | const candlestick = fc.seriesSvgCandlestick().decorate((sel) => {
26 | sel.enter().style("fill", (_, i) => (_.open > _.close ? "blue" : "red"));
27 | sel
28 | .enter()
29 | .style("stroke", (_, i) => (_.open > _.close ? "blue" : "red"));
30 | });
31 |
32 | const multi = fc.seriesSvgMulti().series([gridlines, candlestick]);
33 | // 줌용 설정
34 | const x = d3.scaleTime();
35 | const y = d3.scaleLinear();
36 |
37 | const x2 = d3.scaleTime();
38 | const y2 = d3.scaleLinear();
39 |
40 | const zoom = d3.zoom().on("zoom", () => {
41 | // update the scale used by the chart to use the updated domain
42 |
43 | x.domain(d3.event.transform.rescaleX(x2).domain());
44 | y.domain(d3.event.transform.rescaleY(y2).domain());
45 | d3.select(".chartContainer").datum(selectedCandles).call(chart);
46 | });
47 | const chart = fc
48 | .chartCartesian(x, y)
49 | .xDomain(xExtent(selectedCandles))
50 | .yDomain(yExtent(selectedCandles))
51 | .svgPlotArea(multi)
52 | .decorate((sel) => {
53 | sel
54 | .enter()
55 | .select(".plot-area")
56 | .on("measure.range", () => {
57 | x2.range([0, d3.event.detail.width]);
58 | y2.range([d3.event.detail.height, 0]);
59 | })
60 | .call(zoom);
61 | });
62 |
63 | x2.domain(chart.xDomain());
64 | y2.domain(chart.yDomain());
65 |
66 | d3.select(".chartContainer").datum(selectedCandles).call(chart);
67 | }
68 |
69 | useEffect(() => {}, [selectedCandles]);
70 |
71 | return (
72 |
73 | {/* */}
74 |
75 | );
76 | };
77 |
78 | export default MainChart;
79 |
--------------------------------------------------------------------------------
/src/Container/withSelectedCoinPrice.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector } from "react-redux";
3 |
4 | const withSelectedCoinPrice = () => (OriginalComponent) => (props) => {
5 | const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
6 | const selectedCoinData = useSelector(
7 | (state) => state.Coin.candle.data[selectedMarket]
8 | );
9 |
10 | // 24시간 고가 저가
11 | const highestPrice24Hour = useSelector(
12 | (state) => state.Coin.candle.data[selectedMarket]["highestPrice24Hour"]
13 | );
14 | const lowestPrice24Hour = useSelector(
15 | (state) => state.Coin.candle.data[selectedMarket]["lowestPrice24Hour"]
16 | );
17 |
18 | // 52주 고가 저가
19 | const highestPrice52Week = useSelector(
20 | (state) => state.Coin.candle.data[selectedMarket].highestPrice52Week
21 | );
22 | const highestDate52Week = useSelector(
23 | (state) => state.Coin.candle.data[selectedMarket].highestDate52Week
24 | );
25 | const lowestPrice52Week = useSelector(
26 | (state) => state.Coin.candle.data[selectedMarket].lowestPrice52Week
27 | );
28 | const lowestDate52Week = useSelector(
29 | (state) => state.Coin.candle.data[selectedMarket].lowestDate52Week
30 | );
31 |
32 | // 24시간 거래대금, 거래량
33 | const tradePrice24Hour = Math.floor(selectedCoinData.tradePrice24Hour);
34 | const volume24Hour = Math.floor(selectedCoinData.volume24Hour);
35 |
36 | // 24시간 가격 변화율, 변화량
37 | const changeRate24Hour =
38 | Math.round(selectedCoinData.changeRate24Hour * 10000) / 100;
39 | const changePrice24Hour = selectedCoinData.changePrice24Hour
40 | ? selectedCoinData.changePrice24Hour
41 | : 0;
42 |
43 | // 전일, 당일 가격
44 | const selecteCoinCadnles = useSelector(
45 | (state) => state.Coin.candle.data[selectedMarket].candles
46 | );
47 | const lastCandleIndex = selecteCoinCadnles.length - 1;
48 |
49 | const beforeDayPrice = selecteCoinCadnles.length
50 | ? selecteCoinCadnles[lastCandleIndex].close - changePrice24Hour
51 | : 0;
52 |
53 | const price = selecteCoinCadnles.length
54 | ? selecteCoinCadnles[selecteCoinCadnles.length - 1].close
55 | : 0;
56 |
57 | return (
58 |
73 | );
74 | };
75 |
76 | export default withSelectedCoinPrice;
77 |
--------------------------------------------------------------------------------
/src/Components/Main/TradeListItem.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import isEqual from "react-fast-compare";
4 |
5 | const St = {
6 | TradeListLi: styled.li`
7 | display: flex;
8 | justify-content: space-around;
9 | align-items: center;
10 | width: 100%;
11 | height: 25px;
12 | font-size: 0.9em;
13 | background-color: ${({ bgColor }) => bgColor || "white"};
14 | `,
15 |
16 | Datetime: styled.div`
17 | width: 20%;
18 | text-align: center;
19 | @media ${({ theme }) => theme.mobileS} {
20 | display: none;
21 | }
22 | `,
23 | Date: styled.span`
24 | text-align: center;
25 | `,
26 |
27 | Time: styled.span`
28 | text-align: center;
29 | font-size: 0.8rem;
30 | margin-left: 5px;
31 | `,
32 |
33 | TradePrice: styled.span`
34 | display: block;
35 | width: 20%;
36 | text-align: center;
37 | color: ${({ fontColor }) => fontColor};
38 | font-weight: 600;
39 |
40 | @media ${({ theme }) => theme.mobileS} {
41 | width: 50%;
42 | font-size: 0.7rem;
43 | }
44 | @media ${({ theme }) => theme.mobileM} {
45 | /* width: 50%; */
46 | font-size: 0.8rem;
47 | }
48 | `,
49 |
50 | TradeAmount: styled.span`
51 | display: block;
52 | width: 20%;
53 | text-align: center;
54 | color: ${({ fontColor }) => fontColor};
55 |
56 | @media ${({ theme }) => theme.mobileS} {
57 | width: 50%;
58 | font-size: 0.7rem;
59 | }
60 |
61 | @media ${({ theme }) => theme.mobileM} {
62 | /* width: 50%; */
63 | font-size: 0.8rem;
64 | }
65 | `,
66 |
67 | TradeKRW: styled.span`
68 | display: block;
69 | width: 20%;
70 | text-align: right;
71 |
72 | @media ${({ theme }) => theme.mobileS} {
73 | display: none;
74 | }
75 |
76 | @media ${({ theme }) => theme.mobileM} {
77 | display: none;
78 | }
79 | `,
80 | };
81 |
82 | const TradeListItem = ({
83 | theme,
84 | index,
85 | date,
86 | time,
87 | tradePrice,
88 | changePrice,
89 | tradeAmount,
90 | askBid,
91 | }) => {
92 | return (
93 |
97 |
98 | {date}
99 | {time}
100 |
101 | 0 ? theme.priceUp : theme.priceDown}
103 | >
104 | {tradePrice.toLocaleString()}
105 |
106 |
110 | {tradeAmount.toFixed(5)}
111 |
112 |
113 | {Math.floor(tradePrice * tradeAmount).toLocaleString()}
114 |
115 |
116 | );
117 | };
118 |
119 | export default React.memo(TradeListItem, isEqual);
120 |
--------------------------------------------------------------------------------
/src/Components/Main/OrderInfo.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useDispatch } from "react-redux";
3 | import styled from "styled-components";
4 | import OrderInfoAskBid from "./OrderInfoAskBid";
5 |
6 | import withSelectedOption from "../../Container/withSelectedOption";
7 | import withThemeData from "../../Container/withThemeData";
8 | import withSelectedCoinName from "../../Container/withSelectedCoinName";
9 |
10 | import { changeAskBidOrder } from "../../Reducer/coinReducer";
11 | import isEqual from "react-fast-compare";
12 |
13 | const St = {
14 | Container: styled.section`
15 | width: 100%;
16 | height: 50%;
17 | background-color: white;
18 | `,
19 | HiddenH3: styled.h3`
20 | position: absolute;
21 | width: 1px;
22 | height: 1px;
23 | clip: rect(0, 0);
24 | clip-path: polygon(0, 0);
25 | overflow: hidden;
26 | text-indent: -9999px;
27 | `,
28 | OrderTypeContainer: styled.ul`
29 | display: flex;
30 | height: 40px;
31 | align-items: center;
32 | border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
33 |
34 | @media ${({ theme }) => theme.mobileS} {
35 | font-size: 0.8rem;
36 | }
37 | `,
38 | OrderTypeLi: styled.li`
39 | width: 33.3333%;
40 | height: 100%;
41 | `,
42 | OrderTypeBtn: styled.button`
43 | width: 100%;
44 | height: 100%;
45 | background-color: white;
46 | border: none;
47 | border-bottom: 3px solid ${({ borderBottom }) => borderBottom || "white"};
48 | outline: 0;
49 | font-weight: 900;
50 | color: ${({ fontColor }) => fontColor || "black"};
51 | cursor: pointer;
52 | `,
53 | };
54 |
55 | const OrderInfo = ({
56 | theme,
57 | selectedAskBidOrder,
58 | coinSymbol,
59 | orderPrice,
60 | orderAmount,
61 | orderTotalPrice,
62 | }) => {
63 | const dispatch = useDispatch();
64 | return (
65 |
66 | 주문 정보
67 |
68 |
69 | dispatch(changeAskBidOrder("bid"))}
73 | >
74 | 매수
75 |
76 |
77 |
78 | dispatch(changeAskBidOrder("ask"))}
82 | >
83 | 매도
84 |
85 |
86 |
87 | dispatch(changeAskBidOrder("tradeList"))}
91 | >
92 | 거래내역
93 |
94 |
95 |
96 |
104 |
105 | );
106 | };
107 |
108 | export default withSelectedCoinName()(
109 | withSelectedOption()(withThemeData()(React.memo(OrderInfo, isEqual)))
110 | );
111 |
--------------------------------------------------------------------------------
/src/Components/Main/ChartDataConsole.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import { useDispatch } from "react-redux";
3 | import styled from "styled-components";
4 | import withSelectedOption from "../../Container/withSelectedOption";
5 | import withThemeData from "../../Container/withThemeData";
6 | import { changeTimeTypeAndData } from "../../Reducer/coinReducer";
7 |
8 | const St = {
9 | Container: styled.div`
10 | display: flex;
11 | width: 100%;
12 | background-color: white;
13 | border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
14 | `,
15 | HiddenH3: styled.h3`
16 | position: absolute;
17 | width: 1px;
18 | height: 1px;
19 | clip: rect(0, 0);
20 | clip-path: polygon(0, 0);
21 | overflow: hidden;
22 | text-indent: -9999px;
23 | `,
24 | TimeBtnContainer: styled.div`
25 | display: flex;
26 | align-items: center;
27 | height: 30px;
28 | `,
29 | TimeBtn: styled.button`
30 | /* width: 50px; */
31 | height: 20px;
32 | width: 38px;
33 | margin-left: 5px;
34 | font-size: 0.8rem;
35 | background-color: white;
36 |
37 | border: ${({ theme, isSelected }) =>
38 | isSelected ? `2px solid black` : `1px solid ${theme.lightGray2}`};
39 | outline: none;
40 | cursor: pointer;
41 | `,
42 | };
43 |
44 | const ChartDataConsole = ({ theme, selectedTimeCount, selectedTimeType }) => {
45 | const dispatch = useDispatch();
46 |
47 | const changeChartTime = useCallback(
48 | (timeCount, timeType) => () => {
49 | dispatch(changeTimeTypeAndData({ timeCount, timeType }));
50 | },
51 | [dispatch]
52 | );
53 |
54 | return (
55 |
56 | 차트에 표시할 캔들의 시간 선택
57 |
58 |
62 | 1m
63 |
64 |
68 | 3m
69 |
70 |
74 | 5m
75 |
76 |
82 | 10m
83 |
84 |
90 | 15m
91 |
92 |
98 | 1h
99 |
100 |
106 | 4h
107 |
108 |
112 | 1d
113 |
114 |
115 |
116 | );
117 | };
118 |
119 | export default withSelectedOption()(withThemeData()(ChartDataConsole));
120 |
--------------------------------------------------------------------------------
/src/Components/Global/Footer.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import { faEnvelope } from "@fortawesome/free-solid-svg-icons";
4 | import { faGithub } from "@fortawesome/free-brands-svg-icons";
5 |
6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
7 |
8 | const St = {
9 | Footer: styled.footer`
10 | display: block;
11 | width: 100%;
12 | height: 100%;
13 | /* height: 120px; */
14 | background-color: white;
15 | padding: 20px 0;
16 | @media ${({ theme }) => theme.tablet} {
17 | display: none;
18 | }
19 | `,
20 | Container: styled.div`
21 | display: flex;
22 | justify-content: space-between;
23 | align-items: center;
24 | width: 100%;
25 | height: 100%;
26 | max-width: 1360px;
27 | margin: 0 auto;
28 | padding: 0 20px;
29 |
30 | @media ${({ theme }) => theme.tablet} {
31 | display: block;
32 | max-width: 950px;
33 | }
34 | `,
35 | MainLink: styled.a`
36 | display: block;
37 | background-image: ${({ logo }) => `url(${logo})`};
38 | background-repeat: no-repeat;
39 | background-position: center;
40 | background-size: contain;
41 | color: transparent;
42 | width: 130px;
43 | height: 60px;
44 | `,
45 | Description: styled.p`
46 | font-weight: 600;
47 | font-size: 0.9rem;
48 | color: gray;
49 | height: 85px;
50 | margin-top: 10px;
51 | /* margin-left: 250px; */
52 | `,
53 | DescSpan: styled.span`
54 | display: block;
55 | height: 30px;
56 | `,
57 | ContactContainer: styled.address`
58 | display: flex;
59 | flex-direction: column;
60 | /* margin-left: 250px; */
61 | `,
62 | LinkTitle: styled.span`
63 | height: 25px;
64 | font-size: 0.9rem;
65 | font-weight: 600;
66 | color: gray;
67 | `,
68 | LinkTag: styled.a`
69 | display: flex;
70 | align-items: center;
71 | height: 30px;
72 | color: black;
73 | text-decoration: none;
74 | `,
75 | LinkSpan: styled.span`
76 | display: block;
77 | margin-left: ${({ marginLeft }) => marginLeft || "8px"};
78 | font-weight: 600;
79 | font-size: 0.9rem;
80 | height: 20px;
81 | /* line-height: 1.5rem; */
82 | color: gray;
83 | `,
84 | };
85 |
86 | const Footer = () => {
87 | return (
88 |
89 |
90 |
95 |
96 | Upbit Clone Project - Downbit
97 | Created by Seongkyun Yu
98 |
99 | Copyright © 2020 DOWNBIT INC. ALL RIGHTS RESERVED.
100 |
101 |
102 |
103 | Contact Me
104 |
105 | -
106 |
107 |
112 | github.com/Seongkyun-Yu/upbit-clone
113 |
114 |
115 | -
116 |
117 |
122 | ysungkyun@gmail.com
123 |
124 |
125 |
126 |
127 |
128 |
129 | );
130 | };
131 |
132 | export default Footer;
133 |
--------------------------------------------------------------------------------
/src/Pages/Main.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import withSize from "../Container/withSize";
4 | import { viewSize } from "../styles/theme";
5 |
6 | import Header from "../Components/Global/Header";
7 | import CoinInfoHeader from "../Components/Main/CoinInfoHeader";
8 | import ChartDataConsole from "../Components/Main/ChartDataConsole";
9 | import MainChart from "../Components/Main/MainChart";
10 | import Orderbook from "../Components/Main/Orderbook";
11 | import OrderInfo from "../Components/Main/OrderInfo";
12 | import TradeList from "../Components/Main/TradeList";
13 | import CoinList from "../Components/Main/CoinList";
14 | import Footer from "../Components/Global/Footer";
15 |
16 | const St = {
17 | MainContentContainer: styled.div`
18 | display: flex;
19 | justify-content: center;
20 | max-width: 1500px;
21 | margin: 0 auto;
22 | margin-top: 10px;
23 | margin-bottom: 50px;
24 | width: 100%;
25 | height: 100%;
26 |
27 | @media ${({ theme }) => theme.tablet} {
28 | margin-top: 0;
29 | margin-bottom: 0;
30 | }
31 | `,
32 | ChartAndTradeContainer: styled.section`
33 | display: flex;
34 | flex-direction: column;
35 | align-items: center;
36 | width: 95%;
37 | max-width: 950px;
38 |
39 | @media ${(props) => (props.isRootURL ? props.theme.tablet : true)} {
40 | display: none;
41 | }
42 | `,
43 | HiddenH2: styled.h2`
44 | position: absolute;
45 | width: 1px;
46 | height: 1px;
47 | clip: rect(0, 0);
48 | clip-path: polygon(0, 0);
49 | overflow: hidden;
50 | text-indent: -9999px;
51 | `,
52 | MainChartContainer: styled.div`
53 | width: 100%;
54 | height: 500;
55 | `,
56 | TradeInfoContainer: styled.div`
57 | display: flex;
58 | width: 100%;
59 | margin-top: 10px;
60 | @media ${({ theme }) => theme.mobileM} {
61 | margin-top: 0;
62 | }
63 | `,
64 | TradeOrderContainer: styled.div`
65 | display: flex;
66 | flex-direction: column;
67 | width: 55%;
68 | min-width: 180px;
69 | margin-left: 10px;
70 | @media ${({ theme }) => theme.mobileM} {
71 | margin-left: 0;
72 | border: 2px solid ${({ theme }) => theme.lightGray1};
73 | /* border-top: 1px solid ${({ theme }) => theme.lightGray1};
74 | border-bottom: 1px solid ${({ theme }) => theme.lightGray1};
75 | border-left: 1px solid ${({ theme }) => theme.lightGray1}; */
76 | }
77 | `,
78 | };
79 |
80 | const Main = ({ match, widthSize, heightSize }) => {
81 | const isRootURL = match.path === "/";
82 |
83 | return (
84 | <>
85 |
86 |
87 | {
88 | // 차트 및 주문 관련 뷰는 메인 페이지이면서 tablet 사이즈보다 크거나, 메인 페이지가 아닌 경우에만 그린다
89 | ((isRootURL && widthSize > viewSize.tablet) || !isRootURL) && (
90 |
91 | 차트 및 주문 정보 창
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | )
104 | }
105 | {
106 | // 코인 리스트 뷰는 메인 페이지이거나, 메인 페이지가 아니면서 tablet 사이즈보다 큰 경우에만 그린다
107 | (isRootURL || (!isRootURL && widthSize > viewSize.tablet)) && (
108 |
113 | )
114 | }
115 |
116 |
117 | >
118 | );
119 | };
120 |
121 | export default withSize()(React.memo(Main));
122 |
--------------------------------------------------------------------------------
/src/Components/Main/Orderbook.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useSelector } from "react-redux";
3 | import styled from "styled-components";
4 |
5 | import OrderbookItem from "./OrderbookItem";
6 | import Loading from "../Global/Loading";
7 |
8 | import withThemeData from "../../Container/withThemeData";
9 | import withSelectedCoinPrice from "../../Container/withSelectedCoinPrice";
10 | import withOrderbookData from "../../Container/withOrderbookData";
11 | import withSelectedOption from "../../Container/withSelectedOption";
12 | import withLoadingData from "../../Container/withLoadingData";
13 |
14 | import isEqual from "react-fast-compare";
15 |
16 | const St = {
17 | Container: styled.section`
18 | width: 46%;
19 | max-height: 722px;
20 | height: 100%;
21 | background-color: white;
22 | `,
23 | HiddenH3: styled.h3`
24 | position: absolute;
25 | width: 1px;
26 | height: 1px;
27 | clip: rect(0, 0);
28 | clip-path: polygon(0, 0);
29 | overflow: hidden;
30 | text-indent: -9999px;
31 | `,
32 | OrderUl: styled.ul`
33 | width: 100%;
34 | height: 722px;
35 | overflow-y: scroll;
36 | scrollbar-color: ${({ theme }) => theme.middleGray};
37 | scrollbar-width: thin;
38 | scrollbar-base-color: transparent;
39 | &::-webkit-scrollbar {
40 | width: 5px;
41 | background-color: transparent;
42 | border-radius: 5rem;
43 | }
44 | &::-webkit-scrollbar-thumb {
45 | background-color: ${({ theme }) => theme.middleGray};
46 | border-radius: 5rem;
47 | }
48 | `,
49 | };
50 |
51 | const Orderbook = ({
52 | theme,
53 | // totalData,
54 | askOrderbookData,
55 | bidOrderbookData,
56 | maxOrderSize,
57 | beforeDayPrice,
58 | selectedMarket,
59 | isOrderbookLoading,
60 | }) => {
61 | const lastTradePrice = useSelector(
62 | (state) =>
63 | state.Coin.tradeList.data[selectedMarket] &&
64 | state.Coin.tradeList.data[selectedMarket][0].trade_price
65 | );
66 | return (
67 |
68 | 호가창
69 |
70 | {isOrderbookLoading ? (
71 |
72 | ) : (
73 | askOrderbookData.map((orderbook, i) => {
74 | return (
75 |
90 | );
91 | })
92 | )}
93 | {isOrderbookLoading ||
94 | bidOrderbookData.map((orderbook, i) => {
95 | return (
96 |
111 | );
112 | })}
113 |
114 |
115 | );
116 | };
117 |
118 | export default withOrderbookData()(
119 | withSelectedCoinPrice()(
120 | withSelectedOption()(
121 | withLoadingData()(withThemeData()(React.memo(Orderbook, isEqual)))
122 | )
123 | )
124 | );
125 |
--------------------------------------------------------------------------------
/src/Components/Main/TradeList.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import Decimal from "decimal.js";
4 | import moment from "moment-timezone";
5 |
6 | import TradeListItem from "./TradeListItem";
7 | import Loading from "../Global/Loading";
8 |
9 | import withTradeListData from "../../Container/withTradeListData";
10 | import withThemeData from "../../Container/withThemeData";
11 | import withLoadingData from "../../Container/withLoadingData";
12 |
13 | const St = {
14 | Container: styled.article`
15 | width: 100%;
16 | height: 100%;
17 | background-color: white;
18 | margin-top: 10px;
19 | @media ${({ theme }) => theme.mobileM} {
20 | margin-top: 0;
21 | }
22 | `,
23 | HiddenH3: styled.h3`
24 | position: absolute;
25 | width: 1px;
26 | height: 1px;
27 | clip: rect(0, 0);
28 | clip-path: polygon(0, 0);
29 | overflow: hidden;
30 | text-indent: -9999px;
31 | `,
32 | TradeListUL: styled.ul`
33 | overflow-y: scroll;
34 | scrollbar-color: ${(props) => props.scrollColor};
35 | scrollbar-width: thin;
36 | scrollbar-base-color: ${(props) => props.scrollColor};
37 | &::-webkit-scrollbar {
38 | width: 5px;
39 | background-color: white;
40 | border-radius: 5rem;
41 | }
42 | &::-webkit-scrollbar-thumb {
43 | background-color: ${(props) => props.scrollColor};
44 | border-radius: 5rem;
45 | }
46 | height: 320px;
47 | `,
48 | TradeListTitle: styled.ul`
49 | display: flex;
50 | justify-content: space-around;
51 | align-items: center;
52 | height: 25px;
53 | background-color: ${({ theme }) => theme.lightGray1};
54 | font-size: 0.9rem;
55 |
56 | @media ${({ theme }) => theme.mobileS} {
57 | font-size: 0.6rem;
58 | }
59 | `,
60 | TitleListItem: styled.li`
61 | width: 20%;
62 |
63 | min-width: 58px;
64 | text-align: ${({ textAlign }) => textAlign || "center"};
65 | @media ${({ theme, mobileSNone }) => (mobileSNone ? theme.mobileS : true)} {
66 | display: none;
67 | }
68 |
69 | @media ${({ theme, mobileMNone }) => (mobileMNone ? theme.mobileM : true)} {
70 | display: none;
71 | }
72 |
73 | @media ${({ theme, mobileSNone }) => mobileSNone || theme.mobileS} {
74 | width: 50%;
75 | }
76 | `,
77 | };
78 |
79 | const TradeList = ({ theme, selectedTradeListData, isTradeListLoading }) => {
80 | return (
81 |
82 | 실시간 체결내역
83 |
84 |
85 | 체결시간
86 |
87 | 체결가격
88 | 체결량
89 |
90 | 체결금액
91 |
92 |
93 |
94 | {isTradeListLoading || !selectedTradeListData ? (
95 |
96 | ) : (
97 | selectedTradeListData.map((tradeList, i) => {
98 | const tradeAmount = new Decimal(tradeList.trade_volume) + "";
99 | return (
100 |
112 | );
113 | })
114 | )}
115 |
116 |
117 | );
118 | };
119 |
120 | export default withTradeListData()(
121 | withLoadingData()(withThemeData()(React.memo(TradeList)))
122 | );
123 |
--------------------------------------------------------------------------------
/src/Components/Main/OrderbookItem.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 | import { useDispatch } from "react-redux";
3 | import { changePriceAndTotalPrice } from "../../Reducer/coinReducer";
4 | import styled, { css } from "styled-components";
5 |
6 | import isEqual from "react-fast-compare";
7 |
8 | const St = {
9 | OrderLi: styled.li`
10 | display: flex;
11 | width: 100%;
12 | height: 45px;
13 | &:nth-last-child() {
14 | border-bottom: none;
15 | }
16 | font-size: 0.8rem;
17 | @media ${({ theme }) => theme.mobileS} {
18 | font-size: 0.7rem;
19 | }
20 | `,
21 |
22 | Btn: styled.button`
23 | display: flex;
24 | justify-content: center;
25 | align-items: center;
26 | width: 100%;
27 | height: 100%;
28 | background-color: transparent;
29 | border: none;
30 | outline: none;
31 | padding: 0;
32 | margin: 0;
33 | cursor: pointer;
34 | `,
35 |
36 | OrderAmount: styled.div`
37 | display: flex;
38 | justify-content: flex-start;
39 | align-items: center;
40 | position: relative;
41 | width: 50%;
42 | height: 45px;
43 | border: 1px solid ${({ borderColor }) => borderColor};
44 | padding-left: 5px;
45 | padding-right: 10px;
46 | margin-top: -1px;
47 | margin-left: -1px;
48 | text-align: right;
49 | `,
50 |
51 | OrderAmountSize: styled.div`
52 | position: absolute;
53 | width: ${({ witdhSize }) => witdhSize};
54 | left: 0;
55 | height: 70%;
56 | background-color: ${({ bgColor }) => bgColor};
57 | `,
58 |
59 | OrderPriceContainer: styled.div`
60 | display: flex;
61 | justify-content: center;
62 | align-items: center;
63 | position: relative;
64 | width: 50%;
65 | height: 45px;
66 | border: 1px solid ${({ borderColor }) => borderColor};
67 | margin-top: -1px;
68 | margin-left: -1px;
69 | text-align: right;
70 | color: ${({ fontColor }) => fontColor};
71 | background-color: ${({ bgColor }) => bgColor};
72 |
73 | ${({ outline }) =>
74 | outline &&
75 | css`
76 | border: 2px solid black;
77 | border-right: 3px solid black;
78 | &::after {
79 | content: "";
80 | display: block;
81 | position: absolute;
82 | left: -5px;
83 | width: 0px;
84 | height: 0px;
85 | border-right: 10px solid transparent;
86 | border-bottom: 10px solid black;
87 | transform: rotate(225deg);
88 | -ms-transform: rotate(225deg);
89 | -webkit-transform: rotate(225deg);
90 | -moz-transform: rotate(225deg);
91 | -o-transform: rotate(225deg);
92 | }
93 | `}
94 |
95 | @media ${({ theme }) => theme.mobileM} {
96 | flex-direction: column;
97 | }
98 | `,
99 |
100 | OrderPrice: styled.strong`
101 | font-weight: 800;
102 | `,
103 |
104 | OrderPrcieRatio: styled.span`
105 | padding-left: 13px;
106 | `,
107 | };
108 |
109 | const OrderbookItem = ({
110 | theme,
111 | price,
112 | size,
113 | maxOrderSize,
114 | type,
115 | changeRate24Hour,
116 | index,
117 | outline,
118 | }) => {
119 | const dispatch = useDispatch();
120 | const scrollRef = useRef();
121 |
122 | useEffect(() => {
123 | if (index === 7 && type === "ask") {
124 | const parentNode = scrollRef.current.parentNode;
125 | const parentAbsoluteTop = window.pageYOffset + parentNode.offsetTop;
126 | const absoluteTop = window.pageYOffset + scrollRef.current.offsetTop;
127 | const relativeTop = absoluteTop - parentAbsoluteTop;
128 | scrollRef.current.parentNode.scrollTop = relativeTop;
129 | }
130 | }, []);
131 |
132 | return (
133 |
134 | {
136 | document.activeElement.blur();
137 | dispatch(changePriceAndTotalPrice(price));
138 | }}
139 | >
140 | 0
144 | ? theme.priceUp
145 | : +changeRate24Hour < 0
146 | ? theme.priceDown
147 | : "black"
148 | }
149 | borderColor={theme.lightGray}
150 | bgColor={type === "ask" ? theme.skyBlue1 : theme.lightPink1}
151 | // outline={lastTradePrice === price}
152 | outline={outline}
153 | >
154 | {price.toLocaleString()}
155 | {`${changeRate24Hour}%`}
156 |
157 |
158 | {size}
159 |
163 |
164 |
165 |
166 | );
167 | };
168 |
169 | export default React.memo(OrderbookItem, isEqual);
170 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: { 'Service-Worker': 'script' },
105 | })
106 | .then(response => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get('content-type');
109 | if (
110 | response.status === 404 ||
111 | (contentType != null && contentType.indexOf('javascript') === -1)
112 | ) {
113 | // No service worker found. Probably a different app. Reload the page.
114 | navigator.serviceWorker.ready.then(registration => {
115 | registration.unregister().then(() => {
116 | window.location.reload();
117 | });
118 | });
119 | } else {
120 | // Service worker found. Proceed as normal.
121 | registerValidSW(swUrl, config);
122 | }
123 | })
124 | .catch(() => {
125 | console.log(
126 | 'No internet connection found. App is running in offline mode.'
127 | );
128 | });
129 | }
130 |
131 | export function unregister() {
132 | if ('serviceWorker' in navigator) {
133 | navigator.serviceWorker.ready
134 | .then(registration => {
135 | registration.unregister();
136 | })
137 | .catch(error => {
138 | console.error(error.message);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/Components/Main/MainChart-old.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 | import * as d3 from "d3";
3 | import * as fc from "d3fc";
4 | import { useSelector } from "react-redux";
5 |
6 | const MainChart = () => {
7 | const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
8 | const selectedCandles = useSelector(
9 | (state) => state.Coin.candle.data[selectedMarket].candles
10 | );
11 | const dateFormat = d3.timeParse("%Y-%m-%d %H:%M");
12 |
13 | const svgRef = useRef();
14 |
15 | useEffect(() => {
16 | const margin = { top: 15, right: 65, bottom: 205, left: 50 };
17 | const width = 1000 - margin.left - margin.right;
18 | const height = 625 - margin.top - margin.bottom;
19 |
20 | d3.select("#chartContainer").remove();
21 | const svg = d3
22 | .select("#root")
23 | .append("svg")
24 | .attr("id", "chartContainer")
25 | .attr("width", width + margin.left + margin.right)
26 | .attr("height", height + margin.top + margin.bottom)
27 | .append("g")
28 | .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
29 |
30 | const dates = selectedCandles.map((candle) => dateFormat(candle.datetime));
31 |
32 | const xScale = d3
33 | .scaleLinear()
34 | .domain([-1, dates.length])
35 | .range([0, width]);
36 |
37 | const xDateScale = d3
38 | .scaleQuantize()
39 | .domain([0, dates.length])
40 | .range(dates.length ? dates : ["20-01-01 00:00"]);
41 |
42 | const xBand = d3
43 | .scaleBand()
44 | .domain(d3.range(-1, dates.length))
45 | .range([0, width])
46 | .padding(0.3);
47 |
48 | const xAxis = d3.axisBottom().scale(xScale);
49 |
50 | svg
51 | .append("rect")
52 | .attr("id", "rect")
53 | .attr("width", width)
54 | .attr("height", height)
55 | .style("fill", "none")
56 | .style("pointer-events", "all")
57 | .attr("clip-path", "url(#clip)");
58 |
59 | let gX = svg
60 | .append("g")
61 | .attr("class", "axis x-axis") //Assign "axis" class
62 | .attr("transform", "translate(0," + height + ")")
63 | .call(xAxis);
64 |
65 | gX.selectAll(".tick text");
66 |
67 | const ymin = d3.min(selectedCandles.map((candle) => candle.low));
68 | const ymax = d3.max(selectedCandles.map((candle) => candle.high));
69 | const yScale = d3
70 | .scaleLinear()
71 | .domain([ymin, ymax])
72 | .range([height, 0])
73 | .nice();
74 | const yAxis = d3.axisLeft().scale(yScale);
75 |
76 | const gY = svg.append("g").attr("class", "axis y-axis").call(yAxis);
77 |
78 | const chartBody = svg
79 | .append("g")
80 | .attr("class", "chartBody")
81 | .attr("clip-path", "url(#clip)");
82 |
83 | // 캔들 몸통
84 | const candles = chartBody.selectAll(".candle").data(selectedCandles);
85 | candles
86 | .enter()
87 | .append("rect")
88 | .attr("x", (_, i) => xScale(i) - xBand.bandwidth())
89 | .attr("class", "candle")
90 | .attr("y", (candle) => yScale(Math.max(candle.open, candle.close)))
91 | .attr("width", xBand.bandwidth())
92 | .attr("height", (candle) => {
93 | return candle.open === candle.close
94 | ? 1
95 | : yScale(Math.min(candle.open, candle.close)) -
96 | yScale(Math.max(candle.open, candle.close));
97 | })
98 | .attr("fill", (candle) => {
99 | return candle.open === candle.close
100 | ? "silver"
101 | : candle.open > candle.close
102 | ? "red"
103 | : "green";
104 | });
105 | // candles.exit().remove();
106 |
107 | // 윗꼬리 아랫꼬리
108 | const stems = chartBody.selectAll("g.line").data(selectedCandles);
109 |
110 | stems
111 | .enter()
112 | .append("line")
113 | .attr("class", "stem")
114 | .attr("x1", (_, i) => xScale(i) - xBand.bandwidth() / 2)
115 | .attr("x2", (_, i) => xScale(i) - xBand.bandwidth() / 2)
116 | .attr("y1", (candle) => yScale(candle.high))
117 | .attr("y2", (candle) => yScale(candle.low))
118 | .attr("stroke", (candle) => {
119 | return candle.open === candle.close
120 | ? "white"
121 | : candle.open > candle.close
122 | ? "red"
123 | : "green";
124 | });
125 | // stems.exit().remove();
126 |
127 | svg
128 | .append("defs")
129 | .append("clipPath")
130 | .attr("id", "clip")
131 | .append("rect")
132 | .attr("width", width)
133 | .attr("height", height);
134 |
135 | // d3.getEvent = () => require("d3-selection").event;
136 | // const zoomed = () => {
137 | // const t = currentEvent.transform;
138 | // const xScaleZ = t.rescaleX(xScale);
139 |
140 | // const hideTicksWithoutLabel = () => {
141 | // d3.selectAll(".xAxis .tick text").each((d) => {
142 | // if (this.innerHTML === "") {
143 | // this.parentNode.style.display = "none";
144 | // }
145 | // });
146 | // };
147 |
148 | // gX.call(d3.axisBottom(xScaleZ));
149 |
150 | // candles
151 | // .attr("x", (d, i) => xScaleZ(i) - (xBand.bandwidth() * t.k) / 2)
152 | // .attr("width", xBand.bandwidth() * t.k);
153 | // stems.attr(
154 | // "x1",
155 | // (d, i) => xScaleZ(i) - xBand.bandwidth() / 2 + xBand.bandwidth() * 0.5
156 | // );
157 | // stems.attr(
158 | // "x2",
159 | // (d, i) => xScaleZ(i) - xBand.bandwidth() / 2 + xBand.bandwidth() * 0.5
160 | // );
161 |
162 | // hideTicksWithoutLabel();
163 |
164 | // // gX.selectAll(".tick text").call(wrap, xBand.bandwidth());
165 | // };
166 |
167 | const extent = [
168 | [0, 0],
169 | [width, height],
170 | ];
171 |
172 | let resizeTimer;
173 |
174 | chartBody.exit().remove();
175 | }, [selectedCandles]);
176 |
177 | return ;
178 | };
179 |
180 | export default MainChart;
181 |
--------------------------------------------------------------------------------
/src/Components/Main/CoinListItem.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import { useDispatch, useSelector } from "react-redux";
3 | import { useHistory } from "react-router-dom";
4 | import styled, { css } from "styled-components";
5 | import { startChangeMarketAndData } from "../../Reducer/coinReducer";
6 |
7 | import isEqual from "react-fast-compare";
8 |
9 | const St = {
10 | CoinLi: styled.li`
11 | width: 100%;
12 | height: 45px;
13 |
14 | border-bottom: 1px solid ${({ borderBottomColor }) => borderBottomColor};
15 | &:last-child {
16 | border-bottom: none;
17 | }
18 | background-color: ${({ bgColor }) => bgColor};
19 | `,
20 | CoinBtn: styled.button`
21 | display: flex;
22 | justify-content: space-between;
23 | align-items: center;
24 | width: 100%;
25 | height: 100%;
26 | background-color: transparent;
27 | border: none;
28 | outline: none;
29 | cursor: pointer;
30 | text-align: left;
31 | `,
32 | CoinLogo: styled.i`
33 | display: inline-block;
34 | width: 20px;
35 | height: 20px;
36 | background-image: ${({ coinNameEn }) =>
37 | coinNameEn !== "ADX"
38 | ? `url(https://static.upbit.com/logos/${coinNameEn}.png)`
39 | : "../styles/img/ADX.png"};
40 | background-size: cover;
41 | margin-left: 5px;
42 | margin-right: 15px;
43 | `,
44 | CoinNameContainer: styled.div`
45 | display: flex;
46 | flex-direction: column;
47 | justify-content: center;
48 | width: 20%;
49 | min-width: 55px;
50 | height: 45px;
51 | `,
52 | CoinName: styled.strong`
53 | display: block;
54 | font-size: 12px;
55 | font-weight: 800;
56 | @media ${({ theme }) => theme.tablet} {
57 | font-weight: 500;
58 | }
59 | `,
60 | CoinNameEn: styled.span`
61 | display: block;
62 | font-size: 12px;
63 | `,
64 | Price: styled.strong`
65 | display: block;
66 | width: 20%;
67 | min-width: 55px;
68 | height: 100%;
69 | text-align: right;
70 | line-height: 2.5rem;
71 | font-size: 12px;
72 | font-weight: 800;
73 | color: ${({ fontColor }) => fontColor};
74 |
75 | border: 1px solid transparent;
76 | ${({ isTraded }) =>
77 | isTraded &&
78 | (isTraded === "ASK"
79 | ? css`
80 | animation: disappearBlue 0.6s;
81 | `
82 | : css`
83 | animation: disappearRed 0.6s;
84 | `)};
85 | @keyframes disappearBlue {
86 | 0% {
87 | border-color: ${({ theme }) => theme.strongBlue};
88 | }
89 | 100% {
90 | border-color: ${({ theme }) => theme.strongBlue};
91 | }
92 | }
93 | @keyframes disappearRed {
94 | 0% {
95 | border-color: ${({ theme }) => theme.strongRed};
96 | }
97 | 100% {
98 | border-color: ${({ theme }) => theme.strongRed};
99 | }
100 | }
101 |
102 | @media ${({ theme }) => theme.tablet} {
103 | font-weight: 500;
104 | }
105 | `,
106 | ChangRateContainer: styled.div`
107 | display: flex;
108 | flex-direction: column;
109 | justify-content: center;
110 | width: 20%;
111 | min-width: 55px;
112 | height: 100%;
113 | text-align: right;
114 | `,
115 | ChangeRate: styled.span`
116 | display: block;
117 | font-size: 12px;
118 | color: ${({ fontColor }) => fontColor};
119 | /* font-weight: 800; */
120 | `,
121 | ChangePrice: styled.span`
122 | display: block;
123 | font-size: 12px;
124 | color: ${({ fontColor }) => fontColor};
125 | /* font-weight: 800; */
126 | `,
127 | TradePrice: styled.span`
128 | display: flex;
129 | flex-direction: column;
130 | justify-content: center;
131 | font-size: 12px;
132 | width: 25%;
133 | height: 100%;
134 | text-align: right;
135 | /* font-weight: 800; */
136 | `,
137 | };
138 |
139 | const CoinListItem = ({
140 | theme,
141 | selectedMarket,
142 | marketName,
143 | coinName,
144 | enCoinName,
145 | fontColor,
146 | price,
147 | changeRate24Hour,
148 | changePrice24Hour,
149 | tradePrice24Hour,
150 | }) => {
151 | const dispatch = useDispatch();
152 | const history = useHistory();
153 |
154 | const tradeListData = useSelector(
155 | (state) => state.Coin.tradeList.data[marketName]
156 | );
157 |
158 | const nowTimestamp = +new Date();
159 |
160 | const isTraded =
161 | tradeListData &&
162 | tradeListData.length > 2 &&
163 | nowTimestamp - tradeListData[0].timestamp < 500 &&
164 | tradeListData[0].trade_price !== tradeListData[1].trade_price
165 | ? tradeListData[0].ask_bid
166 | : false;
167 |
168 | const changeMarket = useCallback(() => {
169 | dispatch(startChangeMarketAndData(marketName));
170 | history.push("/trade");
171 | }, [dispatch, marketName, history]);
172 |
173 | return (
174 |
178 |
179 |
183 |
184 | {coinName}
185 | {enCoinName}
186 |
187 |
188 | {price.toLocaleString()}
189 |
190 |
191 |
192 | {changeRate24Hour}
193 |
194 |
195 | {changePrice24Hour.toLocaleString()}
196 |
197 |
198 |
199 | {tradePrice24Hour.toLocaleString() + " 백만"}
200 |
201 |
202 |
203 | );
204 | };
205 |
206 | export default React.memo(CoinListItem, isEqual);
207 |
--------------------------------------------------------------------------------
/src/Components/Main/CoinInfoHeader.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import withSelectedCoinName from "../../Container/withSelectedCoinName";
4 | import withSelectedCoinPrice from "../../Container/withSelectedCoinPrice";
5 | import withThemeData from "../../Container/withThemeData";
6 | import isEqual from "react-fast-compare";
7 |
8 | const St = {
9 | CoinInfoContainer: styled.section`
10 | display: flex;
11 | justify-content: space-between;
12 | align-items: center;
13 | width: 100%;
14 | background-color: white;
15 | padding: 10px;
16 | border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
17 | `,
18 | HiddenH3: styled.h3`
19 | position: absolute;
20 | width: 1px;
21 | height: 1px;
22 | clip: rect(0, 0);
23 | clip-path: polygon(0, 0);
24 | overflow: hidden;
25 | text-indent: -9999px;
26 | `,
27 | CoinInfoMain: styled.div`
28 | display: flex;
29 | align-items: center;
30 | min-width: 380px;
31 | `,
32 | CoinLogo: styled.i`
33 | display: inline-block;
34 | width: 35px;
35 | height: 35px;
36 | background-image: ${({ coinSymbol }) =>
37 | `url(https://static.upbit.com/logos/${coinSymbol}.png)`};
38 | background-size: cover;
39 | margin-left: 5px;
40 | `,
41 | CoinNameContainer: styled.div`
42 | padding: 0 8px 0 13px;
43 | `,
44 | CoinName: styled.strong`
45 | font-size: 1.7rem;
46 | font-weight: 800;
47 | color: #2b2b2b;
48 |
49 | @media ${({ theme }) => theme.mobileS} {
50 | font-size: 1.5rem;
51 | }
52 | `,
53 | CoinMarketName: styled.span`
54 | display: flex;
55 | font-size: 0.9rem;
56 | flex-direction: column;
57 | padding-left: 5px;
58 | margin-top: 7px;
59 | `,
60 | PriceInfo: styled.div`
61 | display: flex;
62 | flex-direction: column;
63 | `,
64 | Price: styled.strong`
65 | color: ${({ priceColor }) => priceColor};
66 | font-size: 2rem;
67 | font-weight: 800;
68 |
69 | @media ${({ theme }) => theme.mobileS} {
70 | font-size: 1.5rem;
71 | }
72 | `,
73 | PriceUnit: styled.span`
74 | font-size: 0.9rem;
75 | font-weight: 500;
76 | padding-left: 5px;
77 | `,
78 | ChangeContainer: styled.span`
79 | font-size: 0.8rem;
80 | margin-top: 5px;
81 | `,
82 | ChangeRate: styled.strong`
83 | font-size: 1rem;
84 | color: ${({ priceColor }) => priceColor};
85 | margin: 0 10px 0 5px;
86 | font-weight: 800;
87 | `,
88 | ChangePrice: styled.strong`
89 | font-size: 1rem;
90 | font-weight: 800;
91 | color: ${({ priceColor }) => priceColor};
92 | `,
93 | TradeInfoContainer: styled.dl`
94 | display: flex;
95 | justify-content: flex-end;
96 | align-items: center;
97 | width: 45%;
98 | height: 100%;
99 | margin: 0 10px 0 0;
100 |
101 | @media ${({ theme, mobileMNone }) => (mobileMNone ? theme.mobileM : true)} {
102 | display: none;
103 | }
104 | `,
105 | InfoContainer: styled.div`
106 | height: 100%;
107 | margin-left: 15px;
108 | @media ${({ theme, tabletNone }) => (tabletNone ? theme.tablet : true)} {
109 | display: none;
110 | }
111 | @media ${({ theme, mobileMNone }) => (mobileMNone ? theme.mobileM : true)} {
112 | display: none;
113 | }
114 | `,
115 | TradeInfo: styled.div`
116 | display: flex;
117 | justify-content: space-between;
118 | height: 50%;
119 | min-width: ${({ minWidth }) => minWidth || "none"};
120 | border-bottom: 1px solid ${({ borderColor }) => borderColor || "none"};
121 | padding: 5px 0 5px 0;
122 | font-size: 0.8rem;
123 | `,
124 | TradeDT: styled.dt`
125 | display: inline-block;
126 | min-width: 50px;
127 | height: 50%;
128 | `,
129 | TradeDD: styled.dd`
130 | margin: 0;
131 | display: inline-block;
132 | height: 50%;
133 | color: ${({ fontColor }) => fontColor || "black"};
134 | font-weight: ${({ fontWeight }) => fontWeight || 500};
135 | `,
136 | };
137 |
138 | const CoinInfoHeader = ({
139 | theme,
140 | coinNameKor,
141 | coinSymbol,
142 | coinNameAndMarketEng,
143 | highestPrice24Hour,
144 | lowestPrice24Hour,
145 | changeRate24Hour,
146 | changePrice24Hour,
147 | tradePrice24Hour,
148 | volume24Hour,
149 | price,
150 | }) => {
151 | const priceColor = changeRate24Hour > 0 ? theme.priceUp : theme.priceDown;
152 | return (
153 |
154 | 코인 가격 및 기타 정보
155 |
156 |
157 |
158 | {coinNameKor}
159 | {coinNameAndMarketEng}
160 |
161 |
162 |
163 | {price.toLocaleString()}
164 | KRW
165 |
166 |
167 | 전일대비
168 |
169 | {changeRate24Hour}%
170 |
171 |
172 | {changePrice24Hour.toLocaleString()}
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 | 고가
181 |
182 | {highestPrice24Hour ? highestPrice24Hour.toLocaleString() : 0}
183 |
184 |
185 |
186 | 저가
187 |
188 | {lowestPrice24Hour ? lowestPrice24Hour.toLocaleString() : 0}
189 |
190 |
191 |
192 |
193 |
194 | 거래량(24h)
195 | {`${volume24Hour.toLocaleString()} ${coinSymbol}`}
196 |
197 |
198 |
199 | 거래대금(24h)
200 |
201 |
202 | {tradePrice24Hour ? tradePrice24Hour.toLocaleString() : 0} KRW
203 |
204 |
205 |
206 |
207 |
208 | );
209 | };
210 |
211 | export default withSelectedCoinName()(
212 | withSelectedCoinPrice()(withThemeData()(React.memo(CoinInfoHeader)))
213 | );
214 |
--------------------------------------------------------------------------------
/src/Components/Main/CoinList.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import { searchCoin } from "../../Reducer/coinReducer";
4 | import { useDispatch } from "react-redux";
5 |
6 | import CoinListItem from "./CoinListItem";
7 | import Loading from "../Global/Loading";
8 |
9 | import withThemeData from "../../Container/withThemeData";
10 | import withSelectedOption from "../../Container/withSelectedOption";
11 | import withMarketNames from "../../Container/withMarketNames";
12 | import withLatestCoinData from "../../Container/withLatestCoinData";
13 | import withLoadingData from "../../Container/withLoadingData";
14 |
15 | const St = {
16 | CoinListContainer: styled.article`
17 | display: none;
18 | position: -webkit-sticky; /* 사파리 */
19 | position: sticky;
20 | top: 70px;
21 | height: 100%;
22 | width: 100%;
23 | background-color: white;
24 | overflow: hidden;
25 |
26 | @media ${({ theme }) => theme.desktop} {
27 | display: block;
28 | max-width: 400px;
29 | height: ${({ heightSize }) => `${heightSize}px`};
30 | margin-left: 10px;
31 | }
32 |
33 | @media ${({ theme, isRootURL }) => (!isRootURL ? theme.mobileM : true)} {
34 | display: none;
35 | }
36 |
37 | @media ${({ theme, isRootURL }) => (isRootURL ? theme.tablet : true)} {
38 | display: block;
39 | margin-top: 0;
40 | margin-bottom: 0;
41 | height: ${({ heightSize }) =>
42 | `${heightSize + 80}px`}; // 모바일 풀 화면을 위해 다시 80px 더해줌
43 | }
44 | `,
45 | HiddenH3: styled.h3`
46 | position: absolute;
47 | width: 1px;
48 | height: 1px;
49 | clip: rect(0, 0);
50 | clip-path: polygon(0, 0);
51 | overflow: hidden;
52 | text-indent: -9999px;
53 | `,
54 | CoinSearchContainer: styled.div`
55 | display: flex;
56 | width: 100%;
57 | border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
58 | `,
59 | CoinSearchInput: styled.input`
60 | width: 100%;
61 | border: none;
62 | padding: 5px;
63 | padding-left: 12px;
64 | &::placeholder {
65 | font-size: 0.7rem;
66 | color: gray;
67 | font-weight: 700;
68 | }
69 | `,
70 | CoinSearchBtn: styled.button`
71 | width: 30px;
72 | height: 30px;
73 | background: url("https://cdn.upbit.com/images/bg.e801517.png") -83px 2px no-repeat;
74 | background-color: white;
75 | padding: 10px;
76 | padding-right: 20px;
77 | padding-left: 20px;
78 | border: none;
79 | `,
80 | CoinSortContainer: styled.ul`
81 | display: flex;
82 | justify-content: space-between;
83 | align-items: center;
84 | background-color: white;
85 | width: 100%;
86 | height: 30px;
87 | border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
88 | font-size: 0.9rem;
89 | font-weight: 800;
90 | color: #666666;
91 | `,
92 | CoinSortList: styled.li`
93 | width: ${({ width }) => width || "20%"};
94 | text-align: ${({ textAlign }) => textAlign || "right"};
95 | margin-right: ${({ marginRight }) => marginRight || 0};
96 | font-size: 0.78rem;
97 | `,
98 | CoinUl: styled.ul`
99 | height: ${({ heightSize }) => `${heightSize + 70}px`};
100 | min-height: 800px;
101 | background-color: white;
102 | overflow-y: scroll;
103 | scrollbar-color: ${({ theme }) => theme.middleGray};
104 | scrollbar-width: thin;
105 | scrollbar-base-color: ${({ theme }) => theme.middleGray};
106 | &::-webkit-scrollbar {
107 | width: 5px;
108 | background-color: white;
109 | border-radius: 5rem;
110 | }
111 | &::-webkit-scrollbar-thumb {
112 | background-color: ${({ theme }) => theme.middleGray};
113 | border-radius: 5rem;
114 | }
115 |
116 | @media ${({ theme }) => theme.desktop} {
117 | display: block;
118 | max-width: 400px;
119 | height: ${({ heightSize }) => `${heightSize}px`};
120 | }
121 | `,
122 | };
123 |
124 | const CoinList = ({
125 | theme,
126 | marketNames,
127 | sortedMarketNames,
128 | latestCoinData,
129 | selectedMarket,
130 | searchCoinInput,
131 | isMarketNamesLoading,
132 | isInitCandleLoading,
133 | heightSize,
134 | isRootURL,
135 | }) => {
136 | const dispatch = useDispatch();
137 |
138 | return (
139 |
140 | 코인 리스트
141 |
142 | dispatch(searchCoin(e.target.value))}
145 | value={searchCoinInput}
146 | placeholder={"코인명/심볼검색"}
147 | />
148 |
149 |
150 |
151 |
152 | 한글명
153 | 현재가
154 | 상승률
155 |
156 | 거래대금
157 |
158 |
159 |
160 | {isMarketNamesLoading || isInitCandleLoading ? (
161 |
162 | ) : (
163 | sortedMarketNames.map((marketName) => {
164 | const splitedName = marketName.split("-");
165 | const enCoinName = splitedName[1] + "/" + splitedName[0];
166 | const changePrice24Hour =
167 | latestCoinData[marketName].changePrice24Hour;
168 | const changeRate24Hour =
169 | latestCoinData[marketName].changeRate24Hour;
170 | const tradePrice24Hour =
171 | latestCoinData[marketName].tradePrice24Hour;
172 | const price = latestCoinData[marketName].price;
173 | // const isTraded = latestCoinData[marketName].isTraded;
174 |
175 | const fontColor =
176 | +changePrice24Hour > 0
177 | ? theme.strongRed
178 | : +changePrice24Hour < 0
179 | ? theme.strongBlue
180 | : "black";
181 | return (
182 |
196 | );
197 | })
198 | )}
199 |
200 |
201 | );
202 | };
203 |
204 | export default withLatestCoinData()(
205 | withMarketNames()(
206 | withSelectedOption()(
207 | withLoadingData()(withThemeData()(React.memo(CoinList)))
208 | )
209 | )
210 | );
211 |
--------------------------------------------------------------------------------
/src/Components/Main/OrderInfoAskBid.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import { useDispatch } from "react-redux";
3 | import styled from "styled-components";
4 | import {
5 | changeAmountAndTotalPrice,
6 | changePriceAndTotalPrice,
7 | changeTotalPriceAndAmount,
8 | } from "../../Reducer/coinReducer";
9 | import OrderInfoTradeList from "./OrderInfoTradeList";
10 |
11 | const St = {
12 | Container: styled.section`
13 | width: 100%;
14 | height: 50%;
15 | background-color: white;
16 | `,
17 | OrderTypeContainer: styled.div`
18 | display: flex;
19 | height: 40px;
20 | align-items: center;
21 | border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
22 |
23 | @media ${({ theme }) => theme.mobileS} {
24 | font-size: 0.8rem;
25 | }
26 | `,
27 | OrderType: styled.button`
28 | width: 33.33%;
29 | height: 100%;
30 | background-color: white;
31 | border: none;
32 | border-bottom: 3px solid
33 | ${({ borderBottom }) => borderBottom || "tranceparent"};
34 | outline: 0;
35 | font-weight: 900;
36 | color: ${({ fontColor }) => fontColor || "black"};
37 | `,
38 | OrderInfoContainer: styled.div`
39 | width: 100%;
40 | padding: 15px;
41 | padding-top: 0;
42 |
43 | @media ${({ theme }) => theme.mobileS} {
44 | padding: 5px;
45 | }
46 | `,
47 | OrderInfoDetailContainer: styled.div`
48 | display: flex;
49 | align-items: center;
50 | width: 100%;
51 | height: 38px;
52 | margin-top: 15px;
53 |
54 | @media ${({ theme }) => theme.mobileS} {
55 | font-size: 0.6rem;
56 | margint-right: 10px;
57 | }
58 | `,
59 | OrderInfoDetailTitle: styled.span`
60 | display: block;
61 | width: 20%;
62 | min-width: 52px;
63 | max-width: 100px;
64 | font-size: 0.8rem;
65 | font-weight: 600;
66 | color: #666;
67 | margin-left: 5px;
68 | margin-right: 5px;
69 | `,
70 | OrderInfoInputContainer: styled.div`
71 | display: flex;
72 | width: 100%;
73 | height: 100%;
74 | `,
75 | OrderInfoInput: styled.input`
76 | width: ${({ width }) => width || "100%"};
77 | height: 100%;
78 | margin: 0;
79 | padding: 5px;
80 | padding-right: 15px;
81 | border: 1px solid ${({ theme }) => theme.lightGray2};
82 | text-align: right;
83 | font-size: 0.95rem;
84 | font-weight: ${({ fontWeight }) => fontWeight};
85 | @media ${({ theme }) => theme.mobileS} {
86 | font-size: 0.6rem;
87 | }
88 | `,
89 | Button: styled.button`
90 | width: ${({ width }) => width || "50px"};
91 | min-width: ${({ minWidth }) => minWidth};
92 | height: ${({ height }) => height || "38px"};
93 | margin-right: ${({ marginRight }) => marginRight};
94 | background-color: ${({ bgColor }) => bgColor || "tranceparent"};
95 | border: none;
96 | border-top: 1px solid ${({ borderColor }) => borderColor || "tranceparent"};
97 | border-right: 1px solid
98 | ${({ borderColor }) => borderColor || "tranceparent"};
99 | border-bottom: 1px solid
100 | ${({ borderColor }) => borderColor || "tranceparent"};
101 | outline: none;
102 | color: ${({ fontColor }) => fontColor || "black"};
103 | font-size: ${({ fontSize }) => fontSize};
104 | font-weight: 900;
105 | `,
106 | PossibleAmount: styled.span`
107 | display: block;
108 | width: 100%;
109 | text-align: right;
110 | font-size: 1.2rem;
111 | font-weight: 600;
112 | @media ${({ theme }) => theme.mobileS} {
113 | font-size: 1rem;
114 | }
115 | `,
116 | Unit: styled.span`
117 | margin-left: 5px;
118 | font-size: 0.8rem;
119 | font-weight: 500;
120 | `,
121 | OrderBtnContainer: styled.div`
122 | display: flex;
123 | justify-content: space-between;
124 | width: 100%;
125 | margin-top: 50px;
126 |
127 | @media ${({ theme }) => theme.mobileS} {
128 | font-size: 0.8rem;
129 | }
130 | `,
131 | };
132 |
133 | const OrderInfoAskBid = ({
134 | theme,
135 | selectedAskBidOrder,
136 | coinSymbol,
137 | orderPrice,
138 | orderAmount,
139 | orderTotalPrice,
140 | }) => {
141 | const dispatch = useDispatch();
142 | const changePrice = useCallback(
143 | (e) =>
144 | dispatch(
145 | changePriceAndTotalPrice(
146 | parseInt(e.target.value.replace(/[^0-9-.]/g, ""))
147 | )
148 | ),
149 | [dispatch]
150 | );
151 | const changeAmount = useCallback(
152 | (e) => {
153 | dispatch(
154 | changeAmountAndTotalPrice(e.target.value.replace(/[^0-9-.]/g, ""))
155 | );
156 | },
157 | [dispatch]
158 | );
159 | const changeTotalPrice = useCallback(
160 | (e) =>
161 | dispatch(
162 | changeTotalPriceAndAmount(
163 | parseInt(e.target.value.replace(/[^0-9-.]/g, ""))
164 | )
165 | ),
166 | [dispatch]
167 | );
168 |
169 | return (
170 |
171 | {selectedAskBidOrder !== "tradeList" ? (
172 | <>
173 |
174 | 주문가능
175 |
176 | 0
177 |
178 | {selectedAskBidOrder === "bid" ? "KRW" : coinSymbol}
179 |
180 |
181 |
182 |
183 |
184 | {selectedAskBidOrder === "bid" ? "매수가격" : "매도가격"}
185 |
186 |
187 |
193 |
199 | +
200 |
201 |
207 | -
208 |
209 |
210 |
211 |
212 | 주문수량
213 |
218 |
219 |
220 | 주문총액
221 |
226 |
227 | >
228 | ) : (
229 |
230 | )}
231 |
232 |
240 | 회원가입
241 |
242 |
248 | 로그인
249 |
250 |
251 |
252 | );
253 | };
254 |
255 | export default React.memo(OrderInfoAskBid);
256 |
--------------------------------------------------------------------------------
/src/Lib/asyncUtil.js:
--------------------------------------------------------------------------------
1 | import { w3cwebsocket as W3CWebSocket } from "websocket";
2 | import { call, put, select, flush, delay } from "redux-saga/effects";
3 | import { startLoading, finishLoading } from "../Reducer/loadingReducer";
4 | import { throttle } from "lodash";
5 | import { buffers, eventChannel, END } from "redux-saga";
6 | import encoding from "text-encoding";
7 |
8 | // 캔들용 사가
9 | const createRequestSaga = (type, api, dataMaker) => {
10 | const SUCCESS = `${type}_SUCCESS`;
11 | const ERROR = `${type}_ERROR`;
12 |
13 | return function* (action = {}) {
14 | yield put(startLoading(type));
15 | try {
16 | const res = yield call(api, action.payload);
17 | const state = yield select();
18 |
19 | yield put({ type: SUCCESS, payload: dataMaker(res.data, state) });
20 | yield put(finishLoading(type));
21 | } catch (e) {
22 | yield put({ type: ERROR, payload: e });
23 | yield put(finishLoading(type));
24 | throw e;
25 | }
26 | };
27 | };
28 |
29 | // 선택 옵션 변경용 사가
30 | const createChangeOptionSaga = (type) => {
31 | const SUCCESS = `${type}_SUCCESS`;
32 |
33 | return function* (action = {}) {
34 | yield put({ type: SUCCESS, payload: action.payload });
35 | };
36 | };
37 |
38 | // 웹소켓 연결용 Thunk
39 | const createConnectSocketThunk = (type, connectType, dataMaker) => {
40 | const SUCCESS = `${type}_SUCCESS`;
41 | const ERROR = `${type}_ERROR`;
42 |
43 | return (action = {}) => (dispatch, getState) => {
44 | const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
45 | client.binaryType = "arraybuffer";
46 |
47 | client.onopen = () => {
48 | client.send(
49 | JSON.stringify([
50 | { ticket: "downbit-clone" },
51 | { type: connectType, codes: action.payload },
52 | ])
53 | );
54 | };
55 |
56 | client.onmessage = (evt) => {
57 | const enc = new encoding.TextDecoder("utf-8");
58 | const arr = new Uint8Array(evt.data);
59 | const data = JSON.parse(enc.decode(arr));
60 | const state = getState();
61 |
62 | dispatch({ type: SUCCESS, payload: dataMaker(data, state) });
63 | };
64 |
65 | client.onerror = (e) => {
66 | dispatch({ type: ERROR, payload: e });
67 | };
68 | };
69 | };
70 |
71 | // 웹소켓 연결용 Thunk
72 | const createConnectSocketThrottleThunk = (type, connectType, dataMaker) => {
73 | const SUCCESS = `${type}_SUCCESS`;
74 | const ERROR = `${type}_ERROR`;
75 | const throttleDispatch = throttle((dispatch, state, data) => {
76 | dispatch({ type: SUCCESS, payload: dataMaker(data, state) });
77 | }, 500);
78 |
79 | return (action = {}) => (dispatch, getState) => {
80 | const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
81 | client.binaryType = "arraybuffer";
82 |
83 | client.onopen = () => {
84 | client.send(
85 | JSON.stringify([
86 | { ticket: "downbit-clone" },
87 | { type: connectType, codes: action.payload },
88 | ])
89 | );
90 | };
91 |
92 | client.onmessage = (evt) => {
93 | const enc = new encoding.TextDecoder("utf-8");
94 | const arr = new Uint8Array(evt.data);
95 | const data = JSON.parse(enc.decode(arr));
96 | const state = getState();
97 |
98 | // dispatch({ type: SUCCESS, payload: dataMaker(data, state) });
99 | throttleDispatch(dispatch, state, data);
100 | };
101 |
102 | client.onerror = (e) => {
103 | dispatch({ type: ERROR, payload: e });
104 | };
105 | };
106 | };
107 |
108 | // 소켓 만들기
109 | const createSocket = () => {
110 | const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
111 | client.binaryType = "arraybuffer";
112 |
113 | return client;
114 | };
115 |
116 | // 소켓 연결용
117 | const connectSocekt = (socket, connectType, action, buffer) => {
118 | return eventChannel((emit) => {
119 | socket.onopen = () => {
120 | socket.send(
121 | JSON.stringify([
122 | { ticket: "downbit-clone" },
123 | { type: connectType, codes: action.payload },
124 | ])
125 | );
126 | };
127 |
128 | socket.onmessage = (evt) => {
129 | const enc = new encoding.TextDecoder("utf-8");
130 | // const arr = new Uint8Array(evt.data);
131 | const data = JSON.parse(enc.decode(evt.data));
132 |
133 | emit(data);
134 | };
135 |
136 | socket.onerror = (evt) => {
137 | emit(evt);
138 | emit(END);
139 | };
140 |
141 | const unsubscribe = () => {
142 | socket.close();
143 | };
144 |
145 | return unsubscribe;
146 | }, buffer || buffers.none());
147 | };
148 |
149 | // 웹소켓 연결용 사가
150 | const createConnectSocketSaga = (type, connectType, dataMaker) => {
151 | const SUCCESS = `${type}_SUCCESS`;
152 | const ERROR = `${type}_ERROR`;
153 |
154 | return function* (action = {}) {
155 | const client = yield call(createSocket);
156 | const clientChannel = yield call(
157 | connectSocekt,
158 | client,
159 | connectType,
160 | action,
161 | buffers.expanding(500)
162 | );
163 |
164 | try {
165 | while (true) {
166 | const datas = yield flush(clientChannel); // 버퍼 데이터 가져오기
167 | const state = yield select();
168 |
169 | if (datas.length) {
170 | const sortedObj = {};
171 | datas.forEach((data) => {
172 | if (sortedObj[data.code]) {
173 | // 버퍼에 있는 데이터중 시간이 가장 최근인 데이터만 남김
174 | sortedObj[data.code] =
175 | sortedObj[data.code].timestamp > data.timestamp
176 | ? sortedObj[data.code]
177 | : data;
178 | } else {
179 | sortedObj[data.code] = data; // 새로운 데이터면 그냥 넣음
180 | }
181 | });
182 |
183 | const sortedData = Object.keys(sortedObj).map(
184 | (data) => sortedObj[data]
185 | );
186 |
187 | yield put({ type: SUCCESS, payload: dataMaker(sortedData, state) });
188 | }
189 | yield delay(500); // 500ms 동안 대기
190 | }
191 | } catch (e) {
192 | yield put({ type: ERROR, payload: e });
193 | } finally {
194 | clientChannel.close(); // emit(END) 접근시 소켓 닫기
195 | }
196 | };
197 | };
198 |
199 | const reducerUtils = {
200 | success: (state, payload, key) => {
201 | return {
202 | ...state,
203 | [key]: {
204 | data: payload,
205 | error: false,
206 | },
207 | };
208 | },
209 | error: (state, error, key) => ({
210 | ...state,
211 | [key]: {
212 | ...state[key],
213 | error: error,
214 | },
215 | }),
216 | };
217 |
218 | const requestActions = (type, key) => {
219 | const [SUCCESS, ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];
220 | return (state, action) => {
221 | switch (action.type) {
222 | case SUCCESS:
223 | return reducerUtils.success(state, action.payload, key);
224 | case ERROR:
225 | return reducerUtils.error(state, action.payload, key);
226 | default:
227 | return state;
228 | }
229 | };
230 | };
231 |
232 | const requestInitActions = (type, key) => {
233 | const [SUCCESS, ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];
234 | return (state, action) => {
235 | switch (action.type) {
236 | case SUCCESS:
237 | return {
238 | ...state,
239 | candleDay: {
240 | data: action.payload,
241 | error: false,
242 | },
243 | [key]: {
244 | data: action.payload,
245 | error: false,
246 | },
247 | };
248 | case ERROR:
249 | return {
250 | ...state,
251 | candleDay: {
252 | ...state.candleDay,
253 | error: action.payload,
254 | },
255 | [key]: {
256 | ...state[key],
257 | error: action.payload,
258 | },
259 | };
260 | default:
261 | return state;
262 | }
263 | };
264 | };
265 |
266 | const changeOptionActions = (type, key) => {
267 | const SUCCESS = `${type}_SUCCESS`;
268 | return (state, action) => {
269 | switch (action.type) {
270 | case SUCCESS:
271 | return {
272 | ...state,
273 | [key]: action.payload,
274 | };
275 | default:
276 | return state;
277 | }
278 | };
279 | };
280 |
281 | export {
282 | createRequestSaga,
283 | createConnectSocketThunk,
284 | createConnectSocketThrottleThunk,
285 | createConnectSocketSaga,
286 | createChangeOptionSaga,
287 | requestActions,
288 | requestInitActions,
289 | changeOptionActions,
290 | };
291 |
--------------------------------------------------------------------------------
/src/Components/Main/MainChart.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "styled-components";
3 | import { useDispatch } from "react-redux";
4 | import { startAddMoreCandleData } from "../../Reducer/coinReducer";
5 |
6 | import { format } from "d3-format";
7 | import { timeFormat } from "d3-time-format";
8 | import {
9 | elderRay,
10 | ema,
11 | discontinuousTimeScaleProviderBuilder,
12 | Chart,
13 | ChartCanvas,
14 | CurrentCoordinate,
15 | BarSeries,
16 | CandlestickSeries,
17 | ElderRaySeries,
18 | LineSeries,
19 | MovingAverageTooltip,
20 | OHLCTooltip,
21 | SingleValueTooltip,
22 | mouseBasedZoomAnchor,
23 | XAxis,
24 | YAxis,
25 | CrossHairCursor,
26 | EdgeIndicator,
27 | MouseCoordinateX,
28 | MouseCoordinateY,
29 | ZoomButtons,
30 | withDeviceRatio,
31 | withSize,
32 | } from "react-financial-charts";
33 |
34 | import Loading from "../Global/Loading";
35 |
36 | import withOHLCData from "../../Container/withOHLCData";
37 | import withThemeData from "../../Container/withThemeData";
38 | import withSelectedOption from "../../Container/withSelectedOption";
39 | import withLoadingData from "../../Container/withLoadingData";
40 | import isEqual from "react-fast-compare";
41 |
42 | const barChartExtents = (data) => {
43 | return data.volume;
44 | };
45 |
46 | const candleChartExtents = (data) => {
47 | return [data.high, data.low];
48 | };
49 |
50 | const yEdgeIndicator = (data) => {
51 | return data.close;
52 | };
53 |
54 | const volumeSeries = (data) => {
55 | return data.volume;
56 | };
57 |
58 | const St = {
59 | ChartContainer: styled.section`
60 | width: 100%;
61 | height: 500px;
62 | background-color: white;
63 | `,
64 | HiddenH3: styled.h3`
65 | position: absolute;
66 | width: 1px;
67 | height: 1px;
68 | clip: rect(0, 0);
69 | clip-path: polygon(0, 0);
70 | overflow: hidden;
71 | text-indent: -9999px;
72 | `,
73 | };
74 |
75 | const margin = { left: 10, right: 80, top: 20, bottom: 20 };
76 | const minHeight = 350;
77 |
78 | const MainChart = ({
79 | data: initialData,
80 | height,
81 | width,
82 | ratio,
83 | selectedTimeType,
84 | theme,
85 | isCandleLoading,
86 | }) => {
87 | if (height > 500) height = 500;
88 | const dispatch = useDispatch();
89 |
90 | const dateTimeFormat =
91 | selectedTimeType === "days" || selectedTimeType === "weeks"
92 | ? "%y-%m-%d"
93 | : "%y-%m-%d %H:%M";
94 | const timeDisplayFormat = timeFormat(dateTimeFormat);
95 | const pricesDisplayFormat = format("");
96 |
97 | const openCloseColor = (data) => {
98 | return data.close > data.open ? theme.priceUp : theme.priceDown;
99 | };
100 |
101 | const volumeColor = (data) => {
102 | return data.close > data.open ? theme.priceUpTrans : theme.priceDownTrans;
103 | };
104 |
105 | const xScaleProvider = discontinuousTimeScaleProviderBuilder().inputDateAccessor(
106 | (d) => d.date
107 | );
108 |
109 | const ema12 = ema()
110 | .id(1)
111 | .options({ windowSize: 12 })
112 | .merge((d, c) => {
113 | d.ema12 = c;
114 | })
115 | .accessor((d) => d.ema12);
116 |
117 | const ema26 = ema()
118 | .id(2)
119 | .options({ windowSize: 26 })
120 | .merge((d, c) => {
121 | d.ema26 = c;
122 | })
123 | .accessor((d) => d.ema26);
124 |
125 | const elder = elderRay();
126 |
127 | const calculatedData = elder(ema26(ema12(initialData)));
128 |
129 | const { data, xScale, xAccessor, displayXAccessor } = xScaleProvider(
130 | calculatedData
131 | );
132 |
133 | // 확대 축소 초기 범위를 정하는 xExtendts설정, max와 min이 변하면 새로운 데이터 추가시 줌 설정이 풀린다
134 | const max = xAccessor(data[Math.min(199, data.length - 1)]);
135 | const min = xAccessor(
136 | data.length < 50 ? 0 : data[Math.min(50, Math.floor(data.length / 2))]
137 | );
138 | const xExtents = [min, max + 5];
139 |
140 | const gridHeight = height - margin.top - margin.bottom;
141 |
142 | const elderRayHeight = 100;
143 | const elderRayOrigin = (_, h) => [0, h - elderRayHeight];
144 | const barChartHeight = gridHeight / 4;
145 | const barChartOrigin = (_, h) => [0, h - barChartHeight - elderRayHeight];
146 | const chartHeight = gridHeight - elderRayHeight;
147 |
148 | return (
149 |
150 | 캔들 차트
151 | {isCandleLoading ? (
152 |
153 | ) : (
154 | {
168 | dispatch(startAddMoreCandleData());
169 | }}
170 | >
171 |
177 |
178 |
179 |
180 |
181 |
182 |
186 |
190 |
194 |
198 |
202 |
206 |
214 |
231 |
232 |
233 |
234 |
235 |
242 |
243 |
244 |
245 |
246 |
250 |
251 |
252 |
253 |
257 | `${pricesDisplayFormat(d.bullPower)}, ${pricesDisplayFormat(
258 | d.bearPower
259 | )}`
260 | }
261 | origin={[8, 16]}
262 | />
263 |
264 |
265 |
266 | )}
267 |
268 | );
269 | };
270 |
271 | export default withOHLCData()(
272 | withSize({
273 | style: {
274 | width: "100%",
275 | height: "500",
276 | minHeight,
277 | },
278 | })(
279 | withDeviceRatio()(
280 | withSelectedOption()(
281 | withLoadingData()(withThemeData()(React.memo(MainChart, isEqual)))
282 | )
283 | )
284 | )
285 | );
286 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Downbit 프로젝트 
2 |
3 | | [](https://youtu.be/zsuSvv9IfM8) |
4 | | :----------------------------------------------------------------------------------------------------------------------------------------------------: |
5 | | _이미지 클릭시 YouTube로 연결됩니다_ |
6 |
7 |
8 |
9 | ~~[downbit.ml](https://downbit.ml)에서 배포된 프로젝트 내역을 확인하실 수 있습니다.~~
10 | 두나무의 요청으로 배포를 중단했습니다.
11 |
12 |
13 |
14 | ## Development motivation
15 |
16 | Upbit의 실제 거래 데이터를 통해
17 |
18 | 많은 데이터 수신시 프론트 엔드의 뷰를 최적화 하는 방법을 학습하고자
19 |
20 | 이번 프로젝트를 시작하였습니다.
21 |
22 |
23 |
24 | ## Skill
25 |
26 | 
27 | 
28 | 
29 | 
30 | 
31 | 
32 | 
33 |
34 |
35 |
36 | ## Requirements
37 |
38 | - Library
39 |
40 | 접기/펼치기 버튼
41 |
42 | React v.16
43 | axios: 0.20.0
44 | d3: 5.15.1
45 | react-redux: 7.2.1
46 | redux-saga v.1.1.3
47 | redux-thunk v.2.3.0
48 | react-router-dom v.5.2.0
49 | axios v.0.19.2
50 | websocket: 1.0.32
51 | react-fast-compare: 3.2.0
52 | react-financial-charts: 1.0.0-alpha.16
53 | decimal.js: 10.2.1
54 | hangul-js: 0.2.6
55 | lodash: 4.17.20
56 | moment-timezone: 0.5.31
57 | styled-components: 5.2.0
58 | styled-normalize: 8.0.7
59 | styled-reset": 4.3.0
60 | @fortawesome/free-brands-svg-icons: 5.15.1
61 | @fortawesome/free-solid-svg-icons: 5.15.1
62 | @fortawesome/react-fontawesome: 0.1.12
63 |
64 |
65 |
66 |
67 |
68 | ## Getting Started
69 |
70 | $ git clone https://github.com/Seongkyun-Yu/upbit-clone.git
71 | $ yarn install
72 | \$ yarn start
73 |
74 |
75 |
76 | ## Main Feature (프로젝트의 모든 기능을 혼자 개발했습니다)
77 |
78 | - 실시간 가격, 거래량 등의 데이터 수신 및 차트 랜더링
79 | - 실시간 호가창, 거래내역 랜더링
80 | - 코인 초성, 심볼 검색
81 | - 매수 총액에 따른 구매수량 자동 조절, 가격 변경에 따른 구매 총액 자동 변경
82 | - 호가창 클릭시 자동 가격 입력
83 | - 반응형
84 |
85 |
86 |
87 | ## 프로젝트 구조
88 |
89 | ```bash
90 | ├── node_modules
91 | ├── public
92 | │ ├── blueLogo.png
93 | │ ├── whiteLogo.png
94 | │ ├── favicon.png
95 | │ └── index.html
96 | ├── build
97 | ├── src
98 | │ ├── Api
99 | │ │ └── api.js
100 | │ ├── Components
101 | │ │ ├── Global
102 | │ │ │ ├── Header.js
103 | │ │ │ ├── Footer.js
104 | │ │ │ └── Loading.js
105 | │ │ └── Main
106 | │ │ ├── ChartDataConsole.js
107 | │ │ ├── CoinInfoHeader.js
108 | │ │ ├── CoinList.js
109 | │ │ ├── CoinListItem.js
110 | │ │ ├── MainChart.js
111 | │ │ ├── Orderbook.js
112 | │ │ ├── OrderbookCoinInfo.js
113 | │ │ ├── OrderbookItem.js
114 | │ │ ├── OrderInfo.js
115 | │ │ ├── OrderInfoAskBid.js
116 | │ │ ├── OrderInfoTradeList.js
117 | │ │ ├── TradeList.js
118 | │ │ └── TradeListItem.js
119 | │ ├── Pages
120 | │ │ └── Main.js
121 | │ ├── Container <-- HOC
122 | │ │ ├── withLatestCoinData.js
123 | │ │ ├── withLoadingData.js
124 | │ │ ├── withMarketNames.js
125 | │ │ ├── withOHLCData.js
126 | │ │ └── ...etc
127 | │ ├── Lib
128 | │ │ ├── asyncUtil.js <-- redux-saga, thunk factory pattern
129 | │ │ └── utils.js <-- etc utils
130 | │ ├── Reducer
131 | │ │ ├── index.js
132 | │ │ ├── coinReducer.js
133 | │ │ └── loadingReducer.js
134 | │ ├── Router
135 | │ │ └── MainRouter.js
136 | │ ├── styles
137 | │ │ ├── fonts
138 | │ │ ├── GlobalStyle.js
139 | │ │ └── theme.js
140 | │ ├── App.js
141 | │ └── index.js
142 | ├── README.md
143 | ├── LICENSE
144 | ├── package.json
145 | ├── yarn.lock
146 | └── .gitignore
147 | ```
148 |
149 |
150 |
151 | ## 프로젝트 관련 생각들
152 |
153 |
154 | - [buffer를 활용하여 상태 갱신 줄이기](https://velog.io/@seongkyun/React-%EC%B5%9C%EC%A0%81%ED%99%94-buffer%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%98%EC%97%AC-%EC%83%81%ED%83%9C-%EA%B0%B1%EC%8B%A0-%EC%A4%84%EC%9D%B4%EA%B8%B0)
155 | - [throttle로 이벤트 캐치 줄이기](https://velog.io/@seongkyun/React-%EC%B5%9C%EC%A0%81%ED%99%94-%EB%B0%98%EC%9D%91%ED%98%95%EA%B3%BC-display-none)
156 |
157 |
158 | ## Technical Issue: Optimization
159 |
160 | - 1초에 최대 150개의 데이터가 전송되어 상태를 변경시킴
161 |
263 |
264 | - Push 방식의 WebSocket을 Redux-Saga를 이용하여 Pull 방식으로 변경
265 | - Redux-Saga의 eventChannel을 이용하여 버퍼 생성
266 | - 0.5초에 한 번 버퍼를 확인하여 중복된 데이터 제거 후 변경내역을 상태에 한번에 업데이트
267 |
268 | - ```javascript
269 | import { call, put, select, flush, delay } from "redux-saga/effects";
270 | import { buffers, eventChannel } from "redux-saga";
271 |
272 | // 소켓 만들기
273 | const createSocket = () => {
274 | const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
275 | client.binaryType = "arraybuffer";
276 |
277 | return client;
278 | };
279 |
280 | // 소켓 연결용
281 | const connectSocekt = (socket, connectType, action, buffer) => {
282 | return eventChannel((emit) => {
283 | socket.onopen = () => {
284 | socket.send(
285 | JSON.stringify([
286 | { ticket: "downbit-clone" },
287 | { type: connectType, codes: action.payload },
288 | ])
289 | );
290 | };
291 |
292 | socket.onmessage = (evt) => {
293 | const enc = new TextDecoder("utf-8");
294 | const arr = new Uint8Array(evt.data);
295 | const data = JSON.parse(enc.decode(arr));
296 |
297 | emit(data);
298 | };
299 |
300 | socket.onerror = (evt) => {
301 | emit(evt);
302 | };
303 |
304 | const unsubscribe = () => {
305 | socket.close();
306 | };
307 |
308 | return unsubscribe;
309 | }, buffer || buffers.none());
310 | };
311 |
312 | // 웹소켓 연결용 사가
313 | const createConnectSocketSaga = (type, connectType, dataMaker) => {
314 | const SUCCESS = `${type}_SUCCESS`;
315 | const ERROR = `${type}_ERROR`;
316 |
317 | return function* (action = {}) {
318 | const client = yield call(createSocket);
319 | const clientChannel = yield call(
320 | connectSocekt,
321 | client,
322 | connectType,
323 | action,
324 | buffers.expanding(500)
325 | );
326 |
327 | while (true) {
328 | try {
329 | const datas = yield flush(clientChannel); // 버퍼 데이터 가져오기
330 | const state = yield select();
331 |
332 | if (datas.length) {
333 | const sortedObj = {};
334 | datas.forEach((data) => {
335 | if (sortedObj[data.code]) {
336 | // 버퍼에 있는 데이터중 시간이 가장 최근인 데이터만 남김
337 | sortedObj[data.code] =
338 | sortedObj[data.code].timestamp > data.timestamp
339 | ? sortedObj[data.code]
340 | : data;
341 | } else {
342 | sortedObj[data.code] = data; // 새로운 데이터면 그냥 넣음
343 | }
344 | });
345 |
346 | const sortedData = Object.keys(sortedObj).map(
347 | (data) => sortedObj[data]
348 | );
349 |
350 | yield put({
351 | type: SUCCESS,
352 | payload: dataMaker(sortedData, state),
353 | });
354 | }
355 | yield delay(500); // 500ms 동안 대기
356 | } catch (e) {
357 | yield put({ type: ERROR, payload: e });
358 | }
359 | }
360 | };
361 | };
362 | ```
363 |
364 | - 반응형으로 제작시 보이지 않는 컴포넌트를 랜더링 처리
365 |
366 |
404 |
405 | - display: none으로 처리해도 DOM에는 사라지지 않기 때문에 상태 변경시 랜더링 시도함
406 |
407 | - width 값을 측정하여 조건이 맞을 경우에만 컴포넌트를 랜더링 하게 함
408 | - throttle 사용으로 과도한 width값 측정 방지
409 | - ```javascript
410 | import React, { useCallback, useEffect, useState } from "react";
411 | import { throttle } from "lodash";
412 |
413 | const withSize = () => (OriginalComponent) => (props) => {
414 | const [widthSize, setWidthSize] = useState(window.innerWidth);
415 | const [heightSize, setHeightSize] = useState(window.innerHeight);
416 |
417 | const handleSize = useCallback(() => {
418 | setWidthSize(window.innerWidth);
419 | setHeightSize(window.innerHeight);
420 | }, []);
421 |
422 | useEffect(() => {
423 | window.addEventListener("resize", throttle(handleSize, 200));
424 | return () => {
425 | window.removeEventListener("resize", handleSize);
426 | };
427 | }, [handleSize]);
428 |
429 | return (
430 |
435 | );
436 | };
437 | export default withSize;
438 | ```
439 |
440 | - 초기 차트 데이터를 얼마나 가져와야 하는지에 대한 문제
441 | - 200개의 캔들을 먼저 가져오고 필요할 시 추가로 요청 후 랜더링
442 |
443 |
444 |
445 | ## Todo
446 |
447 | - [x] WebSocket 통신
448 | - [x] 기본 Reducer 제작
449 | - [x] Thunk Factory Pattern 제작
450 | - [x] Saga Factory Pattern 제작
451 | - [x] 캔들 차트 드로잉
452 | - [x] 호가 차트 드로잉
453 | - [x] 주문 창 구현
454 |
--------------------------------------------------------------------------------
/src/Lib/utils.js:
--------------------------------------------------------------------------------
1 | import moment from "moment-timezone";
2 | import * as d3 from "d3";
3 |
4 | const dateFormat = d3.timeParse("%Y-%m-%d %H:%M");
5 |
6 | const timestampToDatetime = (timeType, timeCount, timestamp) => {
7 | switch (timeType) {
8 | case "minute":
9 | case "minutes":
10 | return (
11 | moment(timestamp)
12 | .minute(
13 | Math.floor(moment(timestamp).minute() / timeCount) * timeCount
14 | )
15 | .second(0)
16 | // .tz("Asia/Seoul")
17 | .format("YYYY-MM-DD HH:mm")
18 | );
19 | case "hour":
20 | case "hours":
21 | return (
22 | moment(timestamp)
23 | .hour(Math.floor(moment(timestamp).hour() / timeCount) * timeCount)
24 | .minute(0)
25 | .second(0)
26 | // .tz("Asia/Seoul")
27 | .format("YYYY-MM-DD HH:mm")
28 | );
29 | case "day":
30 | case "days":
31 | return moment(timestamp)
32 | .hour(9)
33 | .minute(0)
34 | .second(0)
35 | .format("YYYY-MM-DD HH:mm");
36 | case "week":
37 | case "weeks":
38 | return moment(timestamp)
39 | .hour(0)
40 | .minute(0)
41 | .second(0)
42 | .format("YYYY-MM-DD HH:mm");
43 | default:
44 | return undefined;
45 | }
46 | };
47 |
48 | const candleDataUtils = {
49 | init: (candles, state) => {
50 | const selectedTimeType = state.Coin.selectedTimeType;
51 | const selectedTimeCount = state.Coin.selectedTimeCount;
52 |
53 | const data = {};
54 | candles.forEach((candle) => {
55 | data[candle.market] = {};
56 | data[candle.market]["candles"] = [];
57 | data[candle.market]["candles"].push({
58 | date: dateFormat(
59 | timestampToDatetime(
60 | selectedTimeType,
61 | selectedTimeCount,
62 | candle.timestamp
63 | )
64 | ),
65 | datetime: timestampToDatetime(
66 | selectedTimeType,
67 | selectedTimeCount,
68 | candle.timestamp
69 | ),
70 | timestamp: candle.timestamp,
71 | open: candle.opening_price,
72 | high: candle.high_price,
73 | low: candle.low_price,
74 | close: candle.trade_price,
75 | volume: candle.acc_trade_volume,
76 | tradePrice: candle.acc_trade_price,
77 | });
78 | data[candle.market]["tradePrice24Hour"] = candle.acc_trade_price_24h;
79 | data[candle.market]["volume24Hour"] = candle.acc_trade_volume_24h;
80 | data[candle.market]["changeRate24Hour"] = candle.signed_change_rate;
81 | data[candle.market]["changePrice24Hour"] = candle.signed_change_price;
82 | data[candle.market]["highestPrice24Hour"] = candle.high_price;
83 | data[candle.market]["lowestPrice24Hour"] = candle.low_price;
84 | data[candle.market]["highestPrice52Week"] = candle.highest_52_week_price;
85 | data[candle.market]["highestDate52Week"] = candle.highest_52_week_date;
86 | data[candle.market]["lowestPrice52Week"] = candle.lowest_52_week_price;
87 | data[candle.market]["lowestDate52Week"] = candle.lowest_52_week_date;
88 | });
89 |
90 | return data;
91 | },
92 |
93 | update: (candle, state) => {
94 | const candleStateDatas = state.Coin.candle.data;
95 | const selectedTimeType = state.Coin.selectedTimeType;
96 | const selectedTimeCount = state.Coin.selectedTimeCount;
97 |
98 | const coinMarket = candle.code;
99 |
100 | const targetCandles = candleStateDatas[coinMarket].candles;
101 | const lastCandle = targetCandles.slice(-1)[0];
102 |
103 | const date = dateFormat(
104 | timestampToDatetime(selectedTimeType, selectedTimeCount, candle.timestamp)
105 | );
106 | const datetime = timestampToDatetime(
107 | selectedTimeType,
108 | selectedTimeCount,
109 | candle.timestamp
110 | );
111 | const open = lastCandle.open;
112 | const high =
113 | candle.trade_price > lastCandle.high
114 | ? candle.trade_price
115 | : lastCandle.high;
116 | const low =
117 | candle.trade_price < lastCandle.low ? candle.trade_price : lastCandle.low;
118 | const close = candle.trade_price;
119 |
120 | const highestPrice24Hour = candleStateDatas[coinMarket].highestPrice24Hour;
121 | const lowestPrice24Hour = candleStateDatas[coinMarket].lowestPrice24Hour;
122 |
123 | const needUpdate = targetCandles.find(
124 | (candle) => candle.datetime === datetime
125 | );
126 | const dateChanged =
127 | d3.timeParse("YYYY-MM-DD")(lastCandle.date) !==
128 | d3.timeParse("YYYY-MM-DD")(datetime);
129 |
130 | const newData = { ...candleStateDatas }; // 원본 데이터 보장
131 | if (needUpdate) {
132 | const volume = needUpdate.volume + candle.trade_volume;
133 | const tradePrice = needUpdate.tradePrice + candle.trade_price;
134 | const updatedCandles = [...targetCandles];
135 | updatedCandles.pop();
136 | updatedCandles.push({
137 | date,
138 | datetime,
139 | timestamp: candle.timestamp,
140 | open,
141 | high,
142 | low,
143 | close,
144 | volume,
145 | tradePrice,
146 | });
147 |
148 | newData[coinMarket]["candles"] = updatedCandles;
149 | newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h;
150 | newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h;
151 | newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate;
152 | newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price;
153 | newData[coinMarket]["highestPrice24Hour"] =
154 | high > highestPrice24Hour ? high : highestPrice24Hour;
155 | newData[coinMarket]["lowestPrice24Hour"] =
156 | low < lowestPrice24Hour ? low : lowestPrice24Hour;
157 | newData[coinMarket]["highestPrice52Week"] = candle.highest_52_week_price;
158 | newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date;
159 | newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price;
160 | newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date;
161 | } else {
162 | const volume = candle.trade_volume;
163 | const tradePrice = candle.trade_price;
164 |
165 | newData[coinMarket]["candles"] = [
166 | ...targetCandles,
167 | {
168 | date,
169 | datetime,
170 | timestamp: candle.timestamp,
171 | dateKst: candle.trade_date_kst,
172 | timeKst: candle.trade_time_kst,
173 | open: close,
174 | high: close,
175 | low: close,
176 | close,
177 | volume,
178 | tradePrice,
179 | },
180 | ];
181 | newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h;
182 | newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h;
183 | newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate;
184 | newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price;
185 | newData[coinMarket]["highestPrice24Hour"] = dateChanged // 날짜가 바뀌지 않았을때만 고점 갱신기록, 날짜 바뀌면 지금 고점 기록
186 | ? high
187 | : high > highestPrice24Hour
188 | ? high
189 | : highestPrice24Hour;
190 | newData[coinMarket]["lowestPrice24Hour"] = dateChanged
191 | ? low
192 | : low < lowestPrice24Hour
193 | ? low
194 | : lowestPrice24Hour;
195 | newData[coinMarket]["highestPrice52Week"] = candle.highest_52_week_price;
196 | newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date;
197 | newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price;
198 | newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date;
199 | }
200 |
201 | return newData;
202 | },
203 | updates: (candles, state) => {
204 | const candleStateDatas = state.Coin.candle.data;
205 | const selectedTimeType = state.Coin.selectedTimeType;
206 | const selectedTimeCount = state.Coin.selectedTimeCount;
207 |
208 | const newData = { ...candleStateDatas }; // 원본 데이터 보장
209 |
210 | candles.forEach((candle) => {
211 | const coinMarket = candle.code;
212 |
213 | const targetCandles = candleStateDatas[coinMarket].candles;
214 | const lastCandle = targetCandles.slice(-1)[0];
215 |
216 | const date = dateFormat(
217 | timestampToDatetime(
218 | selectedTimeType,
219 | selectedTimeCount,
220 | candle.timestamp
221 | )
222 | );
223 | const datetime = timestampToDatetime(
224 | selectedTimeType,
225 | selectedTimeCount,
226 | candle.timestamp
227 | );
228 | const open = lastCandle.open;
229 | const high =
230 | candle.trade_price > lastCandle.high
231 | ? candle.trade_price
232 | : lastCandle.high;
233 | const low =
234 | candle.trade_price < lastCandle.low
235 | ? candle.trade_price
236 | : lastCandle.low;
237 | const close = candle.trade_price;
238 |
239 | const highestPrice24Hour =
240 | candleStateDatas[coinMarket].highestPrice24Hour;
241 | const lowestPrice24Hour = candleStateDatas[coinMarket].lowestPrice24Hour;
242 |
243 | const needUpdate = targetCandles.find(
244 | (candle) => candle.datetime === datetime
245 | );
246 | const dateChanged =
247 | d3.timeParse("YYYY-MM-DD")(lastCandle.date) !==
248 | d3.timeParse("YYYY-MM-DD")(datetime);
249 |
250 | if (needUpdate) {
251 | const volume = needUpdate.volume + candle.trade_volume;
252 | const tradePrice = needUpdate.tradePrice + candle.trade_price;
253 | const updatedCandles = [...targetCandles];
254 | updatedCandles.pop();
255 | updatedCandles.push({
256 | date,
257 | datetime,
258 | timestamp: candle.timestamp,
259 | open,
260 | high,
261 | low,
262 | close,
263 | volume,
264 | tradePrice,
265 | });
266 |
267 | newData[coinMarket]["candles"] = updatedCandles;
268 | newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h;
269 | newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h;
270 | newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate;
271 | newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price;
272 | newData[coinMarket]["highestPrice24Hour"] =
273 | high > highestPrice24Hour ? high : highestPrice24Hour;
274 | newData[coinMarket]["lowestPrice24Hour"] =
275 | low < lowestPrice24Hour ? low : lowestPrice24Hour;
276 | newData[coinMarket]["highestPrice52Week"] =
277 | candle.highest_52_week_price;
278 | newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date;
279 | newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price;
280 | newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date;
281 | } else {
282 | const volume = candle.trade_volume;
283 | const tradePrice = candle.trade_price;
284 |
285 | newData[coinMarket]["candles"] = [
286 | ...targetCandles,
287 | {
288 | date,
289 | datetime,
290 | timestamp: candle.timestamp,
291 | dateKst: candle.trade_date_kst,
292 | timeKst: candle.trade_time_kst,
293 | open: close,
294 | high: close,
295 | low: close,
296 | close,
297 | volume,
298 | tradePrice,
299 | },
300 | ];
301 | newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h;
302 | newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h;
303 | newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate;
304 | newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price;
305 | newData[coinMarket]["highestPrice24Hour"] = dateChanged // 날짜가 바뀌지 않았을때만 고점 갱신기록, 날짜 바뀌면 지금 고점 기록
306 | ? high
307 | : high > highestPrice24Hour
308 | ? high
309 | : highestPrice24Hour;
310 | newData[coinMarket]["lowestPrice24Hour"] = dateChanged
311 | ? low
312 | : low < lowestPrice24Hour
313 | ? low
314 | : lowestPrice24Hour;
315 | newData[coinMarket]["highestPrice52Week"] =
316 | candle.highest_52_week_price;
317 | newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date;
318 | newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price;
319 | newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date;
320 | }
321 | });
322 |
323 | return newData;
324 | },
325 | oneCoin: (candles, state) => {
326 | const candleStateData = state.Coin.candle.data;
327 | const selectedTimeType = state.Coin.selectedTimeType;
328 | const selectedTimeCount = state.Coin.selectedTimeCount;
329 | const market = candles[0].market;
330 |
331 | const newCandles = candles.map((candle) => {
332 | return {
333 | date: dateFormat(
334 | timestampToDatetime(
335 | selectedTimeType,
336 | selectedTimeCount,
337 | candle.timestamp
338 | )
339 | ),
340 | datetime: timestampToDatetime(
341 | selectedTimeType,
342 | selectedTimeCount,
343 | candle.timestamp
344 | ),
345 | timestamp: candle.timestamp,
346 | open: candle.opening_price,
347 | high: candle.high_price,
348 | low: candle.low_price,
349 | close: candle.trade_price,
350 | volume: candle.candle_acc_trade_volume,
351 | tradePrice: candle.candle_acc_trade_price,
352 | };
353 | });
354 |
355 | const newData = {
356 | ...candleStateData,
357 | [market]: {
358 | ...candleStateData[market],
359 | candles: newCandles,
360 | },
361 | };
362 |
363 | return newData;
364 | },
365 | add: (candles, state) => {
366 | const candleStateData = state.Coin.candle.data;
367 | const selectedTimeType = state.Coin.selectedTimeType;
368 | const selectedTimeCount = state.Coin.selectedTimeCount;
369 | const market = candles[0].market;
370 |
371 | const newCandles = candles.reduce((acc, candle) => {
372 | if (!candle.timestamp) return acc;
373 | if (
374 | candleStateData[market].candles.find(
375 | (stateCandle) => stateCandle.timestamp === candle.timestamp
376 | )
377 | )
378 | return acc;
379 |
380 | return [
381 | ...acc,
382 | {
383 | date: dateFormat(
384 | timestampToDatetime(
385 | selectedTimeType,
386 | selectedTimeCount,
387 | candle.timestamp
388 | )
389 | ),
390 | datetime: timestampToDatetime(
391 | selectedTimeType,
392 | selectedTimeCount,
393 | candle.timestamp
394 | ),
395 | timestamp: candle.timestamp,
396 | open: candle.opening_price,
397 | high: candle.high_price,
398 | low: candle.low_price,
399 | close: candle.trade_price,
400 | volume: candle.candle_acc_trade_volume,
401 | tradePrice: candle.candle_acc_trade_price,
402 | },
403 | ];
404 | }, []);
405 |
406 | const newData = {
407 | ...candleStateData,
408 | [market]: {
409 | ...candleStateData[market],
410 | candles: [...newCandles, ...candleStateData[market].candles],
411 | },
412 | };
413 |
414 | return newData;
415 | },
416 | marketNames: (names) => {
417 | const data = {};
418 | names.forEach((name) => {
419 | if (name.market.split("-")[0] !== "KRW") return;
420 | data[name.market] = {
421 | korean: name.korean_name,
422 | english: name.english_name,
423 | };
424 | });
425 |
426 | return data;
427 | },
428 | };
429 |
430 | const orderbookUtils = {
431 | init: (orderbooks, _) => {
432 | const data = {};
433 | orderbooks.forEach((orderbook) => {
434 | data[orderbook.market] = {
435 | ...orderbook,
436 | code: orderbook.market,
437 | };
438 | });
439 |
440 | return data;
441 | },
442 | update: (orderbook, state) => {
443 | const orderbookData = state.Coin.orderbook.data;
444 | const market = orderbook.code;
445 | return {
446 | ...orderbookData,
447 | [market]: {
448 | ...orderbook,
449 | market,
450 | },
451 | };
452 | },
453 | };
454 |
455 | const tradeListUtils = {
456 | init: (tradeLists, state) => {
457 | const tradeListData = state.Coin.tradeList.data;
458 | const market = tradeLists[0].market;
459 | return {
460 | ...tradeListData,
461 | [market]: tradeLists,
462 | };
463 | },
464 | update: (tradeList, state) => {
465 | const tradeListData = state.Coin.tradeList.data;
466 | const market = tradeList.code;
467 | if (
468 | tradeListData[market] &&
469 | tradeListData[market].find(
470 | (data) => data.sequential_id === tradeList.sequential_id
471 | )
472 | )
473 | return tradeListData;
474 |
475 | // 데이터가 200개까지만 유지되게 만듦
476 | tradeListData[market] &&
477 | tradeListData[market].length > 200 &&
478 | tradeListData[market].pop();
479 |
480 | return tradeListData[market]
481 | ? {
482 | ...tradeListData,
483 | [market]: [tradeList, ...tradeListData[market]],
484 | }
485 | : {
486 | ...tradeListData,
487 | [market]: [tradeList],
488 | };
489 | },
490 | };
491 |
492 | const choHangul = (str) => {
493 | const cho = [
494 | "ㄱ",
495 | "ㄲ",
496 | "ㄴ",
497 | "ㄷ",
498 | "ㄸ",
499 | "ㄹ",
500 | "ㅁ",
501 | "ㅂ",
502 | "ㅃ",
503 | "ㅅ",
504 | "ㅆ",
505 | "ㅇ",
506 | "ㅈ",
507 | "ㅉ",
508 | "ㅊ",
509 | "ㅋ",
510 | "ㅌ",
511 | "ㅍ",
512 | "ㅎ",
513 | ];
514 |
515 | return [...str].reduce((acc, cur) => {
516 | const code = cur.charCodeAt(0) - 44032;
517 | return code > -1 && code < 11172
518 | ? acc + cho[Math.floor(code / 588)]
519 | : acc + cur.charAt(0);
520 | }, "");
521 | };
522 |
523 | export {
524 | timestampToDatetime,
525 | candleDataUtils,
526 | orderbookUtils,
527 | tradeListUtils,
528 | choHangul,
529 | };
530 |
--------------------------------------------------------------------------------
/src/Reducer/coinReducer.js:
--------------------------------------------------------------------------------
1 | import {
2 | createRequestSaga,
3 | createConnectSocketThunk,
4 | createChangeOptionSaga,
5 | requestActions,
6 | changeOptionActions,
7 | requestInitActions,
8 | createConnectSocketSaga,
9 | } from "../Lib/asyncUtil";
10 | import { candleDataUtils, orderbookUtils, tradeListUtils } from "../Lib/utils";
11 | import { coinApi } from "../Api/api";
12 | import { takeEvery, put, select } from "redux-saga/effects";
13 | import moment from "moment-timezone";
14 |
15 | const START_INIT = "coin/START_INIT";
16 | const START_CHANGE_MARKET_AND_DATA = "coin/START_CHANGE_MARKET_AND_DATA";
17 | const CHANGE_TIME_TYPE_AND_DATA = "coin/CHANGE_TIME_TYPE_AND_DATA";
18 | const START_ADD_MORE_CANDLE_DATA = "coin/START_ADD_MORE_CANDLE_DATA";
19 |
20 | const GET_MARKET_NAMES = "coin/GET_MARKET_NAMES";
21 | const GET_MARKET_NAMES_SUCCESS = "coin/GET_MARKET_NAMES_SUCCESS";
22 | const GET_MARKET_NAMES_ERROR = "coin/GET_MARKET_NAMES_ERROR";
23 |
24 | const GET_INIT_CANDLES = "coin/GET_INIT_CANDLES";
25 | const GET_INIT_CANDLES_SUCCESS = "coin/GET_INIT_CANDLES_SUCCESS";
26 | const GET_INIT_CANDLES_ERROR = "coin/GET_INIT_CANDLES_ERROR";
27 |
28 | const GET_ONE_COIN_CANDLES = "coin/GET_ONE_COIN_CANDLES";
29 | const GET_ONE_COIN_CANDLES_SUCCESS = "coin/GET_ONE_COIN_CANDLES_SUCCESS";
30 | const GET_ONE_COIN_CANDLES_ERROR = "coin/GET_ONE_COIN_CANDLES_ERROR";
31 |
32 | const GET_ADDITIONAL_COIN_CANDLES = "coin/GET_ADDITIONAL_COIN_CANDLES";
33 | const GET_ADDITIONAL_COIN_CANDLES_SUCCESS =
34 | "coin/GET_ADDITIONAL_COIN_CANDLES_SUCCESS";
35 | const GET_ADDITIONAL_COIN_CANDLES_ERROR =
36 | "coin/GET_ADDITIONAL_COIN_CANDLES_ERROR";
37 |
38 | const CONNECT_CANDLE_SOCKET = "coin/CONNECT_CANDLE_SOCKET";
39 | const CONNECT_CANDLE_SOCKET_SUCCESS = "coin/CONNECT_CANDLE_SOCKET_SUCCESS";
40 | const CONNECT_CANDLE_SOCKET_ERROR = "coin/CONNECT_CANDLE_SOCKET_ERROR";
41 |
42 | const GET_ONE_COIN_TRADELISTS = "coin/GET_ONE_COIN_TRADELISTS";
43 | const GET_ONE_COIN_TRADELISTS_SUCCESS = "coin/GET_ONE_COIN_TRADELISTS_SUCCESS";
44 | const GET_ONE_COIN_TRADELISTS_ERROR = "coin/GET_ONE_COIN_TRADELISTS_ERROR";
45 |
46 | const CONNECT_TRADELIST_SOCKET = "coin/CONNECT_TRADELIST_SOCKET";
47 | const CONNECT_TRADELIST_SOCKET_SUCCESS =
48 | "coin/CONNECT_TRADELIST_SOCKET_SUCCESS";
49 | const CONNECT_TRADELIST_SOCKET_ERROR = "coin/CONNECT_TRADELIST_SOCKET_ERROR";
50 |
51 | const GET_INIT_ORDERBOOKS = "coin/GET_INIT_ORDERBOOKS";
52 | const GET_INIT_ORDERBOOKS_SUCCESS = "coin/GET_INIT_ORDERBOOKS_SUCCESS";
53 | const GET_INIT_ORDERBOOKS_ERROR = "coin/GET_INIT_ORDERBOOKS_ERROR";
54 |
55 | const CONNECT_ORDERBOOK_SOCKET = "coin/CONNECT_ORDERBOOK_SOCKET";
56 | const CONNECT_ORDERBOOK_SOCKET_SUCCESS =
57 | "coin/CONNECT_ORDERBOOK_SOCKET_SUCCESS";
58 | const CONNECT_ORDERBOOK_SOCKET_ERROR = "coin/CONNECT_ORDERBOOK_SOCKET_ERROR";
59 |
60 | const CHANGE_COIN_MARKET = "coin/CHANGE_COIN_MARKET";
61 | const CHANGE_COIN_MARKET_SUCCESS = "coin/CHANGE_COIN_MARKET_SUCCESS";
62 |
63 | const CHANGE_TIME_TYPE = "coin/CHANGE_TIME_TYPE";
64 | const CHANGE_TIME_TYPE_SUCCESS = "coin/CHANGE_TIME_TYPE_SUCCESS";
65 |
66 | const CHANGE_TIME_COUNT = "coin/CHANGE_TIME_COUNT";
67 | const CHANGE_TIME_COUNT_SUCCESS = "coin/CHANGE_TIME_COUNT_SUCCESS";
68 |
69 | const CHANGE_ASK_BID_ORDER = "coin/CHANGE_ASK_BID_ORDER";
70 | const CHANGE_ASK_BID_ORDER_SUCCESS = "coin/CHANGE_ASK_BID_ORDER_SUCCESS";
71 |
72 | const CHANGE_ORDER_PRICE = "coin/CHANGE_ORDER_PRICE";
73 | const CHANGE_ORDER_PRICE_SUCCESS = "coin/CHANGE_ORDER_PRICE_SUCCESS";
74 |
75 | const CHANGE_ORDER_AMOUNT = "coin/CHANGE_ORDER_AMOUNT";
76 | const CHANGE_ORDER_AMOUNT_SUCCESS = "coin/CHANGE_ORDER_AMOUNT_SUCCESS";
77 |
78 | const CHANGE_ORDER_TOTAL_PRICE = "coin/CHANGE_ORDER_TOTAL_PRICE";
79 | const CHANGE_ORDER_TOTAL_PRICE_SUCCESS =
80 | "coin/CHANGE_ORDER_TOTAL_PRICE_SUCCESS";
81 |
82 | const CHANGE_PRICE_AND_TOTAL_PRICE = "coin/CHANGE_PRICE_AND_TOTAL_PRICE";
83 | const CHANGE_AMOUNT_AND_TOTAL_PRICE = "coin/CHANGE_AMOUNT_AND_TOTAL_PRICE";
84 | const CHANGE_TOTAL_PRICE_AND_AMOUNT = "coin/CHANGE_TOTAL_PRICE_AND_AMOUNT";
85 |
86 | const SEARCH_COIN = "coin/SEARCH_COIN";
87 | const SEARCH_COIN_SUCCESS = "coin/SEARCH_COIN_SUCCESS";
88 |
89 | // 업비트에서 제공하는 코인/마켓 이름들 가져오기 Saga
90 | const getMarketNameSaga = createRequestSaga(
91 | GET_MARKET_NAMES,
92 | coinApi.getMarketCodes,
93 | candleDataUtils.marketNames
94 | );
95 |
96 | // 코인/마켓 캔들들의 일봉 한 개씩 가져오기 Saga
97 | const getInitCandleSaga = createRequestSaga(
98 | GET_INIT_CANDLES,
99 | coinApi.getInitCanldes,
100 | candleDataUtils.init
101 | );
102 |
103 | // 특정 코인 봉 200개 가져오기 Saga
104 | const getOneCoinCandlesSaga = createRequestSaga(
105 | GET_ONE_COIN_CANDLES,
106 | coinApi.getOneCoinCandles,
107 | candleDataUtils.oneCoin
108 | );
109 |
110 | const getAdditionalCoinCandlesSaga = createRequestSaga(
111 | GET_ADDITIONAL_COIN_CANDLES,
112 | coinApi.getAdditionalCoinCandles,
113 | candleDataUtils.add
114 | );
115 |
116 | // 캔들 웹소켓 연결 Thunk
117 | const connectCandleSocketThunk = createConnectSocketThunk(
118 | CONNECT_CANDLE_SOCKET,
119 | "ticker",
120 | candleDataUtils.update
121 | );
122 |
123 | // const connectCandleSocketThunk = createConnectSocketThrottleThunk(
124 | // CONNECT_CANDLE_SOCKET,
125 | // "ticker",
126 | // candleDataUtils.update
127 | // );
128 |
129 | const connectCandleSocketSaga = createConnectSocketSaga(
130 | CONNECT_CANDLE_SOCKET,
131 | "ticker",
132 | candleDataUtils.updates
133 | );
134 |
135 | // 호가창 조기 값 가져오기
136 | const getInitOrderbookSaga = createRequestSaga(
137 | GET_INIT_ORDERBOOKS,
138 | coinApi.getInitOrderbooks,
139 | orderbookUtils.init
140 | );
141 |
142 | // 호가창 웹소켓 연결 Thunk
143 | const connectOrderbookSocketThunk = createConnectSocketThunk(
144 | CONNECT_ORDERBOOK_SOCKET,
145 | "orderbook",
146 | orderbookUtils.update
147 | );
148 |
149 | // 체결내역 200개 가져오기
150 | const getOneCoinTradeListsSaga = createRequestSaga(
151 | GET_ONE_COIN_TRADELISTS,
152 | coinApi.getOneCoinTradeLists,
153 | tradeListUtils.init
154 | );
155 |
156 | // 체결내역 웹소켓 연결 Thunk
157 | const connectTradeListSocketThunk = createConnectSocketThunk(
158 | CONNECT_TRADELIST_SOCKET,
159 | "trade",
160 | tradeListUtils.update
161 | );
162 |
163 | // 선택한 코인마켓 변경하기 Saga
164 | const changeSelectedMarket = (marketName) => ({
165 | type: CHANGE_COIN_MARKET,
166 | payload: marketName,
167 | });
168 | const changeSelectedMarketSaga = createChangeOptionSaga(CHANGE_COIN_MARKET);
169 |
170 | // 선택한 타임 타입(5분봉 할때 '분') 변경하기 Saga
171 | const changeSelectedTimeTypeSaga = createChangeOptionSaga(CHANGE_TIME_TYPE);
172 |
173 | // 선택한 타임 카운트(5분봉 할때 '5') 변경하기 Saga
174 | const changeSelectedTimeCountSaga = createChangeOptionSaga(CHANGE_TIME_COUNT);
175 |
176 | // 매수 매도 옵션 변경하기
177 | const changeAskBidOrder = (askBidOption) => ({
178 | type: CHANGE_ASK_BID_ORDER,
179 | payload: askBidOption,
180 | });
181 | const changeAskBidOrderSaga = createChangeOptionSaga(CHANGE_ASK_BID_ORDER);
182 |
183 | // 주문 가격 변경하기
184 | const changeOrderPriceSaga = createChangeOptionSaga(CHANGE_ORDER_PRICE);
185 |
186 | // 주문 수량 변경하기
187 | const changeOrderAmountSaga = createChangeOptionSaga(CHANGE_ORDER_AMOUNT);
188 |
189 | // 주문 총액 변경하기
190 | const changeOrderTotalPriceSaga = createChangeOptionSaga(
191 | CHANGE_ORDER_TOTAL_PRICE
192 | );
193 |
194 | // 코인 검색 내용 변경하기 Saga
195 | const searchCoin = (searchName) => ({
196 | type: SEARCH_COIN,
197 | payload: searchName,
198 | });
199 | const searchCoinSaga = createChangeOptionSaga(SEARCH_COIN);
200 |
201 | // 시작시 데이터 초기화 작업들
202 | const startInit = () => ({ type: START_INIT });
203 | function* startInittSaga() {
204 | yield getMarketNameSaga(); // 코인/시장 종류 받기
205 |
206 | const state = yield select();
207 | const marketNames = Object.keys(state.Coin.marketNames.data);
208 | const selectedMarket = state.Coin.selectedMarket;
209 | const selectedTimeType = state.Coin.selectedTimeType;
210 | const selectedTimeCount = state.Coin.selectedTimeCount;
211 |
212 | yield getInitCandleSaga({ payload: marketNames }); // 코인 캔들 초기값 받기
213 | yield getInitOrderbookSaga({ payload: selectedMarket }); // 호가창 초기값 받기
214 | yield getOneCoinTradeListsSaga({ payload: selectedMarket }); // 체결내역 초기값 받기
215 | yield getOneCoinCandlesSaga({
216 | payload: {
217 | coin: selectedMarket,
218 | timeType: selectedTimeType,
219 | timeCount: selectedTimeCount,
220 | },
221 | }); // 200개 코인 데이터 받기
222 |
223 | // yield connectCandleSocketSaga({ payload: marketNames }); // 캔들 소켓 연결 사가버전
224 | yield put(connectOrderbookSocketThunk({ payload: marketNames })); // 오더북 소켓 연결
225 | yield put(connectTradeListSocketThunk({ payload: marketNames })); // 체결내역 소켓 연결
226 | // yield put(connectCandleSocketThunk({ payload: marketNames })); // 캔들 소켓 연결
227 | yield connectCandleSocketSaga({ payload: marketNames }); // 캔들 소켓 연결 사가버전
228 | }
229 |
230 | // 선택된 코인/마켓 변경 및 해당 마켓 데이터 받기
231 | const startChangeMarketAndData = (marketName) => ({
232 | type: START_CHANGE_MARKET_AND_DATA,
233 | payload: marketName,
234 | });
235 | function* startChangeMarketAndDataSaga(action) {
236 | const state = yield select();
237 | const selectedTimeType = state.Coin.selectedTimeType;
238 | const selectedTimeCount = state.Coin.selectedTimeCount;
239 | const changingMarketName = action.payload;
240 | const selectedCoinCandles =
241 | state.Coin.candle.data[changingMarketName].candles;
242 |
243 | yield put(changeSelectedMarket(changingMarketName)); // 선택된 마켓 변경
244 | yield getInitOrderbookSaga({ payload: changingMarketName }); // 호가창 초기값 받기
245 | yield getOneCoinTradeListsSaga({ payload: changingMarketName }); // 체결내역 초기값 받기
246 |
247 | // 상태에 저장된 데이터가 200개 미만일때만 api콜 요청함
248 | if (selectedCoinCandles.length < 200) {
249 | yield getOneCoinCandlesSaga({
250 | payload: {
251 | coin: changingMarketName,
252 | timeType: selectedTimeType,
253 | timeCount: selectedTimeCount,
254 | },
255 | });
256 | }
257 | }
258 |
259 | // 추가 캔들 데이터 가져오기
260 | const startAddMoreCandleData = () => ({ type: START_ADD_MORE_CANDLE_DATA });
261 | function* startAddMoreCandleDataSaga() {
262 | const state = yield select();
263 |
264 | const selectedMarket = state.Coin.selectedMarket;
265 | const selectedTimeType = state.Coin.selectedTimeType;
266 | const selectedTimeCount = state.Coin.selectedTimeCount;
267 |
268 | const isLoading = state.Loading[GET_ADDITIONAL_COIN_CANDLES];
269 |
270 | if (isLoading) return;
271 | const datetime =
272 | moment(state.Coin.candle.data[selectedMarket].candles[0].date)
273 | .utc()
274 | .format("YYYY-MM-DDTHH:mm") + ":00Z";
275 |
276 | yield getAdditionalCoinCandlesSaga({
277 | payload: {
278 | coin: selectedMarket,
279 | timeType: selectedTimeType,
280 | timeCount: selectedTimeCount,
281 | datetime,
282 | },
283 | });
284 | }
285 |
286 | // 차트 시간 데이터 변경하고 데이터 받기
287 | const changeTimeTypeAndData = (timeTypeAndCount) => ({
288 | type: CHANGE_TIME_TYPE_AND_DATA,
289 | payload: timeTypeAndCount,
290 | });
291 |
292 | function* changeTimeTypeAndDataSaga(action) {
293 | const state = yield select();
294 | const selectedMarket = state.Coin.selectedMarket;
295 | const selectedTimeType = state.Coin.selectedTimeType;
296 | const selectedTimeCount = state.Coin.selectedTimeCount;
297 |
298 | const newTimeType = action.payload.timeType;
299 | const newTimeCount = action.payload.timeCount;
300 |
301 | if (selectedTimeType === newTimeType && selectedTimeCount === newTimeCount)
302 | return;
303 |
304 | yield changeSelectedTimeTypeSaga({ payload: newTimeType });
305 | yield changeSelectedTimeCountSaga({ payload: newTimeCount });
306 |
307 | yield getOneCoinCandlesSaga({
308 | payload: {
309 | coin: selectedMarket,
310 | timeType: newTimeType,
311 | timeCount: newTimeCount,
312 | },
313 | });
314 | }
315 |
316 | // 가격 변경 후 주문 총액 바꾸기
317 | const changePriceAndTotalPrice = (price) => ({
318 | type: CHANGE_PRICE_AND_TOTAL_PRICE,
319 | payload: price,
320 | });
321 | function* changePriceAndTotalPriceSaga(action) {
322 | const state = yield select();
323 | const orderAmount = state.Coin.orderAmount;
324 |
325 | yield changeOrderPriceSaga({ payload: action.payload });
326 | yield changeOrderTotalPriceSaga({
327 | payload: Math.ceil(action.payload * orderAmount),
328 | });
329 | }
330 |
331 | // 주문수량 변경 후 주문 총액 바꾸기
332 | const changeAmountAndTotalPrice = (amount) => ({
333 | type: CHANGE_AMOUNT_AND_TOTAL_PRICE,
334 | payload: amount,
335 | });
336 | function* changeAmountAndTotalPriceSaga(action) {
337 | const state = yield select();
338 | const orderPrice = state.Coin.orderPrice;
339 |
340 | yield changeOrderAmountSaga({ payload: action.payload });
341 | yield changeOrderTotalPriceSaga({
342 | payload: Math.ceil(action.payload * orderPrice),
343 | });
344 | }
345 |
346 | // 주문총액 변경 후 주문수량 바꾸기
347 | const changeTotalPriceAndAmount = (totalPrice) => ({
348 | type: CHANGE_TOTAL_PRICE_AND_AMOUNT,
349 | payload: totalPrice,
350 | });
351 | function* changeTotalPriceAndAmountSaga(action) {
352 | const state = yield select();
353 | const orderPrice = state.Coin.orderPrice;
354 |
355 | yield changeOrderTotalPriceSaga({ payload: action.payload });
356 | yield changeOrderAmountSaga({
357 | payload: orderPrice ? (action.payload / orderPrice).toFixed(8) : 0,
358 | });
359 | }
360 |
361 | function* coinSaga() {
362 | yield takeEvery(GET_MARKET_NAMES, getMarketNameSaga);
363 | yield takeEvery(GET_INIT_CANDLES, getInitCandleSaga);
364 | yield takeEvery(GET_INIT_ORDERBOOKS, getInitOrderbookSaga);
365 | yield takeEvery(GET_ONE_COIN_CANDLES, getOneCoinCandlesSaga);
366 | yield takeEvery(GET_ONE_COIN_TRADELISTS, getOneCoinTradeListsSaga);
367 |
368 | yield takeEvery(CHANGE_COIN_MARKET, changeSelectedMarketSaga);
369 | yield takeEvery(CHANGE_ASK_BID_ORDER, changeAskBidOrderSaga);
370 | yield takeEvery(CHANGE_ORDER_PRICE, changeOrderPriceSaga);
371 | yield takeEvery(CHANGE_ORDER_AMOUNT, changeOrderAmountSaga);
372 | yield takeEvery(CHANGE_ORDER_TOTAL_PRICE, changeOrderTotalPriceSaga);
373 | yield takeEvery(SEARCH_COIN, searchCoinSaga);
374 |
375 | yield takeEvery(START_INIT, startInittSaga);
376 | yield takeEvery(START_CHANGE_MARKET_AND_DATA, startChangeMarketAndDataSaga);
377 | yield takeEvery(START_ADD_MORE_CANDLE_DATA, startAddMoreCandleDataSaga);
378 | yield takeEvery(CHANGE_TIME_TYPE_AND_DATA, changeTimeTypeAndDataSaga);
379 |
380 | yield takeEvery(CHANGE_PRICE_AND_TOTAL_PRICE, changePriceAndTotalPriceSaga);
381 | yield takeEvery(CHANGE_AMOUNT_AND_TOTAL_PRICE, changeAmountAndTotalPriceSaga);
382 | yield takeEvery(CHANGE_TOTAL_PRICE_AND_AMOUNT, changeTotalPriceAndAmountSaga);
383 | }
384 |
385 | const initialState = {
386 | selectedMarket: "KRW-BTC",
387 | selectedTimeType: "minutes",
388 | selectedTimeCount: 5,
389 | selectedAskBidOrder: "bid",
390 | orderPrice: 0,
391 | orderAmount: 0,
392 | orderTotalPrice: 0,
393 | searchCoin: "",
394 | marketNames: {
395 | error: false,
396 | data: {
397 | "KRW-BTC": "비트코인",
398 | },
399 | },
400 | candle: {
401 | error: false,
402 | data: {
403 | "KRW-BTC": {
404 | candles: [
405 | // { date: new Date(), open: 1, close: 1, high: 1, low: 1, volume: 1 },
406 | ],
407 | tradePrice24Hour: 0,
408 | volume24Hour: 0,
409 | changeRate24Hour: 0,
410 | },
411 | },
412 | },
413 | orderbook: {
414 | error: false,
415 | data: {
416 | "KRW-BTC": {
417 | total_bid_size: 0,
418 | total_ask_size: 0,
419 | orderbook_units: [],
420 | },
421 | },
422 | },
423 | tradeList: {
424 | error: false,
425 | data: {},
426 | },
427 | };
428 |
429 | const coinReducer = (state = initialState, action) => {
430 | switch (action.type) {
431 | // 코인 마켓 이름들
432 | case GET_MARKET_NAMES_SUCCESS:
433 | case GET_MARKET_NAMES_ERROR:
434 | return requestActions(GET_MARKET_NAMES, "marketNames")(state, action);
435 |
436 | // 초기 캔들
437 | case GET_INIT_CANDLES_SUCCESS:
438 | case GET_INIT_CANDLES_ERROR:
439 | return requestInitActions(GET_INIT_CANDLES, "candle")(state, action);
440 |
441 | // 코인 한 개 정해서 200개
442 | case GET_ONE_COIN_CANDLES_SUCCESS:
443 | case GET_ONE_COIN_CANDLES_ERROR:
444 | return requestActions(GET_ONE_COIN_CANDLES, "candle")(state, action);
445 |
446 | // 추가 코인 데이터 로드
447 | case GET_ADDITIONAL_COIN_CANDLES_SUCCESS:
448 | case GET_ADDITIONAL_COIN_CANDLES_ERROR:
449 | return requestActions(GET_ADDITIONAL_COIN_CANDLES, "candle")(
450 | state,
451 | action
452 | );
453 |
454 | // 캔들 실시간 정보
455 | case CONNECT_CANDLE_SOCKET_SUCCESS:
456 | case CONNECT_CANDLE_SOCKET_ERROR:
457 | return requestActions(CONNECT_CANDLE_SOCKET, "candle")(state, action);
458 |
459 | // 호가창 초기값
460 | case GET_INIT_ORDERBOOKS_SUCCESS:
461 | case GET_INIT_ORDERBOOKS_ERROR:
462 | return requestActions(GET_INIT_ORDERBOOKS, "orderbook")(state, action);
463 |
464 | // 호가창 실시간 정보
465 | case CONNECT_ORDERBOOK_SOCKET_SUCCESS:
466 | case CONNECT_ORDERBOOK_SOCKET_ERROR:
467 | return requestActions(CONNECT_ORDERBOOK_SOCKET, "orderbook")(
468 | state,
469 | action
470 | );
471 |
472 | // 체결내역 200개 초기값
473 | case GET_ONE_COIN_TRADELISTS_SUCCESS:
474 | case GET_ONE_COIN_TRADELISTS_ERROR:
475 | return requestActions(GET_ONE_COIN_TRADELISTS, "tradeList")(
476 | state,
477 | action
478 | );
479 |
480 | // 체결내역 실시간 정보
481 | case CONNECT_TRADELIST_SOCKET_SUCCESS:
482 | case CONNECT_TRADELIST_SOCKET_ERROR:
483 | return requestActions(CONNECT_TRADELIST_SOCKET, "tradeList")(
484 | state,
485 | action
486 | );
487 |
488 | case CHANGE_COIN_MARKET_SUCCESS:
489 | return changeOptionActions(CHANGE_COIN_MARKET, "selectedMarket")(
490 | state,
491 | action
492 | );
493 |
494 | case CHANGE_TIME_TYPE_SUCCESS:
495 | return changeOptionActions(CHANGE_TIME_TYPE, "selectedTimeType")(
496 | state,
497 | action
498 | );
499 |
500 | case CHANGE_TIME_COUNT_SUCCESS:
501 | return changeOptionActions(CHANGE_TIME_COUNT, "selectedTimeCount")(
502 | state,
503 | action
504 | );
505 |
506 | case CHANGE_ASK_BID_ORDER_SUCCESS:
507 | return changeOptionActions(CHANGE_ASK_BID_ORDER, "selectedAskBidOrder")(
508 | state,
509 | action
510 | );
511 |
512 | case CHANGE_ORDER_PRICE_SUCCESS:
513 | return changeOptionActions(CHANGE_ORDER_PRICE, "orderPrice")(
514 | state,
515 | action
516 | );
517 | case CHANGE_ORDER_AMOUNT_SUCCESS:
518 | return changeOptionActions(CHANGE_ORDER_AMOUNT, "orderAmount")(
519 | state,
520 | action
521 | );
522 | case CHANGE_ORDER_TOTAL_PRICE_SUCCESS:
523 | return changeOptionActions(CHANGE_ORDER_TOTAL_PRICE, "orderTotalPrice")(
524 | state,
525 | action
526 | );
527 |
528 | case SEARCH_COIN_SUCCESS:
529 | return changeOptionActions(SEARCH_COIN, "searchCoin")(state, action);
530 | default:
531 | return state;
532 | }
533 | };
534 |
535 | export {
536 | startInit,
537 | startChangeMarketAndData,
538 | startAddMoreCandleData,
539 | changeTimeTypeAndData,
540 | coinReducer,
541 | coinSaga,
542 | changeAskBidOrder,
543 | changePriceAndTotalPrice,
544 | changeAmountAndTotalPrice,
545 | changeTotalPriceAndAmount,
546 | searchCoin,
547 | };
548 |
--------------------------------------------------------------------------------