diff --git a/apps/web/app/_apis/schemas/kakaoSearch.ts b/apps/web/app/_apis/schemas/kakaoSearch.ts new file mode 100644 index 0000000..45f4b96 --- /dev/null +++ b/apps/web/app/_apis/schemas/kakaoSearch.ts @@ -0,0 +1,21 @@ +export const KAKAO_CATEGORY_CODE = { + restaurant: 'FD6', + cafe: 'CE7', +} + +export type KakaoSearchFuncParams = { + query: string + categoryCode: KakaoCategoryCode + location: { x: number; y: number } +} + +export type SearchPlaceByKakao = { + id: string + place_name: string + x: string + y: string + road_address_name?: string + address_name: string +} + +export type KakaoCategoryCode = keyof typeof KAKAO_CATEGORY_CODE diff --git a/apps/web/app/_apis/schemas/place.ts b/apps/web/app/_apis/schemas/place.ts index c273c24..df1cfd9 100644 --- a/apps/web/app/_apis/schemas/place.ts +++ b/apps/web/app/_apis/schemas/place.ts @@ -37,6 +37,12 @@ export const PlaceByMapSchema = BasePlaceSchema.extend({ photos, }) +export const PlaceBySearchSchema = BasePlaceSchema.pick({ + placeId: true, + placeName: true, + address: true, +}) + export const PlaceDetailSchema = BasePlaceSchema.extend({ location, isLiked: z.boolean(), @@ -75,6 +81,7 @@ export type MapBounds = { export type BasePlace = z.infer export type PlaceByMap = z.infer +export type PlaceBySearch = z.infer export type PlaceDetail = z.infer export type PlaceByPreview = z.infer export type NewPlaceRequest = z.infer diff --git a/apps/web/app/_apis/services/kakaoSearch.ts b/apps/web/app/_apis/services/kakaoSearch.ts new file mode 100644 index 0000000..aefc83b --- /dev/null +++ b/apps/web/app/_apis/services/kakaoSearch.ts @@ -0,0 +1,35 @@ +import axios from 'axios' +import { API_PATH } from '@/_constants/path' +import { + KAKAO_CATEGORY_CODE, + type KakaoSearchFuncParams, + type SearchPlaceByKakao, +} from '@/_apis/schemas/kakaoSearch' + +export const getSearchPlaceByKakao = async ({ + query, + categoryCode, + location, +}: KakaoSearchFuncParams): Promise => { + const KAKAO_API_KEY = process.env.NEXT_PUBLIC_KAKAO_API || '' + const { x, y } = location + try { + const { data } = await axios.get( + API_PATH.KAKAO.SEARCH(query, KAKAO_CATEGORY_CODE[categoryCode], x, y), + { + headers: { + Authorization: `KakaoAK ${KAKAO_API_KEY}`, + }, + }, + ) + + if (!data?.documents || !Array.isArray(data.documents)) { + return [] + } + + return data.documents + } catch (error) { + console.error('카카오 장소 검색 실패:', error) + throw error + } +} diff --git a/apps/web/app/_apis/services/place.ts b/apps/web/app/_apis/services/place.ts index bde879c..a46dae6 100644 --- a/apps/web/app/_apis/services/place.ts +++ b/apps/web/app/_apis/services/place.ts @@ -1,4 +1,3 @@ -import axios from 'axios' import axiosInstance from '@/_lib/axiosInstance' import { API_PATH } from '@/_constants/path' import type { CampusType } from '@/_constants/campus' @@ -8,18 +7,16 @@ import { type PlaceDetail, type MapBounds, type PlaceByMap, + type PlaceBySearch, type PlaceByPreview, type NewPlaceRequest, type NewPlaceResponse, BasePlaceSchema, PlaceByMapSchema, + PlaceBySearchSchema, PlaceDetailSchema, PlaceByPreviewSchema, } from '../schemas/place' -import { - type KakaoSearchFuncParams, - KAKAO_CATEGORY_CODE, -} from '@/_hooks/useSearchPlaceByKakao' export const getPlacesByRanking = async ( sort: RankingPlaceSort, @@ -58,30 +55,18 @@ export const getPlacesByMap = async ({ return PlaceByMapSchema.array().parse(data) } +export const getPlacesBySearch = async ( + keyword: string, +): Promise => { + const { data } = await axiosInstance.get(API_PATH.PLACES.SEARCH(keyword)) + return PlaceBySearchSchema.array().parse(data) +} + export const getPlaceDetail = async (id: string): Promise => { const { data } = await axiosInstance.get(API_PATH.PLACES.DETAIL(id)) return PlaceDetailSchema.parse(data) } -export const getSearchPlaceByKakao = async ({ - query, - categoryCode, - location, -}: KakaoSearchFuncParams) => { - const KAKAO_API_KEY = process.env.NEXT_PUBLIC_KAKAO_API || '' - const { x, y } = location - - const { data } = await axios.get( - API_PATH.KAKAO.SEARCH(query, KAKAO_CATEGORY_CODE[categoryCode], x, y), - { - headers: { - Authorization: `KakaoAK ${KAKAO_API_KEY}`, - }, - }, - ) - return data -} - export const getPlaceByPreview = async ( kakaoPlaceId: string, ): Promise => { diff --git a/apps/web/app/_components/SearchPage/SearchPage.tsx b/apps/web/app/_components/SearchPage/SearchPage.tsx index 0fbd519..bd54d06 100644 --- a/apps/web/app/_components/SearchPage/SearchPage.tsx +++ b/apps/web/app/_components/SearchPage/SearchPage.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' +import { Spinner } from '@heroui/react' import { Icon } from '@repo/ui/components/Icon' import { Flex, VerticalScrollArea } from '@repo/ui/components/Layout' import { SearchPlaceListItem } from './SearchPlaceListItem' @@ -47,15 +48,21 @@ export const SearchPage = ({ useBackHandler = false, }: Props) => { const [inputValue, setInputValue] = useState('') + const [isLoading, setIsLoading] = useState(false) - useEffect(() => { - if (inputValue.length > 0) { - searchFunc(inputValue) + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value + setInputValue(value) + if (value.length > 0) { + searchFunc(value) } - }, [inputValue, searchFunc]) + } return ( <> + {isLoading && ( + + )} {useBackHandler ? ( @@ -64,7 +71,7 @@ export const SearchPage = ({ )} setInputValue(e.target.value)} + onChange={handleInputChange} className={'w-full text-lg font-medium outline-none'} placeholder={placeholder || '장소 또는 주소를 검색하세요'} /> @@ -76,7 +83,10 @@ export const SearchPage = ({ key={place.id} inputValue={inputValue} place={place} - onClick={() => onSelectPlace(place.id)} + onClick={() => { + setIsLoading(true) + onSelectPlace(place.id) + }} /> ))} diff --git a/apps/web/app/_constants/path.ts b/apps/web/app/_constants/path.ts index fa65cd6..ac9cfa8 100644 --- a/apps/web/app/_constants/path.ts +++ b/apps/web/app/_constants/path.ts @@ -26,6 +26,7 @@ export const API_PATH = { POST: (id: string) => `/places/${id}/like`, DELETE: (id: string) => `/places/${id}/like`, }, + SEARCH: (keyword: string) => `/places/search?keyword=${keyword}`, }, KAKAO: { SEARCH: (query: string, categoryCode: string, x: number, y: number) => diff --git a/apps/web/app/_hooks/useSearch.ts b/apps/web/app/_hooks/useSearch.ts new file mode 100644 index 0000000..c2d1aa9 --- /dev/null +++ b/apps/web/app/_hooks/useSearch.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * - 입력된 query를 기준으로 장소 검색 API 호출 + * - 300ms 디바운싱 적용 (빠른 입력 시 불필요한 요청 방지) + * - API 결과를 상태로 관리하여 컴포넌트에서 바로 사용 가능 + * + * @example + * const { searchResult: cafeResult, searchFunc: cafeSearchFunc } = useSearch< + * SearchPlace, + * KakaoSearchFuncParams + * >(getSearchPlaceByKakao) + * + * + * @template T 검색 결과 아이템의 타입 (예: SearchPlace) + * @template P 검색 함수에 들어갈 파라미터 타입 (예: KakaoSearchFuncParams) + * @param fetcher Promise를 반환하는 비동기 검색 함수 + * + * @returns searchResult - 검색된 장소 리스트 + * @returns searchFunc - 검색을 수행하는 함수 + */ + +export const useSearch = (fetcher: (params: P) => Promise) => { + const [searchResult, setSearchResult] = useState([]) + const timeoutRef = useRef | null>(null) + + const searchFunc = useCallback( + (params: P) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + + timeoutRef.current = setTimeout(async () => { + try { + // 주입받은 fetcher 실행 + const result = await fetcher(params) + setSearchResult(result) + } catch (error) { + console.error('Search failed:', error) + setSearchResult([]) + } + }, 300) + }, + [fetcher], // fetcher가 변경되면 함수 재생성 + ) + + // 언마운트 시 타이머 클리어 + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + } + }, []) + + return { searchResult, searchFunc } +} diff --git a/apps/web/app/_hooks/useSearchPlaceByKakao.ts b/apps/web/app/_hooks/useSearchPlaceByKakao.ts deleted file mode 100644 index e28c76d..0000000 --- a/apps/web/app/_hooks/useSearchPlaceByKakao.ts +++ /dev/null @@ -1,89 +0,0 @@ -'use client' - -import { useState, useRef, useEffect, useCallback } from 'react' -import { getSearchPlaceByKakao } from '@/_apis/services/place' - -/** - * 검색된 장소 정보를 나타내는 인터페이스 - */ -export interface SearchPlace { - id: string - place_name: string - x: string - y: string - road_address_name?: string - address_name: string -} - -export const KAKAO_CATEGORY_CODE = { - restaurant: 'FD6', - cafe: 'CE7', -} - -export type KakaoCategoryCode = keyof typeof KAKAO_CATEGORY_CODE -export type KakaoSearchFuncParams = { - query: string - categoryCode: KakaoCategoryCode - location: { x: number; y: number } -} - -/** - * Kakao Local API를 이용한 장소 검색을 수행하는 커스텀 훅 - * - * - 입력된 query를 기준으로 장소 검색 API 호출 - * - 300ms 디바운싱 적용 (빠른 입력 시 불필요한 요청 방지) - * - API 결과를 상태로 관리하여 컴포넌트에서 바로 사용 가능 - * - * @returns 훅이 반환하는 값 - * @returns searchResult - 검색된 장소 리스트 - * @returns searchFunc - 검색을 수행하는 함수 - * - * @example - * const { searchResult, searchFunc } = useSearchData() - * - * // 인풋 변경 시 검색 실행 - * searchFunc(e.target.value)} /> - * - * // 검색된 결과 출력 - * {searchResult.map(place => ( - *
{place.place_name}
- * ))} - */ -export const useSearchPlaceByKakao = () => { - const [searchResult, setSearchResult] = useState([]) - const timeoutRef = useRef(null) - - /** - * 검색 함수 (300ms 디바운스 적용) - * @param query 검색 키워드 - */ - const searchFunc = useCallback( - async ({ query, categoryCode, location }: KakaoSearchFuncParams) => { - if (timeoutRef.current) clearTimeout(timeoutRef.current) - - timeoutRef.current = setTimeout(async () => { - try { - const result = await getSearchPlaceByKakao({ - query, - categoryCode, - location, - }) - setSearchResult([...result.documents]) - } catch (error) { - console.error(error) - setSearchResult([]) - } - }, 300) - }, - [], - ) - - // 언마운트 시 타이머 클리어 - useEffect(() => { - return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current) - } - }, []) - - return { searchResult, searchFunc } -} diff --git a/apps/web/app/places/[id]/PlaceDetailPage.tsx b/apps/web/app/places/[id]/PlaceDetailPage.tsx index 9311d57..d7322de 100644 --- a/apps/web/app/places/[id]/PlaceDetailPage.tsx +++ b/apps/web/app/places/[id]/PlaceDetailPage.tsx @@ -23,23 +23,20 @@ export const PlaceDetailPage = ({ id }: { id: string }) => { right={} /> - {photos.length > 0 && ( - ( - place-photo - ))} - minHeight={180} - showIndicator={true} - /> - )} - + ( + place-photo + ))} + minHeight={180} + showIndicator={true} + /> diff --git a/apps/web/app/places/new/_components/Step/PlacePreview/PlacePreview.tsx b/apps/web/app/places/new/_components/Step/PlacePreview/PlacePreview.tsx index e696f22..e3ed6af 100644 --- a/apps/web/app/places/new/_components/Step/PlacePreview/PlacePreview.tsx +++ b/apps/web/app/places/new/_components/Step/PlacePreview/PlacePreview.tsx @@ -60,6 +60,7 @@ export const PlacePreview = ({ getValues, setValue, nextStep }: Props) => { className={'max-h-[180px] object-contain'} /> ))} + showIndicator={true} minHeight={180} /> diff --git a/apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx b/apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx index d0377f3..df38405 100644 --- a/apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx +++ b/apps/web/app/places/new/_components/Step/PlaceSearch/PlaceSearch.tsx @@ -1,9 +1,10 @@ import { useCallback } from 'react' import { SearchPage } from '@/_components/SearchPage' -import { useSearchPlaceByKakao } from '@/_hooks/useSearchPlaceByKakao' +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' +import { searchCafeAndRestaurant } from '@/places/new/_utils/searchCafeAndRestaurant' type Props = { campus: CampusType @@ -12,39 +13,26 @@ type Props = { } export const PlaceSearch = ({ campus, setValue, nextStep }: Props) => { - const { searchResult: restaurantResult, searchFunc: restaurantSearchFunc } = - useSearchPlaceByKakao() - const { searchResult: cafeResult, searchFunc: cafeSearchFunc } = - useSearchPlaceByKakao() + const { searchResult, searchFunc } = useSearch(searchCafeAndRestaurant) - const places = [...restaurantResult, ...cafeResult].map((item) => ({ + const places = [...searchResult].map((item) => ({ id: item.id, name: item.place_name, address: item.address_name, })) - const searchFunc = useCallback( + const handleSearch = useCallback( (query: string) => { const { longitude: x, latitude: y } = CAMPUS_LOCATION[campus] - const location = { - x, - y, - } - - cafeSearchFunc({ query, categoryCode: 'cafe', location }) - restaurantSearchFunc({ - query, - categoryCode: 'restaurant', - location, - }) + searchFunc({ query, location: { x, y } }) }, - [cafeSearchFunc, campus, restaurantSearchFunc], + [campus, searchFunc], ) return ( { setValue('kakaoPlaceId', id) nextStep() diff --git a/apps/web/app/places/new/_utils/searchCafeAndRestaurant.ts b/apps/web/app/places/new/_utils/searchCafeAndRestaurant.ts new file mode 100644 index 0000000..db1b762 --- /dev/null +++ b/apps/web/app/places/new/_utils/searchCafeAndRestaurant.ts @@ -0,0 +1,18 @@ +import { getSearchPlaceByKakao } from '@/_apis/services/kakaoSearch' + +export const searchCafeAndRestaurant = async (params: { + query: string + location: { x: number; y: number } +}) => { + const [cafesResult, restaurantsResult] = await Promise.allSettled([ + getSearchPlaceByKakao({ ...params, categoryCode: 'cafe' }), + getSearchPlaceByKakao({ ...params, categoryCode: 'restaurant' }), + ]) + + const cafes = cafesResult.status === 'fulfilled' ? cafesResult.value : [] + const restaurants = + restaurantsResult.status === 'fulfilled' ? restaurantsResult.value : [] + + // 두 결과를 합쳐서 반환 + return [...cafes, ...restaurants] +} diff --git a/apps/web/app/places/search/page.tsx b/apps/web/app/places/search/page.tsx new file mode 100644 index 0000000..fb07d96 --- /dev/null +++ b/apps/web/app/places/search/page.tsx @@ -0,0 +1,33 @@ +'use client' + +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( + getPlacesBySearch, + ) + + const newPlaces = searchResult.map((place) => ({ + id: place.placeId, + name: place.placeName, + address: place.address, + })) + + return ( + { + replace(CLIENT_PATH.PLACE_DETAIL(id)) + }} + places={newPlaces} + /> + ) +} + +export default Page diff --git a/packages/ui/src/components/Banner/Banner.tsx b/packages/ui/src/components/Banner/Banner.tsx index c95fda9..7dc6ecd 100644 --- a/packages/ui/src/components/Banner/Banner.tsx +++ b/packages/ui/src/components/Banner/Banner.tsx @@ -56,16 +56,21 @@ export const Banner = ({ (slider) => { let timeout: ReturnType let mouseOver = false + function clearNextTimeout() { clearTimeout(timeout) } + function nextTimeout() { clearTimeout(timeout) if (mouseOver) return timeout = setTimeout(() => { - slider.next() + if (slider.track && slider.track.details) { + slider.next() + } }, 2000) } + slider.on('created', () => { slider.container.addEventListener('mouseover', () => { mouseOver = true @@ -80,10 +85,15 @@ export const Banner = ({ slider.on('dragStarted', clearNextTimeout) slider.on('animationEnded', nextTimeout) slider.on('updated', nextTimeout) + slider.on('destroyed', clearNextTimeout) }, ], ) + if (contents.length === 0) { + return null + } + return (
{currentSlide + 1} {` `}/{` `} - {instanceRef.current?.track.details.slides.length ?? 0} + {instanceRef.current.track?.details?.slides.length ?? 0}
)}