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: map search interface #143

Open
wants to merge 4 commits into
base: dev
Choose a base branch
from
Open
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
2,507 changes: 1,871 additions & 636 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,4 @@
"pnpm": "use npm instead of pnpm",
"bun": "use npm instead of bun"
}
}
}
218 changes: 218 additions & 0 deletions src/app/map/search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
'use client'

import AccessibleIconButton from '@/components/common/accessible-icon-button'
import SearchInput from '@/components/common/search-input'
import Tab from '@/components/common/tab/tab'
import TabLabels from '@/components/common/tab/tab-labels'
import TabPanel from '@/components/common/tab/tab-panel'
import Typography from '@/components/common/typography'
import useSafeRouter from '@/hooks/use-safe-router'
import type { FormEvent } from 'react'
import { useCallback, useEffect, useState } from 'react'
import SearchMapCard from './search-map-card'
import Link from 'next/link'
import SearchMenuList from './search-menu-list'
import SearchCityList from './search-location-list'
import { useSearchParams } from 'next/navigation'
import { recentMapSearchStorage } from '@/utils/storage'
import ChipButton from '@/components/common/chip-button'

const MapSearch = () => {
const searchParams = useSearchParams()
const search = searchParams.get('q') ?? ''
const [query, setQuery] = useState(search)
const [recentKeywords, setRecentKeywords] = useState(
recentMapSearchStorage.getValueOrNull() ?? [],
)
const [searchInput, setSearchInput] = useState('')
const [activeTab, setActiveTab] = useState<'인기 많은순' | '가까운 순'>(
'인기 많은순',
)
const router = useSafeRouter()

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

return params.toString()
},
[searchParams],
)

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

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

const handleSearchLogic = useCallback(
(keyword: string) => {
const param = createQueryString('q', keyword)
addUniqueKeyword(keyword)
setQuery(keyword)
setSearchInput(keyword)
router.push(`/map/search?${param}`)
},
[addUniqueKeyword, createQueryString, router],
)

const handleSubmitInput = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (searchInput === '') return

handleSearchLogic(searchInput)
}

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

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

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

return (
<>
<div className="min-h-dvh bg-neutral-700">
<header className="relative flex items-center pt-4">
<AccessibleIconButton
icon={{ type: 'caretLeft', size: 'xl' }}
label="이전 페이지"
className="p-[10px]"
onClick={() => router.safeBack()}
/>
<Typography
className="absolute left-1/2 translate-x-[-50%]"
as="h1"
size="body0"
>
지도 둘러보기
</Typography>
</header>

<div className="flex flex-col gap-6">
<form
className="mt-2 flex w-full items-end px-5"
onSubmit={handleSubmitInput}
>
<SearchInput
value={searchInput}
placeholder="메뉴나 지역으로 지도를 검색해 주세요"
onChange={(e) => setSearchInput(e.target.value)}
/>
</form>

{recentKeywords.length > 0 && (
<div className="no-scrollbar flex max-w-full flex-nowrap gap-3 overflow-x-scroll px-5">
{recentKeywords.map((keyword, index) => (
<button
key={keyword}
onClick={() => handleSearchLogic(keyword)}
>
<ChipButton
isActive
rightIcon={{
type: 'close',
onClick: (e) => {
e.preventDefault()
e.stopPropagation()
deleteRecentKeyword(index)
},
}}
className="flex-row-reverse whitespace-nowrap"
>
{keyword}
</ChipButton>
</button>
))}
</div>
)}

{query === '' ? (
<div className="flex flex-col gap-3">
<SearchMenuList
className="px-5"
onClickMenu={(menu) => handleSearchLogic(menu)}
/>
<SearchCityList
className="px-5"
onClickLocation={(location) => handleSearchLogic(location)}
/>
</div>
) : (
<Tab
activeTab={activeTab}
setActiveTab={setActiveTab}
className="px-5"
>
<TabLabels labels={['인기 많은순', '가까운 순']} />
<TabPanel tabId="인기 많은순">
<ul className="flex flex-col gap-4">
<Link href={`/map/search/123`}>
<SearchMapCard
map={{
name: '비타민C',
numOfCrews: 123,
numOfPins: 200,
id: '123',
description: 'Mash-Up 최고먹짱들의 지도',
categories: ['돈까스', '강남'],
}}
/>
</Link>
<Link href={`/map/search/123`}>
<SearchMapCard
map={{
name: '비타민C',
numOfCrews: 123,
numOfPins: 200,
id: '123',
description: 'Mash-Up 최고먹짱들의 지도',
categories: ['돈까스', '강남'],
}}
/>
</Link>
</ul>
</TabPanel>

<TabPanel tabId="가까운 순">
<ul className="flex flex-col gap-4">
<Link href={`/map/search/123`}>
<SearchMapCard
map={{
name: '비타민C',
numOfCrews: 123,
numOfPins: 200,
id: '123',
description: 'Mash-Up 최고먹짱들의 지도',
categories: ['돈까스', '강남'],
}}
/>
</Link>
</ul>
</TabPanel>
</Tab>
)}
</div>
</div>
</>
)
}

