소스 검색

feat(stock): 종목 상세 뒤로가기 + 관심종목 ☆ + 사이드바 실목록

- StockDetailHeader: 뒤로가기(router.back) + 관심종목 ☆ 토글
  (useAuth 가드, 낙관적 업데이트, key 리마운트로 stale race 방지)
- MarketSidebar: placeholder → 실 관심종목 목록
  (watchlist:changed 연동, 요청 순번 race 가드, aria-controls)
- /api/stocks 프록시 POST 추가, types Watch
- 환율/금리 위젯은 백엔드 재연결로 자동 표시(프론트 변경 없음)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 1 주 전
부모
커밋
0040cb417b

+ 112 - 0
app/(main)/stock/[code]/_components/StockDetailHeader.tsx

@@ -0,0 +1,112 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { ArrowLeft } from 'lucide-react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faStar as faStarSolid } from '@fortawesome/free-solid-svg-icons';
+import { faStar as faStarRegular } from '@fortawesome/free-regular-svg-icons';
+import useAuth from '@/hooks/useAuth';
+import { fetchApi } from '@/lib/utils/client';
+import { StockWatchStatusResponse, StockWatchToggleResponse } from '@/types/stock';
+
+type Props = {
+	code: string;
+	name: string;
+};
+
+// 종목 상세 상단 바 — 뒤로가기 + 관심종목(☆) 토글. RSC(page.tsx) 안에 마운트되는 client island.
+export default function StockDetailHeader({ code, name }: Props)
+{
+	const router = useRouter();
+	const { isAuthenticated } = useAuth();
+	const [watched, setWatched] = useState(false);
+	const [pending, setPending] = useState(false);
+
+	// 초기 관심 여부 조회 (로그인 시)
+	useEffect(() => {
+		if (!isAuthenticated) {
+			setWatched(false);
+			return;
+		}
+
+		let alive = true;
+
+		fetchApi<StockWatchStatusResponse>(`/api/stocks/watchlist/status?codes=${encodeURIComponent(code)}`, { method: 'GET', silent: true })
+			.then(res => {
+				if (alive && res.success && res.data) {
+					setWatched(res.data.map?.[code] === true);
+				}
+			})
+			.catch(() => {});
+
+		return () => {
+			alive = false;
+		};
+	}, [code, isAuthenticated]);
+
+	const handleBack = useCallback(() => {
+		if (window.history.length > 1) {
+			router.back();
+		}
+		else {
+			router.push('/stock');
+		}
+	}, [router]);
+
+	const handleToggle = useCallback(async () => {
+		if (!isAuthenticated) {
+			if (confirm('로그인 후 이용 가능합니다.\n로그인하시겠습니까?')) {
+				window.dispatchEvent(new CustomEvent('auth:unauthorized'));
+			}
+			return;
+		}
+
+		if (pending) {
+			return;
+		}
+
+		setPending(true);
+		const next = !watched;
+		setWatched(next);
+
+		try {
+			const res = await fetchApi<StockWatchToggleResponse>(`/api/stocks/${encodeURIComponent(code)}/watch`, { method: 'POST', silent: true });
+
+			if (res.success && res.data) {
+				setWatched(res.data.isWatched);
+				window.dispatchEvent(new CustomEvent('watchlist:changed'));
+			}
+			else {
+				setWatched(!next);
+			}
+		}
+		catch {
+			setWatched(!next);
+		}
+		finally {
+			setPending(false);
+		}
+	}, [code, isAuthenticated, pending, watched]);
+
+	return (
+		<div className='stock-detail__topbar'>
+			<button type='button' className='stock-detail__back' onClick={handleBack} aria-label='뒤로 가기'>
+				<ArrowLeft size={20} aria-hidden />
+			</button>
+
+			<button
+				type='button'
+				className={`stock-detail__watch${watched ? ' stock-detail__watch--active' : ''}`}
+				onClick={handleToggle}
+				aria-pressed={watched}
+				aria-label={watched ? `${name} 관심종목 해제` : `${name} 관심종목 추가`}
+				title={watched ? '관심종목 해제' : '관심종목 추가'}
+				disabled={pending}
+			>
+				<FontAwesomeIcon icon={watched ? faStarSolid : faStarRegular} />
+				<span>{watched ? '관심' : '관심 추가'}</span>
+			</button>
+		</div>
+	);
+}

