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)
}
}
Comment on lines +49 to 59
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

입력값 초기화 시 검색 결과가 유지되는 문제

value.length > 0일 때만 setPlaces를 호출하므로, 사용자가 입력을 모두 지워도 이전 검색 결과(places)가 그대로 남아있습니다. inputValue && places.map(...) 조건으로 렌더링은 숨겨지지만, 상태는 남아있어 다시 입력 시 잠깐 이전 결과가 보일 수 있습니다.

입력이 비워질 때 places를 초기화하는 것이 더 깔끔합니다.

🐛 수정 제안
   const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
     const value = e.target.value
     setInputValue(value)
     if (value.length > 0) {
       setPlaces(value)
+    } else {
+      // places 초기화 로직 필요 (useDebouncedFetch에 clear 함수 추가 필요)
     }
   }
🤖 Prompt for AI Agents
In `@apps/web/app/_components/SearchPage/SearchPage.tsx` around lines 49 - 59,
handleInputChange currently only calls setPlaces when value.length > 0, so
clearing the input leaves previous search results in places; update
handleInputChange to call setPlaces([]) (or the appropriate empty value your
useDebouncedFetch expects) when value is empty so places is reset when the user
clears the input; modify the handleInputChange function to branch on
value.length and call setPlaces(value) when non-empty and setPlaces([]) when
empty, referencing handleInputChange, setPlaces and places.


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)
}}
Comment on lines 86 to 89
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

동기 호출로 인해 스피너가 표시되지 않음

setIsSelecting(true)setIsSelecting(false)가 동기적으로 연속 호출되어 React가 이를 배치 처리합니다. 결과적으로 스피너가 실제로 렌더링되지 않습니다.

onSelectPlace가 비동기 작업(예: 페이지 이동)을 수행한다면, async/await를 사용해야 합니다.

🐛 수정 제안
  onClick={() => {
-   setIsSelecting(true)
-   onSelectPlace(place.id)
-   setIsSelecting(false)
+   setIsSelecting(true)
+   // onSelectPlace가 Promise를 반환하지 않으면 스피너 로직 제거 고려
+   onSelectPlace(place.id)
+   // 페이지 이동 시 컴포넌트가 언마운트되므로 false 설정 불필요
  }}

또는 onSelectPlacePromise를 반환하도록 변경:

  onClick={async () => {
    setIsSelecting(true)
    try {
      await onSelectPlace(place.id)
    } finally {
      setIsSelecting(false)
    }
  }}
🤖 Prompt for AI Agents
In `@apps/web/app/_components/SearchPage/SearchPage.tsx` around lines 86 - 90, The
onClick handler currently calls setIsSelecting(true), then
onSelectPlace(place.id), then setIsSelecting(false) synchronously so React
batches updates and the spinner never appears; make the handler async and await
the async work: call setIsSelecting(true), await onSelectPlace(place.id) (or
ensure onSelectPlace returns a Promise), then setIsSelecting(false) in a finally
block to guarantee the spinner is cleared even on error; reference the onClick
handler, setIsSelecting, onSelectPlace, and place.id when updating the code.

/>
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