diff --git a/client/src/client.ts b/client/src/client.ts index a3cef0b1..f0823e4a 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -1,4 +1,4 @@ -import type { AuthProvider, AuthUrl, Cafe, CafeMapLocation, CafeMenu, LikedCafe, MapBounds, Rank, User } from './types'; +import type { AuthProvider, AuthUrl, Cafe, CafeMapLocation, CafeMenu, LikedCafe, MapBounds, Rank, SearchedCafe, User } from './types'; export class ClientNetworkError extends Error { constructor() { @@ -104,6 +104,20 @@ class Client { return this.fetchJson(`/cafes/${cafeId}/menus`); } + searchCafes(searchParams: { name: string; menu?: string; address?: string }) { + const sanitizedSearchParams = Object.fromEntries( + Object.entries({ + cafeName: searchParams.name.trim(), + menu: searchParams.menu?.trim() ?? '', + address: searchParams.address?.trim() ?? '', + }).filter(([, value]) => (value?.length ?? 0) >= 2), + ); + if (Object.keys(sanitizedSearchParams).length === 0) { + return Promise.resolve([]); + } + return this.fetchJson(`/cafes/search?${new URLSearchParams(sanitizedSearchParams).toString()}`); + } + getCafesNearLocation( longitude: MapBounds['longitude'], latitude: MapBounds['latitude'], diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx index 9850b9b3..f378c159 100644 --- a/client/src/components/Button.tsx +++ b/client/src/components/Button.tsx @@ -38,7 +38,7 @@ const ButtonVariants = { }; const Container = styled.button` - padding: ${({ theme }) => theme.space['1.5']} 0; + padding: ${({ theme }) => theme.space['1.5']} ${({ theme }) => theme.space[2]}; font-size: 16px; font-weight: 500; border-radius: 40px; diff --git a/client/src/components/Navbar.tsx b/client/src/components/Navbar.tsx index bde12cdf..be1df9dc 100644 --- a/client/src/components/Navbar.tsx +++ b/client/src/components/Navbar.tsx @@ -1,8 +1,9 @@ import { Suspense, useState } from 'react'; +import { FaSearch } from 'react-icons/fa'; +import { FaRankingStar } from 'react-icons/fa6'; import { Link } from 'react-router-dom'; import { styled } from 'styled-components'; import useUser from '../hooks/useUser'; -import Resource from '../utils/Resource'; import Button from './Button'; import LoginModal from './LoginModal'; @@ -20,20 +21,21 @@ const Navbar = () => { return ( - - - - - + + + - - - - - - + + + + + + + + + + + {user ? ( )} - + {isLoginModalOpen && } @@ -66,22 +68,27 @@ const Container = styled.nav` const ButtonContainer = styled.div` display: flex; + gap: ${({ theme }) => theme.space[2]}; align-items: center; + margin-left: auto; `; -const LogoContainer = styled.div` - flex: 6; +const Logo = styled.img.attrs({ src: '/assets/logo.svg' })` + height: ${({ theme }) => theme.fontSize['3xl']}; `; -const Logo = styled.img.attrs({ src: Resource.getAssetUrl({ filename: 'logo.svg' }) })` - height: ${({ theme }) => theme.fontSize['4xl']}; +const UserButtonContainer = styled.div` + width: 100px; `; -const RankButtonContainer = styled.div` - width: 44px; - margin-right: ${({ theme }) => theme.space[2]}; -`; +const IconButton = styled.button` + cursor: pointer; + + display: flex; + align-items: center; + + padding: ${({ theme }) => theme.space[2]}; -const LoginAndProfileButtonContainer = styled.div` - width: 133px; + font-size: ${({ theme }) => theme.fontSize['2xl']}; + color: ${({ theme }) => theme.color.primary}; `; diff --git a/client/src/components/SearchInput.tsx b/client/src/components/SearchInput.tsx new file mode 100644 index 00000000..5f3c887d --- /dev/null +++ b/client/src/components/SearchInput.tsx @@ -0,0 +1,36 @@ +import type { ComponentPropsWithoutRef } from 'react'; +import styled, { css } from 'styled-components'; + +type SearchInputVariant = 'small' | 'large'; + +type SearchInputProps = ComponentPropsWithoutRef<'input'> & { + variant?: SearchInputVariant; +}; + +const SearchInput = (props: SearchInputProps) => { + const { variant = 'small', ...restProps } = props; + + return ; +}; + +export default SearchInput; + +const Variants = { + large: css` + padding: ${({ theme }) => theme.space[3]}; + font-size: ${({ theme }) => theme.fontSize.lg}; + `, + small: css` + padding: ${({ theme }) => theme.space[2]}; + font-size: ${({ theme }) => theme.fontSize.base}; + `, +}; + +const Input = styled.input<{ $variant: SearchInputVariant }>` + width: 100%; + border: 1px solid #d0d0d0; + border-radius: 4px; + outline: none; + + ${({ $variant }) => Variants[$variant || 'small']} +`; diff --git a/client/src/hooks/useSearchCafes.ts b/client/src/hooks/useSearchCafes.ts new file mode 100644 index 00000000..6e8ca9e6 --- /dev/null +++ b/client/src/hooks/useSearchCafes.ts @@ -0,0 +1,21 @@ +import client from '../client'; +import type { SearchedCafe } from '../types'; +import useSuspenseQuery from './useSuspenseQuery'; + +type SearchCafesQuery = { + searchName: string; + searchMenu?: string | undefined; + searchAddress?: string | undefined; +}; + +const useSearchCafes = (query: SearchCafesQuery) => { + const { searchName, searchMenu, searchAddress } = query; + + return useSuspenseQuery({ + queryKey: ['searchCafes', query], + retry: false, + queryFn: () => client.searchCafes({ name: searchName, menu: searchMenu, address: searchAddress }), + }); +}; + +export default useSearchCafes; diff --git a/client/src/mocks/handlers.ts b/client/src/mocks/handlers.ts index bb9f9611..2f74e3bf 100644 --- a/client/src/mocks/handlers.ts +++ b/client/src/mocks/handlers.ts @@ -1,6 +1,6 @@ import { rest } from 'msw'; import { RankCafes, cafeMarker, cafeMenus, cafes } from '../data/mockData'; -import type { CafeMapLocation, Identity, User } from '../types'; +import type { CafeMapLocation, Identity, SearchedCafe, User } from '../types'; let pageState = 1; @@ -122,6 +122,37 @@ const handlers = [ return res(ctx.status(200), ctx.json(RankCafes.slice(start, end))); }), + rest.get('/api/cafes/search', (req, res, ctx) => { + const { + cafeName: searchName, + menu: searchMenu, + address: searchAddress, + } = Object.fromEntries(req.url.searchParams.entries()); + + let searchedCafes: SearchedCafe[] = cafes.map((cafe) => ({ + id: cafe.id, + name: cafe.name, + address: cafe.address, + likeCount: cafe.likeCount, + image: cafe.images[0], + })); + + if (searchName?.length >= 2) { + searchedCafes = searchedCafes.filter((cafe) => cafe.name.includes(searchName)); + } + + if (searchMenu?.length >= 2) { + searchedCafes = searchedCafes.filter((cafe) => + cafeMenus.find((cafeMenu) => cafeMenu.cafeId === cafe.id)?.menus.some((menu) => menu.name.includes(searchMenu)), + ); + } + if (searchAddress?.length >= 2) { + searchedCafes = searchedCafes.filter((cafe) => cafe.address.includes(searchAddress)); + } + + return res(ctx.status(200), ctx.json(searchedCafes)); + }), + // 좋아요 한 목록 조회 rest.get('/api/members/:memberId/liked-cafes', (req, res, ctx) => { const PAGINATE_UNIT = 15; diff --git a/client/src/pages/SearchPage.tsx b/client/src/pages/SearchPage.tsx new file mode 100644 index 00000000..475d3945 --- /dev/null +++ b/client/src/pages/SearchPage.tsx @@ -0,0 +1,197 @@ +import type { FormEventHandler } from 'react'; +import { useDeferredValue, useEffect, useState } from 'react'; +import { BiCoffeeTogo, BiHome, BiSearch } from 'react-icons/bi'; +import { PiHeartFill } from 'react-icons/pi'; +import { Link, useSearchParams } from 'react-router-dom'; +import styled from 'styled-components'; +import SearchInput from '../components/SearchInput'; +import useSearchCafes from '../hooks/useSearchCafes'; +import type { Theme } from '../styles/theme'; +import Resource from '../utils/Resource'; + +const SearchPage = () => { + const [searchParams, setSearchParams] = useSearchParams(); + + const [searchName, setSearchName] = useState(searchParams.get('cafeName') || ''); + const [searchAddress, setSearchAddress] = useState(searchParams.get('address') || ''); + const [searchMenu, setSearchMenu] = useState(searchParams.get('menu') || ''); + + const [isDetailEnabled, setIsDetailEnabled] = useState(false); + + const query = useDeferredValue(isDetailEnabled ? { searchName, searchAddress, searchMenu } : { searchName }); + const isQueryFilled = searchName || searchAddress || searchMenu; + const { data: searchedCafes } = useSearchCafes(query); + + const handleSearch: FormEventHandler = (event) => { + event.preventDefault(); + }; + + useEffect(() => { + const cleanedSearchParams = Object.fromEntries( + Object.entries({ cafeName: searchName, address: searchAddress, menu: searchMenu }).filter(([, value]) => !!value), + ); + setSearchParams(new URLSearchParams(cleanedSearchParams)); + }, [searchName, searchAddress, searchMenu]); + + return ( + + + ☕ 카페를 검색해보세요! + + +
+ + setSearchName(event.target.value)} /> + + + + + + + + setIsDetailEnabled(!isDetailEnabled)}> + 주소나 메뉴 이름으로 검색하기 + + + + + + + + setSearchAddress(event.target.value)} + /> + + + + + + setSearchMenu(event.target.value)} + /> + + + + + + {searchedCafes.length > 0 ? ( + + {searchedCafes.map((cafe) => ( + + + + + {cafe.name} + {cafe.address} + + + {cafe.likeCount} + + + + ))} + + ) : ( + isQueryFilled && 일치하는 검색 결과가 없습니다! + )} +
+ ); +}; + +export default SearchPage; + +const Container = styled.main` + padding: ${({ theme }) => theme.space[8]}; +`; + +const Form = styled.form``; + +const Spacer = styled.div<{ $size: keyof Theme['space'] }>` + min-height: ${({ theme, $size }) => theme.space[$size]}; +`; + +const Title = styled.h1` + margin-bottom: ${({ theme }) => theme.space[4]}; + font-size: ${({ theme }) => theme.fontSize['3xl']}; + font-weight: 100; + text-align: center; +`; + +const SearchButton = styled.button` + padding: ${({ theme }) => theme.space[2]}; + font-size: ${({ theme }) => theme.fontSize['2xl']}; +`; + +const SearchDetailsButton = styled.button.attrs({ type: 'button' })` + cursor: pointer; + font-size: ${({ theme }) => theme.fontSize.sm}; + color: ${({ theme }) => theme.color.gray}; + background: none; +`; + +const SearchDetails = styled.div<{ $show: boolean }>` + display: ${({ $show }) => ($show ? 'flex' : 'none')}; + flex-direction: column; + gap: ${({ theme }) => theme.space[4]}; +`; + +const StartIcon = styled.div` + padding: ${({ theme }) => theme.space[0.5]}; + font-size: ${({ theme }) => theme.fontSize['2xl']}; +`; + +const FormGroup = styled.fieldset` + display: flex; + gap: ${({ theme }) => theme.space[4]}; + align-items: center; +`; + +const CafeList = styled.ul``; + +const CafeListItem = styled.li` + display: flex; + gap: ${({ theme }) => theme.space[4]}; + align-items: center; + padding: ${({ theme }) => theme.space[2]}; + + &:hover { + cursor: pointer; + background: #eeeeee; + } +`; + +const CafeImage = styled.img` + aspect-ratio: 1 / 1; + height: 48px; + object-fit: cover; + border-radius: 50%; +`; + +const CafeInfo = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.space[1]}; +`; + +const CafeName = styled.h3``; + +const CafeAddress = styled.div` + font-size: ${({ theme }) => theme.fontSize.xs}; + color: ${({ theme }) => theme.color.gray}; +`; + +const CafeListEmpty = styled.div` + padding: ${({ theme }) => theme.space[10]} 0; + color: ${({ theme }) => theme.color.gray}; + text-align: center; +`; + +const CafeLikes = styled.div` + margin-left: auto; + font-size: ${({ theme }) => theme.fontSize.sm}; + color: ${({ theme }) => theme.color.secondary}; +`; diff --git a/client/src/router.tsx b/client/src/router.tsx index 31890208..d03ff658 100644 --- a/client/src/router.tsx +++ b/client/src/router.tsx @@ -1,6 +1,8 @@ import React, { Suspense } from 'react'; import { createBrowserRouter } from 'react-router-dom'; import Root from './pages/Root'; + +const SearchPage = React.lazy(() => import('./pages/SearchPage')); const AuthPage = React.lazy(() => import('./pages/AuthPage')); const CafePage = React.lazy(() => import('./pages/CafePage')); const CafeMapPage = React.lazy(() => import('./pages/CafeMapPage')); @@ -20,9 +22,10 @@ const router = createBrowserRouter([ children: [ { index: true, element: }, { path: 'my-profile', element: }, - { path: '/cafes/:cafeId', element: }, + { path: 'cafes/:cafeId', element: }, { path: 'rank', element: }, { path: 'my-profile/cafes/:cafeId', element: }, + { path: 'search', element: }, { path: 'map', element: }, ], }, diff --git a/client/src/styles/ResetStyle.ts b/client/src/styles/ResetStyle.ts index 8e6c7f14..8c916e9f 100644 --- a/client/src/styles/ResetStyle.ts +++ b/client/src/styles/ResetStyle.ts @@ -89,6 +89,10 @@ const ResetStyle = createGlobalStyle` th { padding: 0; } + + fieldset { + border: 0; + } `; export default ResetStyle; diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 6742cd64..221122c9 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -85,6 +85,14 @@ export type Rank = { likeCount: number; }; +export type SearchedCafe = { + id: Cafe['id']; + name: Cafe['name']; + address: Cafe['address']; + image: string; + likeCount: Cafe['likeCount']; +} + export type CafeMapLocation = { id: number; name: string;