Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: search suggestion api #48

Merged
merged 19 commits into from
Jul 8, 2024
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
15 changes: 13 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"framer-motion": "^11.2.11",
"lodash.debounce": "^4.0.8",
"next": "14.2.3",
"react": "^18",
"react-dom": "^18",
Expand All @@ -38,6 +39,7 @@
"@storybook/test": "^8.1.11",
"@svgr/webpack": "^8.1.0",
"@testing-library/react": "^15.0.7",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
Expand Down
10 changes: 0 additions & 10 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client'

import BoardingInfoPass from '@/components/boarding-pass/boarding-info-pass'
import PlaceAutoSearchItem from '@/components/place/place-auto-search-item'
import PlaceListItem from '@/components/place/place-list-item'
import PlaceMapPopup from '@/components/place/place-map-popup'

Expand All @@ -23,15 +22,6 @@ const Home = () => {
]}
numOfPins={19339}
/>
<PlaceAutoSearchItem
placeId="dasdas"
query="존라"
address="서울시 성동구 장터5길"
name="존라멘"
numOfReviews={324}
category="일본식 라멘"
distance="234m"
/>
<PlaceListItem
placeId="sdfsgasf"
name="존라멘"
Expand Down
95 changes: 5 additions & 90 deletions src/app/search/page.tsx
Original file line number Diff line number Diff line change
@@ -1,103 +1,18 @@
'use client'

import { Suspense, useEffect, useState } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { Suspense } from 'react'

import SearchForm from './search-form'
import RecentKeywords from './recent-keywords'
import { recentSearchStorage } from '@/utils/storage'
import SearchBox from './search-box'
import Spinner from '@/components/spinner'
import { useIsServer } from '@/hooks/use-is-server'

const SearchBox = () => {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const search = searchParams.get('search') ?? ''

const [recentKeywords, setRecentKeywords] = useState(
recentSearchStorage.getValueOrNull() ?? [],
)
const [query, setQuery] = useState(search)
const isShowRecentKeywords =
query === '' && !!recentKeywords.length && search === ''

const createQueryString = (key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set(key, value)

return params.toString()
}

const addUniqueKeyword = (keyword: string) => {
const existRecentKeywords = [...(recentKeywords || [])]
if (!existRecentKeywords.includes(keyword)) {
recentSearchStorage.set([...existRecentKeywords, keyword])
setRecentKeywords([keyword, ...existRecentKeywords])
}
}

const searchByKeyword = (formDataOrKeyword: FormData | string) => {
const searchKeyword =
typeof formDataOrKeyword === 'string'
? formDataOrKeyword
: (formDataOrKeyword.get('query') as string | null)
if (searchKeyword) {
createQueryString('search', searchKeyword)
addUniqueKeyword(searchKeyword)
// TODO: API 연동 및 결과 컴포넌트 로드
setQuery(searchKeyword)
router.push(`${pathname}?${createQueryString('search', searchKeyword)}`)
}
}

const deleteRecentKeyword = (targetIndex: number) => {
const existRecentKeywords = [...(recentKeywords || [])]

if (targetIndex !== -1) {
existRecentKeywords.splice(targetIndex, 1)
recentSearchStorage.set(existRecentKeywords)
setRecentKeywords(existRecentKeywords)
}
}

const handleResetQuery = () => {
setQuery('')
createQueryString('search', '')
router.push(`${pathname}?${createQueryString('search', '')}`)
}

useEffect(() => {
// search와 query 동기화 (삭제, 브라우저 뒤로가기/앞으로가기 등 대응)
setQuery(search)
}, [search])

return (
<div className="w-full min-h-dvh bg-neutral-700 px-5 pt-2">
<SearchForm
value={query}
onChange={(e) => setQuery(e.target.value)}
onResetValue={handleResetQuery}
onSubmit={searchByKeyword}
/>

{isShowRecentKeywords && (
<RecentKeywords
recentKeywords={recentKeywords}
onSearchKeyword={searchByKeyword}
onDeleteKeyword={deleteRecentKeyword}
/>
)}
</div>
)
}

