From 32ef84639084f0f2940df110df7be13178a8923c Mon Sep 17 00:00:00 2001 From: jjamming Date: Mon, 17 Nov 2025 15:10:21 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=A2=85=EB=AA=A9=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20get=20=ED=95=A8=EC=88=98,=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=ED=9B=85=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/_BacktestingPage/components/AssetItem.tsx | 44 ++++++++----------- src/constants/api.ts | 2 +- src/lib/apis/searchAssets.ts | 19 ++++++++ src/lib/apis/types.ts | 7 +++ src/lib/hooks/useGetSearchAssets.ts | 12 +++++ 5 files changed, 57 insertions(+), 27 deletions(-) create mode 100644 src/lib/apis/searchAssets.ts create mode 100644 src/lib/hooks/useGetSearchAssets.ts 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) => (
`/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/lib/apis/searchAssets.ts b/src/lib/apis/searchAssets.ts new file mode 100644 index 0000000..f51ed06 --- /dev/null +++ b/src/lib/apis/searchAssets.ts @@ -0,0 +1,19 @@ +import { instance } from "@/utils/instance"; +import { API_ENDPOINTS } from "@/constants/api"; +import type { ApiResponse, SearchAssetsResponse } from "@/lib/apis/types"; + +export const searchAssets = async (keyword: string) => { + // 빈 문자열이나 공백만 있는 경우 빈 배열 반환 + 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/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 }; +}; From 6977bf5bde3f57365ca3e4a22504f6b6a7c3f1f0 Mon Sep 17 00:00:00 2001 From: jjamming Date: Mon, 17 Nov 2025 15:10:33 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20useDebounce=20=ED=9B=85=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/hooks/useDebounce.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/lib/hooks/useDebounce.ts diff --git a/src/lib/hooks/useDebounce.ts b/src/lib/hooks/useDebounce.ts new file mode 100644 index 0000000..d06c1ed --- /dev/null +++ b/src/lib/hooks/useDebounce.ts @@ -0,0 +1,24 @@ +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; +} + From e2054dbd9a0cc6c1ef1378431461407934ecd2bf Mon Sep 17 00:00:00 2001 From: jjamming Date: Mon, 17 Nov 2025 15:10:52 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20API=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/_MainPage/components/MarketIndexSection.tsx | 2 +- src/{_MainPage => lib}/apis/getIndex.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) rename src/{_MainPage => lib}/apis/getIndex.ts (91%) diff --git a/src/_MainPage/components/MarketIndexSection.tsx b/src/_MainPage/components/MarketIndexSection.tsx index d886d41..80b4d25 100644 --- a/src/_MainPage/components/MarketIndexSection.tsx +++ b/src/_MainPage/components/MarketIndexSection.tsx @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import { getIndexData } from "@/_MainPage/apis/getIndex"; +import { getIndexData } from "@/lib/apis/getIndex"; import MarketIndexCard from "@/_MainPage/components/MarketIndexCard"; import { useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; 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 Date: Mon, 17 Nov 2025 15:56:50 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EC=83=81=EB=8B=A8=EB=B0=94=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=B0=BD=20=EB=B0=8F=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Navbar/SearchBar.tsx | 149 ++++++++++++++++++++++++++-- 1 file changed, 141 insertions(+), 8 deletions(-) diff --git a/src/components/Navbar/SearchBar.tsx b/src/components/Navbar/SearchBar.tsx index f0542ce..e780bd6 100644 --- a/src/components/Navbar/SearchBar.tsx +++ b/src/components/Navbar/SearchBar.tsx @@ -1,16 +1,149 @@ +import type { SearchResult } from "@/_BacktestingPage/types/backtestFormType"; import { Input } from "@/components/ui/input"; +import { useDebounce } from "@/lib/hooks/useDebounce"; +import { useGetSearchAssets } from "@/lib/hooks/useGetSearchAssets"; import { Search } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; const SearchBar = () => { + 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} + +
+
+ + + +
+
+ ))} +
+
+ )} +
+
+
+ )} + ); }; From 19dfb11fcfa1ad2d4462fea7cbc21b441b8697dc Mon Sep 17 00:00:00 2001 From: jjamming Date: Mon, 17 Nov 2025 16:00:21 +0900 Subject: [PATCH 5/5] chore: modify prettier --- src/lib/hooks/useDebounce.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/hooks/useDebounce.ts b/src/lib/hooks/useDebounce.ts index d06c1ed..b22183a 100644 --- a/src/lib/hooks/useDebounce.ts +++ b/src/lib/hooks/useDebounce.ts @@ -21,4 +21,3 @@ export function useDebounce(value: T, delay: number): T { return debouncedValue; } -