"use client"; import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useShallow } from "zustand/react/shallow"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { fetchDashboardBalance } from "@/features/dashboard/apis/dashboard.api"; import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; import { TradeAccessGate } from "@/features/trade/components/guards/TradeAccessGate"; import { TradeDashboardContent } from "@/features/trade/components/layout/TradeDashboardContent"; import { TradeSearchSection } from "@/features/trade/components/search/TradeSearchSection"; import { useStockSearch } from "@/features/trade/hooks/useStockSearch"; import { useOrderBook } from "@/features/trade/hooks/useOrderBook"; import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocket"; import { useStockOverview } from "@/features/trade/hooks/useStockOverview"; import { useCurrentPrice } from "@/features/trade/hooks/useCurrentPrice"; import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel"; import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store"; import type { DashboardStockOrderBookResponse, DashboardStockSearchItem, } from "@/features/trade/types/trade.types"; /** * @description 트레이딩 페이지 메인 컨테이너입니다. * @see app/(main)/trade/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다. * @see app/(main)/settings/page.tsx 미인증 상태일 때 설정 페이지로 이동하도록 안내합니다. * @see features/trade/hooks/useStockSearch.ts 검색 입력/요청/히스토리 상태를 관리합니다. * @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다. */ export function TradeContainer() { const router = useRouter(); const consumePendingTarget = useTradeNavigationStore( (state) => state.consumePendingTarget, ); // [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신) const [realtimeOrderBook, setRealtimeOrderBook] = useState(null); // [State] 선택 종목과 매칭할 보유 종목 목록 const [holdings, setHoldings] = useState([]); const { verifiedCredentials, isKisVerified, _hasHydrated } = useKisRuntimeStore( useShallow((state) => ({ verifiedCredentials: state.verifiedCredentials, isKisVerified: state.isKisVerified, _hasHydrated: state._hasHydrated, })), ); const { keyword, setKeyword, searchResults, setSearchError, isSearching, search, clearSearch, searchHistory, appendSearchHistory, removeSearchHistory, clearSearchHistory, } = useStockSearch(); const { selectedStock, loadOverview, updateRealtimeTradeTick } = useStockOverview(); const selectedSymbol = selectedStock?.symbol; /** * [Effect] query string이 남아 들어온 경우 즉시 깨끗한 /trade URL로 정리합니다. * 과거 링크/브라우저 히스토리로 유입되는 query 오염을 제거하기 위한 방어 로직입니다. */ useEffect(() => { if (typeof window === "undefined") return; if (!window.location.search) return; router.replace("/trade"); }, [router]); /** * [Effect] Dashboard에서 넘긴 종목을 1회 소비해 자동 로드합니다. * @remarks UI 흐름: Dashboard 종목 클릭 -> useTradeNavigationStore.setPendingTarget -> /trade -> consumePendingTarget -> loadOverview */ useEffect(() => { if (!isKisVerified || !verifiedCredentials || !_hasHydrated) { return; } const pendingTarget = consumePendingTarget(); if (!pendingTarget) return; if (selectedSymbol === pendingTarget.symbol) { return; } setKeyword(pendingTarget.name || pendingTarget.symbol); appendSearchHistory({ symbol: pendingTarget.symbol, name: pendingTarget.name || pendingTarget.symbol, market: pendingTarget.market, }); loadOverview( pendingTarget.symbol, verifiedCredentials, pendingTarget.market, ); }, [ isKisVerified, verifiedCredentials, _hasHydrated, consumePendingTarget, selectedSymbol, loadOverview, setKeyword, appendSearchHistory, ]); const canTrade = isKisVerified && !!verifiedCredentials; const canSearch = canTrade; /** * @description 상단 보유 요약 노출을 위해 잔고를 조회합니다. * @summary UI 흐름: TradeContainer -> loadHoldingsSnapshot -> fetchDashboardBalance -> holdings 상태 업데이트 * @see features/dashboard/apis/dashboard.api.ts - fetchDashboardBalance 잔고 API를 재사용합니다. */ const loadHoldingsSnapshot = useCallback(async () => { if (!verifiedCredentials?.accountNo?.trim()) { setHoldings([]); return; } try { const balance = await fetchDashboardBalance(verifiedCredentials); setHoldings(balance.holdings); } catch { // 상단 요약은 보조 정보이므로 실패 시 화면 전체를 막지 않고 빈 상태로 처리합니다. setHoldings([]); } }, [verifiedCredentials]); /** * [Effect] 보유종목 스냅샷 주기 갱신 * @remarks UI 흐름: trade 진입 -> 잔고 조회 -> selectedStock과 symbol 매칭 -> 상단 보유수량/손익 표기 */ useEffect(() => { if (!canTrade || !verifiedCredentials?.accountNo?.trim()) { return; } const initialTimerId = window.setTimeout(() => { void loadHoldingsSnapshot(); }, 0); const intervalId = window.setInterval(() => { void loadHoldingsSnapshot(); }, 60_000); return () => { window.clearTimeout(initialTimerId); window.clearInterval(intervalId); }; }, [canTrade, verifiedCredentials?.accountNo, loadHoldingsSnapshot]); const matchedHolding = useMemo(() => { if (!canTrade || !selectedSymbol) return null; return holdings.find((item) => item.symbol === selectedSymbol) ?? null; }, [canTrade, holdings, selectedSymbol]); const { searchShellRef, isSearchPanelOpen, markSkipNextAutoSearch, openSearchPanel, closeSearchPanel, handleSearchShellBlur, handleSearchShellKeyDown, } = useTradeSearchPanel({ canSearch, keyword, verifiedCredentials, search, clearSearch, }); /** * @description 체결 WS에서 전달받은 실시간 호가를 상태에 저장합니다. * @see features/trade/hooks/useKisTradeWebSocket.ts onOrderBookMessage 콜백 * @see features/trade/hooks/useOrderBook.ts externalRealtimeOrderBook 주입 */ const handleOrderBookMessage = useCallback( (data: DashboardStockOrderBookResponse) => { setRealtimeOrderBook(data); }, [], ); // 1. Trade WebSocket (체결 + 호가 통합) const { latestTick, recentTradeTicks } = useKisTradeWebSocket( selectedSymbol, verifiedCredentials, isKisVerified, updateRealtimeTradeTick, { orderBookSymbol: selectedSymbol, orderBookMarket: selectedStock?.market, onOrderBookMessage: handleOrderBookMessage, }, ); // 2. OrderBook (REST 초기 조회 + WS 실시간 병합) const { orderBook, isLoading: isOrderBookLoading } = useOrderBook( selectedSymbol, selectedStock?.market, verifiedCredentials, isKisVerified, { enabled: !!selectedSymbol && !!verifiedCredentials && isKisVerified, externalRealtimeOrderBook: realtimeOrderBook, }, ); // 3. Price Calculation Logic (Hook) const { currentPrice, change, changeRate, prevClose: referencePrice, } = useCurrentPrice({ stock: selectedStock, latestTick, orderBook, }); /** * @description 검색 전 API 인증 여부를 확인합니다. * @see features/trade/components/search/StockSearchForm.tsx 검색 제출 전 공통 가드로 사용합니다. */ const ensureSearchReady = useCallback(() => { if (canSearch) return true; setSearchError("API 키 검증을 먼저 완료해 주세요."); return false; }, [canSearch, setSearchError]); /** * @description 수동 검색 버튼(엔터 포함) 제출 이벤트를 처리합니다. * @see features/trade/components/search/StockSearchForm.tsx onSubmit 이벤트로 호출됩니다. */ const handleSearchSubmit = useCallback( (event: FormEvent) => { event.preventDefault(); if (!ensureSearchReady() || !verifiedCredentials) return; search(keyword, verifiedCredentials); }, [ensureSearchReady, keyword, search, verifiedCredentials], ); /** * @description 검색 결과/히스토리에서 종목을 선택하면 종목 상세를 로드하고 히스토리를 갱신합니다. * @see features/trade/components/search/StockSearchResults.tsx onSelect 이벤트 * @see features/trade/components/search/StockSearchHistory.tsx onSelect 이벤트 */ const handleSelectStock = useCallback( (item: DashboardStockSearchItem) => { if (!ensureSearchReady() || !verifiedCredentials) return; // 같은 종목 재선택 시 중복 overview 요청을 막고 검색 목록만 닫습니다. if (selectedSymbol === item.symbol) { clearSearch(); closeSearchPanel(); return; } // 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다. markSkipNextAutoSearch(); setKeyword(item.name); clearSearch(); closeSearchPanel(); appendSearchHistory(item); loadOverview(item.symbol, verifiedCredentials, item.market); }, [ ensureSearchReady, verifiedCredentials, selectedSymbol, clearSearch, closeSearchPanel, setKeyword, appendSearchHistory, loadOverview, markSkipNextAutoSearch, ], ); if (!_hasHydrated) { return (
); } if (!canTrade) { return ; } return (
{/* ========== SEARCH SECTION ========== */} {/* ========== DASHBOARD SECTION ========== */}
); }