diff --git a/src/App.jsx b/src/App.jsx
index 1530411..3e20dc8 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,6 +1,7 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Navbar from './components/Navbar';
+import PageHeader from './components/PageHeader';
import Loading from './components/Loading';
import './App.css';
@@ -10,6 +11,7 @@ function App() {
Profile
diff --git a/src/pages/stock/Stock.css b/src/pages/stock/Stock.css
index 51024a0..309574a 100644
--- a/src/pages/stock/Stock.css
+++ b/src/pages/stock/Stock.css
@@ -240,11 +240,11 @@
}
.stock-snapshot__change.is-up {
- color: #f3a7a7;
+ color: #f04452;
}
.stock-snapshot__change.is-down {
- color: #9fc5ff;
+ color: #3b82f6;
}
.stock-schedule {
@@ -291,7 +291,6 @@
.stock-tabs {
display: flex;
gap: 8px;
- margin-bottom: 10px;
}
.stock-tab {
@@ -452,11 +451,11 @@
}
.stock-profit.is-up {
- color: #f3a7a7;
+ color: #f04452;
}
.stock-profit.is-down {
- color: #9fc5ff;
+ color: #3b82f6;
}
.stock-profit.is-flat {
@@ -1122,6 +1121,115 @@
margin-top: 2px;
}
+/* ── F&G Level 설명 ─────────────────────────────────────────────── */
+
+.fg-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.fg-levels {
+ display: grid;
+ gap: 6px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--line);
+}
+
+.fg-level {
+ padding: 10px 12px;
+ border-radius: 10px;
+ border: 1px solid transparent;
+ background: rgba(255, 255, 255, 0.02);
+ transition: background 0.2s ease, border-color 0.2s ease;
+}
+
+.fg-level.is-current {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+.fg-level__head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.fg-level__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.fg-level__label {
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.fg-level__range {
+ font-size: 11px;
+ color: var(--muted);
+ margin-left: auto;
+}
+
+.fg-level__desc {
+ margin: 0;
+ font-size: 12px;
+ color: var(--muted);
+ line-height: 1.6;
+ padding-left: 16px;
+}
+
+/* ── 뉴스 툴바 (탭 + 인라인 필터) ─────────────────────────────── */
+
+.stock-news-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+}
+
+.stock-tab-count {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 9px;
+ background: rgba(96, 165, 250, 0.15);
+ font-size: 10px;
+ font-weight: 700;
+ margin-left: 4px;
+ vertical-align: middle;
+}
+
+.stock-tab.is-active .stock-tab-count {
+ background: rgba(96, 165, 250, 0.3);
+}
+
+.stock-news-limit {
+ border: 1px solid var(--line);
+ background: rgba(0, 0, 0, 0.25);
+ color: var(--text);
+ border-radius: 10px;
+ padding: 6px 10px;
+ font-size: 12px;
+ cursor: pointer;
+ outline: none;
+ transition: border-color 0.2s ease;
+}
+
+.stock-news-limit:hover,
+.stock-news-limit:focus {
+ border-color: rgba(96, 165, 250, 0.4);
+}
+
/* ══════════════════════════════════════════════════════════════════
Report Charts Row
══════════════════════════════════════════════════════════════════ */
@@ -1449,4 +1557,126 @@
.fg-gauge__labels span:nth-child(4) {
display: none;
}
+}
+
+/* ══════════════════════════════════════════════════════════════════
+ VIX Panel
+ ══════════════════════════════════════════════════════════════════ */
+
+.stock-vix {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ padding: 12px 0 8px;
+ text-align: center;
+}
+
+.stock-vix__score {
+ font-size: 52px;
+ font-weight: 800;
+ line-height: 1;
+ transition: color 0.4s ease;
+}
+
+.stock-vix__label {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 700;
+ transition: color 0.4s ease;
+}
+
+.stock-vix__legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 12px;
+ justify-content: center;
+ margin-top: 10px;
+ font-size: 11px;
+}
+
+.stock-vix__legend span {
+ font-weight: 500;
+}
+
+/* ══════════════════════════════════════════════════════════════════
+ News Card Grid
+ ══════════════════════════════════════════════════════════════════ */
+
+.stock-news-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 14px;
+}
+
+.stock-news-card {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ background: rgba(0, 0, 0, 0.2);
+ transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
+}
+
+.stock-news-card:hover {
+ border-color: rgba(96, 165, 250, 0.3);
+ background: rgba(96, 165, 250, 0.04);
+ transform: translateY(-2px);
+}
+
+.stock-news-card__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.stock-news-card__date {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.stock-news-card__title {
+ margin: 0;
+ font-weight: 600;
+ font-size: 15px;
+ color: var(--text);
+ line-height: 1.4;
+}
+
+.stock-news-card__summary {
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.6;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.stock-news-card__link {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ color: var(--accent-stock);
+ text-decoration: none;
+ margin-top: auto;
+ transition: opacity 0.15s;
+ font-weight: 500;
+}
+
+.stock-news-card__link:hover {
+ opacity: 0.75;
+ text-decoration: underline;
+}
+
+@media (max-width: 640px) {
+ .stock-news-grid {
+ grid-template-columns: 1fr;
+ }
}
\ No newline at end of file
diff --git a/src/pages/stock/Stock.jsx b/src/pages/stock/Stock.jsx
index d410c36..0c3941e 100644
--- a/src/pages/stock/Stock.jsx
+++ b/src/pages/stock/Stock.jsx
@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
-import { getStockIndices, getStockNews } from '../../api';
+import { getStockIndices, getStockNews, getFearAndGreed, getVix } from '../../api';
import Loading from '../../components/Loading';
+import FearGreedGauge from '../../components/FearGreedGauge';
import './Stock.css';
const formatDate = (value) => {
@@ -11,21 +12,6 @@ const formatDate = (value) => {
return date.toLocaleString('sv-SE');
};
-const toDateValue = (value) => {
- if (!value) return null;
- const date = new Date(value);
- return Number.isNaN(date.getTime()) ? null : date;
-};
-
-const getLatestBy = (items, key) => {
- const filtered = items
- .map((item) => ({ ...item, __date: toDateValue(item?.[key]) }))
- .filter((item) => item.__date);
- if (!filtered.length) return null;
- filtered.sort((a, b) => b.__date - a.__date);
- return filtered[0]?.[key] ?? null;
-};
-
const normalizeIndices = (data) => {
if (!data) return [];
@@ -65,23 +51,40 @@ const normalizeIndices = (data) => {
};
const getDirection = (change, percent, direction) => {
- if (direction === 'red') return 'up';
- if (direction === 'blue') return 'down';
- const pick = (value) =>
- value === undefined || value === null || value === '' ? null : value;
- const raw = pick(change) ?? pick(percent);
- if (!raw) return '';
- const str = String(raw).trim();
- if (str.startsWith('-')) return 'down';
- if (str.startsWith('+')) return 'up';
- const numeric = Number(str.replace(/[^0-9.-]/g, ''));
- if (Number.isFinite(numeric)) {
- if (numeric > 0) return 'up';
- if (numeric < 0) return 'down';
+ // 숫자 부호로 방향 추출 (percent → change 순서로 시도)
+ const fromStr = (s) => {
+ if (s === undefined || s === null || s === '') return null;
+ const str = String(s).trim();
+ if (str.startsWith('-')) return 'down';
+ if (str.startsWith('+')) return 'up';
+ const numeric = Number(str.replace(/[^0-9.-]/g, ''));
+ if (Number.isFinite(numeric) && numeric !== 0) {
+ return numeric > 0 ? 'up' : 'down';
+ }
+ return null;
+ };
+ // percent 필드가 부호를 가장 신뢰성 있게 포함하는 경우가 많음
+ const byPercent = fromStr(percent);
+ if (byPercent) return byPercent;
+ const byChange = fromStr(change);
+ if (byChange) return byChange;
+ // 숫자로 판별 불가 시 direction 필드 fallback
+ if (direction) {
+ const d = String(direction).toLowerCase();
+ if (d === 'red' || d === 'up' || d === 'rise' || d === 'positive') return 'up';
+ if (d === 'blue' || d === 'down' || d === 'fall' || d === 'negative') return 'down';
}
return '';
};
+const getVixLevel = (score) => {
+ if (score < 12) return { label: '극히 낮음', color: '#22c55e' };
+ if (score < 20) return { label: '정상', color: '#84cc16' };
+ if (score < 30) return { label: '보통', color: '#eab308' };
+ if (score < 40) return { label: '높음', color: '#f97316' };
+ return { label: '극단', color: '#ef4444' };
+};
+
const Stock = () => {
const [newsDomestic, setNewsDomestic] = useState([]);
const [newsOverseas, setNewsOverseas] = useState([]);
@@ -94,14 +97,13 @@ const Stock = () => {
const [indicesLoading, setIndicesLoading] = useState(false);
const [autoRefreshMs] = useState(180000);
+ const [fgData, setFgData] = useState(null);
+ const [vixData, setVixData] = useState(null);
+
const combinedNews = useMemo(
() => [...newsDomestic, ...newsOverseas],
[newsDomestic, newsOverseas]
);
- const latestPublished = useMemo(
- () => getLatestBy(combinedNews, 'published_at'),
- [combinedNews]
- );
const loadNews = async () => {
setLoading(true);
@@ -143,6 +145,19 @@ const Stock = () => {
return () => window.clearInterval(timer);
}, [autoRefreshMs]);
+ useEffect(() => {
+ getFearAndGreed()
+ .then((data) => {
+ const fg = data?.fear_and_greed ?? data;
+ const score = typeof fg?.score === 'number' ? fg.score : parseFloat(fg?.score);
+ if (!isNaN(score)) {
+ setFgData({ score, timestamp: fg?.timestamp ?? null });
+ }
+ })
+ .catch(() => { });
+ getVix().then(setVixData).catch(() => { });
+ }, []);
+
const indexOrder = [
'KOSPI',
'KOSDAQ',
@@ -262,62 +277,53 @@ const Stock = () => {
+ {/* 시장 심리 지표 행 */}
뉴스가 없습니다.
) : (
<>
-