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
30 changes: 15 additions & 15 deletions apps/web/app/_components/SearchPage/SearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import { Icon } from '@repo/ui/components/Icon'
import { Flex, VerticalScrollArea } from '@repo/ui/components/Layout'
import { SearchPlaceListItem } from './SearchPlaceListItem'
import { HeaderBackButton } from '@/_components/HeaderBackButton'
import { useDebouncedFetch } from '@/_hooks/useDebouncedFetch'

export type BasePlace = {
id: string
name: string
address: string
}

export type Props = {
placeholder?: string
places: {
id: string
name: string
address: string
}[]
searchFunc: (inputValue: string) => void
searchFunc: (inputValue: string) => Promise<BasePlace[]>
onSelectPlace: (id: string) => void
useBackHandler?: boolean
}
Expand All @@ -26,41 +28,39 @@ export type Props = {
* - useBackHandler가 true면 헤더에 뒤로가기 버튼, false면 검색 아이콘 표시
*
* @param placeholder 검색 input의 placeholder
* @param places 검색 결과 장소 리스트
* @param searchFunc 검색 함수 (input 변경 시 호출)
* @param onSelectPlace 리스트 아이템 선택 시 호출
* @param useBackHandler 헤더에 뒤로가기 버튼 사용 여부
*
* @example
* <SearchPage
* placeholder="장소를 검색하세요"
* places={places}
* searchFunc={handleSearch}
* onSelectPlace={(id) => console.log(id)}
* useBackHandler={true}
* />
*/
export const SearchPage = ({
placeholder,
places,
placeholder = '장소 또는 주소를 검색하세요',
searchFunc,
onSelectPlace,
useBackHandler = false,
}: Props) => {
const [places, setPlaces] = useDebouncedFetch(searchFunc)
const [inputValue, setInputValue] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isSelecting, setIsSelecting] = useState(false)

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
if (value.length > 0) {
searchFunc(value)
setPlaces(value)
}
}

return (
<>
{isLoading && (
{isSelecting && (
<Spinner className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2' />
)}
<Flex className={'border-b-1 gap-2.5 border-gray-100 p-3.5'}>
Expand All @@ -73,7 +73,7 @@ export const SearchPage = ({
value={inputValue}
onChange={handleInputChange}
className={'w-full text-lg font-medium outline-none'}
placeholder={placeholder || '장소 또는 주소를 검색하세요'}
placeholder={placeholder}
/>
</Flex>
{inputValue && (
Expand All @@ -84,7 +84,7 @@ export const SearchPage = ({
inputValue={inputValue}
place={place}
onClick={() => {
setIsLoading(true)
setIsSelecting(true)
onSelectPlace(place.id)
}}
/>
Expand Down
54 changes: 54 additions & 0 deletions apps/web/app/_hooks/useDebouncedFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, useState } from 'react'

/**
* 비동기 함수(fetcher)를 디바운싱하여 실행하고, 그 결과를 상태로 관리하는 훅
*
* - 입력이 멈춘 후 일정 시간(delay) 뒤에 API를 호출합니다.
* - 검색어 자동완성, 필터링 등 잦은 요청을 방지해야 할 때 유용합니다.
*
* @template T 결과 데이터의 타입
* @template P 파라미터의 타입
*
* @param fetcher 데이터를 가져오는 비동기 함수 (Promise 반환)
* @param delay 디바운스 지연 시간 (ms, 기본값: 300ms)
*
* @returns data - 비동기 작업의 결과 데이터 (초기값: [])
* @returns trigger - 디바운스가 적용된 실행 함수
*
* @example
* const { data: places, trigger: searchPlaces } = useDebouncedFetch(getSearchPlaceByKakao, 500);
* // searchPlaces('강남역') 호출 시 500ms 후 API 호출 -> places 업데이트
*/
export const useDebouncedFetch = <T, P>(
fetcher: (params: P) => Promise<T[]>,
delay: number = 300,
) => {
const [data, setData] = useState<T[]>([])
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)

const trigger = useCallback(
(params: P) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)

timeoutRef.current = setTimeout(async () => {
try {
const result = await fetcher(params)
setData(result)
} catch (error) {
console.error('Debounced fetch failed:', error)
setData([])
}
}, delay)
},
[fetcher, delay],
)

// 언마운트 시 타이머 클리어
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [])

return [data, trigger] as const
}
53 changes: 0 additions & 53 deletions apps/web/app/_hooks/useSearch.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useCallback } from 'react'
import { SearchPage } from '@/_components/SearchPage'
import { useSearch } from '@/_hooks/useSearch'
import type { UseFormSetValue } from 'react-hook-form'
import type { NewPlaceRequest } from '@/_apis/schemas/place'
import { type CampusType, CAMPUS_LOCATION } from '@/_constants/campus'
Expand All @@ -13,27 +12,26 @@ type Props = {
}

export const PlaceSearch = ({ campus, setValue, nextStep }: Props) => {
const { searchResult, searchFunc } = useSearch(searchCafeAndRestaurant)

const places = [...searchResult].map((item) => ({
id: item.id,
name: item.place_name,
address: item.address_name,
}))

const handleSearch = useCallback(
(query: string) => {
async (query: string) => {
const { longitude: x, latitude: y } = CAMPUS_LOCATION[campus]
searchFunc({ query, location: { x, y } })
const result = await searchCafeAndRestaurant({
query,
location: { x, y },
})
return result.map((item) => ({
id: item.id,
name: item.place_name,
address: item.address_name,
}))
},
[campus, searchFunc],
[campus],
)

return (
<SearchPage
places={places}
searchFunc={handleSearch}
onSelectPlace={(id) => {
onSelectPlace={(id: string) => {
setValue('kakaoPlaceId', id)
nextStep()
}}
Expand Down
22 changes: 10 additions & 12 deletions apps/web/app/places/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,27 @@
import { useRouter } from 'next/navigation'
import { SearchPage } from '@/_components/SearchPage'
import { getPlacesBySearch } from '@/_apis/services/place'
import { useSearch } from '@/_hooks/useSearch'
import { CLIENT_PATH } from '@/_constants/path'
import type { PlaceBySearch } from '@/_apis/schemas/place'

const Page = () => {
const { replace } = useRouter()
const { searchResult, searchFunc } = useSearch<PlaceBySearch, string>(
getPlacesBySearch,
)

const newPlaces = searchResult.map((place) => ({
id: place.placeId,
name: place.placeName,
address: place.address,
}))
const handleSearch = async (query: string) => {
const result = await getPlacesBySearch(query)
return result.map((place) => ({
id: place.placeId,
name: place.placeName,
address: place.address,
}))
}

return (
<SearchPage
searchFunc={searchFunc}
useBackHandler={true}
searchFunc={handleSearch}
onSelectPlace={(id) => {
replace(CLIENT_PATH.PLACE_DETAIL(id))
}}
places={newPlaces}
/>
)
}
Expand Down