2026-02-10 11:16:39 +09:00
|
|
|
import { useCallback, useRef, useState } from "react";
|
2026-02-11 16:31:28 +09:00
|
|
|
import { fetchStockSearch } from "@/features/trade/apis/kis-stock.api";
|
|
|
|
|
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
2026-02-11 14:06:06 +09:00
|
|
|
import type {
|
|
|
|
|
DashboardStockSearchHistoryItem,
|
|
|
|
|
DashboardStockSearchItem,
|
2026-02-11 16:31:28 +09:00
|
|
|
} from "@/features/trade/types/trade.types";
|
2026-02-11 14:06:06 +09:00
|
|
|
|
2026-02-13 15:44:41 +09:00
|
|
|
const SEARCH_HISTORY_STORAGE_KEY = "joorine:stock-search-history:v1";
|
2026-02-11 14:06:06 +09:00
|
|
|
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 [];
|
2026-02-10 11:16:39 +09:00
|
|
|
|
2026-02-11 14:06:06 +09:00
|
|
|
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,
|
|
|
|
|
};
|
2026-02-13 15:44:41 +09:00
|
|
|
window.localStorage.setItem(
|
|
|
|
|
SEARCH_HISTORY_STORAGE_KEY,
|
|
|
|
|
JSON.stringify(payload),
|
|
|
|
|
);
|
2026-02-11 14:06:06 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 종목 검색 상태(키워드/결과/에러)와 검색 히스토리(localStorage)를 함께 관리합니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/components/TradeContainer.tsx 검색 제출/자동검색/히스토리 클릭 이벤트에서 호출합니다.
|
|
|
|
|
* @see features/trade/components/search/StockSearchHistory.tsx 히스토리 목록 렌더링 데이터로 사용합니다.
|
2026-02-11 14:06:06 +09:00
|
|
|
*/
|
2026-02-10 11:16:39 +09:00
|
|
|
export function useStockSearch() {
|
2026-02-11 14:06:06 +09:00
|
|
|
// ========== SEARCH STATE ==========
|
2026-02-12 10:24:03 +09:00
|
|
|
const [keyword, setKeyword] = useState("");
|
2026-02-13 15:44:41 +09:00
|
|
|
const [searchResults, setSearchResults] = useState<
|
|
|
|
|
DashboardStockSearchItem[]
|
|
|
|
|
>([]);
|
2026-02-10 11:16:39 +09:00
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [isSearching, setIsSearching] = useState(false);
|
2026-02-11 14:06:06 +09:00
|
|
|
|
|
|
|
|
// ========== SEARCH HISTORY STATE ==========
|
2026-02-13 15:44:41 +09:00
|
|
|
const [searchHistory, setSearchHistory] = useState<
|
|
|
|
|
DashboardStockSearchHistoryItem[]
|
|
|
|
|
>(() => readSearchHistory());
|
2026-02-11 14:06:06 +09:00
|
|
|
|
|
|
|
|
// 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러
|
2026-02-10 11:16:39 +09:00
|
|
|
const abortRef = useRef<AbortController | null>(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);
|
2026-02-11 14:06:06 +09:00
|
|
|
setSearchResults(data.items);
|
2026-02-10 11:16:39 +09:00
|
|
|
return data.items;
|
|
|
|
|
} catch (err) {
|
2026-02-11 14:06:06 +09:00
|
|
|
if (controller.signal.aborted) return [];
|
|
|
|
|
|
|
|
|
|
setError(
|
|
|
|
|
err instanceof Error
|
|
|
|
|
? err.message
|
|
|
|
|
: "종목 검색 중 오류가 발생했습니다.",
|
|
|
|
|
);
|
2026-02-10 11:16:39 +09:00
|
|
|
return [];
|
|
|
|
|
} finally {
|
2026-02-11 14:06:06 +09:00
|
|
|
if (!controller.signal.aborted) {
|
2026-02-10 11:16:39 +09:00
|
|
|
setIsSearching(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-11 14:06:06 +09:00
|
|
|
/**
|
|
|
|
|
* @description 검색어를 받아 종목 검색 API를 호출합니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/components/TradeContainer.tsx handleSearchSubmit 자동/수동 검색에 사용합니다.
|
2026-02-11 14:06:06 +09:00
|
|
|
*/
|
2026-02-10 11:16:39 +09:00
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-11 14:06:06 +09:00
|
|
|
/**
|
|
|
|
|
* @description 검색 결과를 지우고 진행 중인 검색 요청을 중단합니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/components/TradeContainer.tsx 종목 선택 직후 검색 패널 정리에 사용합니다.
|
2026-02-11 14:06:06 +09:00
|
|
|
*/
|
2026-02-10 11:16:39 +09:00
|
|
|
const clearSearch = useCallback(() => {
|
|
|
|
|
abortRef.current?.abort();
|
|
|
|
|
setSearchResults([]);
|
|
|
|
|
setError(null);
|
|
|
|
|
setIsSearching(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-11 14:06:06 +09:00
|
|
|
/**
|
|
|
|
|
* @description API 검증 전 같은 상황에서 공통 에러 메시지를 표시하기 위한 setter입니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/components/TradeContainer.tsx ensureSearchReady 인증 가드에서 사용합니다.
|
2026-02-11 14:06:06 +09:00
|
|
|
*/
|
|
|
|
|
const setSearchError = useCallback((message: string | null) => {
|
|
|
|
|
setError(message);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 선택한 종목을 검색 히스토리 맨 위에 추가(중복 제거)합니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/components/TradeContainer.tsx handleSelectStock 종목 선택 이벤트에서 호출합니다.
|
2026-02-11 14:06:06 +09:00
|
|
|
*/
|
|
|
|
|
const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => {
|
|
|
|
|
setSearchHistory((prev) => {
|
2026-02-13 15:44:41 +09:00
|
|
|
const deduped = prev.filter(
|
|
|
|
|
(historyItem) => historyItem.symbol !== item.symbol,
|
|
|
|
|
);
|
2026-02-11 14:06:06 +09:00
|
|
|
const nextItems: DashboardStockSearchHistoryItem[] = [
|
|
|
|
|
{ ...item, savedAt: Date.now() },
|
|
|
|
|
...deduped,
|
|
|
|
|
].slice(0, SEARCH_HISTORY_LIMIT);
|
|
|
|
|
|
|
|
|
|
writeSearchHistory(nextItems);
|
|
|
|
|
return nextItems;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 종목코드 기준으로 히스토리 항목을 삭제합니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/components/search/StockSearchHistory.tsx 삭제 버튼 클릭 이벤트에서 호출합니다.
|
2026-02-11 14:06:06 +09:00
|
|
|
*/
|
|
|
|
|
const removeSearchHistory = useCallback((symbol: string) => {
|
|
|
|
|
setSearchHistory((prev) => {
|
|
|
|
|
const nextItems = prev.filter((item) => item.symbol !== symbol);
|
|
|
|
|
writeSearchHistory(nextItems);
|
|
|
|
|
return nextItems;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @description 저장된 검색 히스토리를 전체 삭제합니다.
|
2026-02-11 16:31:28 +09:00
|
|
|
* @see features/trade/components/search/StockSearchHistory.tsx 전체 삭제 버튼 이벤트에서 호출합니다.
|
2026-02-11 14:06:06 +09:00
|
|
|
*/
|
|
|
|
|
const clearSearchHistory = useCallback(() => {
|
|
|
|
|
setSearchHistory([]);
|
|
|
|
|
writeSearchHistory([]);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-10 11:16:39 +09:00
|
|
|
return {
|
|
|
|
|
keyword,
|
|
|
|
|
setKeyword,
|
|
|
|
|
searchResults,
|
|
|
|
|
error,
|
|
|
|
|
isSearching,
|
|
|
|
|
search,
|
|
|
|
|
clearSearch,
|
2026-02-11 14:06:06 +09:00
|
|
|
setSearchError,
|
|
|
|
|
searchHistory,
|
|
|
|
|
appendSearchHistory,
|
|
|
|
|
removeSearchHistory,
|
|
|
|
|
clearSearchHistory,
|
2026-02-10 11:16:39 +09:00
|
|
|
};
|
|
|
|
|
}
|