+ 3 - 0
app/(main)/stock/[code]/page.tsx

@@ -16,6 +16,7 @@ import StockDividends from './_components/StockDividends';
 import StockLending from './_components/StockLending';
 import StockLending from './_components/StockLending';
 import StockCbExercises from './_components/StockCbExercises';
 import StockCbExercises from './_components/StockCbExercises';
 import StockPriceChart from './_components/StockPriceChart';
 import StockPriceChart from './_components/StockPriceChart';
+import StockDetailHeader from './_components/StockDetailHeader';
 
 
 const TABS = [
 const TABS = [
 	{ value: 'price', label: '시세' },
 	{ value: 'price', label: '시세' },
@@ -112,6 +113,8 @@ export default async function StockDetailPage({ params, searchParams }: Props)
 
 
 	return (
 	return (
 		<div id='stock-detail'>
 		<div id='stock-detail'>
+			<StockDetailHeader key={detail.code} code={detail.code} name={detail.name} />
+
 			{/* 상단 요약 — T+1 데이터임을 명시 */}
 			{/* 상단 요약 — T+1 데이터임을 명시 */}
 			<header className='stock-detail__summary'>
 			<header className='stock-detail__summary'>
 				<div className='stock-detail__title'>
 				<div className='stock-detail__title'>

+ 62 - 0
app/(main)/stock/[code]/style.scss

@@ -6,6 +6,68 @@
 		padding: var(--sp-5) var(--sp-4);
 		padding: var(--sp-5) var(--sp-4);
 	}
 	}
 
 
+	// 상단 바 — 뒤로가기 + 관심종목
+	.stock-detail__topbar {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: var(--sp-3);
+		margin-bottom: var(--sp-4);
+	}
+
+	.stock-detail__back {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		width: 36px;
+		height: 36px;
+		border: 1px solid var(--border-default);
+		border-radius: 8px;
+		background: var(--bg-card);
+		color: var(--text-secondary);
+		cursor: pointer;
+
+		&:hover,
+		&:focus-visible {
+			background: var(--bg-subtle-hover);
+			color: var(--text-primary);
+		}
+	}
+
+	.stock-detail__watch {
+		display: inline-flex;
+		align-items: center;
+		gap: var(--sp-2);
+		height: 36px;
+		padding: 0 var(--sp-4);
+		border: 1px solid var(--border-default);
+		border-radius: 8px;
+		background: var(--bg-card);
+		color: var(--text-secondary);
+		font-size: var(--fs-sm);
+		cursor: pointer;
+
+		&:hover,
+		&:focus-visible {
+			background: var(--bg-subtle-hover);
+			color: var(--text-primary);
+		}
+
+		&:disabled {
+			opacity: 0.6;
+			cursor: default;
+		}
+
+		&--active {
+			border-color: #f5b301;
+			color: var(--text-primary);
+
+			svg {
+				color: #f5b301;
+			}
+		}
+	}
+
 	// 상단 요약
 	// 상단 요약
 	.stock-detail__summary {
 	.stock-detail__summary {
 		padding-bottom: var(--sp-5);
 		padding-bottom: var(--sp-5);

+ 19 - 0
app/api/stocks/[[...path]]/route.ts

@@ -11,5 +11,24 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
 		method: 'GET'
 		method: 'GET'
 	});
 	});
 
 
