"use client"; import { type FormEvent, useCallback, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useShallow } from "zustand/react/shallow"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; 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 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 searchParams = useSearchParams(); const symbolParam = searchParams.get("symbol"); const nameParam = searchParams.get("name"); // [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신) const [realtimeOrderBook, setRealtimeOrderBook] = useState(null); 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(); /** * [Effect] URL 파라미터(symbol) 감지 시 자동 종목 로드 * 대시보드 등 외부에서 종목 코드를 넘겨받아 트레이딩 페이지로 진입할 때 사용합니다. */ useEffect(() => { if (symbolParam && isKisVerified && verifiedCredentials && _hasHydrated) { // 현재 선택된 종목과 파라미터가 다를 경우에만 자동 로드 수행 if (selectedStock?.symbol !== symbolParam) { setKeyword(nameParam || symbolParam); appendSearchHistory({ symbol: symbolParam, name: nameParam || symbolParam, market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨 }); loadOverview(symbolParam, verifiedCredentials); } } }, [ symbolParam, nameParam, isKisVerified, verifiedCredentials, _hasHydrated, selectedStock?.symbol, loadOverview, setKeyword, appendSearchHistory, ]); const canTrade = isKisVerified && !!verifiedCredentials; const canSearch = canTrade; 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( selectedStock?.symbol, verifiedCredentials, isKisVerified, updateRealtimeTradeTick, { orderBookSymbol: selectedStock?.symbol, onOrderBookMessage: handleOrderBookMessage, }, ); // 2. OrderBook (REST 초기 조회 + WS 실시간 병합) const { orderBook, isLoading: isOrderBookLoading } = useOrderBook( selectedStock?.symbol, selectedStock?.market, verifiedCredentials, isKisVerified, { enabled: !!selectedStock && !!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 (selectedStock?.symbol === item.symbol) { clearSearch(); closeSearchPanel(); return; } // 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 실행되지 않게 한 번 건너뜁니다. markSkipNextAutoSearch(); setKeyword(item.name); clearSearch(); closeSearchPanel(); appendSearchHistory(item); loadOverview(item.symbol, verifiedCredentials, item.market); }, [ ensureSearchReady, verifiedCredentials, selectedStock?.symbol, clearSearch, closeSearchPanel, setKeyword, appendSearchHistory, loadOverview, markSkipNextAutoSearch, ], ); if (!_hasHydrated) { return (
); } if (!canTrade) { return ; } return (
{/* ========== SEARCH SECTION ========== */} {/* ========== DASHBOARD SECTION ========== */}
); }