import { useCallback, useRef, useState } from "react"; import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api"; import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store"; import type { DashboardStockSearchHistoryItem, DashboardStockSearchItem, } from "@/features/dashboard/types/dashboard.types"; const SEARCH_HISTORY_STORAGE_KEY = "jurini:stock-search-history:v1"; const SEARCH_HISTORY_LIMIT = 12; interface StoredSearchHistory { version: 1; items: DashboardStockSearchHistoryItem[]; } function readSearchHistory(): DashboardStockSearchHistoryItem[] { if (typeof window === "undefined") return []; try { const raw = window.localStorage.getItem(SEARCH_HISTORY_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw) as StoredSearchHistory; if (parsed?.version !== 1 || !Array.isArray(parsed.items)) return []; return parsed.items .filter((item) => item?.symbol && item?.name && item?.market) .slice(0, SEARCH_HISTORY_LIMIT); } catch { return []; } } function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) { if (typeof window === "undefined") return; const payload: StoredSearchHistory = { version: 1, items, }; window.localStorage.setItem(SEARCH_HISTORY_STORAGE_KEY, JSON.stringify(payload)); } /** * @description 종목 검색 상태(키워드/결과/에러)와 검색 히스토리(localStorage)를 함께 관리합니다. * @see features/dashboard/components/DashboardContainer.tsx 검색 제출/자동검색/히스토리 클릭 이벤트에서 호출합니다. * @see features/dashboard/components/search/StockSearchHistory.tsx 히스토리 목록 렌더링 데이터로 사용합니다. */ export function useStockSearch() { // ========== SEARCH STATE ========== const [keyword, setKeyword] = useState("삼성전자"); const [searchResults, setSearchResults] = useState([]); const [error, setError] = useState(null); const [isSearching, setIsSearching] = useState(false); // ========== SEARCH HISTORY STATE ========== const [searchHistory, setSearchHistory] = useState( () => readSearchHistory(), ); // 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러 const abortRef = useRef(null); const loadSearch = useCallback(async (query: string) => { const controller = new AbortController(); abortRef.current?.abort(); abortRef.current = controller; setIsSearching(true); setError(null); try { const data = await fetchStockSearch(query, controller.signal); setSearchResults(data.items); return data.items; } catch (err) { if (controller.signal.aborted) return []; setError( err instanceof Error ? err.message : "종목 검색 중 오류가 발생했습니다.", ); return []; } finally { if (!controller.signal.aborted) { setIsSearching(false); } } }, []); /** * @description 검색어를 받아 종목 검색 API를 호출합니다. * @see features/dashboard/components/DashboardContainer.tsx handleSearchSubmit 자동/수동 검색에 사용합니다. */ const search = useCallback( (query: string, credentials: KisRuntimeCredentials | null) => { if (!credentials) { setError("API 키 검증이 필요합니다."); setSearchResults([]); setIsSearching(false); return; } const trimmed = query.trim(); if (!trimmed) { abortRef.current?.abort(); setSearchResults([]); setError(null); setIsSearching(false); return; } void loadSearch(trimmed); }, [loadSearch], ); /** * @description 검색 결과를 지우고 진행 중인 검색 요청을 중단합니다. * @see features/dashboard/components/DashboardContainer.tsx 종목 선택 직후 검색 패널 정리에 사용합니다. */ const clearSearch = useCallback(() => { abortRef.current?.abort(); setSearchResults([]); setError(null); setIsSearching(false); }, []); /** * @description API 검증 전 같은 상황에서 공통 에러 메시지를 표시하기 위한 setter입니다. * @see features/dashboard/components/DashboardContainer.tsx ensureSearchReady 인증 가드에서 사용합니다. */ const setSearchError = useCallback((message: string | null) => { setError(message); }, []); /** * @description 선택한 종목을 검색 히스토리 맨 위에 추가(중복 제거)합니다. * @see features/dashboard/components/DashboardContainer.tsx handleSelectStock 종목 선택 이벤트에서 호출합니다. */ const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => { setSearchHistory((prev) => { const deduped = prev.filter((historyItem) => historyItem.symbol !== item.symbol); const nextItems: DashboardStockSearchHistoryItem[] = [ { ...item, savedAt: Date.now() }, ...deduped, ].slice(0, SEARCH_HISTORY_LIMIT); writeSearchHistory(nextItems); return nextItems; }); }, []); /** * @description 종목코드 기준으로 히스토리 항목을 삭제합니다. * @see features/dashboard/components/search/StockSearchHistory.tsx 삭제 버튼 클릭 이벤트에서 호출합니다. */ const removeSearchHistory = useCallback((symbol: string) => { setSearchHistory((prev) => { const nextItems = prev.filter((item) => item.symbol !== symbol); writeSearchHistory(nextItems); return nextItems; }); }, []); /** * @description 저장된 검색 히스토리를 전체 삭제합니다. * @see features/dashboard/components/search/StockSearchHistory.tsx 전체 삭제 버튼 이벤트에서 호출합니다. */ const clearSearchHistory = useCallback(() => { setSearchHistory([]); writeSearchHistory([]); }, []); return { keyword, setKeyword, searchResults, error, isSearching, search, clearSearch, setSearchError, searchHistory, appendSearchHistory, removeSearchHistory, clearSearchHistory, }; }