+	return NextResponse.json(res);
+}
+
+export async function POST(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/stocks${path && path.length > 0 ? `/${path.join('/')}` : ''}`;
+	const contentType = request.headers.get('content-type') || '';
+	const raw = await request.arrayBuffer();
+
+	if (raw.byteLength > 0) {
+		const res: ResultDto = await fetchJson(endpoint, {
+			method: 'POST',
+			body: raw,
+			headers: contentType ? { 'Content-Type': contentType } : undefined
+		});
+		return NextResponse.json(res);
+	}
+
+	const res: ResultDto = await fetchJson(endpoint, { method: 'POST' });
 	return NextResponse.json(res);
 	return NextResponse.json(res);
 }
 }

+ 80 - 11
app/component/MarketSidebar.tsx

@@ -1,22 +1,30 @@
 'use client';
 'use client';
 
 
 import '@/app/styles/market-sidebar.scss';
 import '@/app/styles/market-sidebar.scss';
-import { useCallback, useEffect, useState } from 'react';
+import Link from 'next/link';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { faAnglesLeft, faAnglesRight, faStar, faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
 import { faAnglesLeft, faAnglesRight, faStar, faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
 import useTheme from '@/hooks/useTheme';
 import useTheme from '@/hooks/useTheme';
+import useAuth from '@/hooks/useAuth';
+import { fetchApi } from '@/lib/utils/client';
+import { changeDirection, changeSymbol, formatChangeRate, formatNumber } from '@/lib/utils/stock';
+import { StockWatchListResponse, StockWatchRow } from '@/types/stock';
 
 
 const STORAGE_KEY = 'az-sidebar-collapsed';
 const STORAGE_KEY = 'az-sidebar-collapsed';
 const WIDTH_EXPANDED = '220px';
 const WIDTH_EXPANDED = '220px';
 const WIDTH_COLLAPSED = '64px';
 const WIDTH_COLLAPSED = '64px';
 
 
-// 좌측 사이드바 자리표시 (D6 그리드 셸) — 관심종목/게시판 즐겨찾기 본구현은 후속(MarketSidebar 본체)
-// 접기 메커니즘은 ChannelSidebar 와 동일: html 루트의 --antooza-sidebar-width 로 layout grid 열 폭 반응
+// 좌측 관심종목 사이드바 — 로그인 회원의 관심종목(종목명·현재가·등락)을 표시. 종목 상세의 ☆ 토글과 'watchlist:changed' 이벤트로 연동.
+// 접기 메커니즘은 ChannelSidebar 와 동일: html 루트의 --antooza-sidebar-width 로 layout grid 열 폭 반응.
 export default function MarketSidebar()
 export default function MarketSidebar()
 {
 {
-	// 자리표시 단계라 기본 접힘(64px 아이콘 레일) — 본문 우선. 토글 시 localStorage 영속.
 	const [collapsed, setCollapsed] = useState(true);
 	const [collapsed, setCollapsed] = useState(true);
 	const { toggleTheme, isDark } = useTheme();
 	const { toggleTheme, isDark } = useTheme();
+	const { isAuthenticated } = useAuth();
+	const [items, setItems] = useState<StockWatchRow[]>([]);
+	const [loading, setLoading] = useState(false);
+	const reqRef = useRef(0);
 
 
 	// 최초 mount 시 localStorage에서 collapsed 상태 복원
 	// 최초 mount 시 localStorage에서 collapsed 상태 복원
 	useEffect(() => {
 	useEffect(() => {
@@ -36,11 +44,49 @@ export default function MarketSidebar()
 			window.localStorage.setItem(STORAGE_KEY, String(collapsed));
 			window.localStorage.setItem(STORAGE_KEY, String(collapsed));
 		} catch {}
 		} catch {}
 		return () => {
 		return () => {
-			// unmount 시 원복 (breakpoint 별 --az-sidebar-w 기본값으로 복귀)
+			// unmount 시 원복 (breakpoint 별 기본값으로 복귀)
 			root.style.removeProperty('--antooza-sidebar-width');
 			root.style.removeProperty('--antooza-sidebar-width');
 		};
 		};
 	}, [collapsed]);
 	}, [collapsed]);
 
 
+	const loadItems = useCallback(() => {
+		if (!isAuthenticated) {
+			setItems([]);
+			return;
+		}
+
+		const reqId = ++reqRef.current;
+		setLoading(true);
+		fetchApi<StockWatchListResponse>('/api/stocks/watchlist', { method: 'GET', silent: true })
+			.then(res => {
+				// 늦게 도착한 이전 요청이 최신 목록을 덮어쓰지 않도록 순번 가드 (auth effect + watchlist:changed 중첩 방지)
+				if (reqId !== reqRef.current) {
+					return;
+				}
+				if (res.success && res.data) {
+					setItems(res.data.list ?? []);
+				}
+			})
+			.catch(() => {})
+			.finally(() => {
+				if (reqId === reqRef.current) {
+					setLoading(false);
+				}
+			});
+	}, [isAuthenticated]);
+
+	// 로그인 상태 변화 시 목록 로드
+	useEffect(() => {
+		loadItems();
+	}, [loadItems]);
+
+	// 상세 페이지 등에서 관심종목 변경 시 갱신
+	useEffect(() => {
+		const handler = () => loadItems();
+		window.addEventListener('watchlist:changed', handler);
+		return () => window.removeEventListener('watchlist:changed', handler);
+	}, [loadItems]);
+
 	const handleToggleCollapse = useCallback(() => {
 	const handleToggleCollapse = useCallback(() => {
 		setCollapsed(prev => !prev);
 		setCollapsed(prev => !prev);
 	}, []);
 	}, []);
@@ -58,24 +104,47 @@ export default function MarketSidebar()
 					onClick={handleToggleCollapse}
 					onClick={handleToggleCollapse}
 					aria-label={collapsed ? '사이드바 펼치기' : '사이드바 접기'}
 					aria-label={collapsed ? '사이드바 펼치기' : '사이드바 접기'}
 					aria-expanded={!collapsed}
 					aria-expanded={!collapsed}
+					aria-controls='market-sidebar-body'
 					title={collapsed ? '사이드바 펼치기' : '사이드바 접기'}
 					title={collapsed ? '사이드바 펼치기' : '사이드바 접기'}
 				>
 				>
 					<FontAwesomeIcon icon={collapsed ? faAnglesRight : faAnglesLeft} />
 					<FontAwesomeIcon icon={collapsed ? faAnglesRight : faAnglesLeft} />
 				</button>
 				</button>
 			</div>
 			</div>
 
 
-			{/* 자리표시 본문 */}
-			<div className='market-sidebar__body'>
+			{/* 본문: 관심종목 목록 */}
+			<div className='market-sidebar__body' id='market-sidebar-body'>
 				{collapsed ? (
 				{collapsed ? (
-					<div className='market-sidebar__icon-placeholder' title='관심종목 (준비 중)' aria-hidden='true'>
+					<div className='market-sidebar__icon-placeholder' title='관심종목' aria-hidden='true'>
 						<FontAwesomeIcon icon={faStar} />
 						<FontAwesomeIcon icon={faStar} />
 					</div>
 					</div>
+				) : !isAuthenticated ? (
+					<p className='market-sidebar__empty'>로그인 후 관심종목을<br />확인할 수 있습니다.</p>
+				) : loading ? (
+					<p className='market-sidebar__empty'>불러오는 중…</p>
+				) : items.length === 0 ? (
+					<p className='market-sidebar__empty'>관심종목이 없습니다.<br />종목 상세에서 ☆ 로 추가하세요.</p>
 				) : (
 				) : (
-					<p className='market-sidebar__empty'>관심종목 기능을 준비 중입니다</p>
+					<ul className='market-sidebar__list'>
+						{items.map(item => {
+							const dir = changeDirection(item.changeRate);
+
+							return (
+								<li key={item.code}>
+									<Link href={`/stock/${item.code}`} className='market-sidebar__item'>
+										<span className='market-sidebar__item-name'>{item.name}</span>
+										<span className='market-sidebar__item-figures'>
+											<span className='market-sidebar__item-close'>{formatNumber(item.closePrice)}</span>
+											<span className={`market-sidebar__item-change stock-change stock-change--${dir}`}>{changeSymbol(dir)} {formatChangeRate(item.changeRate)}</span>
+										</span>
+									</Link>
+								</li>
+							);
+						})}
+					</ul>
 				)}
 				)}
 			</div>
 			</div>
 
 
-			{/* 하단: 테마 전환 (기존 ChannelSidebar 토글이 feature-off 로 미노출되어 여기서 제공) */}
+			{/* 하단: 테마 전환 (feature-off 로 ChannelSidebar 토글 미노출 시 여기서 제공) */}
 			<div className='market-sidebar__footer'>
 			<div className='market-sidebar__footer'>
 				<button type='button' className='market-sidebar__icon-btn' onClick={toggleTheme} aria-label={isDark ? '라이트 모드' : '다크 모드'} title={isDark ? '라이트 모드' : '다크 모드'}>
 				<button type='button' className='market-sidebar__icon-btn' onClick={toggleTheme} aria-label={isDark ? '라이트 모드' : '다크 모드'} title={isDark ? '라이트 모드' : '다크 모드'}>
 					<FontAwesomeIcon icon={isDark ? faSun : faMoon} />
 					<FontAwesomeIcon icon={isDark ? faSun : faMoon} />
@@ -83,4 +152,4 @@ export default function MarketSidebar()
 			</div>
 			</div>
 		</div>
 		</div>
 	);
 	);
-}
+}

+ 47 - 0
app/styles/market-sidebar.scss

@@ -49,6 +49,53 @@
 		font-size: var(--fs-xs);
 		font-size: var(--fs-xs);
 		color: var(--text-muted);
 		color: var(--text-muted);
 		text-align: center;
 		text-align: center;
+		line-height: 1.6;
+	}
+
+	&__list {
+		list-style: none;
+		margin: 0;
+		padding: 0;
+		display: flex;
+		flex-direction: column;
+	}
+
+	&__item {
+		display: flex;
+		flex-direction: column;
+		gap: 2px;
+		padding: 8px 4px;
+		border-bottom: 1px solid var(--border-light);
+		color: var(--text-primary);
+
+		&:hover {
+			background: var(--bg-subtle-hover);
+		}
+	}
+
+	&__item-name {
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+	}
+
+	&__item-figures {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		gap: var(--sp-2);
+		font-variant-numeric: tabular-nums;
+	}
+
+	&__item-close {
+		font-size: var(--fs-sm);
+		color: var(--text-secondary);
+	}
+
+	&__item-change {
+		font-size: var(--fs-xs);
 	}
 	}
 
 
 	&__icon-placeholder {
 	&__icon-placeholder {

+ 24 - 0
types/stock.ts

@@ -77,4 +77,28 @@ export interface StockSuggestItem {
 
 
 export interface StockSuggestResponse {
 export interface StockSuggestResponse {
 	list: StockSuggestItem[];
 	list: StockSuggestItem[];
+}
+
+// 관심종목 (Watch) — Backend Features/Api/Stocks/Watch/*
+// POST /api/stocks/{code}/watch
+export interface StockWatchToggleResponse {
+	isWatched: boolean;
+}
+
+// GET /api/stocks/watchlist/status?codes=CSV
+export interface StockWatchStatusResponse {
+	map: Record<string, boolean>;
+}
+
+// GET /api/stocks/watchlist — 행
+export interface StockWatchRow {
+	code: string;
+	name: string;
+	market: StockMarketName;
+	closePrice: number|null;
+	changeRate: number|null;
+}
+
+export interface StockWatchListResponse {
+	list: StockWatchRow[];
 }
 }