This commit is contained in:
2022-02-10 13:07:45 +09:00
commit f6adf0a3e0
74 changed files with 19126 additions and 0 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Seongkyun Yu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

455
README.md Normal file
View File

@@ -0,0 +1,455 @@
# Downbit 프로젝트 ![ViewCount](https://views.whatilearened.today/views/github/Seongkyun-Yu/upbit-clone.svg)
| [![downbit_image](https://user-images.githubusercontent.com/15887982/99421488-c6cf0080-2941-11eb-9c5f-624593075430.gif)](https://youtu.be/zsuSvv9IfM8) |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: |
| _이미지 클릭시 YouTube로 연결됩니다_ |
<br>
2020년 9월 1일부터 11월 10일 동안 매일 2시간씩 진행한 업비트 클론 프로젝트 입니다.<br>
~~[downbit.ml](https://downbit.ml)에서 배포된 프로젝트 내역을 확인하실 수 있습니다.~~<br>
두나무의 요청으로 배포를 중단했습니다.<br>
<br>
## Development motivation
Upbit의 실제 거래 데이터를 통해<br>
많은 데이터 수신시 프론트 엔드의 뷰를 최적화 하는 방법을 학습하고자<br>
이번 프로젝트를 시작하였습니다.<br>
<br>
## Skill
![HTML5](https://img.shields.io/badge/-HTML5-E34F26?style=flat-square&logo=html5&logoColor=white)
![CSS3](https://img.shields.io/badge/-CSS3-1572B6?style=flat-square&logo=css3)
![JavaScript](https://img.shields.io/badge/-JavaScript-black?style=flat-square&logo=javascript)<br>
![React](https://img.shields.io/badge/-React-black?style=flat-square&logo=react)
![Redux](https://img.shields.io/badge/Redux-7F43C5?style=flat&logo=redux&logoColor=white)
![Styled-Components](https://img.shields.io/badge/-Styled%20Component-pink?style=flat-square&logo=styled-components)<br>
![Amazon AWS](https://img.shields.io/badge/Amazon%20AWS-232F3E?style=flat-square&logo=amazon-aws)
<br>
## Requirements
- Library
<details>
<summary>접기/펼치기 버튼</summary>
<div markdown="1">
React v.16<br>
axios: 0.20.0<br>
d3: 5.15.1<br>
react-redux: 7.2.1<br>
redux-saga v.1.1.3<br>
redux-thunk v.2.3.0<br>
react-router-dom v.5.2.0<br>
axios v.0.19.2<br>
websocket: 1.0.32<br>
react-fast-compare: 3.2.0<br>
react-financial-charts: 1.0.0-alpha.16<br>
decimal.js: 10.2.1<br>
hangul-js: 0.2.6<br>
lodash: 4.17.20<br>
moment-timezone: 0.5.31<br>
styled-components: 5.2.0<br>
styled-normalize: 8.0.7<br>
styled-reset": 4.3.0<br>
@fortawesome/free-brands-svg-icons: 5.15.1<br>
@fortawesome/free-solid-svg-icons: 5.15.1<br>
@fortawesome/react-fontawesome: 0.1.12<br>
</div>
</details>
<br>
## Getting Started
$ git clone https://github.com/Seongkyun-Yu/upbit-clone.git<br>
$ yarn install<br>
\$ yarn start<br>
<br>
## Main Feature (프로젝트의 모든 기능을 혼자 개발했습니다)
- 실시간 가격, 거래량 등의 데이터 수신 및 차트 랜더링
- 실시간 호가창, 거래내역 랜더링
- 코인 초성, 심볼 검색
- 매수 총액에 따른 구매수량 자동 조절, 가격 변경에 따른 구매 총액 자동 변경
- 호가창 클릭시 자동 가격 입력
- 반응형
<br>
## 프로젝트 구조
```bash
├── node_modules
├── public
│ ├── blueLogo.png
│ ├── whiteLogo.png
│ ├── favicon.png
│ └── index.html
├── build
├── src
│ ├── Api
│ │ └── api.js
│ ├── Components
│ │ ├── Global
│ │ │ ├── Header.js
│ │ │ ├── Footer.js
│ │ │ └── Loading.js
│ │ └── Main
│ │ ├── ChartDataConsole.js
│ │ ├── CoinInfoHeader.js
│ │ ├── CoinList.js
│ │ ├── CoinListItem.js
│ │ ├── MainChart.js
│ │ ├── Orderbook.js
│ │ ├── OrderbookCoinInfo.js
│ │ ├── OrderbookItem.js
│ │ ├── OrderInfo.js
│ │ ├── OrderInfoAskBid.js
│ │ ├── OrderInfoTradeList.js
│ │ ├── TradeList.js
│ │ └── TradeListItem.js
│ ├── Pages
│ │ └── Main.js
│ ├── Container <-- HOC
│ │ ├── withLatestCoinData.js
│ │ ├── withLoadingData.js
│ │ ├── withMarketNames.js
│ │ ├── withOHLCData.js
│ │ └── ...etc
│ ├── Lib
│ │ ├── asyncUtil.js <-- redux-saga, thunk factory pattern
│ │ └── utils.js <-- etc utils
│ ├── Reducer
│ │ ├── index.js
│ │ ├── coinReducer.js
│ │ └── loadingReducer.js
│ ├── Router
│ │ └── MainRouter.js
│ ├── styles
│ │ ├── fonts
│ │ ├── GlobalStyle.js
│ │ └── theme.js
│ ├── App.js
│ └── index.js
├── README.md
├── LICENSE
├── package.json
├── yarn.lock
└── .gitignore
```
<br>
## 프로젝트 관련 생각들
- [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)
- [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)
<br>
## Technical Issue: Optimization
- 1초에 최대 150개의 데이터가 전송되어 상태를 변경시킴
<!-- - <details>
<summary>해결 코드 접기/펼치기 버튼</summary>
<div markdown="1">
```javascript
import { call, put, select, flush, delay } from "redux-saga/effects";
import { buffers, eventChannel } from "redux-saga";
// 소켓 만들기
const createSocket = () => {
const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
client.binaryType = "arraybuffer";
return client;
};
// 소켓 연결용
const connectSocekt = (socket, connectType, action, buffer) => {
return eventChannel((emit) => {
socket.onopen = () => {
socket.send(
JSON.stringify([
{ ticket: "downbit-clone" },
{ type: connectType, codes: action.payload },
])
);
};
socket.onmessage = (evt) => {
const enc = new TextDecoder("utf-8");
const arr = new Uint8Array(evt.data);
const data = JSON.parse(enc.decode(arr));
emit(data);
};
socket.onerror = (evt) => {
emit(evt);
};
const unsubscribe = () => {
socket.close();
};
return unsubscribe;
}, buffer || buffers.none());
};
// 웹소켓 연결용 사가
const createConnectSocketSaga = (type, connectType, dataMaker) => {
const SUCCESS = `${type}_SUCCESS`;
const ERROR = `${type}_ERROR`;
return function* (action = {}) {
const client = yield call(createSocket);
const clientChannel = yield call(
connectSocekt,
client,
connectType,
action,
buffers.expanding(500)
);
while (true) {
try {
const datas = yield flush(clientChannel); // 버퍼 데이터 가져오기
const state = yield select();
if (datas.length) {
const sortedObj = {};
datas.forEach((data) => {
if (sortedObj[data.code]) {
// 버퍼에 있는 데이터중 시간이 가장 최근인 데이터만 남김
sortedObj[data.code] =
sortedObj[data.code].timestamp > data.timestamp
? sortedObj[data.code]
: data;
} else {
sortedObj[data.code] = data; // 새로운 데이터면 그냥 넣음
}
});
const sortedData = Object.keys(sortedObj).map(
(data) => sortedObj[data]
);
yield put({
type: SUCCESS,
payload: dataMaker(sortedData, state),
});
}
yield delay(500); // 500ms 동안 대기
} catch (e) {
yield put({ type: ERROR, payload: e });
}
}
};
};
```
</div>
</details> -->
- Push 방식의 WebSocket을 Redux-Saga를 이용하여 Pull 방식으로 변경
- Redux-Saga의 eventChannel을 이용하여 버퍼 생성
- 0.5초에 한 번 버퍼를 확인하여 중복된 데이터 제거 후 변경내역을 상태에 한번에 업데이트
- ```javascript
import { call, put, select, flush, delay } from "redux-saga/effects";
import { buffers, eventChannel } from "redux-saga";
// 소켓 만들기
const createSocket = () => {
const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
client.binaryType = "arraybuffer";
return client;
};
// 소켓 연결용
const connectSocekt = (socket, connectType, action, buffer) => {
return eventChannel((emit) => {
socket.onopen = () => {
socket.send(
JSON.stringify([
{ ticket: "downbit-clone" },
{ type: connectType, codes: action.payload },
])
);
};
socket.onmessage = (evt) => {
const enc = new TextDecoder("utf-8");
const arr = new Uint8Array(evt.data);
const data = JSON.parse(enc.decode(arr));
emit(data);
};
socket.onerror = (evt) => {
emit(evt);
};
const unsubscribe = () => {
socket.close();
};
return unsubscribe;
}, buffer || buffers.none());
};
// 웹소켓 연결용 사가
const createConnectSocketSaga = (type, connectType, dataMaker) => {
const SUCCESS = `${type}_SUCCESS`;
const ERROR = `${type}_ERROR`;
return function* (action = {}) {
const client = yield call(createSocket);
const clientChannel = yield call(
connectSocekt,
client,
connectType,
action,
buffers.expanding(500)
);
while (true) {
try {
const datas = yield flush(clientChannel); // 버퍼 데이터 가져오기
const state = yield select();
if (datas.length) {
const sortedObj = {};
datas.forEach((data) => {
if (sortedObj[data.code]) {
// 버퍼에 있는 데이터중 시간이 가장 최근인 데이터만 남김
sortedObj[data.code] =
sortedObj[data.code].timestamp > data.timestamp
? sortedObj[data.code]
: data;
} else {
sortedObj[data.code] = data; // 새로운 데이터면 그냥 넣음
}
});
const sortedData = Object.keys(sortedObj).map(
(data) => sortedObj[data]
);
yield put({
type: SUCCESS,
payload: dataMaker(sortedData, state),
});
}
yield delay(500); // 500ms 동안 대기
} catch (e) {
yield put({ type: ERROR, payload: e });
}
}
};
};
```
- 반응형으로 제작시 보이지 않는 컴포넌트를 랜더링 처리
<!-- - <details>
<summary>해결 코드 접기/펼치기 버튼</summary>
<div markdown="1">
```javascript
import React, { useCallback, useEffect, useState } from "react";
import { throttle } from "lodash";
const withSize = () => (OriginalComponent) => (props) => {
const [widthSize, setWidthSize] = useState(window.innerWidth);
const [heightSize, setHeightSize] = useState(window.innerHeight);
const handleSize = useCallback(() => {
setWidthSize(window.innerWidth);
setHeightSize(window.innerHeight);
}, []);
useEffect(() => {
window.addEventListener("resize", throttle(handleSize, 200));
return () => {
window.removeEventListener("resize", handleSize);
};
}, [handleSize]);
return (
<OriginalComponent
{...props}
widthSize={widthSize}
heightSize={heightSize}
/>
);
};
export default withSize;
```
</div>
</details> -->
- display: none으로 처리해도 DOM에는 사라지지 않기 때문에 상태 변경시 랜더링 시도함
- width 값을 측정하여 조건이 맞을 경우에만 컴포넌트를 랜더링 하게 함
- throttle 사용으로 과도한 width값 측정 방지
- ```javascript
import React, { useCallback, useEffect, useState } from "react";
import { throttle } from "lodash";
const withSize = () => (OriginalComponent) => (props) => {
const [widthSize, setWidthSize] = useState(window.innerWidth);
const [heightSize, setHeightSize] = useState(window.innerHeight);
const handleSize = useCallback(() => {
setWidthSize(window.innerWidth);
setHeightSize(window.innerHeight);
}, []);
useEffect(() => {
window.addEventListener("resize", throttle(handleSize, 200));
return () => {
window.removeEventListener("resize", handleSize);
};
}, [handleSize]);
return (
<OriginalComponent
{...props}
widthSize={widthSize}
heightSize={heightSize}
/>
);
};
export default withSize;
```
- 초기 차트 데이터를 얼마나 가져와야 하는지에 대한 문제
- 200개의 캔들을 먼저 가져오고 필요할 시 추가로 요청 후 랜더링
<br>
## Todo
- [x] WebSocket 통신<br>
- [x] 기본 Reducer 제작<br>
- [x] Thunk Factory Pattern 제작<br>
- [x] Saga Factory Pattern 제작<br>
- [x] 캔들 차트 드로잉<br>
- [x] 호가 차트 드로잉<br>
- [x] 주문 창 구현<br>

71
package.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "upbit-clone",
"version": "0.1.0",
"private": true,
"dependencies": {
"@babel/polyfill": "^7.12.1",
"@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-brands-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/react-fontawesome": "^0.1.12",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"axios": "^0.20.0",
"core-js": "^3.8.3",
"d3": "^5.15.1",
"d3-selection": "^1.1.0",
"d3-transition": "^1.3.2",
"d3fc": "^15.0.16",
"decimal.js": "^10.2.1",
"hangul-js": "^0.2.6",
"install": "^0.13.0",
"lodash": "^4.17.20",
"moment-timezone": "^0.5.31",
"npm": "^6.14.9",
"raf": "^3.4.1",
"react": "^16.13.1",
"react-app-polyfill": "^2.0.0",
"react-dom": "^16.13.1",
"react-fast-compare": "^3.2.0",
"react-financial-charts": "1.0.0-alpha.16",
"react-loading": "^2.0.3",
"react-redux": "^7.2.1",
"react-router-dom": "^5.2.0",
"react-scripts": "3.2.0",
"redux": "^4.0.5",
"redux-devtools-extension": "^2.13.8",
"redux-saga": "^1.1.3",
"redux-thunk": "^2.3.0",
"regenerator-runtime": "^0.13.7",
"styled-components": "^5.2.0",
"styled-normalize": "^8.0.7",
"styled-reset": "^4.3.0",
"text-encoding": "^0.7.0",
"websocket": "^1.0.32"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
">0.2%",
"ie 9",
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version",
"not dead"
]
}
}

BIN
public/blueLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

20
public/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="upbit clone project created by Seongkyun Yu"
/>
<title>Downbit</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

3
public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

BIN
public/whiteLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

60
src/Api/api.js Normal file
View File

@@ -0,0 +1,60 @@
import axios from "axios";
export const coinApi = {
getMarketCodes: () =>
axios.get("https://api.upbit.com/v1/market/all?isDetails=false"),
getInitCanldes: (coins) =>
axios.get(`https://api.upbit.com/v1/ticker?markets=${coins}`),
getInitOrderbooks: (coins) =>
axios.get(`https://api.upbit.com/v1/orderbook?markets=${coins}`),
getOneCoinCandles: ({ coin, timeType, timeCount }) => {
if (timeType === "minutes")
return axios
.get(
`https://api.upbit.com/v1/candles/${timeType}/${timeCount}?market=${coin}&count=200`
)
.then((res) => {
return {
...res,
data: res.data.sort((a, b) => a.timestamp - b.timestamp),
};
});
else
return axios
.get(
`https://api.upbit.com/v1/candles/${timeType}?market=${coin}&count=200`
)
.then((res) => {
return {
...res,
data: res.data.sort((a, b) => a.timestamp - b.timestamp),
};
});
},
getAdditionalCoinCandles: ({ coin, timeType, timeCount, datetime }) => {
if (timeType === "minutes")
return axios
.get(
`https://api.upbit.com/v1/candles/${timeType}/${timeCount}?market=${coin}&to=${datetime}&count=200`
)
.then((res) => {
return {
...res,
data: res.data.sort((a, b) => a.timestamp - b.timestamp),
};
});
else
return axios
.get(
`https://api.upbit.com/v1/candles/${timeType}?market=${coin}&to=${datetime}&count=200`
)
.then((res) => {
return {
...res,
data: res.data.sort((a, b) => a.timestamp - b.timestamp),
};
});
},
getOneCoinTradeLists: (coin) =>
axios.get(`https://api.upbit.com/v1/trades/ticks?market=${coin}&count=50`),
};

15
src/App.js Normal file
View File

@@ -0,0 +1,15 @@
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
import { startInit } from "./Reducer/coinReducer";
import MainRouter from "./Router/MainRouter";
function App() {
const dispatch = useDispatch();
useEffect(() => {
dispatch(startInit());
}, [dispatch]);
return <MainRouter />;
}
export default App;

View File

@@ -0,0 +1,132 @@
import React from "react";
import styled from "styled-components";
import { faEnvelope } from "@fortawesome/free-solid-svg-icons";
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
const St = {
Footer: styled.footer`
display: block;
width: 100%;
height: 100%;
/* height: 120px; */
background-color: white;
padding: 20px 0;
@media ${({ theme }) => theme.tablet} {
display: none;
}
`,
Container: styled.div`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
height: 100%;
max-width: 1360px;
margin: 0 auto;
padding: 0 20px;
@media ${({ theme }) => theme.tablet} {
display: block;
max-width: 950px;
}
`,
MainLink: styled.a`
display: block;
background-image: ${({ logo }) => `url(${logo})`};
background-repeat: no-repeat;
background-position: center;
background-size: contain;
color: transparent;
width: 130px;
height: 60px;
`,
Description: styled.p`
font-weight: 600;
font-size: 0.9rem;
color: gray;
height: 85px;
margin-top: 10px;
/* margin-left: 250px; */
`,
DescSpan: styled.span`
display: block;
height: 30px;
`,
ContactContainer: styled.address`
display: flex;
flex-direction: column;
/* margin-left: 250px; */
`,
LinkTitle: styled.span`
height: 25px;
font-size: 0.9rem;
font-weight: 600;
color: gray;
`,
LinkTag: styled.a`
display: flex;
align-items: center;
height: 30px;
color: black;
text-decoration: none;
`,
LinkSpan: styled.span`
display: block;
margin-left: ${({ marginLeft }) => marginLeft || "8px"};
font-weight: 600;
font-size: 0.9rem;
height: 20px;
/* line-height: 1.5rem; */
color: gray;
`,
};
const Footer = () => {
return (
<St.Footer>
<St.Container>
<St.MainLink
href="/"
title={"메인으로 이동"}
logo={process.env.PUBLIC_URL + "/blueLogo.png"}
/>
<St.Description>
<St.DescSpan>Upbit Clone Project - Downbit</St.DescSpan>
<St.DescSpan>Created by Seongkyun Yu</St.DescSpan>
<St.DescSpan>
Copyright © 2020 DOWNBIT INC. ALL RIGHTS RESERVED.
</St.DescSpan>
</St.Description>
<St.ContactContainer>
<St.LinkTitle>Contact Me</St.LinkTitle>
<ul>
<li>
<St.LinkTag href="https://github.com/Seongkyun-Yu/upbit-clone">
<FontAwesomeIcon
icon={faGithub}
size="lg"
title={"Github 아이콘"}
/>
<St.LinkSpan>github.com/Seongkyun-Yu/upbit-clone</St.LinkSpan>
</St.LinkTag>
</li>
<li>
<St.LinkTag href="mailto:ysungkyun@gmail.com">
<FontAwesomeIcon
icon={faEnvelope}
size="lg"
title={"이메일 아이콘"}
/>
<St.LinkSpan>ysungkyun@gmail.com</St.LinkSpan>
</St.LinkTag>
</li>
</ul>
</St.ContactContainer>
</St.Container>
</St.Footer>
);
};
export default Footer;

View File

@@ -0,0 +1,64 @@
import React from "react";
import styled from "styled-components";
const St = {
Header: styled.header`
position: sticky;
top: 0;
z-index: 100;
width: 100%;
height: 60px;
background-color: rgb(9, 54, 135);
`,
Container: styled.div`
display: flex;
align-items: center;
width: 100%;
height: 100%;
max-width: 1360px;
margin: 0 auto;
@media ${({ theme, isRootURL }) => (!isRootURL ? theme.tablet : true)} {
max-width: 950px;
}
@media ${({ theme, isRootURL }) => (isRootURL ? theme.tablet : true)} {
max-width: 100%;
}
`,
SiteHeading: styled.h1`
padding: 0 20px;
width: 150px;
height: 100%;
`,
MainLink: styled.a`
display: block;
background-image: ${({ logo }) => `url(${logo})`};
background-repeat: no-repeat;
background-position: center;
background-size: contain;
color: transparent;
width: 100%;
height: 100%;
`,
};
const Header = ({ isRootURL }) => {
return (
<St.Header>
<St.Container isRootURL={isRootURL}>
<St.SiteHeading>
<St.MainLink
href="/"
logo={process.env.PUBLIC_URL + "/whiteLogo.png"}
title={"메인으로 이동"}
>
업비트
</St.MainLink>
</St.SiteHeading>
</St.Container>
</St.Header>
);
};
export default Header;

View File

@@ -0,0 +1,34 @@
import React from "react";
import ReactLoading from "react-loading";
import styled, { css } from "styled-components";
const St = {
Container: styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
${({ isCenter }) =>
!isCenter &&
css`
align-items: stretch;
margin-top: 200px;
`}
`,
};
const Loading = ({ center = true }) => {
return (
<St.Container isCenter={center}>
<ReactLoading
type={"spokes"}
color={"rgb(18, 97, 196)"}
height={"100px"}
width={"100px"}
/>
</St.Container>
);
};
export default Loading;

View File

@@ -0,0 +1,119 @@
import React, { useCallback } from "react";
import { useDispatch } from "react-redux";
import styled from "styled-components";
import withSelectedOption from "../../Container/withSelectedOption";
import withThemeData from "../../Container/withThemeData";
import { changeTimeTypeAndData } from "../../Reducer/coinReducer";
const St = {
Container: styled.div`
display: flex;
width: 100%;
background-color: white;
border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
`,
HiddenH3: styled.h3`
position: absolute;
width: 1px;
height: 1px;
clip: rect(0, 0);
clip-path: polygon(0, 0);
overflow: hidden;
text-indent: -9999px;
`,
TimeBtnContainer: styled.div`
display: flex;
align-items: center;
height: 30px;
`,
TimeBtn: styled.button`
/* width: 50px; */
height: 20px;
width: 38px;
margin-left: 5px;
font-size: 0.8rem;
background-color: white;
border: ${({ theme, isSelected }) =>
isSelected ? `2px solid black` : `1px solid ${theme.lightGray2}`};
outline: none;
cursor: pointer;
`,
};
const ChartDataConsole = ({ theme, selectedTimeCount, selectedTimeType }) => {
const dispatch = useDispatch();
const changeChartTime = useCallback(
(timeCount, timeType) => () => {
dispatch(changeTimeTypeAndData({ timeCount, timeType }));
},
[dispatch]
);
return (
<St.Container theme={theme}>
<St.HiddenH3>차트에 표시할 캔들의 시간 선택</St.HiddenH3>
<St.TimeBtnContainer>
<St.TimeBtn
onClick={changeChartTime(1, "minutes")}
isSelected={selectedTimeCount === 1 && selectedTimeType === "minutes"}
>
1m
</St.TimeBtn>
<St.TimeBtn
onClick={changeChartTime(3, "minutes")}
isSelected={selectedTimeCount === 3 && selectedTimeType === "minutes"}
>
3m
</St.TimeBtn>
<St.TimeBtn
onClick={changeChartTime(5, "minutes")}
isSelected={selectedTimeCount === 5 && selectedTimeType === "minutes"}
>
5m
</St.TimeBtn>
<St.TimeBtn
onClick={changeChartTime(10, "minutes")}
isSelected={
selectedTimeCount === 10 && selectedTimeType === "minutes"
}
>
10m
</St.TimeBtn>
<St.TimeBtn
onClick={changeChartTime(15, "minutes")}
isSelected={
selectedTimeCount === 15 && selectedTimeType === "minutes"
}
>
15m
</St.TimeBtn>
<St.TimeBtn
onClick={changeChartTime(60, "minutes")}
isSelected={
selectedTimeCount === 60 && selectedTimeType === "minutes"
}
>
1h
</St.TimeBtn>
<St.TimeBtn
onClick={changeChartTime(240, "minutes")}
isSelected={
selectedTimeCount === 240 && selectedTimeType === "minutes"
}
>
4h
</St.TimeBtn>
<St.TimeBtn
onClick={changeChartTime(1, "days")}
isSelected={selectedTimeCount === 1 && selectedTimeType === "days"}
>
1d
</St.TimeBtn>
</St.TimeBtnContainer>
</St.Container>
);
};
export default withSelectedOption()(withThemeData()(ChartDataConsole));

View File

@@ -0,0 +1,213 @@
import React from "react";
import styled from "styled-components";
import withSelectedCoinName from "../../Container/withSelectedCoinName";
import withSelectedCoinPrice from "../../Container/withSelectedCoinPrice";
import withThemeData from "../../Container/withThemeData";
import isEqual from "react-fast-compare";
const St = {
CoinInfoContainer: styled.section`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
background-color: white;
padding: 10px;
border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
`,
HiddenH3: styled.h3`
position: absolute;
width: 1px;
height: 1px;
clip: rect(0, 0);
clip-path: polygon(0, 0);
overflow: hidden;
text-indent: -9999px;
`,
CoinInfoMain: styled.div`
display: flex;
align-items: center;
min-width: 380px;
`,
CoinLogo: styled.i`
display: inline-block;
width: 35px;
height: 35px;
background-image: ${({ coinSymbol }) =>
`url(https://static.upbit.com/logos/${coinSymbol}.png)`};
background-size: cover;
margin-left: 5px;
`,
CoinNameContainer: styled.div`
padding: 0 8px 0 13px;
`,
CoinName: styled.strong`
font-size: 1.7rem;
font-weight: 800;
color: #2b2b2b;
@media ${({ theme }) => theme.mobileS} {
font-size: 1.5rem;
}
`,
CoinMarketName: styled.span`
display: flex;
font-size: 0.9rem;
flex-direction: column;
padding-left: 5px;
margin-top: 7px;
`,
PriceInfo: styled.div`
display: flex;
flex-direction: column;
`,
Price: styled.strong`
color: ${({ priceColor }) => priceColor};
font-size: 2rem;
font-weight: 800;
@media ${({ theme }) => theme.mobileS} {
font-size: 1.5rem;
}
`,
PriceUnit: styled.span`
font-size: 0.9rem;
font-weight: 500;
padding-left: 5px;
`,
ChangeContainer: styled.span`
font-size: 0.8rem;
margin-top: 5px;
`,
ChangeRate: styled.strong`
font-size: 1rem;
color: ${({ priceColor }) => priceColor};
margin: 0 10px 0 5px;
font-weight: 800;
`,
ChangePrice: styled.strong`
font-size: 1rem;
font-weight: 800;
color: ${({ priceColor }) => priceColor};
`,
TradeInfoContainer: styled.dl`
display: flex;
justify-content: flex-end;
align-items: center;
width: 45%;
height: 100%;
margin: 0 10px 0 0;
@media ${({ theme, mobileMNone }) => (mobileMNone ? theme.mobileM : true)} {
display: none;
}
`,
InfoContainer: styled.div`
height: 100%;
margin-left: 15px;
@media ${({ theme, tabletNone }) => (tabletNone ? theme.tablet : true)} {
display: none;
}
@media ${({ theme, mobileMNone }) => (mobileMNone ? theme.mobileM : true)} {
display: none;
}
`,
TradeInfo: styled.div`
display: flex;
justify-content: space-between;
height: 50%;
min-width: ${({ minWidth }) => minWidth || "none"};
border-bottom: 1px solid ${({ borderColor }) => borderColor || "none"};
padding: 5px 0 5px 0;
font-size: 0.8rem;
`,
TradeDT: styled.dt`
display: inline-block;
min-width: 50px;
height: 50%;
`,
TradeDD: styled.dd`
margin: 0;
display: inline-block;
height: 50%;
color: ${({ fontColor }) => fontColor || "black"};
font-weight: ${({ fontWeight }) => fontWeight || 500};
`,
};
const CoinInfoHeader = ({
theme,
coinNameKor,
coinSymbol,
coinNameAndMarketEng,
highestPrice24Hour,
lowestPrice24Hour,
changeRate24Hour,
changePrice24Hour,
tradePrice24Hour,
volume24Hour,
price,
}) => {
const priceColor = changeRate24Hour > 0 ? theme.priceUp : theme.priceDown;
return (
<St.CoinInfoContainer>
<St.HiddenH3>코인 가격 및 기타 정보</St.HiddenH3>
<St.CoinInfoMain>
<St.CoinLogo coinSymbol={coinSymbol} title={`${coinNameKor} 로고`} />
<St.CoinNameContainer>
<St.CoinName>{coinNameKor}</St.CoinName>
<St.CoinMarketName>{coinNameAndMarketEng}</St.CoinMarketName>
</St.CoinNameContainer>
<St.PriceInfo>
<St.Price priceColor={priceColor}>
{price.toLocaleString()}
<St.PriceUnit priceColor={priceColor}>KRW</St.PriceUnit>
</St.Price>
<St.ChangeContainer>
전일대비
<St.ChangeRate priceColor={priceColor}>
{changeRate24Hour}%
</St.ChangeRate>
<St.ChangePrice priceColor={priceColor}>
{changePrice24Hour.toLocaleString()}
</St.ChangePrice>
</St.ChangeContainer>
</St.PriceInfo>
</St.CoinInfoMain>
<St.TradeInfoContainer mobileMNone={true}>
<St.InfoContainer tabletNone={true}>
<St.TradeInfo minWidth={"100px"} borderColor={theme.lightGray2}>
<St.TradeDT>고가</St.TradeDT>
<St.TradeDD fontColor={theme.priceUp} fontWeight={800}>
{highestPrice24Hour ? highestPrice24Hour.toLocaleString() : 0}
</St.TradeDD>
</St.TradeInfo>
<St.TradeInfo minWidth={"100px"}>
<St.TradeDT borderColor={theme.lightGray2}>저가</St.TradeDT>
<St.TradeDD fontColor={theme.priceDown} fontWeight={800}>
{lowestPrice24Hour ? lowestPrice24Hour.toLocaleString() : 0}
</St.TradeDD>
</St.TradeInfo>
</St.InfoContainer>
<St.InfoContainer mobileMNone={true}>
<St.TradeInfo minWidth={"220px"} borderColor={theme.lightGray2}>
<St.TradeDT>거래량(24h)</St.TradeDT>
<St.TradeDD>{`${volume24Hour.toLocaleString()} ${coinSymbol}`}</St.TradeDD>
</St.TradeInfo>
<St.TradeInfo minWidth={"220px"}>
<St.TradeDT borderColor={theme.lightGray2}>
거래대금(24h)
</St.TradeDT>
<St.TradeDD>
{tradePrice24Hour ? tradePrice24Hour.toLocaleString() : 0} KRW
</St.TradeDD>
</St.TradeInfo>
</St.InfoContainer>
</St.TradeInfoContainer>
</St.CoinInfoContainer>
);
};
export default withSelectedCoinName()(
withSelectedCoinPrice()(withThemeData()(React.memo(CoinInfoHeader)))
);

View File

@@ -0,0 +1,210 @@
import React from "react";
import styled from "styled-components";
import { searchCoin } from "../../Reducer/coinReducer";
import { useDispatch } from "react-redux";
import CoinListItem from "./CoinListItem";
import Loading from "../Global/Loading";
import withThemeData from "../../Container/withThemeData";
import withSelectedOption from "../../Container/withSelectedOption";
import withMarketNames from "../../Container/withMarketNames";
import withLatestCoinData from "../../Container/withLatestCoinData";
import withLoadingData from "../../Container/withLoadingData";
const St = {
CoinListContainer: styled.article`
display: none;
position: -webkit-sticky; /* 사파리 */
position: sticky;
top: 70px;
height: 100%;
width: 100%;
background-color: white;
overflow: hidden;
@media ${({ theme }) => theme.desktop} {
display: block;
max-width: 400px;
height: ${({ heightSize }) => `${heightSize}px`};
margin-left: 10px;
}
@media ${({ theme, isRootURL }) => (!isRootURL ? theme.mobileM : true)} {
display: none;
}
@media ${({ theme, isRootURL }) => (isRootURL ? theme.tablet : true)} {
display: block;
margin-top: 0;
margin-bottom: 0;
height: ${({ heightSize }) =>
`${heightSize + 80}px`}; // 모바일 풀 화면을 위해 다시 80px 더해줌
}
`,
HiddenH3: styled.h3`
position: absolute;
width: 1px;
height: 1px;
clip: rect(0, 0);
clip-path: polygon(0, 0);
overflow: hidden;
text-indent: -9999px;
`,
CoinSearchContainer: styled.div`
display: flex;
width: 100%;
border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
`,
CoinSearchInput: styled.input`
width: 100%;
border: none;
padding: 5px;
padding-left: 12px;
&::placeholder {
font-size: 0.7rem;
color: gray;
font-weight: 700;
}
`,
CoinSearchBtn: styled.button`
width: 30px;
height: 30px;
background: url("https://cdn.upbit.com/images/bg.e801517.png") -83px 2px no-repeat;
background-color: white;
padding: 10px;
padding-right: 20px;
padding-left: 20px;
border: none;
`,
CoinSortContainer: styled.ul`
display: flex;
justify-content: space-between;
align-items: center;
background-color: white;
width: 100%;
height: 30px;
border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
font-size: 0.9rem;
font-weight: 800;
color: #666666;
`,
CoinSortList: styled.li`
width: ${({ width }) => width || "20%"};
text-align: ${({ textAlign }) => textAlign || "right"};
margin-right: ${({ marginRight }) => marginRight || 0};
font-size: 0.78rem;
`,
CoinUl: styled.ul`
height: ${({ heightSize }) => `${heightSize + 70}px`};
min-height: 800px;
background-color: white;
overflow-y: scroll;
scrollbar-color: ${({ theme }) => theme.middleGray};
scrollbar-width: thin;
scrollbar-base-color: ${({ theme }) => theme.middleGray};
&::-webkit-scrollbar {
width: 5px;
background-color: white;
border-radius: 5rem;
}
&::-webkit-scrollbar-thumb {
background-color: ${({ theme }) => theme.middleGray};
border-radius: 5rem;
}
@media ${({ theme }) => theme.desktop} {
display: block;
max-width: 400px;
height: ${({ heightSize }) => `${heightSize}px`};
}
`,
};
const CoinList = ({
theme,
marketNames,
sortedMarketNames,
latestCoinData,
selectedMarket,
searchCoinInput,
isMarketNamesLoading,
isInitCandleLoading,
heightSize,
isRootURL,
}) => {
const dispatch = useDispatch();
return (
<St.CoinListContainer isRootURL={isRootURL} heightSize={heightSize - 80}>
<St.HiddenH3>코인 리스트</St.HiddenH3>
<St.CoinSearchContainer>
<St.CoinSearchInput
type="search"
onChange={(e) => dispatch(searchCoin(e.target.value))}
value={searchCoinInput}
placeholder={"코인명/심볼검색"}
/>
<St.CoinSearchBtn />
</St.CoinSearchContainer>
<St.CoinSortContainer>
<St.CoinSortList width={"50px"} />
<St.CoinSortList textAlign={"left"}>한글명</St.CoinSortList>
<St.CoinSortList>현재가</St.CoinSortList>
<St.CoinSortList>상승률</St.CoinSortList>
<St.CoinSortList width={"25%"} marginRight={"10px"}>
거래대금
</St.CoinSortList>
</St.CoinSortContainer>
<St.CoinUl heightSize={heightSize - 140}>
{isMarketNamesLoading || isInitCandleLoading ? (
<Loading center={false} />
) : (
sortedMarketNames.map((marketName) => {
const splitedName = marketName.split("-");
const enCoinName = splitedName[1] + "/" + splitedName[0];
const changePrice24Hour =
latestCoinData[marketName].changePrice24Hour;
const changeRate24Hour =
latestCoinData[marketName].changeRate24Hour;
const tradePrice24Hour =
latestCoinData[marketName].tradePrice24Hour;
const price = latestCoinData[marketName].price;
// const isTraded = latestCoinData[marketName].isTraded;
const fontColor =
+changePrice24Hour > 0
? theme.strongRed
: +changePrice24Hour < 0
? theme.strongBlue
: "black";
return (
<CoinListItem
theme={theme}
marketName={marketName}
selectedMarket={selectedMarket}
coinName={marketNames[marketName].korean}
enCoinName={enCoinName}
fontColor={fontColor}
price={price}
changeRate24Hour={changeRate24Hour + "%"}
changePrice24Hour={changePrice24Hour}
tradePrice24Hour={tradePrice24Hour}
// isTraded={isTraded}
key={`coinList-${marketName}`}
/>
);
})
)}
</St.CoinUl>
</St.CoinListContainer>
);
};
export default withLatestCoinData()(
withMarketNames()(
withSelectedOption()(
withLoadingData()(withThemeData()(React.memo(CoinList)))
)
)
);

View File

@@ -0,0 +1,206 @@
import React, { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useHistory } from "react-router-dom";
import styled, { css } from "styled-components";
import { startChangeMarketAndData } from "../../Reducer/coinReducer";
import isEqual from "react-fast-compare";
const St = {
CoinLi: styled.li`
width: 100%;
height: 45px;
border-bottom: 1px solid ${({ borderBottomColor }) => borderBottomColor};
&:last-child {
border-bottom: none;
}
background-color: ${({ bgColor }) => bgColor};
`,
CoinBtn: styled.button`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
height: 100%;
background-color: transparent;
border: none;
outline: none;
cursor: pointer;
text-align: left;
`,
CoinLogo: styled.i`
display: inline-block;
width: 20px;
height: 20px;
background-image: ${({ coinNameEn }) =>
coinNameEn !== "ADX"
? `url(https://static.upbit.com/logos/${coinNameEn}.png)`
: "../styles/img/ADX.png"};
background-size: cover;
margin-left: 5px;
margin-right: 15px;
`,
CoinNameContainer: styled.div`
display: flex;
flex-direction: column;
justify-content: center;
width: 20%;
min-width: 55px;
height: 45px;
`,
CoinName: styled.strong`
display: block;
font-size: 12px;
font-weight: 800;
@media ${({ theme }) => theme.tablet} {
font-weight: 500;
}
`,
CoinNameEn: styled.span`
display: block;
font-size: 12px;
`,
Price: styled.strong`
display: block;
width: 20%;
min-width: 55px;
height: 100%;
text-align: right;
line-height: 2.5rem;
font-size: 12px;
font-weight: 800;
color: ${({ fontColor }) => fontColor};
border: 1px solid transparent;
${({ isTraded }) =>
isTraded &&
(isTraded === "ASK"
? css`
animation: disappearBlue 0.6s;
`
: css`
animation: disappearRed 0.6s;
`)};
@keyframes disappearBlue {
0% {
border-color: ${({ theme }) => theme.strongBlue};
}
100% {
border-color: ${({ theme }) => theme.strongBlue};
}
}
@keyframes disappearRed {
0% {
border-color: ${({ theme }) => theme.strongRed};
}
100% {
border-color: ${({ theme }) => theme.strongRed};
}
}
@media ${({ theme }) => theme.tablet} {
font-weight: 500;
}
`,
ChangRateContainer: styled.div`
display: flex;
flex-direction: column;
justify-content: center;
width: 20%;
min-width: 55px;
height: 100%;
text-align: right;
`,
ChangeRate: styled.span`
display: block;
font-size: 12px;
color: ${({ fontColor }) => fontColor};
/* font-weight: 800; */
`,
ChangePrice: styled.span`
display: block;
font-size: 12px;
color: ${({ fontColor }) => fontColor};
/* font-weight: 800; */
`,
TradePrice: styled.span`
display: flex;
flex-direction: column;
justify-content: center;
font-size: 12px;
width: 25%;
height: 100%;
text-align: right;
/* font-weight: 800; */
`,
};
const CoinListItem = ({
theme,
selectedMarket,
marketName,
coinName,
enCoinName,
fontColor,
price,
changeRate24Hour,
changePrice24Hour,
tradePrice24Hour,
}) => {
const dispatch = useDispatch();
const history = useHistory();
const tradeListData = useSelector(
(state) => state.Coin.tradeList.data[marketName]
);
const nowTimestamp = +new Date();
const isTraded =
tradeListData &&
tradeListData.length > 2 &&
nowTimestamp - tradeListData[0].timestamp < 500 &&
tradeListData[0].trade_price !== tradeListData[1].trade_price
? tradeListData[0].ask_bid
: false;
const changeMarket = useCallback(() => {
dispatch(startChangeMarketAndData(marketName));
history.push("/trade");
}, [dispatch, marketName, history]);
return (
<St.CoinLi
borderBottomColor={theme.lightGray}
bgColor={selectedMarket === marketName ? theme.lightGray : "white"}
>
<St.CoinBtn onClick={changeMarket}>
<St.CoinLogo
coinNameEn={enCoinName.split("/")[0]}
title={`${coinName} 로고`}
/>
<St.CoinNameContainer>
<St.CoinName theme={theme}>{coinName}</St.CoinName>
<St.CoinNameEn>{enCoinName}</St.CoinNameEn>
</St.CoinNameContainer>
<St.Price theme={theme} fontColor={fontColor} isTraded={isTraded}>
{price.toLocaleString()}
</St.Price>
<St.ChangRateContainer>
<St.ChangeRate fontColor={fontColor}>
{changeRate24Hour}
</St.ChangeRate>
<St.ChangePrice fontColor={fontColor}>
{changePrice24Hour.toLocaleString()}
</St.ChangePrice>
</St.ChangRateContainer>
<St.TradePrice>
{tradePrice24Hour.toLocaleString() + " 백만"}
</St.TradePrice>
</St.CoinBtn>
</St.CoinLi>
);
};
export default React.memo(CoinListItem, isEqual);

View File

@@ -0,0 +1,78 @@
import React, { useEffect, useRef } from "react";
import * as d3 from "d3";
import * as fc from "d3fc";
import { useSelector } from "react-redux";
const MainChart = () => {
const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
// const selectedCandles = useSelector(
// (state) => state.Coin.candle.data[selectedMarket].candles
// );
// console.log(test[0].date);
const selectedCandles = fc.randomFinancial()(200);
let updated = false;
if (selectedCandles.length > 2) {
let updated = true;
// console.log("여기야", selectedCandles);
const xExtent = fc.extentDate().accessors([(d) => d.date]);
const yExtent = fc.extentLinear().accessors([(d) => d.high, (d) => d.low]);
const gridlines = fc.annotationSvgGridline();
const candlestick = fc.seriesSvgCandlestick().decorate((sel) => {
sel.enter().style("fill", (_, i) => (_.open > _.close ? "blue" : "red"));
sel
.enter()
.style("stroke", (_, i) => (_.open > _.close ? "blue" : "red"));
});
const multi = fc.seriesSvgMulti().series([gridlines, candlestick]);
// 줌용 설정
const x = d3.scaleTime();
const y = d3.scaleLinear();
const x2 = d3.scaleTime();
const y2 = d3.scaleLinear();
const zoom = d3.zoom().on("zoom", () => {
// update the scale used by the chart to use the updated domain
x.domain(d3.event.transform.rescaleX(x2).domain());
y.domain(d3.event.transform.rescaleY(y2).domain());
d3.select(".chartContainer").datum(selectedCandles).call(chart);
});
const chart = fc
.chartCartesian(x, y)
.xDomain(xExtent(selectedCandles))
.yDomain(yExtent(selectedCandles))
.svgPlotArea(multi)
.decorate((sel) => {
sel
.enter()
.select(".plot-area")
.on("measure.range", () => {
x2.range([0, d3.event.detail.width]);
y2.range([d3.event.detail.height, 0]);
})
.call(zoom);
});
x2.domain(chart.xDomain());
y2.domain(chart.yDomain());
d3.select(".chartContainer").datum(selectedCandles).call(chart);
}
useEffect(() => {}, [selectedCandles]);
return (
<div className="chartContainer">
{/* <svg id="chartContainer" ref={svgRef}></svg> */}
</div>
);
};
export default MainChart;

View File

@@ -0,0 +1,180 @@
import React, { useEffect, useRef } from "react";
import * as d3 from "d3";
import * as fc from "d3fc";
import { useSelector } from "react-redux";
const MainChart = () => {
const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
const selectedCandles = useSelector(
(state) => state.Coin.candle.data[selectedMarket].candles
);
const dateFormat = d3.timeParse("%Y-%m-%d %H:%M");
const svgRef = useRef();
useEffect(() => {
const margin = { top: 15, right: 65, bottom: 205, left: 50 };
const width = 1000 - margin.left - margin.right;
const height = 625 - margin.top - margin.bottom;
d3.select("#chartContainer").remove();
const svg = d3
.select("#root")
.append("svg")
.attr("id", "chartContainer")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
const dates = selectedCandles.map((candle) => dateFormat(candle.datetime));
const xScale = d3
.scaleLinear()
.domain([-1, dates.length])
.range([0, width]);
const xDateScale = d3
.scaleQuantize()
.domain([0, dates.length])
.range(dates.length ? dates : ["20-01-01 00:00"]);
const xBand = d3
.scaleBand()
.domain(d3.range(-1, dates.length))
.range([0, width])
.padding(0.3);
const xAxis = d3.axisBottom().scale(xScale);
svg
.append("rect")
.attr("id", "rect")
.attr("width", width)
.attr("height", height)
.style("fill", "none")
.style("pointer-events", "all")
.attr("clip-path", "url(#clip)");
let gX = svg
.append("g")
.attr("class", "axis x-axis") //Assign "axis" class
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
gX.selectAll(".tick text");
const ymin = d3.min(selectedCandles.map((candle) => candle.low));
const ymax = d3.max(selectedCandles.map((candle) => candle.high));
const yScale = d3
.scaleLinear()
.domain([ymin, ymax])
.range([height, 0])
.nice();
const yAxis = d3.axisLeft().scale(yScale);
const gY = svg.append("g").attr("class", "axis y-axis").call(yAxis);
const chartBody = svg
.append("g")
.attr("class", "chartBody")
.attr("clip-path", "url(#clip)");
// 캔들 몸통
const candles = chartBody.selectAll(".candle").data(selectedCandles);
candles
.enter()
.append("rect")
.attr("x", (_, i) => xScale(i) - xBand.bandwidth())
.attr("class", "candle")
.attr("y", (candle) => yScale(Math.max(candle.open, candle.close)))
.attr("width", xBand.bandwidth())
.attr("height", (candle) => {
return candle.open === candle.close
? 1
: yScale(Math.min(candle.open, candle.close)) -
yScale(Math.max(candle.open, candle.close));
})
.attr("fill", (candle) => {
return candle.open === candle.close
? "silver"
: candle.open > candle.close
? "red"
: "green";
});
// candles.exit().remove();
// 윗꼬리 아랫꼬리
const stems = chartBody.selectAll("g.line").data(selectedCandles);
stems
.enter()
.append("line")
.attr("class", "stem")
.attr("x1", (_, i) => xScale(i) - xBand.bandwidth() / 2)
.attr("x2", (_, i) => xScale(i) - xBand.bandwidth() / 2)
.attr("y1", (candle) => yScale(candle.high))
.attr("y2", (candle) => yScale(candle.low))
.attr("stroke", (candle) => {
return candle.open === candle.close
? "white"
: candle.open > candle.close
? "red"
: "green";
});
// stems.exit().remove();
svg
.append("defs")
.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width)
.attr("height", height);
// d3.getEvent = () => require("d3-selection").event;
// const zoomed = () => {
// const t = currentEvent.transform;
// const xScaleZ = t.rescaleX(xScale);
// const hideTicksWithoutLabel = () => {
// d3.selectAll(".xAxis .tick text").each((d) => {
// if (this.innerHTML === "") {
// this.parentNode.style.display = "none";
// }
// });
// };
// gX.call(d3.axisBottom(xScaleZ));
// candles
// .attr("x", (d, i) => xScaleZ(i) - (xBand.bandwidth() * t.k) / 2)
// .attr("width", xBand.bandwidth() * t.k);
// stems.attr(
// "x1",
// (d, i) => xScaleZ(i) - xBand.bandwidth() / 2 + xBand.bandwidth() * 0.5
// );
// stems.attr(
// "x2",
// (d, i) => xScaleZ(i) - xBand.bandwidth() / 2 + xBand.bandwidth() * 0.5
// );
// hideTicksWithoutLabel();
// // gX.selectAll(".tick text").call(wrap, xBand.bandwidth());
// };
const extent = [
[0, 0],
[width, height],
];
let resizeTimer;
chartBody.exit().remove();
}, [selectedCandles]);
return <svg id="chartContainer" ref={svgRef}></svg>;
};
export default MainChart;

View File

@@ -0,0 +1,285 @@
import React from "react";
import styled from "styled-components";
import { useDispatch } from "react-redux";
import { startAddMoreCandleData } from "../../Reducer/coinReducer";
import { format } from "d3-format";
import { timeFormat } from "d3-time-format";
import {
elderRay,
ema,
discontinuousTimeScaleProviderBuilder,
Chart,
ChartCanvas,
CurrentCoordinate,
BarSeries,
CandlestickSeries,
ElderRaySeries,
LineSeries,
MovingAverageTooltip,
OHLCTooltip,
SingleValueTooltip,
mouseBasedZoomAnchor,
XAxis,
YAxis,
CrossHairCursor,
EdgeIndicator,
MouseCoordinateX,
MouseCoordinateY,
ZoomButtons,
withDeviceRatio,
withSize,
} from "react-financial-charts";
import Loading from "../Global/Loading";
import withOHLCData from "../../Container/withOHLCData";
import withThemeData from "../../Container/withThemeData";
import withSelectedOption from "../../Container/withSelectedOption";
import withLoadingData from "../../Container/withLoadingData";
import isEqual from "react-fast-compare";
const barChartExtents = (data) => {
return data.volume;
};
const candleChartExtents = (data) => {
return [data.high, data.low];
};
const yEdgeIndicator = (data) => {
return data.close;
};
const volumeSeries = (data) => {
return data.volume;
};
const St = {
ChartContainer: styled.section`
width: 100%;
height: 500px;
background-color: white;
`,
HiddenH3: styled.h3`
position: absolute;
width: 1px;
height: 1px;
clip: rect(0, 0);
clip-path: polygon(0, 0);
overflow: hidden;
text-indent: -9999px;
`,
};
const margin = { left: 10, right: 80, top: 20, bottom: 20 };
const minHeight = 350;
const MainChart = ({
data: initialData,
height,
width,
ratio,
selectedTimeType,
theme,
isCandleLoading,
}) => {
if (height > 500) height = 500;
const dispatch = useDispatch();
const dateTimeFormat =
selectedTimeType === "days" || selectedTimeType === "weeks"
? "%y-%m-%d"
: "%y-%m-%d %H:%M";
const timeDisplayFormat = timeFormat(dateTimeFormat);
const pricesDisplayFormat = format("");
const openCloseColor = (data) => {
return data.close > data.open ? theme.priceUp : theme.priceDown;
};
const volumeColor = (data) => {
return data.close > data.open ? theme.priceUpTrans : theme.priceDownTrans;
};
const xScaleProvider = discontinuousTimeScaleProviderBuilder().inputDateAccessor(
(d) => d.date
);
const ema12 = ema()
.id(1)
.options({ windowSize: 12 })
.merge((d, c) => {
d.ema12 = c;
})
.accessor((d) => d.ema12);
const ema26 = ema()
.id(2)
.options({ windowSize: 26 })
.merge((d, c) => {
d.ema26 = c;
})
.accessor((d) => d.ema26);
const elder = elderRay();
const calculatedData = elder(ema26(ema12(initialData)));
const { data, xScale, xAccessor, displayXAccessor } = xScaleProvider(
calculatedData
);
// 확대 축소 초기 범위를 정하는 xExtendts설정, max와 min이 변하면 새로운 데이터 추가시 줌 설정이 풀린다
const max = xAccessor(data[Math.min(199, data.length - 1)]);
const min = xAccessor(
data.length < 50 ? 0 : data[Math.min(50, Math.floor(data.length / 2))]
);
const xExtents = [min, max + 5];
const gridHeight = height - margin.top - margin.bottom;
const elderRayHeight = 100;
const elderRayOrigin = (_, h) => [0, h - elderRayHeight];
const barChartHeight = gridHeight / 4;
const barChartOrigin = (_, h) => [0, h - barChartHeight - elderRayHeight];
const chartHeight = gridHeight - elderRayHeight;
return (
<St.ChartContainer>
<St.HiddenH3>캔들 차트</St.HiddenH3>
{isCandleLoading ? (
<Loading />
) : (
<ChartCanvas
height={height}
ratio={ratio}
width={width}
margin={margin}
data={data}
displayXAccessor={displayXAccessor}
seriesName="Data"
xScale={xScale}
xAccessor={xAccessor}
xExtents={xExtents}
disableInteraction={false}
zoomAnchor={mouseBasedZoomAnchor}
onLoadBefore={() => {
dispatch(startAddMoreCandleData());
}}
>
<Chart
id={2}
height={barChartHeight}
origin={barChartOrigin}
yExtents={barChartExtents}
>
<BarSeries fillStyle={volumeColor} yAccessor={volumeSeries} />
</Chart>
<Chart id={3} height={chartHeight} yExtents={candleChartExtents}>
<XAxis showGridLines showTickLabel={false} />
<YAxis showGridLines tickFormat={pricesDisplayFormat} />
<CandlestickSeries
fill={openCloseColor}
wickStroke={openCloseColor}
/>
<LineSeries
yAccessor={ema26.accessor()}
strokeStyle={ema26.stroke()}
/>
<CurrentCoordinate
yAccessor={ema26.accessor()}
fillStyle={ema26.stroke()}
/>
<LineSeries
yAccessor={ema12.accessor()}
strokeStyle={ema12.stroke()}
/>
<CurrentCoordinate
yAccessor={ema12.accessor()}
fillStyle={ema12.stroke()}
/>
<MouseCoordinateY
rectWidth={margin.right}
displayFormat={pricesDisplayFormat}
/>
<EdgeIndicator
itemType="last"
rectWidth={margin.right}
fill={openCloseColor}
lineStroke={openCloseColor}
displayFormat={pricesDisplayFormat}
yAccessor={yEdgeIndicator}
/>
<MovingAverageTooltip
origin={[8, 24]}
options={[
{
yAccessor: ema26.accessor(),
type: "EMA",
stroke: ema26.stroke(),
windowSize: ema26.options().windowSize,
},
{
yAccessor: ema12.accessor(),
type: "EMA",
stroke: ema12.stroke(),
windowSize: ema12.options().windowSize,
},
]}
/>
<ZoomButtons />
<OHLCTooltip origin={[8, 16]} />
</Chart>
<Chart
id={4}
height={elderRayHeight}
yExtents={[0, elder.accessor()]}
origin={elderRayOrigin}
padding={{ top: 8, bottom: 8 }}
>
<XAxis showGridLines gridLinesStrokeStyle="#e0e3eb" />
<YAxis ticks={4} tickFormat={pricesDisplayFormat} />
<MouseCoordinateX displayFormat={timeDisplayFormat} />
<MouseCoordinateY
rectWidth={margin.right}
displayFormat={pricesDisplayFormat}
/>
<ElderRaySeries yAccessor={elder.accessor()} />
<SingleValueTooltip
yAccessor={elder.accessor()}
yLabel="Elder Ray"
yDisplayFormat={(d) =>
`${pricesDisplayFormat(d.bullPower)}, ${pricesDisplayFormat(
d.bearPower
)}`
}
origin={[8, 16]}
/>
</Chart>
<CrossHairCursor snapX={false} />
</ChartCanvas>
)}
</St.ChartContainer>
);
};
export default withOHLCData()(
withSize({
style: {
width: "100%",
height: "500",
minHeight,
},
})(
withDeviceRatio()(
withSelectedOption()(
withLoadingData()(withThemeData()(React.memo(MainChart, isEqual)))
)
)
)
);

View File

@@ -0,0 +1,110 @@
import React from "react";
import { useDispatch } from "react-redux";
import styled from "styled-components";
import OrderInfoAskBid from "./OrderInfoAskBid";
import withSelectedOption from "../../Container/withSelectedOption";
import withThemeData from "../../Container/withThemeData";
import withSelectedCoinName from "../../Container/withSelectedCoinName";
import { changeAskBidOrder } from "../../Reducer/coinReducer";
import isEqual from "react-fast-compare";
const St = {
Container: styled.section`
width: 100%;
height: 50%;
background-color: white;
`,
HiddenH3: styled.h3`
position: absolute;
width: 1px;
height: 1px;
clip: rect(0, 0);
clip-path: polygon(0, 0);
overflow: hidden;
text-indent: -9999px;
`,
OrderTypeContainer: styled.ul`
display: flex;
height: 40px;
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
@media ${({ theme }) => theme.mobileS} {
font-size: 0.8rem;
}
`,
OrderTypeLi: styled.li`
width: 33.3333%;
height: 100%;
`,
OrderTypeBtn: styled.button`
width: 100%;
height: 100%;
background-color: white;
border: none;
border-bottom: 3px solid ${({ borderBottom }) => borderBottom || "white"};
outline: 0;
font-weight: 900;
color: ${({ fontColor }) => fontColor || "black"};
cursor: pointer;
`,
};
const OrderInfo = ({
theme,
selectedAskBidOrder,
coinSymbol,
orderPrice,
orderAmount,
orderTotalPrice,
}) => {
const dispatch = useDispatch();
return (
<St.Container>
<St.HiddenH3>주문 정보</St.HiddenH3>
<St.OrderTypeContainer>
<St.OrderTypeLi>
<St.OrderTypeBtn
borderBottom={selectedAskBidOrder === "bid" && theme.strongRed}
fontColor={selectedAskBidOrder === "bid" && theme.strongRed}
onClick={() => dispatch(changeAskBidOrder("bid"))}
>
매수
</St.OrderTypeBtn>
</St.OrderTypeLi>
<St.OrderTypeLi>
<St.OrderTypeBtn
borderBottom={selectedAskBidOrder === "ask" && theme.strongBlue}
fontColor={selectedAskBidOrder === "ask" && theme.strongBlue}
onClick={() => dispatch(changeAskBidOrder("ask"))}
>
매도
</St.OrderTypeBtn>
</St.OrderTypeLi>
<St.OrderTypeLi>
<St.OrderTypeBtn
borderBottom={selectedAskBidOrder === "tradeList" && "black"}
fontColor={selectedAskBidOrder === "tradeList" && "black"}
onClick={() => dispatch(changeAskBidOrder("tradeList"))}
>
거래내역
</St.OrderTypeBtn>
</St.OrderTypeLi>
</St.OrderTypeContainer>
<OrderInfoAskBid
theme={theme}
selectedAskBidOrder={selectedAskBidOrder}
coinSymbol={coinSymbol}
orderPrice={orderPrice}
orderAmount={orderAmount}
orderTotalPrice={orderTotalPrice}
/>
</St.Container>
);
};
export default withSelectedCoinName()(
withSelectedOption()(withThemeData()(React.memo(OrderInfo, isEqual)))
);

View File

@@ -0,0 +1,255 @@
import React, { useCallback } from "react";
import { useDispatch } from "react-redux";
import styled from "styled-components";
import {
changeAmountAndTotalPrice,
changePriceAndTotalPrice,
changeTotalPriceAndAmount,
} from "../../Reducer/coinReducer";
import OrderInfoTradeList from "./OrderInfoTradeList";
const St = {
Container: styled.section`
width: 100%;
height: 50%;
background-color: white;
`,
OrderTypeContainer: styled.div`
display: flex;
height: 40px;
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.lightGray2};
@media ${({ theme }) => theme.mobileS} {
font-size: 0.8rem;
}
`,
OrderType: styled.button`
width: 33.33%;
height: 100%;
background-color: white;
border: none;
border-bottom: 3px solid
${({ borderBottom }) => borderBottom || "tranceparent"};
outline: 0;
font-weight: 900;
color: ${({ fontColor }) => fontColor || "black"};
`,
OrderInfoContainer: styled.div`
width: 100%;
padding: 15px;
padding-top: 0;
@media ${({ theme }) => theme.mobileS} {
padding: 5px;
}
`,
OrderInfoDetailContainer: styled.div`
display: flex;
align-items: center;
width: 100%;
height: 38px;
margin-top: 15px;
@media ${({ theme }) => theme.mobileS} {
font-size: 0.6rem;
margint-right: 10px;
}
`,
OrderInfoDetailTitle: styled.span`
display: block;
width: 20%;
min-width: 52px;
max-width: 100px;
font-size: 0.8rem;
font-weight: 600;
color: #666;
margin-left: 5px;
margin-right: 5px;
`,
OrderInfoInputContainer: styled.div`
display: flex;
width: 100%;
height: 100%;
`,
OrderInfoInput: styled.input`
width: ${({ width }) => width || "100%"};
height: 100%;
margin: 0;
padding: 5px;
padding-right: 15px;
border: 1px solid ${({ theme }) => theme.lightGray2};
text-align: right;
font-size: 0.95rem;
font-weight: ${({ fontWeight }) => fontWeight};
@media ${({ theme }) => theme.mobileS} {
font-size: 0.6rem;
}
`,
Button: styled.button`
width: ${({ width }) => width || "50px"};
min-width: ${({ minWidth }) => minWidth};
height: ${({ height }) => height || "38px"};
margin-right: ${({ marginRight }) => marginRight};
background-color: ${({ bgColor }) => bgColor || "tranceparent"};
border: none;
border-top: 1px solid ${({ borderColor }) => borderColor || "tranceparent"};
border-right: 1px solid
${({ borderColor }) => borderColor || "tranceparent"};
border-bottom: 1px solid
${({ borderColor }) => borderColor || "tranceparent"};
outline: none;
color: ${({ fontColor }) => fontColor || "black"};
font-size: ${({ fontSize }) => fontSize};
font-weight: 900;
`,
PossibleAmount: styled.span`
display: block;
width: 100%;
text-align: right;
font-size: 1.2rem;
font-weight: 600;
@media ${({ theme }) => theme.mobileS} {
font-size: 1rem;
}
`,
Unit: styled.span`
margin-left: 5px;
font-size: 0.8rem;
font-weight: 500;
`,
OrderBtnContainer: styled.div`
display: flex;
justify-content: space-between;
width: 100%;
margin-top: 50px;
@media ${({ theme }) => theme.mobileS} {
font-size: 0.8rem;
}
`,
};
const OrderInfoAskBid = ({
theme,
selectedAskBidOrder,
coinSymbol,
orderPrice,
orderAmount,
orderTotalPrice,
}) => {
const dispatch = useDispatch();
const changePrice = useCallback(
(e) =>
dispatch(
changePriceAndTotalPrice(
parseInt(e.target.value.replace(/[^0-9-.]/g, ""))
)
),
[dispatch]
);
const changeAmount = useCallback(
(e) => {
dispatch(
changeAmountAndTotalPrice(e.target.value.replace(/[^0-9-.]/g, ""))
);
},
[dispatch]
);
const changeTotalPrice = useCallback(
(e) =>
dispatch(
changeTotalPriceAndAmount(
parseInt(e.target.value.replace(/[^0-9-.]/g, ""))
)
),
[dispatch]
);
return (
<St.OrderInfoContainer theme={theme}>
{selectedAskBidOrder !== "tradeList" ? (
<>
<St.OrderInfoDetailContainer>
<St.OrderInfoDetailTitle>주문가능</St.OrderInfoDetailTitle>
<St.PossibleAmount>
0
<St.Unit>
{selectedAskBidOrder === "bid" ? "KRW" : coinSymbol}
</St.Unit>
</St.PossibleAmount>
</St.OrderInfoDetailContainer>
<St.OrderInfoDetailContainer>
<St.OrderInfoDetailTitle>
{selectedAskBidOrder === "bid" ? "매수가격" : "매도가격"}
</St.OrderInfoDetailTitle>
<St.OrderInfoInputContainer>
<St.OrderInfoInput
onChange={changePrice}
value={orderPrice ? orderPrice.toLocaleString() : ""}
fontWeight={800}
placeholder={0}
/>
<St.Button
bgColor={theme.lightGray}
borderColor={theme.lightGray2}
fontColor={"#666"}
fontSize={"1.1rem"}
>
+
</St.Button>
<St.Button
bgColor={theme.lightGray}
borderColor={theme.lightGray2}
fontColor={"#666"}
fontSize={"1.1rem"}
>
-
</St.Button>
</St.OrderInfoInputContainer>
</St.OrderInfoDetailContainer>
<St.OrderInfoDetailContainer>
<St.OrderInfoDetailTitle>주문수량</St.OrderInfoDetailTitle>
<St.OrderInfoInput
onChange={changeAmount}
value={orderAmount ? orderAmount.toLocaleString() : ""}
placeholder={0}
/>
</St.OrderInfoDetailContainer>
<St.OrderInfoDetailContainer>
<St.OrderInfoDetailTitle>주문총액</St.OrderInfoDetailTitle>
<St.OrderInfoInput
onChange={changeTotalPrice}
value={orderTotalPrice ? orderTotalPrice.toLocaleString() : ""}
placeholder={0}
/>
</St.OrderInfoDetailContainer>
</>
) : (
<OrderInfoTradeList theme={theme} />
)}
<St.OrderBtnContainer>
<St.Button
width={"30%"}
minWidth={"70px"}
marginRight={"5px"}
bgColor={theme.deepBlue}
fontSize={"0.9rem"}
fontColor={"white"}
>
회원가입
</St.Button>
<St.Button
width={"65%"}
bgColor={theme.priceDown}
fontSize={"0.9rem"}
fontColor={"white"}
>
로그인
</St.Button>
</St.OrderBtnContainer>
</St.OrderInfoContainer>
);
};
export default React.memo(OrderInfoAskBid);

View File

@@ -0,0 +1,21 @@
import React from "react";
import styled from "styled-components";
const St = {
Container: styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 212px;
background-color: white;
font-size: 0.8rem;
color: #666;
`,
};
const OrderInfoTradeList = ({ theme }) => {
return <St.Container>로그인 사용 가능합니다.</St.Container>;
};
export default React.memo(OrderInfoTradeList);

View File

@@ -0,0 +1,124 @@
import React from "react";
import { useSelector } from "react-redux";
import styled from "styled-components";
import OrderbookItem from "./OrderbookItem";
import Loading from "../Global/Loading";
import withThemeData from "../../Container/withThemeData";
import withSelectedCoinPrice from "../../Container/withSelectedCoinPrice";
import withOrderbookData from "../../Container/withOrderbookData";
import withSelectedOption from "../../Container/withSelectedOption";
import withLoadingData from "../../Container/withLoadingData";
import isEqual from "react-fast-compare";
const St = {
Container: styled.section`
width: 46%;
max-height: 722px;
height: 100%;
background-color: white;
`,
HiddenH3: styled.h3`
position: absolute;
width: 1px;
height: 1px;
clip: rect(0, 0);
clip-path: polygon(0, 0);
overflow: hidden;
text-indent: -9999px;
`,
OrderUl: styled.ul`
width: 100%;
height: 722px;
overflow-y: scroll;
scrollbar-color: ${({ theme }) => theme.middleGray};
scrollbar-width: thin;
scrollbar-base-color: transparent;
&::-webkit-scrollbar {
width: 5px;
background-color: transparent;
border-radius: 5rem;
}
&::-webkit-scrollbar-thumb {
background-color: ${({ theme }) => theme.middleGray};
border-radius: 5rem;
}
`,
};
const Orderbook = ({
theme,
// totalData,
askOrderbookData,
bidOrderbookData,
maxOrderSize,
beforeDayPrice,
selectedMarket,
isOrderbookLoading,
}) => {
const lastTradePrice = useSelector(
(state) =>
state.Coin.tradeList.data[selectedMarket] &&
state.Coin.tradeList.data[selectedMarket][0].trade_price
);
return (
<St.Container>
<St.HiddenH3>호가창</St.HiddenH3>
<St.OrderUl>
{isOrderbookLoading ? (
<Loading />
) : (
askOrderbookData.map((orderbook, i) => {
return (
<OrderbookItem
theme={theme}
price={orderbook.askPrice}
size={orderbook.askSize}
maxOrderSize={maxOrderSize}
// key={`askOrder-${orderbook.askPrice}`}
key={`askOrder-${i}`}
type={"ask"}
changeRate24Hour={(
((orderbook.askPrice - beforeDayPrice) / beforeDayPrice) *
100
).toFixed(2)}
index={i}
outline={lastTradePrice === orderbook.askPrice}
/>
);
})
)}
{isOrderbookLoading ||
bidOrderbookData.map((orderbook, i) => {
return (
<OrderbookItem
theme={theme}
price={orderbook.bidPrice}
size={orderbook.bidSize}
maxOrderSize={maxOrderSize}
// key={`bidOrder-${orderbook.bidPrice}`}
key={`bidOrder-${i}`}
type={"bid"}
changeRate24Hour={(
((orderbook.bidPrice - beforeDayPrice) / beforeDayPrice) *
100
).toFixed(2)}
index={i}
outline={lastTradePrice === orderbook.bidPrice}
/>
);
})}
</St.OrderUl>
</St.Container>
);
};
export default withOrderbookData()(
withSelectedCoinPrice()(
withSelectedOption()(
withLoadingData()(withThemeData()(React.memo(Orderbook, isEqual)))
)
)
);

View File

@@ -0,0 +1,64 @@
import React from "react";
import styled from "styled-components";
const St = {
CandleInfoContainer: styled.div`
display: flex;
flex-direction: column;
justify-content: flex-end;
width: 33.3333%;
border: 1px solid gray;
margin-top: -1px;
margin-left: -1px;
`,
InfoContainer: styled.div`
display: flex;
width: 90%;
font-size: 0.8rem;
padding: 5px;
`,
InfoTxt: styled.span`
display: block;
width: 50%;
`,
InfoValue: styled.span`
display: block;
width: 50%;
text-align: right;
`,
};
const OrderbookCoinInfo = ({
volume24,
tradePrice24,
highestPrice52Week,
highestDate52Week,
lowestPrice52Week,
lowestDate52Week,
}) => {
return (
<St.CandleInfoContainer>
<St.InfoContainer>
<St.InfoTxt>거래량</St.InfoTxt>
<St.InfoValue>{volume24}</St.InfoValue>
</St.InfoContainer>
<St.InfoContainer>
<St.InfoTxt>거래대금</St.InfoTxt>
<St.InfoValue>{`${tradePrice24}백만`}</St.InfoValue>
</St.InfoContainer>
<St.InfoContainer>
<St.InfoTxt>52 최고</St.InfoTxt>
<St.InfoValue>{`${highestPrice52Week} (${highestDate52Week})`}</St.InfoValue>
</St.InfoContainer>
<St.InfoContainer>
<St.InfoTxt>52 최저</St.InfoTxt>
<St.InfoValue>{`${lowestPrice52Week} (${lowestDate52Week})`}</St.InfoValue>
</St.InfoContainer>
</St.CandleInfoContainer>
);
};
export default React.memo(OrderbookCoinInfo);

View File

@@ -0,0 +1,169 @@
import React, { useEffect, useRef } from "react";
import { useDispatch } from "react-redux";
import { changePriceAndTotalPrice } from "../../Reducer/coinReducer";
import styled, { css } from "styled-components";
import isEqual from "react-fast-compare";
const St = {
OrderLi: styled.li`
display: flex;
width: 100%;
height: 45px;
&:nth-last-child() {
border-bottom: none;
}
font-size: 0.8rem;
@media ${({ theme }) => theme.mobileS} {
font-size: 0.7rem;
}
`,
Btn: styled.button`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: transparent;
border: none;
outline: none;
padding: 0;
margin: 0;
cursor: pointer;
`,
OrderAmount: styled.div`
display: flex;
justify-content: flex-start;
align-items: center;
position: relative;
width: 50%;
height: 45px;
border: 1px solid ${({ borderColor }) => borderColor};
padding-left: 5px;
padding-right: 10px;
margin-top: -1px;
margin-left: -1px;
text-align: right;
`,
OrderAmountSize: styled.div`
position: absolute;
width: ${({ witdhSize }) => witdhSize};
left: 0;
height: 70%;
background-color: ${({ bgColor }) => bgColor};
`,
OrderPriceContainer: styled.div`
display: flex;
justify-content: center;
align-items: center;
position: relative;
width: 50%;
height: 45px;
border: 1px solid ${({ borderColor }) => borderColor};
margin-top: -1px;
margin-left: -1px;
text-align: right;
color: ${({ fontColor }) => fontColor};
background-color: ${({ bgColor }) => bgColor};
${({ outline }) =>
outline &&
css`
border: 2px solid black;
border-right: 3px solid black;
&::after {
content: "";
display: block;
position: absolute;
left: -5px;
width: 0px;
height: 0px;
border-right: 10px solid transparent;
border-bottom: 10px solid black;
transform: rotate(225deg);
-ms-transform: rotate(225deg);
-webkit-transform: rotate(225deg);
-moz-transform: rotate(225deg);
-o-transform: rotate(225deg);
}
`}
@media ${({ theme }) => theme.mobileM} {
flex-direction: column;
}
`,
OrderPrice: styled.strong`
font-weight: 800;
`,
OrderPrcieRatio: styled.span`
padding-left: 13px;
`,
};
const OrderbookItem = ({
theme,
price,
size,
maxOrderSize,
type,
changeRate24Hour,
index,
outline,
}) => {
const dispatch = useDispatch();
const scrollRef = useRef();
useEffect(() => {
if (index === 7 && type === "ask") {
const parentNode = scrollRef.current.parentNode;
const parentAbsoluteTop = window.pageYOffset + parentNode.offsetTop;
const absoluteTop = window.pageYOffset + scrollRef.current.offsetTop;
const relativeTop = absoluteTop - parentAbsoluteTop;
scrollRef.current.parentNode.scrollTop = relativeTop;
}
}, []);
return (
<St.OrderLi ref={scrollRef} theme={theme}>
<St.Btn
onClick={(_) => {
document.activeElement.blur();
dispatch(changePriceAndTotalPrice(price));
}}
>
<St.OrderPriceContainer
theme={theme}
fontColor={
changeRate24Hour > 0
? theme.priceUp
: +changeRate24Hour < 0
? theme.priceDown
: "black"
}
borderColor={theme.lightGray}
bgColor={type === "ask" ? theme.skyBlue1 : theme.lightPink1}
// outline={lastTradePrice === price}
outline={outline}
>
<St.OrderPrice>{price.toLocaleString()}</St.OrderPrice>
<St.OrderPrcieRatio>{`${changeRate24Hour}%`}</St.OrderPrcieRatio>
</St.OrderPriceContainer>
<St.OrderAmount amountAlign={"left"} borderColor={theme.lightGray}>
{size}
<St.OrderAmountSize
witdhSize={`${Math.floor((size / maxOrderSize) * 100 - 10)}%`}
bgColor={type === "ask" ? theme.skyBlue2 : theme.lightPink2}
/>
</St.OrderAmount>
</St.Btn>
</St.OrderLi>
);
};
export default React.memo(OrderbookItem, isEqual);

View File

@@ -0,0 +1,122 @@
import React from "react";
import styled from "styled-components";
import Decimal from "decimal.js";
import moment from "moment-timezone";
import TradeListItem from "./TradeListItem";
import Loading from "../Global/Loading";
import withTradeListData from "../../Container/withTradeListData";
import withThemeData from "../../Container/withThemeData";
import withLoadingData from "../../Container/withLoadingData";
const St = {
Container: styled.article`
width: 100%;
height: 100%;
background-color: white;
margin-top: 10px;
@media ${({ theme }) => theme.mobileM} {
margin-top: 0;
}
`,
HiddenH3: styled.h3`
position: absolute;
width: 1px;
height: 1px;
clip: rect(0, 0);
clip-path: polygon(0, 0);
overflow: hidden;
text-indent: -9999px;
`,
TradeListUL: styled.ul`
overflow-y: scroll;
scrollbar-color: ${(props) => props.scrollColor};
scrollbar-width: thin;
scrollbar-base-color: ${(props) => props.scrollColor};
&::-webkit-scrollbar {
width: 5px;
background-color: white;
border-radius: 5rem;
}
&::-webkit-scrollbar-thumb {
background-color: ${(props) => props.scrollColor};
border-radius: 5rem;
}
height: 320px;
`,
TradeListTitle: styled.ul`
display: flex;
justify-content: space-around;
align-items: center;
height: 25px;
background-color: ${({ theme }) => theme.lightGray1};
font-size: 0.9rem;
@media ${({ theme }) => theme.mobileS} {
font-size: 0.6rem;
}
`,
TitleListItem: styled.li`
width: 20%;
min-width: 58px;
text-align: ${({ textAlign }) => textAlign || "center"};
@media ${({ theme, mobileSNone }) => (mobileSNone ? theme.mobileS : true)} {
display: none;
}
@media ${({ theme, mobileMNone }) => (mobileMNone ? theme.mobileM : true)} {
display: none;
}
@media ${({ theme, mobileSNone }) => mobileSNone || theme.mobileS} {
width: 50%;
}
`,
};
const TradeList = ({ theme, selectedTradeListData, isTradeListLoading }) => {
return (
<St.Container>
<St.HiddenH3>실시간 체결내역</St.HiddenH3>
<St.TradeListTitle bgColor={theme.lightGray1}>
<St.TitleListItem mobileSNone={true} textAlign={"center"}>
체결시간
</St.TitleListItem>
<St.TitleListItem>체결가격</St.TitleListItem>
<St.TitleListItem>체결량</St.TitleListItem>
<St.TitleListItem mobileMNone={true} textAlign={"right"}>
체결금액
</St.TitleListItem>
</St.TradeListTitle>
<St.TradeListUL scrollColor={theme.middleGray}>
{isTradeListLoading || !selectedTradeListData ? (
<Loading />
) : (
selectedTradeListData.map((tradeList, i) => {
const tradeAmount = new Decimal(tradeList.trade_volume) + "";
return (
<TradeListItem
theme={theme}
index={i}
// key={`tradeList-${tradeList.sequential_id}`}
key={`tradeList-${i}`}
date={moment(tradeList.timestamp).format("MM.DD")}
time={moment(tradeList.timestamp).format("HH:mm")}
tradePrice={tradeList.trade_price}
changePrice={tradeList.change_price}
tradeAmount={+tradeAmount}
askBid={tradeList.ask_bid}
/>
);
})
)}
</St.TradeListUL>
</St.Container>
);
};
export default withTradeListData()(
withLoadingData()(withThemeData()(React.memo(TradeList)))
);

View File

@@ -0,0 +1,119 @@
import React from "react";
import styled from "styled-components";
import isEqual from "react-fast-compare";
const St = {
TradeListLi: styled.li`
display: flex;
justify-content: space-around;
align-items: center;
width: 100%;
height: 25px;
font-size: 0.9em;
background-color: ${({ bgColor }) => bgColor || "white"};
`,
Datetime: styled.div`
width: 20%;
text-align: center;
@media ${({ theme }) => theme.mobileS} {
display: none;
}
`,
Date: styled.span`
text-align: center;
`,
Time: styled.span`
text-align: center;
font-size: 0.8rem;
margin-left: 5px;
`,
TradePrice: styled.span`
display: block;
width: 20%;
text-align: center;
color: ${({ fontColor }) => fontColor};
font-weight: 600;
@media ${({ theme }) => theme.mobileS} {
width: 50%;
font-size: 0.7rem;
}
@media ${({ theme }) => theme.mobileM} {
/* width: 50%; */
font-size: 0.8rem;
}
`,
TradeAmount: styled.span`
display: block;
width: 20%;
text-align: center;
color: ${({ fontColor }) => fontColor};
@media ${({ theme }) => theme.mobileS} {
width: 50%;
font-size: 0.7rem;
}
@media ${({ theme }) => theme.mobileM} {
/* width: 50%; */
font-size: 0.8rem;
}
`,
TradeKRW: styled.span`
display: block;
width: 20%;
text-align: right;
@media ${({ theme }) => theme.mobileS} {
display: none;
}
@media ${({ theme }) => theme.mobileM} {
display: none;
}
`,
};
const TradeListItem = ({
theme,
index,
date,
time,
tradePrice,
changePrice,
tradeAmount,
askBid,
}) => {
return (
<St.TradeListLi
bgColor={index % 2 ? theme.lightGray1 : "white"}
index={index}
>
<St.Datetime>
<St.Date>{date}</St.Date>
<St.Time>{time}</St.Time>
</St.Datetime>
<St.TradePrice
fontColor={changePrice > 0 ? theme.priceUp : theme.priceDown}
>
{tradePrice.toLocaleString()}
</St.TradePrice>
<St.TradeAmount
theme={theme}
fontColor={askBid === "BID" ? theme.priceUp : theme.priceDown}
>
{tradeAmount.toFixed(5)}
</St.TradeAmount>
<St.TradeKRW theme={theme}>
{Math.floor(tradePrice * tradeAmount).toLocaleString()}
</St.TradeKRW>
</St.TradeListLi>
);
};
export default React.memo(TradeListItem, isEqual);

View File

@@ -0,0 +1,33 @@
import React from "react";
import { useSelector } from "react-redux";
const withLatestCoinData = () => (OriginalComponent) => (props) => {
const coinListDatas = useSelector((state) => state.Coin.candle.data); // 코인들 데이터
const latestCoinData = {};
if (Object.keys(coinListDatas).length > 2) {
Object.keys(coinListDatas).forEach((marketName) => {
latestCoinData[marketName] = {};
latestCoinData[marketName].price =
coinListDatas[marketName].candles[
coinListDatas[marketName].candles.length - 1
].close;
latestCoinData[marketName].changeRate24Hour = (
Math.round(coinListDatas[marketName].changeRate24Hour * 10000) / 100
).toFixed(2);
latestCoinData[marketName].changePrice24Hour =
coinListDatas[marketName].changePrice24Hour;
latestCoinData[marketName].tradePrice24Hour = Math.floor(
coinListDatas[marketName].tradePrice24Hour / 1000000
);
});
}
return <OriginalComponent {...props} latestCoinData={latestCoinData} />;
};
export default withLatestCoinData;

View File

@@ -0,0 +1,33 @@
import React from "react";
import { useSelector } from "react-redux";
const withLoadingData = () => (OriginalComponent) => (props) => {
const isCandleLoading = useSelector(
(state) => state.Loading["coin/GET_ONE_COIN_CANDLES"]
);
const isOrderbookLoading = useSelector(
(state) => state.Loading["coin/GET_INIT_ORDERBOOKS"]
);
const isTradeListLoading = useSelector(
(state) => state.Loading["coin/GET_ONE_COIN_TRADELISTS"]
);
const isInitCandleLoading = useSelector(
(state) => state.Loading["coin/GET_INIT_CANDLES"]
);
const isMarketNamesLoading = useSelector(
(state) => state.Loading["coin/GET_MARKET_NAMES"]
);
return (
<OriginalComponent
{...props}
isCandleLoading={isCandleLoading}
isOrderbookLoading={isOrderbookLoading}
isTradeListLoading={isTradeListLoading}
isInitCandleLoading={isInitCandleLoading}
isMarketNamesLoading={isMarketNamesLoading}
/>
);
};
export default withLoadingData;

View File

@@ -0,0 +1,52 @@
import React from "react";
import { useSelector } from "react-redux";
import * as Hangul from "hangul-js";
import { choHangul } from "../Lib/utils";
const withMarketNames = () => (OriginalComponent) => (props) => {
const marketNames = useSelector((state) => state.Coin.marketNames.data); // 코인 마켓 이름들(객체)
let marketNamesArr = Object.keys(marketNames); // 코인 마켓 이름 배열화
const coinListDatas = useSelector((state) => state.Coin.candle.data); // 코인들 데이터
const coinSearchInputData = useSelector((state) => state.Coin.searchCoin); // 검색한 코인 이름
// 데이터 받는 데 성공하면 필터링 및 정렬한다
if (Object.keys(coinListDatas).length > 1) {
// 검색 기준 필터링
marketNamesArr = marketNamesArr.filter(
(coin) =>
// 영어 검색
marketNames[coin].english
.toLowerCase()
.includes(coinSearchInputData.toLowerCase()) ||
// 코인 심볼 검색
coin
.split("-")[1]
.toLowerCase()
.includes(coinSearchInputData.toLowerCase()) ||
// 한글 검색
Hangul.disassembleToString(marketNames[coin].korean).includes(
Hangul.disassembleToString(coinSearchInputData)
) ||
// 초성 검색
choHangul(marketNames[coin].korean).includes(coinSearchInputData)
);
// 정렬
marketNamesArr = marketNamesArr.sort((coin1, coin2) => {
return (
+coinListDatas[coin2].tradePrice24Hour -
+coinListDatas[coin1].tradePrice24Hour
);
});
}
return (
<OriginalComponent
{...props}
marketNames={marketNames}
sortedMarketNames={marketNamesArr}
/>
);
};
export default withMarketNames;

View File

@@ -0,0 +1,18 @@
import React from "react";
import { useSelector } from "react-redux";
const withOHLCData = () => (OriginalComponent) => () => {
const selectedMarket = useSelector((state) => state.Coin.selectedMarket); // 선택된 코인/마켓
const selectedCandles = useSelector(
(state) => state.Coin.candle.data[selectedMarket].candles
); // 선택된 코인/마켓 캔들 정보
// return selectedCandles.length ? (
// <OriginalComponent data={selectedCandles} />
// ) : (
// <div className="center">Chart Loading</div>
// );
return <OriginalComponent data={selectedCandles} />;
};
export default withOHLCData;

View File

@@ -0,0 +1,59 @@
import React from "react";
import { useSelector } from "react-redux";
const withOrderbookData = () => (OriginalComponent) => (props) => {
const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
const orderbook = useSelector(
(state) => state.Coin.orderbook.data[selectedMarket]
);
let totalData;
let bidOrderbookData;
let askOrderbookData;
let orderbookData;
let maxOrderSize = 0;
if (orderbook) {
totalData = {
totalBidSize: orderbook.total_bid_size,
totalAskSize: orderbook.total_ask_size,
};
bidOrderbookData = [];
askOrderbookData = [];
// let maxOrderSize = 0;
// 호가 데이터 분리 정렬
orderbook.orderbook_units.forEach((orderbook, i) => {
const bidSize = orderbook.bid_size.toFixed(3);
const askSize = orderbook.ask_size.toFixed(3);
bidOrderbookData.push({
bidPrice: orderbook.bid_price,
bidSize: orderbook.bid_size.toFixed(3),
});
askOrderbookData.push({
askPrice: orderbook.ask_price,
askSize: orderbook.ask_size.toFixed(3),
});
maxOrderSize = Math.max(maxOrderSize, bidSize, askSize);
});
orderbookData = [...askOrderbookData, ...bidOrderbookData];
// 매도 호가창은 가격 내림차순으로 정렬해줌 (매수는 원래 가격 내림차순임)
askOrderbookData.sort((book1, book2) => +book2.askPrice - +book1.askPrice);
}
return (
<OriginalComponent
{...props}
totalData={totalData || []}
orderbookData={orderbookData || []}
bidOrderbookData={bidOrderbookData || []}
askOrderbookData={askOrderbookData || []}
maxOrderSize={maxOrderSize || 0}
/>
);
};
export default withOrderbookData;

View File

@@ -0,0 +1,28 @@
import React from "react";
import { useSelector } from "react-redux";
const withSelectedCoinName = () => (OriginalComponent) => (props) => {
const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
const coinNameKor = useSelector(
(state) => state.Coin.marketNames.data[selectedMarket].korean
);
const coinNameEng = useSelector(
(state) => state.Coin.marketNames.data[selectedMarket].english
);
const splitedName = selectedMarket.split("-");
const coinSymbol = splitedName[1];
const coinNameAndMarketEng = splitedName[1] + "/" + splitedName[0];
return (
<OriginalComponent
{...props}
coinNameKor={coinNameKor}
coinNameEng={coinNameEng}
coinSymbol={coinSymbol}
coinNameAndMarketEng={coinNameAndMarketEng}
/>
);
};
export default withSelectedCoinName;

View File

@@ -0,0 +1,76 @@
import React from "react";
import { useSelector } from "react-redux";
const withSelectedCoinPrice = () => (OriginalComponent) => (props) => {
const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
const selectedCoinData = useSelector(
(state) => state.Coin.candle.data[selectedMarket]
);
// 24시간 고가 저가
const highestPrice24Hour = useSelector(
(state) => state.Coin.candle.data[selectedMarket]["highestPrice24Hour"]
);
const lowestPrice24Hour = useSelector(
(state) => state.Coin.candle.data[selectedMarket]["lowestPrice24Hour"]
);
// 52주 고가 저가
const highestPrice52Week = useSelector(
(state) => state.Coin.candle.data[selectedMarket].highestPrice52Week
);
const highestDate52Week = useSelector(
(state) => state.Coin.candle.data[selectedMarket].highestDate52Week
);
const lowestPrice52Week = useSelector(
(state) => state.Coin.candle.data[selectedMarket].lowestPrice52Week
);
const lowestDate52Week = useSelector(
(state) => state.Coin.candle.data[selectedMarket].lowestDate52Week
);
// 24시간 거래대금, 거래량
const tradePrice24Hour = Math.floor(selectedCoinData.tradePrice24Hour);
const volume24Hour = Math.floor(selectedCoinData.volume24Hour);
// 24시간 가격 변화율, 변화량
const changeRate24Hour =
Math.round(selectedCoinData.changeRate24Hour * 10000) / 100;
const changePrice24Hour = selectedCoinData.changePrice24Hour
? selectedCoinData.changePrice24Hour
: 0;
// 전일, 당일 가격
const selecteCoinCadnles = useSelector(
(state) => state.Coin.candle.data[selectedMarket].candles
);
const lastCandleIndex = selecteCoinCadnles.length - 1;
const beforeDayPrice = selecteCoinCadnles.length
? selecteCoinCadnles[lastCandleIndex].close - changePrice24Hour
: 0;
const price = selecteCoinCadnles.length
? selecteCoinCadnles[selecteCoinCadnles.length - 1].close
: 0;
return (
<OriginalComponent
{...props}
highestPrice52Week={highestPrice52Week}
highestDate52Week={highestDate52Week}
lowestPrice52Week={lowestPrice52Week}
lowestDate52Week={lowestDate52Week}
highestPrice24Hour={highestPrice24Hour}
lowestPrice24Hour={lowestPrice24Hour}
tradePrice24Hour={tradePrice24Hour}
volume24Hour={volume24Hour}
changeRate24Hour={changeRate24Hour}
changePrice24Hour={changePrice24Hour}
beforeDayPrice={beforeDayPrice}
price={price}
/>
);
};
export default withSelectedCoinPrice;

View File

@@ -0,0 +1,33 @@
import React from "react";
import { useSelector } from "react-redux";
const withSelectedOption = () => (OriginalComponent) => (props) => {
const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
const selectedTimeType = useSelector((state) => state.Coin.selectedTimeType);
const selectedTimeCount = useSelector(
(state) => state.Coin.selectedTimeCount
);
const selectedAskBidOrder = useSelector(
(state) => state.Coin.selectedAskBidOrder
);
const searchCoin = useSelector((state) => state.Coin.searchCoin);
const orderPrice = useSelector((state) => state.Coin.orderPrice);
const orderAmount = useSelector((state) => state.Coin.orderAmount);
const orderTotalPrice = useSelector((state) => state.Coin.orderTotalPrice);
return (
<OriginalComponent
{...props}
selectedMarket={selectedMarket}
selectedTimeType={selectedTimeType}
selectedTimeCount={selectedTimeCount}
selectedAskBidOrder={selectedAskBidOrder}
searchCoinInput={searchCoin}
orderPrice={orderPrice}
orderAmount={orderAmount}
orderTotalPrice={orderTotalPrice}
/>
);
};
export default withSelectedOption;

29
src/Container/withSize.js Normal file
View File

@@ -0,0 +1,29 @@
import { throttle } from "lodash";
import React, { useCallback, useEffect, useState } from "react";
const withSize = () => (OriginalComponent) => (props) => {
const [widthSize, setWidthSize] = useState(window.innerWidth);
const [heightSize, setHeightSize] = useState(window.innerHeight);
const handleSize = useCallback(() => {
setWidthSize(window.innerWidth);
setHeightSize(window.innerHeight);
}, []);
useEffect(() => {
window.addEventListener("resize", throttle(handleSize, 200));
return () => {
window.removeEventListener("resize", handleSize);
};
}, [handleSize]);
return (
<OriginalComponent
{...props}
widthSize={widthSize}
heightSize={heightSize}
/>
);
};
export default withSize;

View File

@@ -0,0 +1,9 @@
import React, { useContext } from "react";
import { ThemeContext } from "styled-components";
const withThemeData = () => (OriginalComponent) => (props) => {
const theme = useContext(ThemeContext); // 테마 정보
return <OriginalComponent {...props} theme={theme} />;
};
export default withThemeData;

View File

@@ -0,0 +1,18 @@
import React from "react";
import { useSelector } from "react-redux";
const withTradeListData = () => (OriginalComponent) => (props) => {
const selectedMarket = useSelector((state) => state.Coin.selectedMarket);
const selectedTradeListData = useSelector(
(state) => state.Coin.tradeList.data[selectedMarket]
);
return (
<OriginalComponent
{...props}
selectedTradeListData={selectedTradeListData}
/>
);
};
export default withTradeListData;

290
src/Lib/asyncUtil.js Normal file
View File

@@ -0,0 +1,290 @@
import { w3cwebsocket as W3CWebSocket } from "websocket";
import { call, put, select, flush, delay } from "redux-saga/effects";
import { startLoading, finishLoading } from "../Reducer/loadingReducer";
import { throttle } from "lodash";
import { buffers, eventChannel, END } from "redux-saga";
import encoding from "text-encoding";
// 캔들용 사가
const createRequestSaga = (type, api, dataMaker) => {
const SUCCESS = `${type}_SUCCESS`;
const ERROR = `${type}_ERROR`;
return function* (action = {}) {
yield put(startLoading(type));
try {
const res = yield call(api, action.payload);
const state = yield select();
yield put({ type: SUCCESS, payload: dataMaker(res.data, state) });
yield put(finishLoading(type));
} catch (e) {
yield put({ type: ERROR, payload: e });
yield put(finishLoading(type));
throw e;
}
};
};
// 선택 옵션 변경용 사가
const createChangeOptionSaga = (type) => {
const SUCCESS = `${type}_SUCCESS`;
return function* (action = {}) {
yield put({ type: SUCCESS, payload: action.payload });
};
};
// 웹소켓 연결용 Thunk
const createConnectSocketThunk = (type, connectType, dataMaker) => {
const SUCCESS = `${type}_SUCCESS`;
const ERROR = `${type}_ERROR`;
return (action = {}) => (dispatch, getState) => {
const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
client.binaryType = "arraybuffer";
client.onopen = () => {
client.send(
JSON.stringify([
{ ticket: "downbit-clone" },
{ type: connectType, codes: action.payload },
])
);
};
client.onmessage = (evt) => {
const enc = new encoding.TextDecoder("utf-8");
const arr = new Uint8Array(evt.data);
const data = JSON.parse(enc.decode(arr));
const state = getState();
dispatch({ type: SUCCESS, payload: dataMaker(data, state) });
};
client.onerror = (e) => {
dispatch({ type: ERROR, payload: e });
};
};
};
// 웹소켓 연결용 Thunk
const createConnectSocketThrottleThunk = (type, connectType, dataMaker) => {
const SUCCESS = `${type}_SUCCESS`;
const ERROR = `${type}_ERROR`;
const throttleDispatch = throttle((dispatch, state, data) => {
dispatch({ type: SUCCESS, payload: dataMaker(data, state) });
}, 500);
return (action = {}) => (dispatch, getState) => {
const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
client.binaryType = "arraybuffer";
client.onopen = () => {
client.send(
JSON.stringify([
{ ticket: "downbit-clone" },
{ type: connectType, codes: action.payload },
])
);
};
client.onmessage = (evt) => {
const enc = new encoding.TextDecoder("utf-8");
const arr = new Uint8Array(evt.data);
const data = JSON.parse(enc.decode(arr));
const state = getState();
// dispatch({ type: SUCCESS, payload: dataMaker(data, state) });
throttleDispatch(dispatch, state, data);
};
client.onerror = (e) => {
dispatch({ type: ERROR, payload: e });
};
};
};
// 소켓 만들기
const createSocket = () => {
const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
client.binaryType = "arraybuffer";
return client;
};
// 소켓 연결용
const connectSocekt = (socket, connectType, action, buffer) => {
return eventChannel((emit) => {
socket.onopen = () => {
socket.send(
JSON.stringify([
{ ticket: "downbit-clone" },
{ type: connectType, codes: action.payload },
])
);
};
socket.onmessage = (evt) => {
const enc = new encoding.TextDecoder("utf-8");
// const arr = new Uint8Array(evt.data);
const data = JSON.parse(enc.decode(evt.data));
emit(data);
};
socket.onerror = (evt) => {
emit(evt);
emit(END);
};
const unsubscribe = () => {
socket.close();
};
return unsubscribe;
}, buffer || buffers.none());
};
// 웹소켓 연결용 사가
const createConnectSocketSaga = (type, connectType, dataMaker) => {
const SUCCESS = `${type}_SUCCESS`;
const ERROR = `${type}_ERROR`;
return function* (action = {}) {
const client = yield call(createSocket);
const clientChannel = yield call(
connectSocekt,
client,
connectType,
action,
buffers.expanding(500)
);
try {
while (true) {
const datas = yield flush(clientChannel); // 버퍼 데이터 가져오기
const state = yield select();
if (datas.length) {
const sortedObj = {};
datas.forEach((data) => {
if (sortedObj[data.code]) {
// 버퍼에 있는 데이터중 시간이 가장 최근인 데이터만 남김
sortedObj[data.code] =
sortedObj[data.code].timestamp > data.timestamp
? sortedObj[data.code]
: data;
} else {
sortedObj[data.code] = data; // 새로운 데이터면 그냥 넣음
}
});
const sortedData = Object.keys(sortedObj).map(
(data) => sortedObj[data]
);
yield put({ type: SUCCESS, payload: dataMaker(sortedData, state) });
}
yield delay(500); // 500ms 동안 대기
}
} catch (e) {
yield put({ type: ERROR, payload: e });
} finally {
clientChannel.close(); // emit(END) 접근시 소켓 닫기
}
};
};
const reducerUtils = {
success: (state, payload, key) => {
return {
...state,
[key]: {
data: payload,
error: false,
},
};
},
error: (state, error, key) => ({
...state,
[key]: {
...state[key],
error: error,
},
}),
};
const requestActions = (type, key) => {
const [SUCCESS, ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];
return (state, action) => {
switch (action.type) {
case SUCCESS:
return reducerUtils.success(state, action.payload, key);
case ERROR:
return reducerUtils.error(state, action.payload, key);
default:
return state;
}
};
};
const requestInitActions = (type, key) => {
const [SUCCESS, ERROR] = [`${type}_SUCCESS`, `${type}_ERROR`];
return (state, action) => {
switch (action.type) {
case SUCCESS:
return {
...state,
candleDay: {
data: action.payload,
error: false,
},
[key]: {
data: action.payload,
error: false,
},
};
case ERROR:
return {
...state,
candleDay: {
...state.candleDay,
error: action.payload,
},
[key]: {
...state[key],
error: action.payload,
},
};
default:
return state;
}
};
};
const changeOptionActions = (type, key) => {
const SUCCESS = `${type}_SUCCESS`;
return (state, action) => {
switch (action.type) {
case SUCCESS:
return {
...state,
[key]: action.payload,
};
default:
return state;
}
};
};
export {
createRequestSaga,
createConnectSocketThunk,
createConnectSocketThrottleThunk,
createConnectSocketSaga,
createChangeOptionSaga,
requestActions,
requestInitActions,
changeOptionActions,
};

529
src/Lib/utils.js Normal file
View File

@@ -0,0 +1,529 @@
import moment from "moment-timezone";
import * as d3 from "d3";
const dateFormat = d3.timeParse("%Y-%m-%d %H:%M");
const timestampToDatetime = (timeType, timeCount, timestamp) => {
switch (timeType) {
case "minute":
case "minutes":
return (
moment(timestamp)
.minute(
Math.floor(moment(timestamp).minute() / timeCount) * timeCount
)
.second(0)
// .tz("Asia/Seoul")
.format("YYYY-MM-DD HH:mm")
);
case "hour":
case "hours":
return (
moment(timestamp)
.hour(Math.floor(moment(timestamp).hour() / timeCount) * timeCount)
.minute(0)
.second(0)
// .tz("Asia/Seoul")
.format("YYYY-MM-DD HH:mm")
);
case "day":
case "days":
return moment(timestamp)
.hour(9)
.minute(0)
.second(0)
.format("YYYY-MM-DD HH:mm");
case "week":
case "weeks":
return moment(timestamp)
.hour(0)
.minute(0)
.second(0)
.format("YYYY-MM-DD HH:mm");
default:
return undefined;
}
};
const candleDataUtils = {
init: (candles, state) => {
const selectedTimeType = state.Coin.selectedTimeType;
const selectedTimeCount = state.Coin.selectedTimeCount;
const data = {};
candles.forEach((candle) => {
data[candle.market] = {};
data[candle.market]["candles"] = [];
data[candle.market]["candles"].push({
date: dateFormat(
timestampToDatetime(
selectedTimeType,
selectedTimeCount,
candle.timestamp
)
),
datetime: timestampToDatetime(
selectedTimeType,
selectedTimeCount,
candle.timestamp
),
timestamp: candle.timestamp,
open: candle.opening_price,
high: candle.high_price,
low: candle.low_price,
close: candle.trade_price,
volume: candle.acc_trade_volume,
tradePrice: candle.acc_trade_price,
});
data[candle.market]["tradePrice24Hour"] = candle.acc_trade_price_24h;
data[candle.market]["volume24Hour"] = candle.acc_trade_volume_24h;
data[candle.market]["changeRate24Hour"] = candle.signed_change_rate;
data[candle.market]["changePrice24Hour"] = candle.signed_change_price;
data[candle.market]["highestPrice24Hour"] = candle.high_price;
data[candle.market]["lowestPrice24Hour"] = candle.low_price;
data[candle.market]["highestPrice52Week"] = candle.highest_52_week_price;
data[candle.market]["highestDate52Week"] = candle.highest_52_week_date;
data[candle.market]["lowestPrice52Week"] = candle.lowest_52_week_price;
data[candle.market]["lowestDate52Week"] = candle.lowest_52_week_date;
});
return data;
},
update: (candle, state) => {
const candleStateDatas = state.Coin.candle.data;
const selectedTimeType = state.Coin.selectedTimeType;
const selectedTimeCount = state.Coin.selectedTimeCount;
const coinMarket = candle.code;
const targetCandles = candleStateDatas[coinMarket].candles;
const lastCandle = targetCandles.slice(-1)[0];
const date = dateFormat(
timestampToDatetime(selectedTimeType, selectedTimeCount, candle.timestamp)
);
const datetime = timestampToDatetime(
selectedTimeType,
selectedTimeCount,
candle.timestamp
);
const open = lastCandle.open;
const high =
candle.trade_price > lastCandle.high
? candle.trade_price
: lastCandle.high;
const low =
candle.trade_price < lastCandle.low ? candle.trade_price : lastCandle.low;
const close = candle.trade_price;
const highestPrice24Hour = candleStateDatas[coinMarket].highestPrice24Hour;
const lowestPrice24Hour = candleStateDatas[coinMarket].lowestPrice24Hour;
const needUpdate = targetCandles.find(
(candle) => candle.datetime === datetime
);
const dateChanged =
d3.timeParse("YYYY-MM-DD")(lastCandle.date) !==
d3.timeParse("YYYY-MM-DD")(datetime);
const newData = { ...candleStateDatas }; // 원본 데이터 보장
if (needUpdate) {
const volume = needUpdate.volume + candle.trade_volume;
const tradePrice = needUpdate.tradePrice + candle.trade_price;
const updatedCandles = [...targetCandles];
updatedCandles.pop();
updatedCandles.push({
date,
datetime,
timestamp: candle.timestamp,
open,
high,
low,
close,
volume,
tradePrice,
});
newData[coinMarket]["candles"] = updatedCandles;
newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h;
newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h;
newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate;
newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price;
newData[coinMarket]["highestPrice24Hour"] =
high > highestPrice24Hour ? high : highestPrice24Hour;
newData[coinMarket]["lowestPrice24Hour"] =
low < lowestPrice24Hour ? low : lowestPrice24Hour;
newData[coinMarket]["highestPrice52Week"] = candle.highest_52_week_price;
newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date;
newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price;
newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date;
} else {
const volume = candle.trade_volume;
const tradePrice = candle.trade_price;
newData[coinMarket]["candles"] = [
...targetCandles,
{
date,
datetime,
timestamp: candle.timestamp,
dateKst: candle.trade_date_kst,
timeKst: candle.trade_time_kst,
open: close,
high: close,
low: close,
close,
volume,
tradePrice,
},
];
newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h;
newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h;
newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate;
newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price;
newData[coinMarket]["highestPrice24Hour"] = dateChanged // 날짜가 바뀌지 않았을때만 고점 갱신기록, 날짜 바뀌면 지금 고점 기록
? high
: high > highestPrice24Hour
? high
: highestPrice24Hour;
newData[coinMarket]["lowestPrice24Hour"] = dateChanged
? low
: low < lowestPrice24Hour
? low
: lowestPrice24Hour;
newData[coinMarket]["highestPrice52Week"] = candle.highest_52_week_price;
newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date;
newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price;
newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date;
}
return newData;
},
updates: (candles, state) => {
const candleStateDatas = state.Coin.candle.data;
const selectedTimeType = state.Coin.selectedTimeType;
const selectedTimeCount = state.Coin.selectedTimeCount;
const newData = { ...candleStateDatas }; // 원본 데이터 보장
candles.forEach((candle) => {
const coinMarket = candle.code;
const targetCandles = candleStateDatas[coinMarket].candles;
const lastCandle = targetCandles.slice(-1)[0];
const date = dateFormat(
timestampToDatetime(
selectedTimeType,
selectedTimeCount,
candle.timestamp
)
);
const datetime = timestampToDatetime(
selectedTimeType,
selectedTimeCount,
candle.timestamp
);
const open = lastCandle.open;
const high =
candle.trade_price > lastCandle.high
? candle.trade_price
: lastCandle.high;
const low =
candle.trade_price < lastCandle.low
? candle.trade_price
: lastCandle.low;
const close = candle.trade_price;
const highestPrice24Hour =
candleStateDatas[coinMarket].highestPrice24Hour;
const lowestPrice24Hour = candleStateDatas[coinMarket].lowestPrice24Hour;
const needUpdate = targetCandles.find(
(candle) => candle.datetime === datetime
);
const dateChanged =
d3.timeParse("YYYY-MM-DD")(lastCandle.date) !==
d3.timeParse("YYYY-MM-DD")(datetime);
if (needUpdate) {
const volume = needUpdate.volume + candle.trade_volume;
const tradePrice = needUpdate.tradePrice + candle.trade_price;
const updatedCandles = [...targetCandles];
updatedCandles.pop();
updatedCandles.push({
date,
datetime,
timestamp: candle.timestamp,
open,
high,
low,
close,
volume,
tradePrice,
});
newData[coinMarket]["candles"] = updatedCandles;
newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h;
newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h;
newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate;
newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price;
newData[coinMarket]["highestPrice24Hour"] =
high > highestPrice24Hour ? high : highestPrice24Hour;
newData[coinMarket]["lowestPrice24Hour"] =
low < lowestPrice24Hour ? low : lowestPrice24Hour;
newData[coinMarket]["highestPrice52Week"] =
candle.highest_52_week_price;
newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date;
newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price;
newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date;
} else {
const volume = candle.trade_volume;
const tradePrice = candle.trade_price;
newData[coinMarket]["candles"] = [
...targetCandles,
{
date,
datetime,
timestamp: candle.timestamp,
dateKst: candle.trade_date_kst,
timeKst: candle.trade_time_kst,
open: close,
high: close,
low: close,
close,
volume,
tradePrice,
},
];
newData[coinMarket]["tradePrice24Hour"] = candle.acc_trade_price_24h;
newData[coinMarket]["volume24Hour"] = candle.acc_trade_volume_24h;
newData[coinMarket]["changeRate24Hour"] = candle.signed_change_rate;
newData[coinMarket]["changePrice24Hour"] = candle.signed_change_price;
newData[coinMarket]["highestPrice24Hour"] = dateChanged // 날짜가 바뀌지 않았을때만 고점 갱신기록, 날짜 바뀌면 지금 고점 기록
? high
: high > highestPrice24Hour
? high
: highestPrice24Hour;
newData[coinMarket]["lowestPrice24Hour"] = dateChanged
? low
: low < lowestPrice24Hour
? low
: lowestPrice24Hour;
newData[coinMarket]["highestPrice52Week"] =
candle.highest_52_week_price;
newData[coinMarket]["highestDate52Week"] = candle.highest_52_week_date;
newData[coinMarket]["lowestPrice52Week"] = candle.lowest_52_week_price;
newData[coinMarket]["lowestDate52Week"] = candle.lowest_52_week_date;
}
});
return newData;
},
oneCoin: (candles, state) => {
const candleStateData = state.Coin.candle.data;
const selectedTimeType = state.Coin.selectedTimeType;
const selectedTimeCount = state.Coin.selectedTimeCount;
const market = candles[0].market;
const newCandles = candles.map((candle) => {
return {
date: dateFormat(
timestampToDatetime(
selectedTimeType,
selectedTimeCount,
candle.timestamp
)
),
datetime: timestampToDatetime(
selectedTimeType,
selectedTimeCount,
candle.timestamp
),
timestamp: candle.timestamp,
open: candle.opening_price,
high: candle.high_price,
low: candle.low_price,
close: candle.trade_price,
volume: candle.candle_acc_trade_volume,
tradePrice: candle.candle_acc_trade_price,
};
});
const newData = {
...candleStateData,
[market]: {
...candleStateData[market],
candles: newCandles,
},
};
return newData;
},
add: (candles, state) => {
const candleStateData = state.Coin.candle.data;
const selectedTimeType = state.Coin.selectedTimeType;
const selectedTimeCount = state.Coin.selectedTimeCount;
const market = candles[0].market;
const newCandles = candles.reduce((acc, candle) => {
if (!candle.timestamp) return acc;
if (
candleStateData[market].candles.find(
(stateCandle) => stateCandle.timestamp === candle.timestamp
)
)
return acc;
return [
...acc,
{
date: dateFormat(
timestampToDatetime(
selectedTimeType,
selectedTimeCount,
candle.timestamp
)
),
datetime: timestampToDatetime(
selectedTimeType,
selectedTimeCount,
candle.timestamp
),
timestamp: candle.timestamp,
open: candle.opening_price,
high: candle.high_price,
low: candle.low_price,
close: candle.trade_price,
volume: candle.candle_acc_trade_volume,
tradePrice: candle.candle_acc_trade_price,
},
];
}, []);
const newData = {
...candleStateData,
[market]: {
...candleStateData[market],
candles: [...newCandles, ...candleStateData[market].candles],
},
};
return newData;
},
marketNames: (names) => {
const data = {};
names.forEach((name) => {
if (name.market.split("-")[0] !== "KRW") return;
data[name.market] = {
korean: name.korean_name,
english: name.english_name,
};
});
return data;
},
};
const orderbookUtils = {
init: (orderbooks, _) => {
const data = {};
orderbooks.forEach((orderbook) => {
data[orderbook.market] = {
...orderbook,
code: orderbook.market,
};
});
return data;
},
update: (orderbook, state) => {
const orderbookData = state.Coin.orderbook.data;
const market = orderbook.code;
return {
...orderbookData,
[market]: {
...orderbook,
market,
},
};
},
};
const tradeListUtils = {
init: (tradeLists, state) => {
const tradeListData = state.Coin.tradeList.data;
const market = tradeLists[0].market;
return {
...tradeListData,
[market]: tradeLists,
};
},
update: (tradeList, state) => {
const tradeListData = state.Coin.tradeList.data;
const market = tradeList.code;
if (
tradeListData[market] &&
tradeListData[market].find(
(data) => data.sequential_id === tradeList.sequential_id
)
)
return tradeListData;
// 데이터가 200개까지만 유지되게 만듦
tradeListData[market] &&
tradeListData[market].length > 200 &&
tradeListData[market].pop();
return tradeListData[market]
? {
...tradeListData,
[market]: [tradeList, ...tradeListData[market]],
}
: {
...tradeListData,
[market]: [tradeList],
};
},
};
const choHangul = (str) => {
const cho = [
"ㄱ",
"ㄲ",
"ㄴ",
"ㄷ",
"ㄸ",
"ㄹ",
"ㅁ",
"ㅂ",
"ㅃ",
"ㅅ",
"ㅆ",
"ㅇ",
"ㅈ",
"ㅉ",
"ㅊ",
"ㅋ",
"ㅌ",
"ㅍ",
"ㅎ",
];
return [...str].reduce((acc, cur) => {
const code = cur.charCodeAt(0) - 44032;
return code > -1 && code < 11172
? acc + cho[Math.floor(code / 588)]
: acc + cur.charAt(0);
}, "");
};
export {
timestampToDatetime,
candleDataUtils,
orderbookUtils,
tradeListUtils,
choHangul,
};

121
src/Pages/Main.js Normal file
View File

@@ -0,0 +1,121 @@
import React from "react";
import styled from "styled-components";
import withSize from "../Container/withSize";
import { viewSize } from "../styles/theme";
import Header from "../Components/Global/Header";
import CoinInfoHeader from "../Components/Main/CoinInfoHeader";
import ChartDataConsole from "../Components/Main/ChartDataConsole";
import MainChart from "../Components/Main/MainChart";
import Orderbook from "../Components/Main/Orderbook";
import OrderInfo from "../Components/Main/OrderInfo";
import TradeList from "../Components/Main/TradeList";
import CoinList from "../Components/Main/CoinList";
import Footer from "../Components/Global/Footer";
const St = {
MainContentContainer: styled.div`
display: flex;
justify-content: center;
max-width: 1500px;
margin: 0 auto;
margin-top: 10px;
margin-bottom: 50px;
width: 100%;
height: 100%;
@media ${({ theme }) => theme.tablet} {
margin-top: 0;
margin-bottom: 0;
}
`,
ChartAndTradeContainer: styled.section`
display: flex;
flex-direction: column;
align-items: center;
width: 95%;
max-width: 950px;
@media ${(props) => (props.isRootURL ? props.theme.tablet : true)} {
display: none;
}
`,
HiddenH2: styled.h2`
position: absolute;
width: 1px;
height: 1px;
clip: rect(0, 0);
clip-path: polygon(0, 0);
overflow: hidden;
text-indent: -9999px;
`,
MainChartContainer: styled.div`
width: 100%;
height: 500;
`,
TradeInfoContainer: styled.div`
display: flex;
width: 100%;
margin-top: 10px;
@media ${({ theme }) => theme.mobileM} {
margin-top: 0;
}
`,
TradeOrderContainer: styled.div`
display: flex;
flex-direction: column;
width: 55%;
min-width: 180px;
margin-left: 10px;
@media ${({ theme }) => theme.mobileM} {
margin-left: 0;
border: 2px solid ${({ theme }) => theme.lightGray1};
/* border-top: 1px solid ${({ theme }) => theme.lightGray1};
border-bottom: 1px solid ${({ theme }) => theme.lightGray1};
border-left: 1px solid ${({ theme }) => theme.lightGray1}; */
}
`,
};
const Main = ({ match, widthSize, heightSize }) => {
const isRootURL = match.path === "/";
return (
<>
<Header isRootURL={isRootURL} />
<St.MainContentContainer>
{
// 차트 및 주문 관련 뷰는 메인 페이지이면서 tablet 사이즈보다 크거나, 메인 페이지가 아닌 경우에만 그린다
((isRootURL && widthSize > viewSize.tablet) || !isRootURL) && (
<St.ChartAndTradeContainer isRootURL={isRootURL}>
<St.HiddenH2>차트 주문 정보 </St.HiddenH2>
<CoinInfoHeader />
<ChartDataConsole />
<MainChart />
<St.TradeInfoContainer>
<Orderbook />
<St.TradeOrderContainer>
<OrderInfo />
<TradeList />
</St.TradeOrderContainer>
</St.TradeInfoContainer>
</St.ChartAndTradeContainer>
)
}
{
// 코인 리스트 뷰는 메인 페이지이거나, 메인 페이지가 아니면서 tablet 사이즈보다 큰 경우에만 그린다
(isRootURL || (!isRootURL && widthSize > viewSize.tablet)) && (
<CoinList
widthSize={widthSize}
heightSize={heightSize}
isRootURL={isRootURL}
/>
)
}
</St.MainContentContainer>
<Footer />
</>
);
};
export default withSize()(React.memo(Main));

547
src/Reducer/coinReducer.js Normal file
View File

@@ -0,0 +1,547 @@
import {
createRequestSaga,
createConnectSocketThunk,
createChangeOptionSaga,
requestActions,
changeOptionActions,
requestInitActions,
createConnectSocketSaga,
} from "../Lib/asyncUtil";
import { candleDataUtils, orderbookUtils, tradeListUtils } from "../Lib/utils";
import { coinApi } from "../Api/api";
import { takeEvery, put, select } from "redux-saga/effects";
import moment from "moment-timezone";
const START_INIT = "coin/START_INIT";
const START_CHANGE_MARKET_AND_DATA = "coin/START_CHANGE_MARKET_AND_DATA";
const CHANGE_TIME_TYPE_AND_DATA = "coin/CHANGE_TIME_TYPE_AND_DATA";
const START_ADD_MORE_CANDLE_DATA = "coin/START_ADD_MORE_CANDLE_DATA";
const GET_MARKET_NAMES = "coin/GET_MARKET_NAMES";
const GET_MARKET_NAMES_SUCCESS = "coin/GET_MARKET_NAMES_SUCCESS";
const GET_MARKET_NAMES_ERROR = "coin/GET_MARKET_NAMES_ERROR";
const GET_INIT_CANDLES = "coin/GET_INIT_CANDLES";
const GET_INIT_CANDLES_SUCCESS = "coin/GET_INIT_CANDLES_SUCCESS";
const GET_INIT_CANDLES_ERROR = "coin/GET_INIT_CANDLES_ERROR";
const GET_ONE_COIN_CANDLES = "coin/GET_ONE_COIN_CANDLES";
const GET_ONE_COIN_CANDLES_SUCCESS = "coin/GET_ONE_COIN_CANDLES_SUCCESS";
const GET_ONE_COIN_CANDLES_ERROR = "coin/GET_ONE_COIN_CANDLES_ERROR";
const GET_ADDITIONAL_COIN_CANDLES = "coin/GET_ADDITIONAL_COIN_CANDLES";
const GET_ADDITIONAL_COIN_CANDLES_SUCCESS =
"coin/GET_ADDITIONAL_COIN_CANDLES_SUCCESS";
const GET_ADDITIONAL_COIN_CANDLES_ERROR =
"coin/GET_ADDITIONAL_COIN_CANDLES_ERROR";
const CONNECT_CANDLE_SOCKET = "coin/CONNECT_CANDLE_SOCKET";
const CONNECT_CANDLE_SOCKET_SUCCESS = "coin/CONNECT_CANDLE_SOCKET_SUCCESS";
const CONNECT_CANDLE_SOCKET_ERROR = "coin/CONNECT_CANDLE_SOCKET_ERROR";
const GET_ONE_COIN_TRADELISTS = "coin/GET_ONE_COIN_TRADELISTS";
const GET_ONE_COIN_TRADELISTS_SUCCESS = "coin/GET_ONE_COIN_TRADELISTS_SUCCESS";
const GET_ONE_COIN_TRADELISTS_ERROR = "coin/GET_ONE_COIN_TRADELISTS_ERROR";
const CONNECT_TRADELIST_SOCKET = "coin/CONNECT_TRADELIST_SOCKET";
const CONNECT_TRADELIST_SOCKET_SUCCESS =
"coin/CONNECT_TRADELIST_SOCKET_SUCCESS";
const CONNECT_TRADELIST_SOCKET_ERROR = "coin/CONNECT_TRADELIST_SOCKET_ERROR";
const GET_INIT_ORDERBOOKS = "coin/GET_INIT_ORDERBOOKS";
const GET_INIT_ORDERBOOKS_SUCCESS = "coin/GET_INIT_ORDERBOOKS_SUCCESS";
const GET_INIT_ORDERBOOKS_ERROR = "coin/GET_INIT_ORDERBOOKS_ERROR";
const CONNECT_ORDERBOOK_SOCKET = "coin/CONNECT_ORDERBOOK_SOCKET";
const CONNECT_ORDERBOOK_SOCKET_SUCCESS =
"coin/CONNECT_ORDERBOOK_SOCKET_SUCCESS";
const CONNECT_ORDERBOOK_SOCKET_ERROR = "coin/CONNECT_ORDERBOOK_SOCKET_ERROR";
const CHANGE_COIN_MARKET = "coin/CHANGE_COIN_MARKET";
const CHANGE_COIN_MARKET_SUCCESS = "coin/CHANGE_COIN_MARKET_SUCCESS";
const CHANGE_TIME_TYPE = "coin/CHANGE_TIME_TYPE";
const CHANGE_TIME_TYPE_SUCCESS = "coin/CHANGE_TIME_TYPE_SUCCESS";
const CHANGE_TIME_COUNT = "coin/CHANGE_TIME_COUNT";
const CHANGE_TIME_COUNT_SUCCESS = "coin/CHANGE_TIME_COUNT_SUCCESS";
const CHANGE_ASK_BID_ORDER = "coin/CHANGE_ASK_BID_ORDER";
const CHANGE_ASK_BID_ORDER_SUCCESS = "coin/CHANGE_ASK_BID_ORDER_SUCCESS";
const CHANGE_ORDER_PRICE = "coin/CHANGE_ORDER_PRICE";
const CHANGE_ORDER_PRICE_SUCCESS = "coin/CHANGE_ORDER_PRICE_SUCCESS";
const CHANGE_ORDER_AMOUNT = "coin/CHANGE_ORDER_AMOUNT";
const CHANGE_ORDER_AMOUNT_SUCCESS = "coin/CHANGE_ORDER_AMOUNT_SUCCESS";
const CHANGE_ORDER_TOTAL_PRICE = "coin/CHANGE_ORDER_TOTAL_PRICE";
const CHANGE_ORDER_TOTAL_PRICE_SUCCESS =
"coin/CHANGE_ORDER_TOTAL_PRICE_SUCCESS";
const CHANGE_PRICE_AND_TOTAL_PRICE = "coin/CHANGE_PRICE_AND_TOTAL_PRICE";
const CHANGE_AMOUNT_AND_TOTAL_PRICE = "coin/CHANGE_AMOUNT_AND_TOTAL_PRICE";
const CHANGE_TOTAL_PRICE_AND_AMOUNT = "coin/CHANGE_TOTAL_PRICE_AND_AMOUNT";
const SEARCH_COIN = "coin/SEARCH_COIN";
const SEARCH_COIN_SUCCESS = "coin/SEARCH_COIN_SUCCESS";
// 업비트에서 제공하는 코인/마켓 이름들 가져오기 Saga
const getMarketNameSaga = createRequestSaga(
GET_MARKET_NAMES,
coinApi.getMarketCodes,
candleDataUtils.marketNames
);
// 코인/마켓 캔들들의 일봉 한 개씩 가져오기 Saga
const getInitCandleSaga = createRequestSaga(
GET_INIT_CANDLES,
coinApi.getInitCanldes,
candleDataUtils.init
);
// 특정 코인 봉 200개 가져오기 Saga
const getOneCoinCandlesSaga = createRequestSaga(
GET_ONE_COIN_CANDLES,
coinApi.getOneCoinCandles,
candleDataUtils.oneCoin
);
const getAdditionalCoinCandlesSaga = createRequestSaga(
GET_ADDITIONAL_COIN_CANDLES,
coinApi.getAdditionalCoinCandles,
candleDataUtils.add
);
// 캔들 웹소켓 연결 Thunk
const connectCandleSocketThunk = createConnectSocketThunk(
CONNECT_CANDLE_SOCKET,
"ticker",
candleDataUtils.update
);
// const connectCandleSocketThunk = createConnectSocketThrottleThunk(
// CONNECT_CANDLE_SOCKET,
// "ticker",
// candleDataUtils.update
// );
const connectCandleSocketSaga = createConnectSocketSaga(
CONNECT_CANDLE_SOCKET,
"ticker",
candleDataUtils.updates
);
// 호가창 조기 값 가져오기
const getInitOrderbookSaga = createRequestSaga(
GET_INIT_ORDERBOOKS,
coinApi.getInitOrderbooks,
orderbookUtils.init
);
// 호가창 웹소켓 연결 Thunk
const connectOrderbookSocketThunk = createConnectSocketThunk(
CONNECT_ORDERBOOK_SOCKET,
"orderbook",
orderbookUtils.update
);
// 체결내역 200개 가져오기
const getOneCoinTradeListsSaga = createRequestSaga(
GET_ONE_COIN_TRADELISTS,
coinApi.getOneCoinTradeLists,
tradeListUtils.init
);
// 체결내역 웹소켓 연결 Thunk
const connectTradeListSocketThunk = createConnectSocketThunk(
CONNECT_TRADELIST_SOCKET,
"trade",
tradeListUtils.update
);
// 선택한 코인마켓 변경하기 Saga
const changeSelectedMarket = (marketName) => ({
type: CHANGE_COIN_MARKET,
payload: marketName,
});
const changeSelectedMarketSaga = createChangeOptionSaga(CHANGE_COIN_MARKET);
// 선택한 타임 타입(5분봉 할때 '분') 변경하기 Saga
const changeSelectedTimeTypeSaga = createChangeOptionSaga(CHANGE_TIME_TYPE);
// 선택한 타임 카운트(5분봉 할때 '5') 변경하기 Saga
const changeSelectedTimeCountSaga = createChangeOptionSaga(CHANGE_TIME_COUNT);
// 매수 매도 옵션 변경하기
const changeAskBidOrder = (askBidOption) => ({
type: CHANGE_ASK_BID_ORDER,
payload: askBidOption,
});
const changeAskBidOrderSaga = createChangeOptionSaga(CHANGE_ASK_BID_ORDER);
// 주문 가격 변경하기
const changeOrderPriceSaga = createChangeOptionSaga(CHANGE_ORDER_PRICE);
// 주문 수량 변경하기
const changeOrderAmountSaga = createChangeOptionSaga(CHANGE_ORDER_AMOUNT);
// 주문 총액 변경하기
const changeOrderTotalPriceSaga = createChangeOptionSaga(
CHANGE_ORDER_TOTAL_PRICE
);
// 코인 검색 내용 변경하기 Saga
const searchCoin = (searchName) => ({
type: SEARCH_COIN,
payload: searchName,
});
const searchCoinSaga = createChangeOptionSaga(SEARCH_COIN);
// 시작시 데이터 초기화 작업들
const startInit = () => ({ type: START_INIT });
function* startInittSaga() {
yield getMarketNameSaga(); // 코인/시장 종류 받기
const state = yield select();
const marketNames = Object.keys(state.Coin.marketNames.data);
const selectedMarket = state.Coin.selectedMarket;
const selectedTimeType = state.Coin.selectedTimeType;
const selectedTimeCount = state.Coin.selectedTimeCount;
yield getInitCandleSaga({ payload: marketNames }); // 코인 캔들 초기값 받기
yield getInitOrderbookSaga({ payload: selectedMarket }); // 호가창 초기값 받기
yield getOneCoinTradeListsSaga({ payload: selectedMarket }); // 체결내역 초기값 받기
yield getOneCoinCandlesSaga({
payload: {
coin: selectedMarket,
timeType: selectedTimeType,
timeCount: selectedTimeCount,
},
}); // 200개 코인 데이터 받기
// yield connectCandleSocketSaga({ payload: marketNames }); // 캔들 소켓 연결 사가버전
yield put(connectOrderbookSocketThunk({ payload: marketNames })); // 오더북 소켓 연결
yield put(connectTradeListSocketThunk({ payload: marketNames })); // 체결내역 소켓 연결
// yield put(connectCandleSocketThunk({ payload: marketNames })); // 캔들 소켓 연결
yield connectCandleSocketSaga({ payload: marketNames }); // 캔들 소켓 연결 사가버전
}
// 선택된 코인/마켓 변경 및 해당 마켓 데이터 받기
const startChangeMarketAndData = (marketName) => ({
type: START_CHANGE_MARKET_AND_DATA,
payload: marketName,
});
function* startChangeMarketAndDataSaga(action) {
const state = yield select();
const selectedTimeType = state.Coin.selectedTimeType;
const selectedTimeCount = state.Coin.selectedTimeCount;
const changingMarketName = action.payload;
const selectedCoinCandles =
state.Coin.candle.data[changingMarketName].candles;
yield put(changeSelectedMarket(changingMarketName)); // 선택된 마켓 변경
yield getInitOrderbookSaga({ payload: changingMarketName }); // 호가창 초기값 받기
yield getOneCoinTradeListsSaga({ payload: changingMarketName }); // 체결내역 초기값 받기
// 상태에 저장된 데이터가 200개 미만일때만 api콜 요청함
if (selectedCoinCandles.length < 200) {
yield getOneCoinCandlesSaga({
payload: {
coin: changingMarketName,
timeType: selectedTimeType,
timeCount: selectedTimeCount,
},
});
}
}
// 추가 캔들 데이터 가져오기
const startAddMoreCandleData = () => ({ type: START_ADD_MORE_CANDLE_DATA });
function* startAddMoreCandleDataSaga() {
const state = yield select();
const selectedMarket = state.Coin.selectedMarket;
const selectedTimeType = state.Coin.selectedTimeType;
const selectedTimeCount = state.Coin.selectedTimeCount;
const isLoading = state.Loading[GET_ADDITIONAL_COIN_CANDLES];
if (isLoading) return;
const datetime =
moment(state.Coin.candle.data[selectedMarket].candles[0].date)
.utc()
.format("YYYY-MM-DDTHH:mm") + ":00Z";
yield getAdditionalCoinCandlesSaga({
payload: {
coin: selectedMarket,
timeType: selectedTimeType,
timeCount: selectedTimeCount,
datetime,
},
});
}
// 차트 시간 데이터 변경하고 데이터 받기
const changeTimeTypeAndData = (timeTypeAndCount) => ({
type: CHANGE_TIME_TYPE_AND_DATA,
payload: timeTypeAndCount,
});
function* changeTimeTypeAndDataSaga(action) {
const state = yield select();
const selectedMarket = state.Coin.selectedMarket;
const selectedTimeType = state.Coin.selectedTimeType;
const selectedTimeCount = state.Coin.selectedTimeCount;
const newTimeType = action.payload.timeType;
const newTimeCount = action.payload.timeCount;
if (selectedTimeType === newTimeType && selectedTimeCount === newTimeCount)
return;
yield changeSelectedTimeTypeSaga({ payload: newTimeType });
yield changeSelectedTimeCountSaga({ payload: newTimeCount });
yield getOneCoinCandlesSaga({
payload: {
coin: selectedMarket,
timeType: newTimeType,
timeCount: newTimeCount,
},
});
}
// 가격 변경 후 주문 총액 바꾸기
const changePriceAndTotalPrice = (price) => ({
type: CHANGE_PRICE_AND_TOTAL_PRICE,
payload: price,
});
function* changePriceAndTotalPriceSaga(action) {
const state = yield select();
const orderAmount = state.Coin.orderAmount;
yield changeOrderPriceSaga({ payload: action.payload });
yield changeOrderTotalPriceSaga({
payload: Math.ceil(action.payload * orderAmount),
});
}
// 주문수량 변경 후 주문 총액 바꾸기
const changeAmountAndTotalPrice = (amount) => ({
type: CHANGE_AMOUNT_AND_TOTAL_PRICE,
payload: amount,
});
function* changeAmountAndTotalPriceSaga(action) {
const state = yield select();
const orderPrice = state.Coin.orderPrice;
yield changeOrderAmountSaga({ payload: action.payload });
yield changeOrderTotalPriceSaga({
payload: Math.ceil(action.payload * orderPrice),
});
}
// 주문총액 변경 후 주문수량 바꾸기
const changeTotalPriceAndAmount = (totalPrice) => ({
type: CHANGE_TOTAL_PRICE_AND_AMOUNT,
payload: totalPrice,
});
function* changeTotalPriceAndAmountSaga(action) {
const state = yield select();
const orderPrice = state.Coin.orderPrice;
yield changeOrderTotalPriceSaga({ payload: action.payload });
yield changeOrderAmountSaga({
payload: orderPrice ? (action.payload / orderPrice).toFixed(8) : 0,
});
}
function* coinSaga() {
yield takeEvery(GET_MARKET_NAMES, getMarketNameSaga);
yield takeEvery(GET_INIT_CANDLES, getInitCandleSaga);
yield takeEvery(GET_INIT_ORDERBOOKS, getInitOrderbookSaga);
yield takeEvery(GET_ONE_COIN_CANDLES, getOneCoinCandlesSaga);
yield takeEvery(GET_ONE_COIN_TRADELISTS, getOneCoinTradeListsSaga);
yield takeEvery(CHANGE_COIN_MARKET, changeSelectedMarketSaga);
yield takeEvery(CHANGE_ASK_BID_ORDER, changeAskBidOrderSaga);
yield takeEvery(CHANGE_ORDER_PRICE, changeOrderPriceSaga);
yield takeEvery(CHANGE_ORDER_AMOUNT, changeOrderAmountSaga);
yield takeEvery(CHANGE_ORDER_TOTAL_PRICE, changeOrderTotalPriceSaga);
yield takeEvery(SEARCH_COIN, searchCoinSaga);
yield takeEvery(START_INIT, startInittSaga);
yield takeEvery(START_CHANGE_MARKET_AND_DATA, startChangeMarketAndDataSaga);
yield takeEvery(START_ADD_MORE_CANDLE_DATA, startAddMoreCandleDataSaga);
yield takeEvery(CHANGE_TIME_TYPE_AND_DATA, changeTimeTypeAndDataSaga);
yield takeEvery(CHANGE_PRICE_AND_TOTAL_PRICE, changePriceAndTotalPriceSaga);
yield takeEvery(CHANGE_AMOUNT_AND_TOTAL_PRICE, changeAmountAndTotalPriceSaga);
yield takeEvery(CHANGE_TOTAL_PRICE_AND_AMOUNT, changeTotalPriceAndAmountSaga);
}
const initialState = {
selectedMarket: "KRW-BTC",
selectedTimeType: "minutes",
selectedTimeCount: 5,
selectedAskBidOrder: "bid",
orderPrice: 0,
orderAmount: 0,
orderTotalPrice: 0,
searchCoin: "",
marketNames: {
error: false,
data: {
"KRW-BTC": "비트코인",
},
},
candle: {
error: false,
data: {
"KRW-BTC": {
candles: [
// { date: new Date(), open: 1, close: 1, high: 1, low: 1, volume: 1 },
],
tradePrice24Hour: 0,
volume24Hour: 0,
changeRate24Hour: 0,
},
},
},
orderbook: {
error: false,
data: {
"KRW-BTC": {
total_bid_size: 0,
total_ask_size: 0,
orderbook_units: [],
},
},
},
tradeList: {
error: false,
data: {},
},
};
const coinReducer = (state = initialState, action) => {
switch (action.type) {
// 코인 마켓 이름들
case GET_MARKET_NAMES_SUCCESS:
case GET_MARKET_NAMES_ERROR:
return requestActions(GET_MARKET_NAMES, "marketNames")(state, action);
// 초기 캔들
case GET_INIT_CANDLES_SUCCESS:
case GET_INIT_CANDLES_ERROR:
return requestInitActions(GET_INIT_CANDLES, "candle")(state, action);
// 코인 한 개 정해서 200개
case GET_ONE_COIN_CANDLES_SUCCESS:
case GET_ONE_COIN_CANDLES_ERROR:
return requestActions(GET_ONE_COIN_CANDLES, "candle")(state, action);
// 추가 코인 데이터 로드
case GET_ADDITIONAL_COIN_CANDLES_SUCCESS:
case GET_ADDITIONAL_COIN_CANDLES_ERROR:
return requestActions(GET_ADDITIONAL_COIN_CANDLES, "candle")(
state,
action
);
// 캔들 실시간 정보
case CONNECT_CANDLE_SOCKET_SUCCESS:
case CONNECT_CANDLE_SOCKET_ERROR:
return requestActions(CONNECT_CANDLE_SOCKET, "candle")(state, action);
// 호가창 초기값
case GET_INIT_ORDERBOOKS_SUCCESS:
case GET_INIT_ORDERBOOKS_ERROR:
return requestActions(GET_INIT_ORDERBOOKS, "orderbook")(state, action);
// 호가창 실시간 정보
case CONNECT_ORDERBOOK_SOCKET_SUCCESS:
case CONNECT_ORDERBOOK_SOCKET_ERROR:
return requestActions(CONNECT_ORDERBOOK_SOCKET, "orderbook")(
state,
action
);
// 체결내역 200개 초기값
case GET_ONE_COIN_TRADELISTS_SUCCESS:
case GET_ONE_COIN_TRADELISTS_ERROR:
return requestActions(GET_ONE_COIN_TRADELISTS, "tradeList")(
state,
action
);
// 체결내역 실시간 정보
case CONNECT_TRADELIST_SOCKET_SUCCESS:
case CONNECT_TRADELIST_SOCKET_ERROR:
return requestActions(CONNECT_TRADELIST_SOCKET, "tradeList")(
state,
action
);
case CHANGE_COIN_MARKET_SUCCESS:
return changeOptionActions(CHANGE_COIN_MARKET, "selectedMarket")(
state,
action
);
case CHANGE_TIME_TYPE_SUCCESS:
return changeOptionActions(CHANGE_TIME_TYPE, "selectedTimeType")(
state,
action
);
case CHANGE_TIME_COUNT_SUCCESS:
return changeOptionActions(CHANGE_TIME_COUNT, "selectedTimeCount")(
state,
action
);
case CHANGE_ASK_BID_ORDER_SUCCESS:
return changeOptionActions(CHANGE_ASK_BID_ORDER, "selectedAskBidOrder")(
state,
action
);
case CHANGE_ORDER_PRICE_SUCCESS:
return changeOptionActions(CHANGE_ORDER_PRICE, "orderPrice")(
state,
action
);
case CHANGE_ORDER_AMOUNT_SUCCESS:
return changeOptionActions(CHANGE_ORDER_AMOUNT, "orderAmount")(
state,
action
);
case CHANGE_ORDER_TOTAL_PRICE_SUCCESS:
return changeOptionActions(CHANGE_ORDER_TOTAL_PRICE, "orderTotalPrice")(
state,
action
);
case SEARCH_COIN_SUCCESS:
return changeOptionActions(SEARCH_COIN, "searchCoin")(state, action);
default:
return state;
}
};
export {
startInit,
startChangeMarketAndData,
startAddMoreCandleData,
changeTimeTypeAndData,
coinReducer,
coinSaga,
changeAskBidOrder,
changePriceAndTotalPrice,
changeAmountAndTotalPrice,
changeTotalPriceAndAmount,
searchCoin,
};

15
src/Reducer/index.js Normal file
View File

@@ -0,0 +1,15 @@
import { combineReducers } from "redux";
import { coinReducer, coinSaga } from "./coinReducer";
import { loadingReducer } from "./loadingReducer";
import { all } from "redux-saga/effects";
const rootReducer = combineReducers({
Coin: coinReducer,
Loading: loadingReducer,
});
function* rootSaga() {
yield all([coinSaga()]);
}
export { rootReducer, rootSaga };

View File

@@ -0,0 +1,33 @@
const START_LOADING = "loading/START_LOADING";
const FINISH_LOADING = "loading/FINISH_LOADING";
const startLoading = (payload) => ({ type: START_LOADING, payload });
const finishLoading = (payload) => ({ type: FINISH_LOADING, payload });
const initialState = {
"coin/GET_ONE_COIN_CANDLES": true,
"coin/GET_INIT_ORDERBOOKS": true,
"coin/GET_ONE_COIN_TRADELISTS": true,
"coin/GET_INIT_CANDLES": true,
"coin/GET_MARKET_NAMES": true,
"coin/GET_ADDITIONAL_COIN_CANDLES": false,
};
const loadingReducer = (state = initialState, action) => {
switch (action.type) {
case START_LOADING:
return {
...state,
[action.payload]: true,
};
case FINISH_LOADING:
return {
...state,
[action.payload]: false,
};
default:
return state;
}
};
export { startLoading, finishLoading, loadingReducer };

14
src/Router/MainRouter.js Normal file
View File

@@ -0,0 +1,14 @@
import React from "react";
import { Switch, Route } from "react-router-dom";
import Main from "../Pages/Main";
const MainRouter = () => {
return (
<Switch>
<Route exact path="/" component={Main} />
<Route exact path="/trade" component={Main} />
</Switch>
);
};
export default MainRouter;

40
src/index.css Normal file
View File

@@ -0,0 +1,40 @@
html {
font-family: "Noto Sans CJK KR", sans-serif;
}
/*
@font-face {
font-family: "Noto Sans CJK KR";
font-style: normal;
font-weight: 100;
src: url("/src/styles/fonts/NotoSansKR-Light.woff2") format("woff2"),
url("/src/styles/fonts/NotoSansKR-Light.woff") format("woff"),
url("/src/styles/fonts/NotoSansKR-Light.otf") format("truetype");
}
@font-face {
font-family: "Noto Sans CJK KR";
font-style: normal;
font-weight: normal;
src: url("/src/styles/fonts/NotoSansKR-Regular.woff2") format("woff2"),
url("/src/styles/fonts/NotoSansKR-Regular.woff") format("woff"),
url("/src/styles/fonts/NotoSansKR-Regular.otf") format("truetype");
}
@font-face {
font-family: "Noto Sans CJK KR";
font-style: normal;
font-weight: 500;
src: url("/src/styles/fonts/NotoSansKR-Medium.woff2") format("woff2"),
url("/src/styles/fonts/NotoSansKR-Medium.woff") format("woff"),
url("/src/styles/fonts/NotoSansKR-Medium.otf") format("truetype");
}
@font-face {
font-family: "Noto Sans CJK KR";
font-style: normal;
font-weight: 800;
src: url("/src/styles/fonts/NotoSansKR-Bold.woff2") format("woff2"),
url("/src/styles/fonts/NotoSansKR-Bold.woff") format("woff"),
url("/src/styles/fonts/NotoSansKR-Bold.otf") format("truetype");
} */

51
src/index.js Normal file
View File

@@ -0,0 +1,51 @@
import "core-js/stable";
import "core-js/es/set";
import "core-js/es/map";
import "regenerator-runtime/runtime";
import "raf/polyfill";
import "react-app-polyfill/ie9";
import "react-app-polyfill/stable";
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import createSagaMiddleware from "redux-saga";
import ReduxThunk from "redux-thunk";
import { createStore, applyMiddleware } from "redux";
import { rootReducer, rootSaga } from "./Reducer";
import { composeWithDevTools } from "redux-devtools-extension";
import { Provider } from "react-redux";
import { BrowserRouter } from "react-router-dom";
import { ThemeProvider } from "styled-components";
import theme from "./styles/theme";
import GlobalStyle from "./styles/GlobalStyle";
// import "./index.css";
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(ReduxThunk, sagaMiddleware))
);
sagaMiddleware.run(rootSaga);
ReactDOM.render(
<ThemeProvider theme={theme}>
<Provider store={store}>
<BrowserRouter>
<GlobalStyle />
<App />
</BrowserRouter>
</Provider>
</ThemeProvider>,
document.getElementById("root")
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

141
src/serviceWorker.js Normal file
View File

@@ -0,0 +1,141 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
})
.catch(error => {
console.error(error.message);
});
}
}

5
src/setupTests.js Normal file
View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

18
src/styles/GlobalStyle.js Normal file
View File

@@ -0,0 +1,18 @@
import { createGlobalStyle } from "styled-components";
import normalize from "styled-normalize";
import reset from "styled-reset";
const GlobalStyle = createGlobalStyle`
${normalize}
${reset}
* {
box-sizing: border-box;
}
body {
background-color: rgb(231, 234, 239);
/* height: 100%; */
}
`;
export default GlobalStyle;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

31
src/styles/theme.js Normal file
View File

@@ -0,0 +1,31 @@
const viewSize = {
mobileS: 480,
mobileM: 770,
tablet: 1279,
desktop: 1280,
};
const theme = {
deepBlue: "#093687",
skyBlue1: "rgba(0,98,223,.03)",
skyBlue2: "rgba(0,98,223,.09)",
lightPink1: "rgba(216,14,53,.03);",
lightPink2: "rgba(216,14,53,.09);",
strongRed: "#d80e35",
strongBlue: "#115DCB",
priceUp: "rgb(210, 79, 69)",
priceDown: "rgb(18, 97, 196)",
priceUpTrans: "rgba(210, 79, 69, 0.5)",
priceDownTrans: "rgba(18, 97, 196, 0.5)",
middleGray: "#00000033",
lightGray: "rgb(244, 245, 248)",
lightGray1: "rgb(249, 250, 252)",
lightGray2: "rgb(212, 214, 220)",
mobileS: `(max-width: ${viewSize.mobileS}px)`,
mobileM: `(max-width: ${viewSize.mobileM}px)`,
tablet: `(max-width: ${viewSize.tablet}px)`,
desktop: `(min-width: ${viewSize.desktop}px)`,
};
export { viewSize };
export default theme;

13753
yarn.lock Normal file

File diff suppressed because it is too large Load Diff