feat(stock): 모바일 반응형 — 캐러셀 지표 + 스와이프 탭 + FAB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 14:46:58 +09:00
parent fd13f65faa
commit e7427ff1d5
3 changed files with 110 additions and 29 deletions

View File

@@ -2943,3 +2943,41 @@
justify-content: flex-end;
}
}
@media (max-width: 768px) {
/* 필터 가로 스크롤 */
.stock-filter-row {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
}
.stock-filter-row > * {
flex-shrink: 0;
}
/* 지표 카드 가로 스크롤 캐러셀 */
.stock-snapshot {
display: flex;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
gap: 12px;
padding-bottom: 8px;
scroll-snap-type: x mandatory;
}
.stock-snapshot > * {
flex: 0 0 200px;
scroll-snap-align: start;
}
/* 뉴스 1컬럼 */
.stock-news-grid {
grid-template-columns: 1fr;
}
/* 매크로 지표 1컬럼 */
.stock-macro-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,8 +1,11 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { getStockIndices, getStockNews, getFearAndGreed, getVix, getTreasury10Y, getWTI, getBrent } from '../../api';
import Loading from '../../components/Loading';
import FearGreedGauge from '../../components/FearGreedGauge';
import { useIsMobile } from '../../hooks/useIsMobile';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import './Stock.css';
const formatDate = (value) => {
@@ -109,6 +112,7 @@ const getVixLevel = (score) => {
};
const Stock = () => {
const isMobile = useIsMobile();
const [newsDomestic, setNewsDomestic] = useState([]);
const [newsOverseas, setNewsOverseas] = useState([]);
const [newsCategory, setNewsCategory] = useState('domestic');
@@ -146,6 +150,10 @@ const Stock = () => {
}
};
const handleRefresh = useCallback(async () => {
await loadNews();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const loadIndices = async () => {
setIndicesLoading(true);
setIndicesError('');
@@ -217,6 +225,7 @@ const Stock = () => {
newsCategory === 'domestic' ? newsDomestic : newsOverseas;
return (
<PullToRefresh onRefresh={handleRefresh}>
<div className="stock">
<header className="stock-header">
<div>
@@ -559,6 +568,13 @@ const Stock = () => {
)}
</section>
</div>
<FAB onClick={loadNews} label="뉴스 새로고침" icon={
<svg className="fab__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
} />
</PullToRefresh>
);
};

View File

@@ -1,6 +1,8 @@
import React, { useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import './Stock.css';
import { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
import {
formatNumber, formatPercent,
toNumeric, profitColorClass,
@@ -28,6 +30,12 @@ import SellHistoryDrawer from './components/SellHistoryDrawer';
const StockTrade = () => {
const [activeTab, setActiveTab] = React.useState(TAB_REPORT);
const isMobile = useIsMobile();
const TAB_ORDER = [TAB_PORTFOLIO, TAB_AI, TAB_REPORT, TAB_ADVISOR];
const tabLabels = ['포트폴리오', 'AI 트레이드', '리포트', '어드바이저'];
const tabIndex = TAB_ORDER.indexOf(activeTab);
const handleTabChange = useCallback((idx) => setActiveTab(TAB_ORDER[idx]), []); // eslint-disable-line react-hooks/exhaustive-deps
/* ── hooks ────────────────────────────────────────────────────── */
const pf = usePortfolio();
@@ -166,35 +174,54 @@ const StockTrade = () => {
</div>
</header>
{/* Tab bar */}
<div className="stock-main-tabs">
{[
{ id: TAB_PORTFOLIO, icon: '💼', label: '쟁승토리 계좌', badge: pf.portfolioHoldings.length || null },
{ id: TAB_AI, icon: '🤖', label: 'AI 투자', sub: '모의투자' },
{ id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' },
{ id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' },
].map(({ id, icon, label, sub, badge, className: cls }) => (
<button
key={id}
type="button"
className={`stock-main-tab ${cls ?? ''} ${activeTab === id ? 'is-active' : ''}`}
onClick={() => setActiveTab(id)}
>
<span className="stock-main-tab__icon">{icon}</span>
<span className="stock-main-tab__label">{label}</span>
{sub && <span className="stock-main-tab__sub">{sub}</span>}
{badge > 0 && <span className="stock-main-tab__badge">{badge}</span>}
</button>
))}
</div>
{/* Tab bar + Tab content */}
{isMobile ? (
<SwipeableView
tabs={TAB_ORDER.map((tabId, i) => ({
key: tabId,
label: tabLabels[i],
content: tabId === TAB_PORTFOLIO
? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
: tabId === TAB_AI
? <AiTradeTab aib={aib} />
: tabId === TAB_REPORT
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
: <AdvisorTab pf={pf} advisor={advisor} />,
}))}
activeIndex={tabIndex}
onTabChange={handleTabChange}
/>
) : (
<>
<div className="stock-main-tabs">
{[
{ id: TAB_PORTFOLIO, icon: '💼', label: '쟁승토리 계좌', badge: pf.portfolioHoldings.length || null },
{ id: TAB_AI, icon: '🤖', label: 'AI 투자', sub: '모의투자' },
{ id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' },
{ id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' },
].map(({ id, icon, label, sub, badge, className: cls }) => (
<button
key={id}
type="button"
className={`stock-main-tab ${cls ?? ''} ${activeTab === id ? 'is-active' : ''}`}
onClick={() => setActiveTab(id)}
>
<span className="stock-main-tab__icon">{icon}</span>
<span className="stock-main-tab__label">{label}</span>
{sub && <span className="stock-main-tab__sub">{sub}</span>}
{badge > 0 && <span className="stock-main-tab__badge">{badge}</span>}
</button>
))}
</div>
{/* Tab content */}
{activeTab === TAB_PORTFOLIO && (
<PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
{activeTab === TAB_PORTFOLIO && (
<PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
)}
{activeTab === TAB_AI && <AiTradeTab aib={aib} />}
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
</>
)}
{activeTab === TAB_AI && <AiTradeTab aib={aib} />}
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
{/* Sell history drawer (always mounted) */}
<SellHistoryDrawer