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 ( +
+
+ +
+ {title} +
+
+ +
+
{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}
+
+ +
+
AI 예측
+ +
+ +
+
실제 결과
+
+ {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 ( +
+

{content}

+
+ +
+
+ ) +} + +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) +
+
+ logo +
+
+ {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;