diff --git a/src/_BacktestingPage/components/AssetItem.tsx b/src/_BacktestingPage/components/AssetItem.tsx index a0a5dac..96695fd 100644 --- a/src/_BacktestingPage/components/AssetItem.tsx +++ b/src/_BacktestingPage/components/AssetItem.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from "react"; import type { SearchResult, Asset } from "@/_BacktestingPage/types/backtestFormType"; -import { debounce } from "lodash"; +import { useGetSearchAssets } from "@/lib/hooks/useGetSearchAssets"; +import { useDebounce } from "@/lib/hooks/useDebounce"; type AssetItemProps = { AssetIndex: number; asset: Asset; @@ -8,51 +9,42 @@ type AssetItemProps = { onDelete: () => void; }; -const mockSearchAsset = async (query: string): Promise => { - if (!query) return []; - return [ - { name: "삼성전자", ticker: "005930" }, - { name: "삼성물산", ticker: "028260" }, - { name: "삼성SDI", ticker: "006400" }, - { name: "삼성바이오로직스", ticker: "207940" }, - { name: "삼성에스디에스", ticker: "018260" }, - ]; -}; - const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) => { const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [searchResults, setSearchResults] = useState([]); const [query, setQuery] = useState(asset.name); const wrapperRef = useRef(null); const skipSearchRef = useRef(false); const hasClearedRef = useRef(false); - // API 연결 시 useEffect 의존성 최적화 필요 - const handleSearch = debounce(async (keyword: string) => { - const results = await mockSearchAsset(keyword); - setSearchResults(results); - setIsDropdownOpen(true); - }, 500); + const debouncedQuery = useDebounce(query, 500); + + const { data: searchAssets } = useGetSearchAssets(debouncedQuery); + + const searchResults: SearchResult[] = searchAssets + ? searchAssets.map((item) => ({ + name: item.stockName, + ticker: item.stockCode, + })) + : []; + // 검색 결과가 있을 때 드롭다운 열기 useEffect(() => { if (skipSearchRef.current) { skipSearchRef.current = false; return; } - if (query) { - handleSearch(query); - } else { - setSearchResults([]); + if (searchResults.length > 0 && debouncedQuery) { + setIsDropdownOpen(true); + } else if (!debouncedQuery) { setIsDropdownOpen(false); } - }, [query]); + }, [searchResults, debouncedQuery]); const handleSelect = (selected: SearchResult) => { const displayValue = `${selected.name} (${selected.ticker})`; onUpdate({ ...asset, name: selected.name, ticker: selected.ticker }); skipSearchRef.current = true; setQuery(displayValue); - setSearchResults([]); setIsDropdownOpen(false); hasClearedRef.current = false; }; @@ -95,7 +87,7 @@ const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) => /> {isDropdownOpen && searchResults.length > 0 && ( -
+
{searchResults.map((item) => (
{ + const [query, setQuery] = useState(""); + const [isOverlayOpen, setIsOverlayOpen] = useState(false); + const debouncedQuery = useDebounce(query, 500); + const wrapperRef = useRef(null); + const overlayContentRef = useRef(null); + const navigate = useNavigate(); + + const { data: searchAssets } = useGetSearchAssets(debouncedQuery); + + const searchResults: SearchResult[] = searchAssets + ? searchAssets.map((item) => ({ + name: item.stockName, + ticker: item.stockCode, + })) + : []; + + // SearchBar 클릭 시 overlay 열기 + const handleSearchBarClick = () => { + setIsOverlayOpen(true); + }; + + // overlay 외부 클릭 시 닫기 및 검색어 초기화 + const handleOverlayClick = (e: React.MouseEvent) => { + if (overlayContentRef.current && !overlayContentRef.current.contains(e.target as Node)) { + setIsOverlayOpen(false); + setQuery(""); + } + }; + + useEffect(() => { + if (isOverlayOpen) { + // overlay가 열려있을 때 body 스크롤 방지 + document.body.style.overflow = "hidden"; + } + return () => { + document.body.style.overflow = "unset"; + }; + }, [isOverlayOpen]); + + const handleSelect = (selected: SearchResult) => { + setQuery(""); + setIsOverlayOpen(false); + navigate(`/markets/${selected.ticker}`); + }; + return ( -
- - -
+ <> + {/* 기본 SearchBar */} +
+ + +
+ + {/* Overlay */} + {isOverlayOpen && ( +
+
+ {/* 검색바와 리스트를 감싸는 컨테이너 */} +
+ {/* Overlay 내 SearchBar */} +
+ + setQuery(e.target.value)} + placeholder="종목명/종목코드 검색" + className="bg-white/95 backdrop-blur-xs pl-10 border-2 border-white rounded-4xl w-full h-14 text-navy text-lg transition-all duration-200" + autoFocus + /> +
+ + {/* 검색 결과 리스트 */} + {searchResults.length > 0 && debouncedQuery && ( +
+
+ {searchResults.map((item) => ( +
handleSelect(item)} + > +
+ + {item.ticker.slice(-2)} + +
+
+ + {item.name} + + + {item.ticker} + +
+
+ + + +
+
+ ))} +
+
+ )} +
+
+
+ )} + ); }; diff --git a/src/constants/api.ts b/src/constants/api.ts index 4924d87..4fa7e1a 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -9,7 +9,7 @@ export const API_ENDPOINTS = { return `/api/index/period/${marketType}${query ? `?${query}` : ""}`; }, indexCurrent: () => `/api/index/current`, - searchAsset: (keyword: string) => `/api/stock?keyword=${keyword}`, + searchAssets: (keyword: string) => `/api/stock?query=${encodeURIComponent(keyword)}`, stockData: (stockcode: string, startDate?: string, endDate?: string) => { const params = new URLSearchParams(); if (startDate !== undefined) params.append("startDate", startDate); diff --git a/src/_MainPage/apis/getIndex.ts b/src/lib/apis/getIndex.ts similarity index 91% rename from src/_MainPage/apis/getIndex.ts rename to src/lib/apis/getIndex.ts index 03c9115..4312376 100644 --- a/src/_MainPage/apis/getIndex.ts +++ b/src/lib/apis/getIndex.ts @@ -18,6 +18,10 @@ export const getIndexData = async (marketType: "KOSPI" | "KOSDAQ"): Promise { + // 빈 문자열이나 공백만 있는 경우 빈 배열 반환 + if (!keyword || !keyword.trim()) { + return []; + } + + const response = await instance.get>( + API_ENDPOINTS.searchAssets(keyword.trim()) + ); + + if (!response.data.isSuccess) { + throw new Error(response.data.message); + } + return response.data.result; +}; diff --git a/src/lib/apis/types.ts b/src/lib/apis/types.ts index 27fdebd..4e23b88 100644 --- a/src/lib/apis/types.ts +++ b/src/lib/apis/types.ts @@ -18,3 +18,10 @@ export interface ApiErrorResponse { detail: string; instance: string; } + +// 종목 검색 응답 타입 +export type SearchAssetsResponse = { + stockName: string; + stockCode: string; + isinCode: string; +}; diff --git a/src/lib/hooks/useDebounce.ts b/src/lib/hooks/useDebounce.ts new file mode 100644 index 0000000..b22183a --- /dev/null +++ b/src/lib/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +/** + * 값을 debounce하는 커스텀 훅 + * @param value - debounce할 값 + * @param delay - debounce 지연 시간 (ms) + * @returns debounce된 값 + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/lib/hooks/useGetSearchAssets.ts b/src/lib/hooks/useGetSearchAssets.ts new file mode 100644 index 0000000..ebaa388 --- /dev/null +++ b/src/lib/hooks/useGetSearchAssets.ts @@ -0,0 +1,12 @@ +import { searchAssets } from "@/lib/apis/searchAssets"; +import { useQuery } from "@tanstack/react-query"; + +export const useGetSearchAssets = (keyword: string) => { + const { data, isLoading, error } = useQuery({ + queryKey: ["searchAssets", keyword], + queryFn: () => searchAssets(keyword), + enabled: Boolean(keyword && keyword.trim().length > 0), + }); + + return { data, isLoading, error }; +};