diff --git a/.env.example b/.env.example
deleted file mode 100644
index 3df6247..0000000
--- a/.env.example
+++ /dev/null
@@ -1,6 +0,0 @@
-# Environment Variables
-
-# Backend API URL
-# Development: http://localhost:8080 or http://localhost:8080/api
-# Production: https://newstailor.site/coinsight
-VITE_BACKEND_URL=https://newstailor.site/coinsight
diff --git a/package-lock.json b/package-lock.json
index 43d9507..74a6654 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "iconify-icon": "^3.0.2",
"vite": "^7.2.4"
}
},
@@ -935,6 +936,13 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2627,6 +2635,19 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/iconify-icon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/iconify-icon/-/iconify-icon-3.0.2.tgz",
+ "integrity": "sha512-DYPAumiUeUeT/GHT8x2wrAVKn1FqZJqFH0Y5pBefapWRreV1BBvqBVMb0020YQ2njmbR59r/IathL2d2OrDrxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@iconify/types": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyberalien"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
diff --git a/package.json b/package.json
index d56fd70..64376f2 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "iconify-icon": "^3.0.2",
"vite": "^7.2.4"
}
}
diff --git a/src/App.jsx b/src/App.jsx
index bbf75f1..0cddd74 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -5,6 +5,7 @@ import CoinDetailPage from './pages/CoinDetailPage';
import NewsDetailPage from './pages/NewsDetailPage';
import GamePage from './pages/GamePage';
import LeaderBoardPage from './pages/LeaderBoardPage';
+import SearchResultPage from './pages/SearchResultPage';
function App() {
@@ -12,13 +13,15 @@ function App() {
} />
- } />
+ } />
} />
} />
} />
+ } />
)
}
export default App
+
diff --git a/src/apis/CoinDetail/aihistory.js b/src/apis/CoinDetail/aihistory.js
new file mode 100644
index 0000000..c9d2a5b
--- /dev/null
+++ b/src/apis/CoinDetail/aihistory.js
@@ -0,0 +1,23 @@
+import { instance } from "../../utils/axios";
+
+export const getAiPredictions = async (ticker) => {
+ try {
+ const res = await instance.get(`/api/v1/crypto/${ticker}/predictions`);
+ //console.log(res);
+ return res.data;
+ } catch (error) {
+ console.error("코인 예측 히스토리 불러오기 실패: ", error);
+ throw error;
+ }
+}
+
+export const getPredictionNews = async (ticker, predictionId) => {
+ try {
+ const res = await instance.get(`/api/v1/crypto/${ticker}/predictions/${predictionId}/news`);
+ console.log(res);
+ return res.data;
+ } catch (error) {
+ console.error("예측에 사용된 뉴스 목록 조회 실패 : ", error);
+ throw error;
+ }
+}
\ No newline at end of file
diff --git a/src/apis/CoinDetail/chart.js b/src/apis/CoinDetail/chart.js
index 9fcf10f..ce27e58 100644
--- a/src/apis/CoinDetail/chart.js
+++ b/src/apis/CoinDetail/chart.js
@@ -1,2 +1,13 @@
-const BACKEND = import.meta.env.VITE_BACKEND_URL;
+import { instance } from "../../utils/axios";
+export const getCandleInfo = async (ticker, unit=1, count=200) => {
+ try {
+ const res = await instance.get(`/api/v1/market/candles/${ticker}?unit=${unit}&count=${count}`);
+ //console.log(res.data);
+ return res.data;
+ } catch (error) {
+ console.error("코인 분봉 차트 불러오기 실패: ", error);
+ throw error;
+ }
+
+}
\ No newline at end of file
diff --git a/src/apis/CoinDetail/coinInfo.js b/src/apis/CoinDetail/coinInfo.js
index e178e9f..e845d5e 100644
--- a/src/apis/CoinDetail/coinInfo.js
+++ b/src/apis/CoinDetail/coinInfo.js
@@ -3,8 +3,8 @@ import { instance } from "../../utils/axios";
export const getCoinInfo = async (ticker) => {
try {
const res = await instance.get(`/api/v1/crypto/${ticker}`);
- console.log(res);
- return res;
+ //console.log(res);
+ return res.data;
} catch (error) {
console.error("코인 정보 불러오기 실패: ", error);
throw error;
diff --git a/src/apis/CoinDetail/coinNews.js b/src/apis/CoinDetail/coinNews.js
new file mode 100644
index 0000000..6845a68
--- /dev/null
+++ b/src/apis/CoinDetail/coinNews.js
@@ -0,0 +1,14 @@
+import { instance } from "../../utils/axios";
+
+export const getCoinNews = async (ticker, page=0, size=20) => {
+ try {
+ const res = await instance.get(`/api/v1/crypto/${ticker}/news/recent`, {
+ params: {page, size}
+ });
+ //console.log(res);
+ return res.data;
+ } catch (error) {
+ console.error("코인 관련 뉴스 불러오기 실패: ", error);
+ throw error;
+ }
+}
\ No newline at end of file
diff --git a/src/apis/Market/tickers.js b/src/apis/Market/tickers.js
new file mode 100644
index 0000000..f839148
--- /dev/null
+++ b/src/apis/Market/tickers.js
@@ -0,0 +1,13 @@
+import { instance } from "../../utils/axios";
+
+export const getTickers = async (marketType = "KRW", sortBy = "tradeValue") => {
+ try {
+ const res = await instance.get("/api/v1/market/tickers", {
+ params: { marketType, sortBy },
+ });
+ return res.data;
+ } catch (error) {
+ console.error("코인 시세 목록 불러오기 실패: ", error);
+ throw error;
+ }
+};
diff --git a/src/apis/News/analysis.js b/src/apis/News/analysis.js
new file mode 100644
index 0000000..cff8341
--- /dev/null
+++ b/src/apis/News/analysis.js
@@ -0,0 +1,13 @@
+import { instance } from "../../utils/axios";
+
+export const getNewsAnalysis = async (page = 0, size = 20) => {
+ try {
+ const res = await instance.get("/api/v1/news/analysis", {
+ params: { page, size },
+ });
+ return res.data;
+ } catch (error) {
+ console.error("뉴스 분석 목록 불러오기 실패: ", error);
+ throw error;
+ }
+};
diff --git a/src/apis/News/search.js b/src/apis/News/search.js
new file mode 100644
index 0000000..81797d7
--- /dev/null
+++ b/src/apis/News/search.js
@@ -0,0 +1,13 @@
+import { instance } from "../../utils/axios";
+
+export const searchNews = async (keyword, page = 0, size = 20) => {
+ try {
+ const res = await instance.get("/api/v1/news/search", {
+ params: { keyword, page, size },
+ });
+ return res.data;
+ } catch (error) {
+ console.error("뉴스 검색 실패: ", error);
+ throw error;
+ }
+};
diff --git a/src/assets/pixelarticons--coin.svg b/src/assets/pixelarticons--coin.svg
new file mode 100644
index 0000000..d8be252
--- /dev/null
+++ b/src/assets/pixelarticons--coin.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/CoinDetail/CoinChart.jsx b/src/components/CoinDetail/CoinChart.jsx
index f8e55e0..8b05ed7 100644
--- a/src/components/CoinDetail/CoinChart.jsx
+++ b/src/components/CoinDetail/CoinChart.jsx
@@ -1,99 +1,79 @@
import { init, dispose } from "klinecharts";
import { useEffect, useState, useRef } from "react";
-
-const CoinChart = ({coinName}) => {
- const customStyles = {
- candle: {
- bar: {
+import { getCandleInfo } from "../../apis/CoinDetail/chart";
+
+const customStyles = {
+ candle: {
+ bar: {
+ upColor: '#4073FF',
+ downColor: '#FF4D66',
+ upBorderColor: '#4073FF',
+ downBorderColor: '#FF4D66',
+ upWickColor: '#4073FF',
+ downWickColor: '#FF4D66',
+ },
+ priceMark: {
+ last: {
upColor: '#4073FF',
downColor: '#FF4D66',
- upBorderColor: '#4073FF',
- downBorderColor: '#FF4D66',
- upWickColor: '#4073FF',
- downWickColor: '#FF4D66',
},
- priceMark: {
- last: {
- upColor: '#4073FF',
- downColor: '#FF4D66',
- },
- high: {
- color: '#4073FF',
- },
- low: {
- color: '#FF4D66',
- }
+ high: {
+ color: '#4073FF',
+ },
+ low: {
+ color: '#FF4D66',
}
}
}
+}
+const CoinChart = ({ ticker }) => {
const [coinInfo, setCoinInfo] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isError, setIsError] = useState(false);
const chartRef = useRef(null);
const formatData = (data) => {
return data.map(e => ({
timestamp: e.timestamp,
- open: e.opening_price,
- high: e.high_price,
- low: e.low_price,
- close: e.trade_price,
- volume: e.candle_acc_trade_volume,
+ open: e.openPrice || e.opening_price,
+ high: e.highPrice || e.high_price,
+ low: e.lowPrice || e.low_price,
+ close: e.closePrice || e.trade_price,
+ volume: e.volume || e.candle_acc_trade_volume,
})).reverse(); // 과거 -> 현재 순서로 바꾸기
};
- const onClickMinute = () => {
- const options = {method: 'GET', headers: {accept: 'application/json'}};
-
- fetch(`https://api.bithumb.com/v1/candles/minutes/1?market=${coinName}&count=200`, options)
- .then(response => response.json())
- .then(response => {
- //console.log(response)
- if (response && response.length > 0) {
- const formattedData = formatData(response);
- setCoinInfo(formattedData);
- }
- })
- .catch(err => console.error(err));
- }
-
-
- const onClickHour = () => {
- const options = {method: 'GET', headers: {accept: 'application/json'}};
-
- fetch(`https://api.bithumb.com/v1/candles/minutes/60?market=${coinName}&count=200`, options)
- .then(response => response.json())
- .then(response => {
- //console.log(response)
- if (response && response.length > 0) {
- const formattedData = formatData(response);
- setCoinInfo(formattedData);
- }
- })
- .catch(err => console.error(err));
- }
-
- const onClickFourHour = () => {
- const options = {method: 'GET', headers: {accept: 'application/json'}};
-
- fetch(`https://api.bithumb.com/v1/candles/minutes/240?market=${coinName}&count=200`, options)
- .then(response => response.json())
- .then(response => {
- //console.log(response)
- if (response && response.length > 0) {
- const formattedData = formatData(response);
- setCoinInfo(formattedData);
- }
- })
- .catch(err => console.error(err));
+ const fetchCandleData = async (unit = 1) => {
+ setIsLoading(true);
+ setIsError(false);
+
+ try {
+ const res = await getCandleInfo(ticker, unit);
+ if (res && res.data.length > 0) {
+ const formattedData = formatData(res.data);
+ setCoinInfo(formattedData);
+ } else {
+ setIsError(true);
+ }
+ } catch (error) {
+ setIsError(true);
+ console.error("데이터 불러오기 실패 : ", error);
+ } finally {
+ setIsLoading(false);
+ }
}
+ const onClickMinute = () => fetchCandleData();
+ const onClickHour = () => fetchCandleData(60);
+ const onClickFourHour = () => fetchCandleData(240);
+
const onClickDay = () => {
const options = {method: 'GET', headers: {accept: 'application/json'}};
- fetch(`https://api.bithumb.com/v1/candles/days?market=${coinName}&count=200`, options)
+ fetch(`https://api.bithumb.com/v1/candles/days?market=${ticker}&count=200`, options)
.then(response => response.json())
.then(response => {
- //console.log(response)
if (response && response.length > 0) {
const formattedData = formatData(response);
setCoinInfo(formattedData);
@@ -103,19 +83,8 @@ const CoinChart = ({coinName}) => {
}
useEffect(() => {
- const options = {method: 'GET', headers: {accept: 'application/json'}};
-
- fetch(`https://api.bithumb.com/v1/candles/minutes/1?market=${coinName}&count=200`, options)
- .then(response => response.json())
- .then(response => {
- //console.log(response)
- if (response && response.length > 0) {
- const formattedData = formatData(response);
- setCoinInfo(formattedData);
- }
- })
- .catch(err => console.error(err));
- }, [coinName]);
+ fetchCandleData();
+ }, [ticker]);
useEffect(() => {
if (!coinInfo) return;
@@ -125,7 +94,7 @@ const CoinChart = ({coinName}) => {
}
const chart = chartRef.current;
chart.setStyles(customStyles)
- chart.setSymbol({ ticker: coinName })
+ chart.setSymbol({ ticker: ticker })
chart.setPeriod({ span: 1, type: 'day' })
chart.setDataLoader({
getBars: ({ callback}) => {
@@ -137,15 +106,13 @@ const CoinChart = ({coinName}) => {
dispose('chart');
chartRef.current = null;
}
- }, [coinInfo, coinName]);
-
-
+ }, [coinInfo, ticker]);
return (
- {coinName}
+ {ticker}
{
1일
-
+
+ {isError && (
+
+
데이터를 불러오는 데 실패했습니다.
+
+
+ )}
+ {isLoading &&
}
diff --git a/src/components/CoinDetail/DailyNews.jsx b/src/components/CoinDetail/DailyNews.jsx
new file mode 100644
index 0000000..44e9c1e
--- /dev/null
+++ b/src/components/CoinDetail/DailyNews.jsx
@@ -0,0 +1,77 @@
+import { useEffect, useState } from "react";
+import DailyNewsCard from "./DailyNewsCard";
+import { getCoinNews } from "../../apis/CoinDetail/coinNews";
+
+const DailyNews = ({ticker}) => {
+ const [newsData, setNewsData] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isError, setIsError] = useState(null);
+
+ useEffect(() => {
+ const fetchCoinNews = async () => {
+ try {
+ setIsLoading(true);
+ setIsError(null);
+ const res = await getCoinNews(ticker, 0, 20);
+ //console.log(res.data);
+ setNewsData(res.data?.content || []);
+ } catch (error) {
+ console.log("백엔드 불러오기 실패 : ", error);
+ setIsError("데이터를 불러오는 중에 문제가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ fetchCoinNews();
+ }, [ticker]);
+
+ const renderContent = () => {
+ if (isLoading) {
+ return (
+
+ 뉴스를 불러오는 중입니다.
+
+ )
+ }
+ if (isError) {
+ return (
+
+ {isError}
+
+ )
+ }
+ if (newsData.length === 0) {
+ return (
+
+ 등록된 뉴스가 없습니다.
+
+ )
+ }
+
+ return newsData.map((news) => (
+
+ ))
+ }
+
+
+ return (
+
+
+ 최근 24시간 호재/악재 뉴스
+
+
+
+ {renderContent()}
+
+
+ )
+}
+
+export default DailyNews;
\ No newline at end of file
diff --git a/src/components/CoinDetail/DailyNewsCard.jsx b/src/components/CoinDetail/DailyNewsCard.jsx
new file mode 100644
index 0000000..99aa8d1
--- /dev/null
+++ b/src/components/CoinDetail/DailyNewsCard.jsx
@@ -0,0 +1,53 @@
+import RoundButton from "../common/RoundButton";
+
+const DailyNewsCard = ({status, title, publisher, reliability, time}) => {
+ const labelType = {
+ NEGATIVE : "negative",
+ POSITIVE : "positive"
+ }
+
+ const contentType = {
+ NEGATIVE : "악재",
+ POSITIVE : "호재"
+ }
+
+ const CalculateTime = (t) => {
+ const getTime = new Date(t);
+ const milliSeconds = new Date() - getTime;
+ const seconds = milliSeconds / 1000;
+ if (seconds < 60) return `방금 전`;
+ const minutes = seconds / 60;
+ if (minutes < 60) return `${Math.floor(minutes)}분 전`;
+ const hours = minutes / 60;
+ if (hours < 24) return `${Math.floor(hours)}시간 전`;
+ const days = hours / 24;
+ if (days < 7) return `${Math.floor(days)}일 전`;
+ else return `한참 전`;
+ };
+
+ const RoundReliability = (r) => {
+ const percentage = r*100;
+ return Math.ceil(percentage);
+ }
+
+ return (
+
+
+
+
+
{CalculateTime(time)}
+ |
+
신뢰도: 약 {RoundReliability(reliability)}%
+ |
+
출처: {publisher}
+
+
+ )
+}
+
+export default DailyNewsCard;
\ No newline at end of file
diff --git a/src/components/CoinDetail/History.jsx b/src/components/CoinDetail/History.jsx
index 53729e4..b442d65 100644
--- a/src/components/CoinDetail/History.jsx
+++ b/src/components/CoinDetail/History.jsx
@@ -1,7 +1,92 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
+import HistoryCard from "./HistoryCard";
+import { getAiPredictions } from "../../apis/CoinDetail/aihistory";
+
+const History = ({ticker, currentPrice}) => {
+ //mock data
+ // const mockData= [
+ // {id: 1, date: "2026.02.08", time: "16:00", prediction: "호재", result: "53", isSuccess: true},
+ // {id: 2, date: "2026.02.08", time: "16:30", prediction: "호재", result: "54", isSuccess: true},
+ // {id: 3, date: "2026.02.08", time: "17:00", prediction: "악재", result: "42", isSuccess: false},
+ // ]
+ const timeMapping = {
+ "1시간": "HOUR_1",
+ "3시간": "HOUR_3",
+ "12시간": "HOUR_12",
+ "24시간": "HOUR_24",
+ }
+ const [selectedTime, setSelectedTime] = useState("1시간");
+ const [predictionList, setPredictionList] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isError, setIsError] = useState(null);
+
+ useEffect(() => {
+ const fetchAiHistory = async () => {
+ try {
+ setIsLoading(true);
+ setIsError(null);
+ const res = await getAiPredictions(ticker);
+ //console.log(res);
+ setPredictionList(res.data?.content || []);
+
+ } catch (error) {
+ console.error("백엔드 데이터 불러오기 실패 : ", error);
+ setIsError("데이터를 불러오는 데 실패했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ fetchAiHistory();
+ }, [ticker]);
+ console.log(predictionList);
+
+ const renderContent = () => {
+ if (isLoading) {
+ return (
+
+ AI 예측 히스토리를 불러오는 중입니다.
+
+ )
+ }
+ if (isError) {
+ return (
+
+ {isError}
+
+ )
+ }
+ if (predictionList.length === 0) {
+ return (
+
+ AI 히스토리가 없습니다.
+
+ )
+ }
+
+ return (
+ predictionList.map((item) => {
+ const targetInterval = timeMapping[selectedTime]; //HOUR_1
+ const verification = item.verifications?.find(v => v.intervalType === targetInterval);
+ if (!verification) return null;
+
+ return (
+
+ )
+ })
+ );
+ }
-const History = () => {
- const [selectedTime, setSelectedTime] = useState("1h");
const time_type = ["1시간", "3시간", "12시간", "24시간"];
return (
@@ -23,6 +108,10 @@ const History = () => {
))}
+
+
+ {renderContent()}
+
)
}
diff --git a/src/components/CoinDetail/HistoryCard.jsx b/src/components/CoinDetail/HistoryCard.jsx
new file mode 100644
index 0000000..67a78a8
--- /dev/null
+++ b/src/components/CoinDetail/HistoryCard.jsx
@@ -0,0 +1,106 @@
+import 'iconify-icon';
+import RoundButton from "../common/RoundButton";
+import { useState, useMemo } from 'react';
+import PredictionNewsCard from './PredictionNewsCard';
+
+const HistoryCard = ({date, time, ai_prediction, actual_price, current_price, prediction_result, ai_status, prediction_status}) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const LabelToContent = { UP : '호재', DOWN: '악재', NEUTRAL: '중립' }
+
+ const LabelToStatus = { UP : 'positive', DOWN: 'negative', NEUTRAL: 'neutral' }
+
+ const StatusConfig = {
+ positive: { color: 'text-[#FF4D66]', icon: "mingcute:arrow-up-fill" },
+ negative: { color: 'text-[#4073FF]', icon: "mingcute:arrow-down-fill" },
+ neutral: { color: 'text-[#9E9E9E]', icon: null }
+ };
+
+ const {rate, status} = useMemo(() => {
+ if (!actual_price || !current_price) {
+ return {rate: "0.00", status: "neutral"}
+ }
+
+ const upDownrate = ((current_price - actual_price) / actual_price) * 100;
+ let currentStatus = "neutral";
+ if (upDownrate > 0) currentStatus = "positive";
+ else if (upDownrate < 0) currentStatus = "negative";
+ return {
+ rate: upDownrate.toFixed(2),
+ status: currentStatus
+ }
+ }, [actual_price, current_price]);
+
+ return (
+
+
+
+
{time.split("T")[1].split(":")[0]}:{time.split("T")[1].split(":")[1]}
+
{date}
+
+
+
+
+
+
실제 결과
+
+ {rate}%
+ {StatusConfig[status].icon && (
+
+ )}
+
+
+
+
+
+
setIsOpen(!isOpen)}
+ >
+
사용된 기사 보기
+ {isOpen ?
+ (
) :
+ (
)
+ }
+
+
+
+ {isOpen && (
+
+ )}
+
+ )
+}
+
+export default HistoryCard;
\ No newline at end of file
diff --git a/src/components/CoinDetail/PredictionNewsCard.jsx b/src/components/CoinDetail/PredictionNewsCard.jsx
new file mode 100644
index 0000000..ac1f331
--- /dev/null
+++ b/src/components/CoinDetail/PredictionNewsCard.jsx
@@ -0,0 +1,35 @@
+import RoundButton from "../common/RoundButton";
+
+const PredictionNewsCard = ({status, title, publisher, time}) => {
+ const labelType = {
+ NEGATIVE : "negative",
+ POSITIVE : "positive"
+ }
+ const contentType = {
+ NEGATIVE : "악재",
+ POSITIVE : "호재"
+ }
+ const formatTime = (t) => {
+ const time = new Date(t);
+
+ return `${time.getFullYear()}년 ${time.getMonth() + 1}월 ${time.getDate()}일
+ ${time.getHours()}:${time.getMinutes()}`
+ }
+
+
+ return (
+
+
+
+
+
+ {title}
+
+
+ {publisher} | {formatTime(time)}
+
+
+ )
+}
+
+export default PredictionNewsCard;
\ No newline at end of file
diff --git a/src/components/MainPage/CoinListTable.jsx b/src/components/MainPage/CoinListTable.jsx
new file mode 100644
index 0000000..9d5dc0c
--- /dev/null
+++ b/src/components/MainPage/CoinListTable.jsx
@@ -0,0 +1,128 @@
+import { useState, useEffect } from "react";
+import { getTickers } from "../../apis/Market/tickers";
+import { useNavigate } from "react-router-dom";
+
+const formatPrice = (price) => {
+ if (price == null) return "-";
+ return price.toLocaleString("ko-KR");
+};
+
+const formatTradeValue = (value) => {
+ if (value == null) return "-";
+ const eok = value / 100000000;
+ if (eok >= 1) {
+ return `${Math.round(eok).toLocaleString("ko-KR")}억`;
+ }
+ return value.toLocaleString("ko-KR");
+};
+
+const formatChangeRate = (rate) => {
+ if (rate == null) return "-";
+ const percent = (rate * 100).toFixed(2);
+ return rate >= 0 ? `+${percent}` : percent;
+};
+
+const CoinListTable = () => {
+ const nav = useNavigate();
+
+ const [activeTab, setActiveTab] = useState("domestic");
+ const [coins, setCoins] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchCoins = async (marketType) => {
+ try {
+ setLoading(true);
+ setError(null);
+ const res = await getTickers(marketType);
+ setCoins(res.data || []);
+ } catch (err) {
+ setError("시세 정보를 불러오는데 실패했습니다.");
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ const marketType = activeTab === "domestic" ? "KRW" : "BTC";
+ fetchCoins(marketType);
+ }, [activeTab]);
+
+ return (
+
+ {/* Header */}
+
+
코인 시세
+
+
+
+
+
+
+
+ {/* Table Header */}
+
+ 코인명
+ 가격
+ 등락폭
+ 거래대금
+
+
+ {/* Table Rows */}
+ {loading ? (
+
+ 로딩 중...
+
+ ) : error ? (
+
+ {error}
+
+ ) : (
+ coins.map((coin, index) => (
+
nav(`/coindetail/${coin.symbol}`)}
+ >
+
+ {coin.name} ({coin.symbol})
+
+
+ {activeTab === "domestic" ? "₩" : ""}{formatPrice(coin.price)}
+
+ = 0 ? "text-[#FF4242]" : "text-[#4073FF]"
+ }`}>
+ {formatChangeRate(coin.changeRate)}%
+
+
+ {activeTab === "domestic" ? "₩" : ""}{formatTradeValue(coin.tradeValue)}
+
+
+ ))
+ )}
+
+ )
+}
+
+export default CoinListTable;
+
diff --git a/src/components/MainPage/CoinSearchBar.jsx b/src/components/MainPage/CoinSearchBar.jsx
new file mode 100644
index 0000000..40b9a83
--- /dev/null
+++ b/src/components/MainPage/CoinSearchBar.jsx
@@ -0,0 +1,16 @@
+const CoinSearchBar = () => {
+ return (
+
+ )
+}
+
+export default CoinSearchBar;
diff --git a/src/components/MainPage/NewsCard.jsx b/src/components/MainPage/NewsCard.jsx
new file mode 100644
index 0000000..6ba1f24
--- /dev/null
+++ b/src/components/MainPage/NewsCard.jsx
@@ -0,0 +1,47 @@
+import RoundButton from "../common/RoundButton";
+
+const sentimentMap = {
+ POSITIVE: { label: "호재", status: "positive" },
+ NEGATIVE: { label: "악재", status: "negative" },
+ NEUTRAL: { label: "중립", status: "neutral" },
+};
+
+const formatTimeAgo = (dateStr) => {
+ const now = new Date();
+ const date = new Date(dateStr);
+ const diffMs = now - date;
+ const diffMin = Math.floor(diffMs / 60000);
+ const diffHour = Math.floor(diffMs / 3600000);
+ const diffDay = Math.floor(diffMs / 86400000);
+
+ if (diffMin < 60) return `${diffMin}분 전`;
+ if (diffHour < 24) return `${diffHour}시간 전`;
+ return `${diffDay}일 전`;
+};
+
+const NewsCard = ({ title, sentimentLabel, sentimentScore, relatedCryptos, publishedAt }) => {
+ const sentiment = sentimentMap[sentimentLabel] || sentimentMap.NEUTRAL;
+ const coinNames = relatedCryptos?.map(c => c.ticker).join(", ") || "-";
+ const confidence = Math.round((sentimentScore || 0) * 100);
+
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ 영향 코인: {coinNames} | {formatTimeAgo(publishedAt)} | 신뢰도: {confidence}%
+
+
+
+
+ )
+}
+
+export default NewsCard;
+
diff --git a/src/components/MainPage/NewsSearchBar.jsx b/src/components/MainPage/NewsSearchBar.jsx
new file mode 100644
index 0000000..5ea772b
--- /dev/null
+++ b/src/components/MainPage/NewsSearchBar.jsx
@@ -0,0 +1,50 @@
+import { useState } from "react";
+
+const NewsSearchBar = ({ onSearch }) => {
+ const [keyword, setKeyword] = useState("");
+
+ const handleSearch = () => {
+ onSearch(keyword.trim());
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === "Enter") {
+ handleSearch();
+ }
+ };
+
+ const handleClear = () => {
+ setKeyword("");
+ onSearch("");
+ };
+
+ return (
+
+
+
+
+
setKeyword(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="뉴스 검색 (예: ETF, 규제, 상승)"
+ className="w-full h-12 pl-12 pr-10 border border-[#E0E0E0] rounded-lg bg-white text-[14px] text-[#212121] placeholder-[#9E9E9E] outline-none focus:border-[#1F78F2] transition-colors"
+ />
+ {keyword && (
+
+
+
+ )}
+
+ )
+}
+
+export default NewsSearchBar;
+
diff --git a/src/components/common/Navbar.jsx b/src/components/common/Navbar.jsx
index 5bf7b0b..1af39de 100644
--- a/src/components/common/Navbar.jsx
+++ b/src/components/common/Navbar.jsx
@@ -3,27 +3,27 @@ import { useNavigate } from "react-router-dom";
const Navbar = () => {
const nav = useNavigate();
return (
-
+
CoinSight
-
nav("/")}
- className="cursor-pointer"
+ className="cursor-pointer"
>
메인
-
nav("/game")}
- className="cursor-pointer"
+ className="cursor-pointer"
>
게임
-
nav("/leaderboard")}
- className="cursor-pointer"
+ className="cursor-pointer"
>
리더보드
diff --git a/src/components/common/RoundButton.jsx b/src/components/common/RoundButton.jsx
new file mode 100644
index 0000000..9b6c627
--- /dev/null
+++ b/src/components/common/RoundButton.jsx
@@ -0,0 +1,34 @@
+const RoundButton = ({ content, status }) => {
+ const buttonColor = {
+ positive : 'bg-[#FF4D66]',
+ negative: 'bg-[#4073FF]',
+ neutral: 'bg-[#9E9E9E]',
+ predict_success: 'bg-[#47D994]',
+ predict_fail: 'bg-[#8C8C8C]'
+ }
+
+ const Icon = {
+ positive: "mingcute:arrow-up-fill",
+ negative: "mingcute:arrow-down-fill",
+ neutral: "null",
+ predict_success: "mingcute:check-fill",
+ predict_fail: "fa6-solid:xmark",
+ }[status];
+
+ return (
+
+ )
+}
+
+export default RoundButton;
\ No newline at end of file
diff --git a/src/components/common/SearchBar.jsx b/src/components/common/SearchBar.jsx
new file mode 100644
index 0000000..723207e
--- /dev/null
+++ b/src/components/common/SearchBar.jsx
@@ -0,0 +1,54 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+const SearchBar = ({ defaultKeyword = "" }) => {
+ const [keyword, setKeyword] = useState(defaultKeyword);
+ const nav = useNavigate();
+
+ const handleSearch = () => {
+ const trimmed = keyword.trim();
+ if (trimmed) {
+ nav(`/search?keyword=${encodeURIComponent(trimmed)}`);
+ }
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === "Enter") {
+ handleSearch();
+ }
+ };
+
+ const handleClear = () => {
+ setKeyword("");
+ nav("/");
+ };
+
+ return (
+
+
+
+
+
setKeyword(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="검색어를 입력하세요 (예: 비트코인, ETF, 규제)"
+ className="w-full h-12 pl-12 pr-10 border border-[#E0E0E0] rounded-lg bg-white text-[14px] text-[#212121] placeholder-[#9E9E9E] outline-none focus:border-[#1F78F2] transition-colors"
+ />
+ {keyword && (
+
+
+
+ )}
+
+ )
+}
+
+export default SearchBar;
diff --git a/src/pages/CoinDetailPage.jsx b/src/pages/CoinDetailPage.jsx
index c042262..a846ef8 100644
--- a/src/pages/CoinDetailPage.jsx
+++ b/src/pages/CoinDetailPage.jsx
@@ -2,17 +2,26 @@ import { useEffect, useState } from "react";
import Navbar from "../components/common/Navbar";
import CoinChart from "../components/CoinDetail/CoinChart";
import History from "../components/CoinDetail/History";
-//import { getCoinInfo } from "../apis/CoinDetail/coinInfo";
+import { getCoinInfo } from "../apis/CoinDetail/coinInfo";
+import BasicLogo from "../assets/pixelarticons--coin.svg";
+import DailyNews from "../components/CoinDetail/DailyNews";
+import { useParams } from "react-router-dom";
const CoinDetailPage = () => {
- const coinName = "KRW-BTC";
- const ticker = "BTC";
- const [currentPrice, setCurrentPrice] = useState(0);
- const [tradePrice, setTradePrice] = useState(0);
- const [fluctuationRange, setFluctuationRange] = useState(0);
- const [isPositive, setIsPositive] = useState(true);
- const [todayDate, setTodayDate] = useState(null);
- const [todayTime, setTodayTime] = useState(null);
+ const { ticker } = useParams();
+
+ const [coinInfo, setCoinInfo] = useState({
+ name: "",
+ ticker1: "",
+ logoURL: BasicLogo,
+ currentPrice: "0",
+ rawCurrentPrice: "0",
+ tradePrice: "0",
+ fluctuationRange: "0",
+ isPositive: true,
+ date: "",
+ time: ""
+ })
const formatPrice = (value) => {
@@ -21,44 +30,30 @@ const CoinDetailPage = () => {
return `${formatPrice.toLocaleString(undefined, {maximumFractionDigits: 1})}억`;
}
- const getDateTime = () => {
- var today = new Date();
-
- var year = today.getFullYear();
- var month = ('0' + (today.getMonth() + 1)).slice(-2);
- var day = ('0' + today.getDate()).slice(-2);
-
- setTodayDate(year + '년 ' + month + '월 ' + day + '일');
-
- var hours = ('0' + today.getHours()).slice(-2);
- var minutes = ('0' + today.getMinutes()).slice(-2);
- var seconds = ('0' + today.getSeconds()).slice(-2);
-
- setTodayTime(hours + ':' + minutes + ':' + seconds);
- }
-
useEffect (() => {
const options = {method: 'GET', headers: {accept: 'application/json'}};
- fetch(`https://api.bithumb.com/v1/ticker?markets=${coinName}`, options)
- .then(response => response.json())
- .then(response => {
- //console.log(response);
- const formattedPrice = formatPrice(response[0].acc_trade_price_24h);
- setTradePrice(formattedPrice);
- const p = response[0].trade_price.toLocaleString('ko-KR');
- setCurrentPrice(p);
- })
- .catch(err => console.error(err));
-
- // const fetchCoinInfo = async () => {
- // try {
- // const res = await getCoinInfo(ticker);
- // console.log(res);
- // } catch (error) {
- // console.error("데이터 호출 실패 : ", error);
- // }
- // }
+ const fetchCoinInfo = async () => {
+ try {
+ const res = await getCoinInfo(ticker);
+ const {data, timestamp} = res;
+ const today = new Date(timestamp);
+
+ setCoinInfo(prev => ({
+ ...prev,
+ name: data.name,
+ ticker1: data.ticker,
+ logoURL: data.logoUrl,
+ currentPrice: Number(data.currentPrice).toLocaleString('ko-KR'),
+ rawCurrentPrice: data.currentPrice,
+ tradePrice: formatPrice(data.tradingVolume),
+ date: `${today.getFullYear()}년 ${today.getMonth() + 1}월 ${today.getDate()}일`,
+ time: `${today.getHours()}:${today.getMinutes()}:${today.getSeconds()}`
+ }));
+ } catch (error) {
+ console.error("데이터 호출 실패 : ", error);
+ }
+ }
fetch(`https://api.bithumb.com/public/candlestick/BTC_KRW/1h`, options)
.then(res => res.json())
@@ -69,14 +64,16 @@ const CoinDetailPage = () => {
const prevPrice = parseFloat(data[len - 2][2]);
const changeRate = ((currentPrice - prevPrice) / prevPrice) * 100;
- setFluctuationRange(changeRate.toFixed(2));
- if (changeRate < 0) setIsPositive(false);
+ setCoinInfo(prevInfo => ({
+ ...prevInfo,
+ fluctuationRange: changeRate.toFixed(2),
+ isPositive: changeRate >= 0
+ }));
})
.catch(err => console.error(err));
- //fetchCoinInfo();
- getDateTime();
- }, [coinName, ticker]);
+ fetchCoinInfo();
+ }, [ticker]);
return (
@@ -84,36 +81,42 @@ const CoinDetailPage = () => {
-
- 비트코인(BTC)
+
+
+

+
+
+ {coinInfo.name}({coinInfo.ticker1})
+
+
- {todayDate} {todayTime} 기준
+ {coinInfo.date} {coinInfo.time} 기준
현재가
-
₩{currentPrice}
+
₩{coinInfo.currentPrice}
1시간 등락
-
{fluctuationRange}%
+
{coinInfo.fluctuationRange}%
24시간 거래대금
-
₩{tradePrice}
+
₩{coinInfo.tradePrice}
-
-
-
+
+
+
)
diff --git a/src/pages/MainPage.jsx b/src/pages/MainPage.jsx
index a5a4161..108e790 100644
--- a/src/pages/MainPage.jsx
+++ b/src/pages/MainPage.jsx
@@ -1,12 +1,85 @@
+import { useState, useEffect } from "react";
import Navbar from "../components/common/Navbar";
+import SearchBar from "../components/common/SearchBar";
+import NewsCard from "../components/MainPage/NewsCard";
+import CoinListTable from "../components/MainPage/CoinListTable";
+import { getNewsAnalysis } from "../apis/News/analysis";
const MainPage = () => {
+ const [newsList, setNewsList] = useState([]);
+ const [newsLoading, setNewsLoading] = useState(true);
+ const [newsError, setNewsError] = useState(null);
+
+ useEffect(() => {
+ const fetchNews = async () => {
+ try {
+ setNewsLoading(true);
+ setNewsError(null);
+ const res = await getNewsAnalysis(0, 20);
+ setNewsList(res.data?.content || []);
+ } catch (err) {
+ setNewsError("뉴스를 불러오는데 실패했습니다.");
+ console.error(err);
+ } finally {
+ setNewsLoading(false);
+ }
+ };
+ fetchNews();
+ }, []);
+
return (
- main page
+
+ {/* 통합 검색바 */}
+
+
+
+
+
+ {/* 왼쪽: 뉴스 영역 */}
+
+
+
AI 뉴스 분석
+
+ {newsLoading ? (
+
+ 로딩 중...
+
+ ) : newsError ? (
+
+ {newsError}
+
+ ) : newsList.length === 0 ? (
+
+ 뉴스가 없습니다.
+
+ ) : (
+ newsList.map((news) => (
+
+ ))
+ )}
+
+
+
+
+ {/* 오른쪽: 코인 시세 영역 */}
+
+
+
+
+
)
}
-export default MainPage;
\ No newline at end of file
+export default MainPage;
+
+
diff --git a/src/pages/SearchResultPage.jsx b/src/pages/SearchResultPage.jsx
new file mode 100644
index 0000000..dd4d10a
--- /dev/null
+++ b/src/pages/SearchResultPage.jsx
@@ -0,0 +1,85 @@
+import { useState, useEffect } from "react";
+import { useSearchParams } from "react-router-dom";
+import Navbar from "../components/common/Navbar";
+import SearchBar from "../components/common/SearchBar";
+import NewsCard from "../components/MainPage/NewsCard";
+import { searchNews } from "../apis/News/search";
+
+const SearchResultPage = () => {
+ const [searchParams] = useSearchParams();
+ const keyword = searchParams.get("keyword") || "";
+
+ const [newsList, setNewsList] = useState([]);
+ const [newsLoading, setNewsLoading] = useState(true);
+ const [newsError, setNewsError] = useState(null);
+
+ useEffect(() => {
+ if (!keyword) return;
+
+ const fetchSearch = async () => {
+ try {
+ setNewsLoading(true);
+ setNewsError(null);
+ const res = await searchNews(keyword, 0, 20);
+ setNewsList(res.data?.content || []);
+ } catch (err) {
+ setNewsError("뉴스 검색에 실패했습니다.");
+ console.error(err);
+ } finally {
+ setNewsLoading(false);
+ }
+ };
+ fetchSearch();
+ }, [keyword]);
+
+ return (
+
+
+
+ {/* 검색바 */}
+
+
+
+
+ {/* 뉴스 검색 결과 */}
+
+
+
+ "{keyword}" 뉴스 검색 결과
+
+
+ {newsLoading ? (
+
+ 로딩 중...
+
+ ) : newsError ? (
+
+ {newsError}
+
+ ) : newsList.length === 0 ? (
+
+ 검색 결과가 없습니다.
+
+ ) : (
+ newsList.map((news) => (
+
+ ))
+ )}
+
+
+
+ {/* 코인 검색 결과 (추후 추가) */}
+
+
+
+ )
+}
+
+export default SearchResultPage;