From e0eb383aca0524faba33ff1fc362a2a875052bad Mon Sep 17 00:00:00 2001 From: solo5star Date: Tue, 26 Sep 2023 14:33:23 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20API=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/client.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/client/src/client.ts b/client/src/client.ts index f0c6983b..4d0c7e3d 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -1,4 +1,4 @@ -import type { AuthProvider, AuthUrl, Cafe, CafeMenu, LikedCafe, Rank, User } from './types'; +import type { AuthProvider, AuthUrl, Cafe, CafeMenu, LikedCafe, 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()}`); + } + /** * 인증 수행 시, OAuth 제공자(provider)와 인증 코드(Authorization Code) 값을 * 백엔드에 전송하면 백엔드에서 발급한 accessToken을 응답으로 받을 수 있다. From 51c4cee06fafa7f52fa852f5ba3524a80ef7ba3e Mon Sep 17 00:00:00 2001 From: solo5star Date: Tue, 26 Sep 2023 14:35:02 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/SearchInput.tsx | 36 +++++ client/src/hooks/useSearchCafes.ts | 21 +++ client/src/pages/SearchPage.tsx | 196 ++++++++++++++++++++++++++ client/src/styles/ResetStyle.ts | 4 + client/src/types/index.ts | 8 ++ 5 files changed, 265 insertions(+) create mode 100644 client/src/components/SearchInput.tsx create mode 100644 client/src/hooks/useSearchCafes.ts create mode 100644 client/src/pages/SearchPage.tsx 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/pages/SearchPage.tsx b/client/src/pages/SearchPage.tsx new file mode 100644 index 00000000..377c0298 --- /dev/null +++ b/client/src/pages/SearchPage.tsx @@ -0,0 +1,196 @@ +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'; + +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/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 0ea661ed..3ab6ef2c 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -84,3 +84,11 @@ export type Rank = { image: string; likeCount: number; }; + +export type SearchedCafe = { + id: Cafe['id']; + name: Cafe['name']; + address: Cafe['address']; + image: string; + likeCount: Cafe['likeCount']; +}; From 45354d41c17c6aad7ddf7b68ec8389f569d288bf Mon Sep 17 00:00:00 2001 From: solo5star Date: Tue, 26 Sep 2023 14:35:20 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test:=20MSW=20=EB=AA=A8=ED=82=B9=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/mocks/handlers.ts | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/client/src/mocks/handlers.ts b/client/src/mocks/handlers.ts index c12e5d8a..fde8cc7c 100644 --- a/client/src/mocks/handlers.ts +++ b/client/src/mocks/handlers.ts @@ -1,6 +1,6 @@ import { rest } from 'msw'; import { RankCafes, cafeMenus, cafes } from '../data/mockData'; -import type { Identity, User } from '../types'; +import type { Identity, SearchedCafe, User } from '../types'; let pageState = 1; @@ -80,6 +80,37 @@ export 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; From 3f3a3fa4743848bb8af7b7e99edf71d292d88fc7 Mon Sep 17 00:00:00 2001 From: solo5star Date: Tue, 26 Sep 2023 14:36:43 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B0=94=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=80=EC=83=89=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Button.tsx | 2 +- client/src/components/Navbar.tsx | 56 ++++++++++++++++++-------------- client/src/router.tsx | 5 ++- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx index e142a04d..cf539570 100644 --- a/client/src/components/Button.tsx +++ b/client/src/components/Button.tsx @@ -40,7 +40,7 @@ const ButtonVariants = { const Container = styled.button` cursor: pointer; - padding: ${({ theme }) => theme.space['1.5']} 0; + padding: ${({ theme }) => theme.space['1.5']} ${({ theme }) => theme.space[2]}; font-size: 16px; font-weight: 500; diff --git a/client/src/components/Navbar.tsx b/client/src/components/Navbar.tsx index e16b0631..be1df9dc 100644 --- a/client/src/components/Navbar.tsx +++ b/client/src/components/Navbar.tsx @@ -1,4 +1,6 @@ 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'; @@ -19,20 +21,21 @@ const Navbar = () => { return ( - - - - - + + + - - - - - - + + + + + + + + + + + {user ? ( )} - + {isLoginModalOpen && } @@ -65,22 +68,27 @@ const Container = styled.nav` const ButtonContainer = styled.div` display: flex; + gap: ${({ theme }) => theme.space[2]}; align-items: center; -`; - -const LogoContainer = styled.div` - flex: 6; + margin-left: auto; `; const Logo = styled.img.attrs({ src: '/assets/logo.svg' })` - height: ${({ theme }) => theme.fontSize['4xl']}; + height: ${({ theme }) => theme.fontSize['3xl']}; `; -const RankButtonContainer = styled.div` - width: 44px; - margin-right: ${({ theme }) => theme.space[2]}; +const UserButtonContainer = styled.div` + width: 100px; `; -const LoginAndProfileButtonContainer = styled.div` - width: 133px; +const IconButton = styled.button` + cursor: pointer; + + display: flex; + align-items: center; + + padding: ${({ theme }) => theme.space[2]}; + + font-size: ${({ theme }) => theme.fontSize['2xl']}; + color: ${({ theme }) => theme.color.primary}; `; diff --git a/client/src/router.tsx b/client/src/router.tsx index 9141d096..92049503 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 HomePage = React.lazy(() => import('./pages/HomePage')); @@ -19,9 +21,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: }, ], }, { From 95bd9890d54fe26af05bf65720122d4776f8f571 Mon Sep 17 00:00:00 2001 From: solo5star Date: Tue, 26 Sep 2023 14:41:22 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=EB=85=BC=EB=A6=AC=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/SearchPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/pages/SearchPage.tsx b/client/src/pages/SearchPage.tsx index 377c0298..f392ee1d 100644 --- a/client/src/pages/SearchPage.tsx +++ b/client/src/pages/SearchPage.tsx @@ -18,7 +18,7 @@ const SearchPage = () => { const [isDetailEnabled, setIsDetailEnabled] = useState(false); const query = useDeferredValue(isDetailEnabled ? { searchName, searchAddress, searchMenu } : { searchName }); - const isQueryFilled = searchName && searchAddress && searchMenu; + const isQueryFilled = searchName || searchAddress || searchMenu; const { data: searchedCafes } = useSearchCafes(query); const handleSearch: FormEventHandler = (event) => { From d249278209bf73fde8cda976d1650e7b931ad063 Mon Sep 17 00:00:00 2001 From: solo5star Date: Thu, 5 Oct 2023 15:08:07 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/SearchPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/pages/SearchPage.tsx b/client/src/pages/SearchPage.tsx index f392ee1d..475d3945 100644 --- a/client/src/pages/SearchPage.tsx +++ b/client/src/pages/SearchPage.tsx @@ -7,6 +7,7 @@ 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(); @@ -82,7 +83,7 @@ const SearchPage = () => { {searchedCafes.map((cafe) => ( - + {cafe.name} {cafe.address}