Files
auto-trade/features/trade/components/TradeContainer.tsx

289 lines
9.3 KiB
TypeScript

"use client";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useRouter } 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 { 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<DashboardStockOrderBookResponse | null>(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] 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 (selectedStock?.symbol === 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,
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 (
<div className="flex h-full items-center justify-center p-6">
<LoadingSpinner />
</div>
);
}
if (!canTrade) {
return <TradeAccessGate canTrade={canTrade} />;
}
return (
<div className="relative h-full flex flex-col">
{/* ========== SEARCH SECTION ========== */}
<TradeSearchSection
canSearch={canSearch}
isSearchPanelOpen={isSearchPanelOpen}
isSearching={isSearching}
keyword={keyword}
selectedSymbol={selectedStock?.symbol}
searchResults={searchResults}
searchHistory={searchHistory}
searchShellRef={searchShellRef}
onKeywordChange={setKeyword}
onSearchSubmit={handleSearchSubmit}
onSearchFocus={openSearchPanel}
onSearchShellBlur={handleSearchShellBlur}
onSearchShellKeyDown={handleSearchShellKeyDown}
onSelectStock={handleSelectStock}
onRemoveHistory={removeSearchHistory}
onClearHistory={clearSearchHistory}
/>
{/* ========== DASHBOARD SECTION ========== */}
<TradeDashboardContent
selectedStock={selectedStock}
verifiedCredentials={verifiedCredentials}
latestTick={latestTick}
recentTradeTicks={recentTradeTicks}
orderBook={orderBook}
isOrderBookLoading={isOrderBookLoading}
referencePrice={referencePrice}
currentPrice={currentPrice}
change={change}
changeRate={changeRate}
/>
</div>
);
}