diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js index 36a992745..d8bc3ace3 100644 --- a/frontend/public/mockServiceWorker.js +++ b/frontend/public/mockServiceWorker.js @@ -2,7 +2,7 @@ /* tslint:disable */ /** - * Mock Service Worker (1.2.3). + * Mock Service Worker (1.3.0). * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. diff --git a/frontend/src/components/Common/Header/Header.tsx b/frontend/src/components/Common/Header/Header.tsx index a52adaf21..ae2431ed8 100644 --- a/frontend/src/components/Common/Header/Header.tsx +++ b/frontend/src/components/Common/Header/Header.tsx @@ -2,14 +2,33 @@ import { Link } from '@fun-eat/design-system'; import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; +import SvgIcon from '../Svg/SvgIcon'; + import Logo from '@/assets/logo.svg'; import { PATH } from '@/constants/path'; -const Header = () => { +interface HeaderProps { + hasSearch?: boolean; +} + +const Header = ({ hasSearch = true }: HeaderProps) => { + if (hasSearch) { + return ( + + + + + + + + + ); + } + return ( - + ); @@ -17,6 +36,15 @@ const Header = () => { export default Header; +const HeaderWithSearchContainer = styled.header` + display: flex; + justify-content: space-between; + align-items: center; + width: calc(100% - 40px); + height: 60px; + margin: 0 auto; +`; + const HeaderContainer = styled.header` display: flex; justify-content: center; diff --git a/frontend/src/components/Common/Title/Title.stories.tsx b/frontend/src/components/Common/Title/Title.stories.tsx deleted file mode 100644 index 9f455c1a5..000000000 --- a/frontend/src/components/Common/Title/Title.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; - -import Title from './Title'; - -const meta: Meta = { - title: 'common/Title', - component: Title, - args: { - headingTitle: '상품 목록', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/components/Common/Title/Title.tsx b/frontend/src/components/Common/Title/Title.tsx deleted file mode 100644 index 96aa3e7ef..000000000 --- a/frontend/src/components/Common/Title/Title.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Link, Text, theme } from '@fun-eat/design-system'; -import { Link as RouterLink } from 'react-router-dom'; -import styled from 'styled-components'; - -import SvgIcon from '../Svg/SvgIcon'; - -import { PATH } from '@/constants/path'; - -interface TitleProps { - headingTitle: string; - routeDestination: string; -} - -const Title = ({ headingTitle, routeDestination }: TitleProps) => { - return ( - - - - - - - {headingTitle} - - - - - ); -}; - -export default Title; - -const TitleContainer = styled.div` - position: relative; - display: flex; - flex-direction: row; - justify-content: center; -`; - -const HomeLink = styled(Link)` - position: absolute; - top: 8px; - left: 0; -`; - -const TitleLink = styled(Link)` - display: flex; - gap: 20px; - align-items: center; -`; - -const DropDownIcon = styled(SvgIcon)` - rotate: 270deg; -`; diff --git a/frontend/src/components/Common/index.ts b/frontend/src/components/Common/index.ts index 395277010..e48a05d20 100644 --- a/frontend/src/components/Common/index.ts +++ b/frontend/src/components/Common/index.ts @@ -7,7 +7,6 @@ export { default as SvgSprite } from './Svg/SvgSprite'; export { default as SvgIcon } from './Svg/SvgIcon'; export { default as TabMenu } from './TabMenu/TabMenu'; export { default as TagList } from './TagList/TagList'; -export { default as Title } from './Title/Title'; export { default as SectionTitle } from './SectionTitle/SectionTitle'; export { default as ScrollButton } from './ScrollButton/ScrollButton'; export { default as Input } from './Input/Input'; diff --git a/frontend/src/components/Layout/AuthLayout.tsx b/frontend/src/components/Layout/AuthLayout.tsx index e65109b6b..0cc0570c2 100644 --- a/frontend/src/components/Layout/AuthLayout.tsx +++ b/frontend/src/components/Layout/AuthLayout.tsx @@ -1,10 +1,13 @@ -import type { PropsWithChildren } from 'react'; import { Navigate } from 'react-router-dom'; import { PATH } from '@/constants/path'; import { useMemberQuery } from '@/hooks/queries/members'; -const AuthLayout = ({ children }: PropsWithChildren) => { +interface AuthLayoutProps { + children: JSX.Element; +} + +const AuthLayout = ({ children }: AuthLayoutProps) => { const { data: member } = useMemberQuery(); if (!member) { diff --git a/frontend/src/components/Layout/SimpleHeaderLayout.tsx b/frontend/src/components/Layout/SimpleHeaderLayout.tsx new file mode 100644 index 000000000..c6c469169 --- /dev/null +++ b/frontend/src/components/Layout/SimpleHeaderLayout.tsx @@ -0,0 +1,31 @@ +import type { PropsWithChildren } from 'react'; +import styled from 'styled-components'; + +import Header from '../Common/Header/Header'; +import NavigationBar from '../Common/NavigationBar/NavigationBar'; + +const SimpleHeaderLayout = ({ children }: PropsWithChildren) => { + return ( + +
+ {children} + + + ); +}; + +export default SimpleHeaderLayout; + +const SimpleHeaderLayoutContainer = styled.div` + height: 100%; + max-width: 600px; + margin: 0 auto; +`; + +const MainWrapper = styled.main` + position: relative; + height: calc(100% - 120px); + padding: 20px; + overflow-x: hidden; + overflow-y: auto; +`; diff --git a/frontend/src/components/Layout/index.ts b/frontend/src/components/Layout/index.ts index 32a1e00e0..69ec1a40c 100644 --- a/frontend/src/components/Layout/index.ts +++ b/frontend/src/components/Layout/index.ts @@ -2,3 +2,4 @@ export { default as DefaultLayout } from './DefaultLayout'; export { default as MinimalLayout } from './MinimalLayout'; export { default as HeaderOnlyLayout } from './HeaderOnlyLayout'; export { default as AuthLayout } from './AuthLayout'; +export { default as SimpleHeaderLayout } from './SimpleHeaderLayout'; diff --git a/frontend/src/components/Product/ProductTitle/ProductTitle.stories.tsx b/frontend/src/components/Product/ProductTitle/ProductTitle.stories.tsx new file mode 100644 index 000000000..f4e210607 --- /dev/null +++ b/frontend/src/components/Product/ProductTitle/ProductTitle.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ProductTitle from './ProductTitle'; + +const meta: Meta = { + title: 'common/ProductTitle', + component: ProductTitle, + args: { + headingTitle: '상품 목록', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Product/ProductTitle/ProductTitle.tsx b/frontend/src/components/Product/ProductTitle/ProductTitle.tsx new file mode 100644 index 000000000..be0e499f1 --- /dev/null +++ b/frontend/src/components/Product/ProductTitle/ProductTitle.tsx @@ -0,0 +1,51 @@ +import { Heading, Link, theme } from '@fun-eat/design-system'; +import { Link as RouterLink } from 'react-router-dom'; +import styled from 'styled-components'; + +import SvgIcon from '../../Common/Svg/SvgIcon'; + +import { PATH } from '@/constants/path'; + +interface ProductTitleProps { + content: string; + routeDestination: string; +} + +const ProductTitle = ({ content, routeDestination }: ProductTitleProps) => { + return ( + + + {content} + + + + + + + ); +}; + +export default ProductTitle; + +const ProductTitleContainer = styled.div` + position: relative; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const ProductTitleLink = styled(Link)` + display: flex; + gap: 20px; + align-items: center; + margin-left: 36%; +`; + +const HeadingTitle = styled(Heading)` + font-size: 2.4rem; +`; + +const DropDownIcon = styled(SvgIcon)` + rotate: 270deg; +`; diff --git a/frontend/src/components/Product/index.ts b/frontend/src/components/Product/index.ts index 4038ee740..47ce2703d 100644 --- a/frontend/src/components/Product/index.ts +++ b/frontend/src/components/Product/index.ts @@ -4,3 +4,4 @@ export { default as ProductList } from './ProductList/ProductList'; export { default as ProductOverviewItem } from './ProductOverviewItem/ProductOverviewItem'; export { default as PBProductList } from './PBProductList/PBProductList'; export { default as ProductRecipeList } from './ProductRecipeList/ProductRecipeList'; +export { default as ProductTitle } from './ProductTitle/ProductTitle'; diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index a45e4103f..66e733697 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -3,11 +3,6 @@ import { PATH } from './path'; import type { NavigationMenu } from '@/types/common'; export const NAVIGATION_MENU: NavigationMenu[] = [ - { - variant: 'search', - name: '검색', - path: PATH.SEARCH, - }, { variant: 'list', name: '목록', @@ -58,7 +53,8 @@ export const TAG_TITLE = { export const MIN_DISPLAYED_TAGS_LENGTH = 3; -export const SEARCH_PAGE_TABS = ['상품', '꿀조합'] as const; +export const SEARCH_TAB_VARIANTS = ['상품', '꿀조합']; +export const SEARCH_PAGE_VARIANTS = { products: '상품', recipes: '꿀조합', integrated: '통합' } as const; export const CATEGORY_TYPE = { FOOD: 'food', diff --git a/frontend/src/pages/IntegratedSearchPage.tsx b/frontend/src/pages/IntegratedSearchPage.tsx new file mode 100644 index 000000000..64d66d155 --- /dev/null +++ b/frontend/src/pages/IntegratedSearchPage.tsx @@ -0,0 +1,123 @@ +import { Button, Heading, Spacing, Text } from '@fun-eat/design-system'; +import { useQueryErrorResetBoundary } from '@tanstack/react-query'; +import type { MouseEventHandler } from 'react'; +import { Suspense, useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { ErrorBoundary, ErrorComponent, Input, Loading, SvgIcon, TabMenu } from '@/components/Common'; +import { RecommendList, ProductSearchResultList, RecipeSearchResultList } from '@/components/Search'; +import { SEARCH_TAB_VARIANTS } from '@/constants'; +import { useDebounce } from '@/hooks/common'; +import { useSearch } from '@/hooks/search'; + +const isProductSearchTab = (tabMenu: string) => tabMenu === SEARCH_TAB_VARIANTS[0]; +const getInputPlaceholder = (tabMenu: string) => + isProductSearchTab(tabMenu) ? '상품 이름을 검색해보세요.' : '꿀조합에 포함된 상품을 입력해보세요.'; + +const IntegratedSearchPage = () => { + const { + inputRef, + searchQuery, + isSubmitted, + isAutocompleteOpen, + handleSearchQuery, + handleSearch, + handleSearchClick, + handleAutocompleteClose, + } = useSearch(); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery || ''); + const [selectedTabMenu, setSelectedTabMenu] = useState(SEARCH_TAB_VARIANTS[0]); + const { reset } = useQueryErrorResetBoundary(); + + const handleTabMenuSelect: MouseEventHandler = (event) => { + setSelectedTabMenu(event.currentTarget.value); + }; + + useDebounce( + () => { + setDebouncedSearchQuery(searchQuery); + }, + 200, + [searchQuery] + ); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + return ( + <> + +
+ + + + } + value={searchQuery} + onChange={handleSearchQuery} + ref={inputRef} + /> + + {!isSubmitted && debouncedSearchQuery && isAutocompleteOpen && ( + + }> + + + + )} +
+ + + + {isSubmitted && debouncedSearchQuery ? ( + <> + + '{searchQuery}'에 대한 검색결과입니다. + + + }> + + {isProductSearchTab(selectedTabMenu) ? ( + + ) : ( + + )} + + + + ) : ( + {selectedTabMenu}을 검색해보세요. + )} + + + ); +}; + +export default IntegratedSearchPage; + +const SearchSection = styled.section` + position: relative; +`; + +const SearchResultSection = styled.section` + margin-top: 30px; +`; + +const Mark = styled.mark` + font-weight: ${({ theme }) => theme.fontWeights.bold}; + background-color: ${({ theme }) => theme.backgroundColors.default}; +`; diff --git a/frontend/src/pages/ProductListPage.tsx b/frontend/src/pages/ProductListPage.tsx index 8e7da4b50..32ecc5475 100644 --- a/frontend/src/pages/ProductListPage.tsx +++ b/frontend/src/pages/ProductListPage.tsx @@ -8,13 +8,12 @@ import { CategoryMenu, SortButton, SortOptionList, - Title, ScrollButton, Loading, ErrorBoundary, ErrorComponent, } from '@/components/Common'; -import { ProductList } from '@/components/Product'; +import { ProductTitle, ProductList } from '@/components/Product'; import { PRODUCT_SORT_OPTIONS } from '@/constants'; import { PATH } from '@/constants/path'; import { useSortOption } from '@/hooks/common'; @@ -40,8 +39,8 @@ const ProductListPage = () => { return ( <>
- <Spacing size={30} /> diff --git a/frontend/src/pages/RecipePage.tsx b/frontend/src/pages/RecipePage.tsx index 43bf5a82f..706e6b941 100644 --- a/frontend/src/pages/RecipePage.tsx +++ b/frontend/src/pages/RecipePage.tsx @@ -16,6 +16,7 @@ import { } from '@/components/Common'; import { RecipeList, RecipeRegisterForm } from '@/components/Recipe'; import { RECIPE_SORT_OPTIONS } from '@/constants'; +import { PATH } from '@/constants/path'; import RecipeFormProvider from '@/contexts/RecipeFormContext'; import { useSortOption } from '@/hooks/common'; @@ -41,12 +42,12 @@ const RecipePage = () => { return ( <> - <Title size="xl" weight="bold"> - {RECIPE_PAGE_TITLE} - - - - + + {RECIPE_PAGE_TITLE} + + + + }> @@ -84,14 +85,14 @@ const RecipePage = () => { export default RecipePage; -const Title = styled(Heading)` - font-size: 24px; +const TitleWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; `; -const SearchPageLink = styled(Link)` - position: absolute; - top: 24px; - right: 20px; +const Title = styled(Heading)` + font-size: 24px; `; const SortButtonWrapper = styled.div` diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index 7089b0dfc..6a1c9f892 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -1,18 +1,20 @@ -import { Button, Heading, Spacing, Text } from '@fun-eat/design-system'; +import { Button, Heading, Spacing, Text, useTheme } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; -import type { MouseEventHandler } from 'react'; import { Suspense, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; import styled from 'styled-components'; -import { ErrorBoundary, ErrorComponent, Input, Loading, SvgIcon, TabMenu } from '@/components/Common'; +import { ErrorBoundary, ErrorComponent, Input, Loading, SvgIcon } from '@/components/Common'; import { RecommendList, ProductSearchResultList, RecipeSearchResultList } from '@/components/Search'; -import { SEARCH_PAGE_TABS } from '@/constants'; -import { useDebounce } from '@/hooks/common'; +import { SEARCH_PAGE_VARIANTS } from '@/constants'; +import { useDebounce, useRoutePage } from '@/hooks/common'; import { useSearch } from '@/hooks/search'; -const isProductSearchTab = (tabMenu: string) => tabMenu === SEARCH_PAGE_TABS[0]; -const getInputPlaceholder = (tabMenu: string) => - isProductSearchTab(tabMenu) ? '상품 이름을 검색해보세요.' : '꿀조합에 포함된 상품을 입력해보세요.'; +const isProductSearchPage = (path: string) => path === 'products'; +const getInputPlaceholder = (path: string) => + isProductSearchPage(path) ? '상품 이름을 검색해보세요.' : '꿀조합에 포함된 상품을 입력해보세요.'; + +type SearchPageType = keyof typeof SEARCH_PAGE_VARIANTS; const SearchPage = () => { const { @@ -26,12 +28,12 @@ const SearchPage = () => { handleAutocompleteClose, } = useSearch(); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery || ''); - const [selectedTabMenu, setSelectedTabMenu] = useState(SEARCH_PAGE_TABS[0]); const { reset } = useQueryErrorResetBoundary(); + const { routeBack } = useRoutePage(); - const handleTabMenuSelect: MouseEventHandler = (event) => { - setSelectedTabMenu(event.currentTarget.value); - }; + const theme = useTheme(); + + const { searchVariant } = useParams(); useDebounce( () => { @@ -47,13 +49,28 @@ const SearchPage = () => { } }, []); + const isSearchVariant = (value: string): value is SearchPageType => { + return value === 'products' || value === 'recipes'; + }; + + if (!searchVariant || !isSearchVariant(searchVariant)) { + return null; + } + return ( <> + + + {SEARCH_PAGE_VARIANTS[searchVariant]} 검색 + +
@@ -76,12 +93,6 @@ const SearchPage = () => { )} - - {isSubmitted && debouncedSearchQuery ? ( <> @@ -91,7 +102,7 @@ const SearchPage = () => { }> - {isProductSearchTab(selectedTabMenu) ? ( + {isProductSearchPage(searchVariant) ? ( ) : ( @@ -100,7 +111,7 @@ const SearchPage = () => { ) : ( - {selectedTabMenu}을 검색해보세요. + {SEARCH_PAGE_VARIANTS[searchVariant]}을 검색해보세요. )} @@ -117,6 +128,16 @@ const SearchResultSection = styled.section` margin-top: 30px; `; +const TitleWrapper = styled.div` + display: flex; + gap: 12px; + align-items: center; +`; + +const HeadingTitle = styled(Heading)` + font-size: 2.4rem; +`; + const Mark = styled.mark` font-weight: ${({ theme }) => theme.fontWeights.bold}; background-color: ${({ theme }) => theme.backgroundColors.default}; diff --git a/frontend/src/router/App.tsx b/frontend/src/router/App.tsx index b4428b177..a8c4bfd10 100644 --- a/frontend/src/router/App.tsx +++ b/frontend/src/router/App.tsx @@ -3,11 +3,11 @@ import { Suspense } from 'react'; import { Outlet } from 'react-router-dom'; import { ErrorBoundary, ErrorComponent, Loading } from '@/components/Common'; -import { MinimalLayout, DefaultLayout, HeaderOnlyLayout } from '@/components/Layout'; +import { MinimalLayout, DefaultLayout, HeaderOnlyLayout, SimpleHeaderLayout } from '@/components/Layout'; import { useRouteChangeTracker } from '@/hooks/common'; interface AppProps { - layout?: 'default' | 'headerOnly' | 'minimal'; + layout?: 'default' | 'headerOnly' | 'minimal' | 'simpleHeader'; } const App = ({ layout = 'default' }: AppProps) => { @@ -39,6 +39,18 @@ const App = ({ layout = 'default' }: AppProps) => { ); } + if (layout === 'simpleHeader') { + return ( + + }> + + + + + + ); + } + return ( }> diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 8391765a3..0eaa96fb6 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -7,6 +7,7 @@ import { PATH } from '@/constants/path'; import CategoryProvider from '@/contexts/CategoryContext'; import AuthPage from '@/pages/AuthPage'; import HomePage from '@/pages/HomePage'; +import IntegratedSearchPage from '@/pages/IntegratedSearchPage'; import LoginPage from '@/pages/LoginPage'; import MemberModifyPage from '@/pages/MemberModifyPage'; import MemberPage from '@/pages/MemberPage'; @@ -33,18 +34,6 @@ const router = createBrowserRouter([ ), }, - { - path: `${PATH.PRODUCT_LIST}/:category`, - element: ( - - - - ), - }, - { - path: PATH.RECIPE, - element: , - }, { path: `${PATH.RECIPE}/:recipeId`, element: ( @@ -53,10 +42,6 @@ const router = createBrowserRouter([ ), }, - { - path: PATH.SEARCH, - element: , - }, { path: PATH.MEMBER, element: ( @@ -117,6 +102,33 @@ const router = createBrowserRouter([ }, ], }, + { + path: '/', + element: , + errorElement: , + children: [ + { + path: `${PATH.PRODUCT_LIST}/:category`, + element: ( + + + + ), + }, + { + path: PATH.RECIPE, + element: , + }, + { + path: `${PATH.SEARCH}/integrated`, + element: , + }, + { + path: `${PATH.SEARCH}/:searchVariant`, + element: , + }, + ], + }, ]); export default router;