const Search = () => {
const isServer = useIsServer()

if (isServer) return <div>검색 페이지 로딩 중...</div>
if (isServer) return <Spinner />

return (
<Suspense>
<Suspense fallback={<Spinner />}>
<SearchBox />
</Suspense>
)
Expand Down
145 changes: 145 additions & 0 deletions src/app/search/search-box.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
'use client'

import debounce from 'lodash.debounce'
import { useCallback, useState } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'

import { api } from '@/utils/api'
import SearchForm from './search-form'
import { Typography } from '@/components'
import RecentKeywords from './recent-keywords'
import SuggestPlaceList from './suggest-place-list'
import { formatBoundToRect } from '@/utils/location'
import type { KakaoPlaceItem } from '@/types/map/kakao-raw-type'
import { mapBoundSessionStorage, recentSearchStorage } from '@/utils/storage'
import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'

const SearchBox = () => {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const search = searchParams.get('search') ?? ''

const mapBounds = mapBoundSessionStorage.getValueOrNull()
const [recentKeywords, setRecentKeywords] = useState(
recentSearchStorage.getValueOrNull() ?? [],
)
const [query, setQuery] = useState(search)
const [suggestedPlaces, setSuggestedPlaces] = useState<KakaoPlaceItem[]>([])
const isShowRecentKeywords =
query === '' && !!recentKeywords.length && search === ''
const isShowResultPlaces = search !== ''
const isShowSuggestionPlaces = !isShowRecentKeywords && !isShowResultPlaces

const createQueryString = (key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set(key, value)

return params.toString()
}

const addUniqueKeyword = (keyword: string) => {
const existRecentKeywords = [...(recentKeywords || [])]

const keywordIndex = existRecentKeywords.indexOf(keyword)
if (keywordIndex !== -1) {
existRecentKeywords.splice(keywordIndex, 1)
}
recentSearchStorage.set([...existRecentKeywords, keyword])
setRecentKeywords([keyword, ...existRecentKeywords])
}

const searchByKeyword = (formDataOrKeyword: FormData | string) => {
const searchKeyword =
typeof formDataOrKeyword === 'string'
? formDataOrKeyword
: (formDataOrKeyword.get('query') as string | null)
if (searchKeyword) {
createQueryString('search', searchKeyword)
addUniqueKeyword(searchKeyword)
setQuery(searchKeyword)
router.push(`${pathname}?${createQueryString('search', searchKeyword)}`)
}
}

const deleteRecentKeyword = (targetIndex: number) => {
const existRecentKeywords = [...(recentKeywords || [])]

if (targetIndex !== -1) {
existRecentKeywords.splice(targetIndex, 1)
recentSearchStorage.set(existRecentKeywords)
setRecentKeywords(existRecentKeywords)
}
}

const handleResetQuery = () => {
setQuery('')
createQueryString('search', '')
router.push(`${pathname}?${createQueryString('search', '')}`)
}

useIsomorphicLayoutEffect(() => {
Copy link
Member

Choose a reason for hiding this comment

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

react.dev에서 useLayoutEffect 사용을 지양하라고 해서, 언제 사용해야 성능 이슈보다 이점이 더 클지 잘 감이 안 오더라구요 useLayoutEffect 사용 기준을 따로 갖고 계신지 궁금합니다!

Copy link
Member Author

Choose a reason for hiding this comment

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

useLayoutEffect는 레이아웃 관련 작업이나 깜박임 또는 레이아웃이 깨지는 문제를 방지할 때 말고 잘 안쓰긴 했는데, 여기선 Next라 useIsomorphicLayoutEffect로 넣긴 했네요!

Copy link
Member

Choose a reason for hiding this comment

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

결국 useIsomorphicLayoutEffect를 쓴다는 건 client side에서 useLayoutEffect를 사용하겠다는 건데, 사용했을 때 깜빡임이 확실하게 사라지거나 더 빠르게 그려지는게 확실하지 않다보니, 성능 이슈를 고려해서 사용 기준을 명확하게 정해보는 것도 좋을 것 같아요!

// search와 query 동기화 (삭제, 브라우저 뒤로가기/앞으로가기 등 대응)
setQuery(search)
}, [search])

const getSuggestPlaces = useCallback(async () => {
if (!query) return

try {
const res = await api.search.searchPlaces({
q: query,
rect: formatBoundToRect(mapBounds),
})
setSuggestedPlaces(res.data)
} catch (err) {
// TODO: Error 처리
}
}, [mapBounds, query])

useIsomorphicLayoutEffect(() => {
const debounceGetSuggestPlaces = debounce(getSuggestPlaces, 500)

debounceGetSuggestPlaces()

return () => {
debounceGetSuggestPlaces.cancel()
}
}, [query])

return (
<div className="w-full min-h-dvh bg-neutral-700 px-5 py-2">
<SearchForm
value={query}
onChange={(e) => setQuery(e.target.value)}
onResetValue={handleResetQuery}
onSubmit={searchByKeyword}
/>

{isShowRecentKeywords && (
<RecentKeywords
recentKeywords={recentKeywords}
onSearchKeyword={searchByKeyword}
onDeleteKeyword={deleteRecentKeyword}
/>
)}

{isShowSuggestionPlaces &&
(suggestedPlaces.length > 0 ? (
<SuggestPlaceList places={suggestedPlaces} query={query} />
) : (
<Typography
size="body2"
color="neutral-200"
className="flex justify-center mt-[112px]"
>
검색 결과가 없습니다.
</Typography>
))}

{isShowResultPlaces && <div>Result</div>}
</div>
)
}

export default SearchBox
3 changes: 3 additions & 0 deletions src/app/search/search-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const SearchForm = ({
return (
<form action={onSubmit}>
<SearchInput
ref={(node) => {
node?.focus()
}}
name="query"
value={value}
leftIcon={{
Expand Down
39 changes: 39 additions & 0 deletions src/app/search/suggest-place-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { formatDistance, getDistance } from '@/utils/location'
import useUserGeoLocation from '@/hooks/use-user-geo-location'
import type { KakaoPlaceItem } from '@/types/map/kakao-raw-type'
import PlaceAutoSearchItem from '@/components/place/place-auto-search-item'

interface SuggestPlaceListProps {
places: KakaoPlaceItem[]
query: string
}

const SuggestPlaceList = ({ places, query }: SuggestPlaceListProps) => {
const userLocation = useUserGeoLocation()

return (
<ul className="flex flex-col space-y-[13px] divide-y divide-neutral-600 mx-[-20px]">
{places.map((place) => {
const diffDistance = getDistance(
userLocation.latitude,
userLocation.longitude,
place.y,
place.x,
)

return (
<PlaceAutoSearchItem
key={place.id}
{...place}
className="px-5"
query={query}
distance={formatDistance(diffDistance)}
numOfReviews={312}
/>
)
})}
</ul>
)
}

export default SuggestPlaceList
Loading
Loading