export default MapSearch
30 changes: 30 additions & 0 deletions src/app/map/search/search-icon-chip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { IconKey } from '@/components/common/icon'
import Icon from '@/components/common/icon'
import Typography from '@/components/common/typography'
import cn from '@/utils/cn'

const SearchIconChip = ({
label,
iconType,
className,
}: {
label: string
iconType?: IconKey
className?: string
}) => {
return (
<span
className={cn(
'flex w-fit items-center justify-center gap-1 rounded-full bg-neutral-500 px-[10px] py-[5.5px]',
className,
)}
>
{iconType && <Icon type={iconType} size="md" />}
<Typography size="body3" className="text-[#dcdcdc]">
{label}
</Typography>
</span>
)
}

export default SearchIconChip
49 changes: 49 additions & 0 deletions src/app/map/search/search-location-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Typography from '@/components/common/typography'
import type { ClassName } from '@/models/common'
import cn from '@/utils/cn'
import SearchIconChip from './search-icon-chip'

interface SearchLocationListProps extends ClassName {
onClickLocation: (location: string) => void
}

const locations: string[] = [
'종로',
'강남',
'여의도',
'가산',
'성수',
'판교',
'마곡',
'구로 디지털단지',
'문정',
' 상암',
'세종',
]

const SearchLocationList = ({
className,
onClickLocation,
}: SearchLocationListProps) => {
return (
<div className={cn('flex flex-col gap-3', className)}>
<Typography size="body3" color="neutral-300">
지역
</Typography>

<ul className="flex flex-wrap gap-[10px]">
{locations.map((location) => (
<button
type="button"
key={location}
onClick={() => onClickLocation(location)}
>
<SearchIconChip label={location} className="px-[10px] py-2" />
</button>
))}
</ul>
</div>
)
}

export default SearchLocationList
64 changes: 64 additions & 0 deletions src/app/map/search/search-map-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Icon from '@/components/common/icon'
import Typography from '@/components/common/typography'
import type { ClassName } from '@/models/common'
import type { MapInfo } from '@/models/map'
import cn from '@/utils/cn'
import SearchIconChip from './search-icon-chip'

interface SearchMapCardProps extends ClassName {
map: Pick<MapInfo, 'id' | 'name' | 'description'> & {
numOfCrews: number
numOfPins: number
categories?: string[]
}
}

const SearchMapCard = ({ map, className }: SearchMapCardProps) => {
return (
<li
className={cn(
'flex w-full flex-col gap-2 rounded-[20px] bg-neutral-600 p-5',
className,
)}
>
<div className="flex items-center justify-between">
<Typography size="h3">{map.name}</Typography>
{map.categories && (
<div className="flex items-center justify-center gap-2">
{map.categories.map((category) => (
<SearchIconChip key={category} label={category} />
))}
</div>
)}
</div>

<div className="flex items-center gap-2">
<div className="flex items-center gap-[2px]">
<Icon type="person" size="md" />
<Typography size="body3" color="neutral-300">
Crew
</Typography>
<Typography size="body3" color="neutral-100">
{map.numOfCrews.toLocaleString()}
</Typography>
</div>

<div className="flex items-center gap-[2px]">
<Icon type="pin" size="md" />
<Typography size="body3" color="neutral-300">
Pins
</Typography>
<Typography size="body3" color="neutral-100">
{map.numOfPins.toLocaleString()}
</Typography>
</div>
</div>

<Typography size="body2" color="neutral-200">
{map.description}
</Typography>
</li>
)
}

export default SearchMapCard
Loading
Loading