Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 18 additions & 26 deletions src/_BacktestingPage/components/AssetItem.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,50 @@
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;
onUpdate: (updatedAsset: Asset) => void;
onDelete: () => void;
};

const mockSearchAsset = async (query: string): Promise<SearchResult[]> => {
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<SearchResult[]>([]);
const [query, setQuery] = useState(asset.name);
const wrapperRef = useRef<HTMLDivElement>(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;
};
Expand Down Expand Up @@ -95,7 +87,7 @@ const AssetItem = ({ AssetIndex, asset, onUpdate, onDelete }: AssetItemProps) =>
/>

{isDropdownOpen && searchResults.length > 0 && (
<div className="top-full left-55 z-20 absolute flex flex-col items-center bg-navy mt-2 border rounded w-60 h-auto text-[#E0E6ED] cursor-pointer">
<div className="top-full left-55 z-20 absolute flex flex-col items-center bg-navy mt-2 border rounded w-60 max-h-[240px] overflow-y-auto text-[#E0E6ED] cursor-pointer">
{searchResults.map((item) => (
<div
key={item.ticker}
Expand Down
2 changes: 1 addition & 1 deletion src/_MainPage/components/MarketIndexSection.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
149 changes: 141 additions & 8 deletions src/components/Navbar/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -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<string>("");
const [isOverlayOpen, setIsOverlayOpen] = useState(false);
const debouncedQuery = useDebounce(query, 500);
const wrapperRef = useRef<HTMLDivElement>(null);
const overlayContentRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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 (
<div className="relative flex justify-center items-center mr-4 w-full max-w-xl">
<Search className="top-1/2 left-4 absolute mr-1.5 text-gray-400 -translate-y-1/2" size={20} />
<Input
type="text"
placeholder="종목명/종목코드 검색"
className="pl-10 border-2 border-gray-400/50 focus:border-white rounded-4xl h-13 transition-all duration-200"
/>
</div>
<>
{/* 기본 SearchBar */}
<div
ref={wrapperRef}
className="relative flex justify-center items-center mr-4 w-full max-w-xl"
onClick={handleSearchBarClick}
>
<Search
className="top-1/2 left-4 z-10 absolute mr-1.5 text-gray-400 -translate-y-1/2"
size={20}
/>
<Input
type="text"
value=""
placeholder="종목명/종목코드 검색"
className="pl-10 border-2 border-gray-400/50 focus:border-white rounded-4xl h-13 transition-all duration-200 cursor-pointer"
readOnly
/>
</div>

{/* Overlay */}
{isOverlayOpen && (
<div
className="z-[9999] fixed inset-0 bg-black/10 backdrop-blur-xs"
onClick={handleOverlayClick}
>
<div className="flex flex-col items-center mt-20 w-full">
{/* 검색바와 리스트를 감싸는 컨테이너 */}
<div ref={overlayContentRef} className="flex flex-col w-[600px]">
{/* Overlay 내 SearchBar */}
<div className="relative flex items-center w-full">
<Search
className="top-1/2 left-4 z-10 absolute mr-1.5 text-gray-400 -translate-y-1/2"
size={20}
/>
<Input
type="text"
value={query}
onChange={(e) => 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
/>
</div>

{/* 검색 결과 리스트 */}
{searchResults.length > 0 && debouncedQuery && (
<div className="flex flex-col bg-white shadow-2xl backdrop-blur-md mt-4 border border-gray-100/50 rounded-2xl w-full max-h-[60vh] overflow-hidden">
<div className="max-h-[60vh] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
{searchResults.map((item) => (
<div
key={item.ticker}
className="group flex items-center hover:bg-gradient-to-r hover:from-blue-50/50 hover:to-indigo-50/50 px-6 py-4 border-gray-50/80 border-b last:border-b-0 active:scale-[0.98] transition-all duration-200 cursor-pointer"
onClick={() => handleSelect(item)}
>
<div className="flex justify-center items-center bg-gradient-to-br from-blue-100 group-hover:from-blue-200 to-indigo-100 group-hover:to-indigo-200 mr-4 rounded-xl w-10 h-10 transition-all duration-200">
<span className="font-semibold text-blue-600 text-sm">
{item.ticker.slice(-2)}
</span>
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-semibold text-gray-900 group-hover:text-blue-700 text-base truncate transition-colors">
{item.name}
</span>
<span className="mt-0.5 font-medium text-gray-500 text-xs">
{item.ticker}
</span>
</div>
<div className="opacity-0 group-hover:opacity-100 ml-4 transition-opacity">
<svg
className="w-5 h-5 text-blue-500"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</>
);
};

Expand Down
2 changes: 1 addition & 1 deletion src/constants/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/_MainPage/apis/getIndex.ts → src/lib/apis/getIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export const getIndexData = async (marketType: "KOSPI" | "KOSDAQ"): Promise<Mark
API_ENDPOINTS.indexPeriod(marketType, startDate, endDate)
);

if (!response.data.isSuccess) {
throw new Error(response.data.message);
}

// ApiResponse의 result 필드 반환
return response.data.result;
};
19 changes: 19 additions & 0 deletions src/lib/apis/searchAssets.ts
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<SearchAssetsResponse[]>>(
API_ENDPOINTS.searchAssets(keyword.trim())
);

if (!response.data.isSuccess) {
throw new Error(response.data.message);
}
return response.data.result;
};
7 changes: 7 additions & 0 deletions src/lib/apis/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,10 @@ export interface ApiErrorResponse {
detail: string;
instance: string;
}

// 종목 검색 응답 타입
export type SearchAssetsResponse = {
stockName: string;
stockCode: string;
isinCode: string;
};
23 changes: 23 additions & 0 deletions src/lib/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";

/**
* 값을 debounce하는 커스텀 훅
* @param value - debounce할 값
* @param delay - debounce 지연 시간 (ms)
* @returns debounce된 값
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);

useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => {
clearTimeout(handler);
};
}, [value, delay]);

return debouncedValue;
}
12 changes: 12 additions & 0 deletions src/lib/hooks/useGetSearchAssets.ts
Original file line number Diff line number Diff line change
@@ -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 };
};