diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 565307ce..a61fa19f 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -21,13 +21,13 @@ initialize( handlers, ); -const rootStyle = { +const getRootStyle = (storyId: string) => ({ width: "48rem", - padding: "1.6rem", + padding: storyId.startsWith("common-header--") ? 0 : "1.6rem", display: "flex", alignItems: "center", justifyContent: "center", -}; +}); const preview: Preview = { parameters: { @@ -39,14 +39,14 @@ const preview: Preview = { }, }, decorators: [ - (Story) => { + (Story, context) => { const queryClient = new QueryClient(); return (
-
+
diff --git a/frontend/src/assets/svg/home-icon.svg b/frontend/src/assets/svg/home-icon.svg new file mode 100644 index 00000000..68e3995e --- /dev/null +++ b/frontend/src/assets/svg/home-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/svg/index.ts b/frontend/src/assets/svg/index.ts index 5863139e..7456eec7 100644 --- a/frontend/src/assets/svg/index.ts +++ b/frontend/src/assets/svg/index.ts @@ -20,3 +20,4 @@ export { default as KaKao } from "./kakao.svg"; export { default as KoreanLogo } from "./korean-logo.svg"; export { default as Plus } from "./plus.svg"; export { default as tturiUrl } from "./tturi.svg?url"; +export { default as HomeIcon } from "./home-icon.svg"; diff --git a/frontend/src/assets/svg/search-icon.svg b/frontend/src/assets/svg/search-icon.svg new file mode 100644 index 00000000..8ad6e851 --- /dev/null +++ b/frontend/src/assets/svg/search-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/common/Header/DefaultHeader/DefaultHeader.tsx b/frontend/src/components/common/Header/DefaultHeader/DefaultHeader.tsx new file mode 100644 index 00000000..a1f389ad --- /dev/null +++ b/frontend/src/components/common/Header/DefaultHeader/DefaultHeader.tsx @@ -0,0 +1,25 @@ +import { useNavigate } from "react-router-dom"; + +import IconButton from "@components/common/IconButton/IconButton"; + +import { ROUTE_PATHS_MAP } from "@constants/route"; + +import Header from "../Header"; + +const DefaultHeader = () => { + const navigation = useNavigate(); + return ( +
navigation(ROUTE_PATHS_MAP.root)} + /> + } + isHamburgerUsed + /> + ); +}; + +export default DefaultHeader; diff --git a/frontend/src/components/common/Header/Header.stories.tsx b/frontend/src/components/common/Header/Header.stories.tsx new file mode 100644 index 00000000..010b68ce --- /dev/null +++ b/frontend/src/components/common/Header/Header.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import Icon from "../Icon/Icon"; +import IconButton from "../IconButton/IconButton"; +import Input from "../Input/Input"; +import Header from "./Header"; +import * as S from "./SearchHeader/SearchHeader.styled"; + +const rightContentOptions = { + None: null, + HomeIcon: , + SearchIcon: , + SearchForm: ( + + + + + + + + + + ), +}; + +const meta = { + title: "common/Header", + component: Header, + argTypes: { + isHamburgerUsed: { control: "boolean" }, + isLogoUsed: { control: "boolean" }, + rightContent: { + options: Object.keys(rightContentOptions), + mapping: rightContentOptions, + control: { + type: "select", + }, + }, + $isRightContentFull: { control: "boolean" }, + }, + decorators: [ + (Story, context) => { + return ( +
+ +
+ ); + }, + ], + tags: ["autodocs"], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const DefaultHeader: Story = { + args: { + isHamburgerUsed: true, + rightContent: "HomeIcon", + }, +}; + +export const HomePageHeader: Story = { + args: { + isLogoUsed: true, + isHamburgerUsed: true, + rightContent: "SearchIcon", + }, +}; + +export const SearchResultPageHeader: Story = { + args: { + isHamburgerUsed: true, + rightContent: "SearchIcon", + }, +}; + +export const SearchHeader: Story = { + args: { + rightContent: "SearchForm", + $isRightContentFull: true, + }, +}; diff --git a/frontend/src/components/common/Header/Header.styled.ts b/frontend/src/components/common/Header/Header.styled.ts index 025ff8c6..5868ff73 100644 --- a/frontend/src/components/common/Header/Header.styled.ts +++ b/frontend/src/components/common/Header/Header.styled.ts @@ -11,12 +11,12 @@ export const HeaderLayout = styled.header` z-index: ${({ theme }) => theme.zIndex.header}; width: 100%; - height: fit-content; + height: 6rem; max-width: 48rem; padding: 1.6rem; border-bottom: 0.1rem solid ${({ theme }) => theme.colors.border}; - background-color: #fff; + background-color: ${PRIMITIVE_COLORS.white}; `; export const DrawHeaderContainer = styled.div` @@ -41,12 +41,16 @@ export const MenuList = styled.ul` background-color: ${PRIMITIVE_COLORS.white}; `; -export const HeaderTitle = styled.span` - ${({ theme }) => theme.typography.mobile.bodyBold} - color: ${({ theme }) => theme.colors.text.primary}; +export const LeftWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; `; -export const HiddenDiv = styled.div` - width: 2.4rem; - height: 2.4rem; +export const RightContainer = styled.div<{ $isRightContentFull?: boolean }>` + display: flex; + gap: ${({ theme }) => theme.spacing.m}; + justify-content: center; + align-items: center; + ${({ $isRightContentFull = false }) => $isRightContentFull && "flex: 1;"} `; diff --git a/frontend/src/components/common/Header/Header.tsx b/frontend/src/components/common/Header/Header.tsx index 29987f65..67b86091 100644 --- a/frontend/src/components/common/Header/Header.tsx +++ b/frontend/src/components/common/Header/Header.tsx @@ -1,7 +1,5 @@ import { useLocation, useNavigate } from "react-router-dom"; -import IconButton from "@components/common/IconButton/IconButton"; - import useUser from "@hooks/useUser"; import { ROUTE_PATHS_MAP } from "@constants/route"; @@ -12,20 +10,28 @@ import { PRIMITIVE_COLORS } from "@styles/tokens"; import { DoubleRightArrow } from "@assets/svg"; import Drawer from "../Drawer/Drawer"; +import IconButton from "../IconButton/IconButton"; import * as S from "./Header.styled"; -const Header = () => { +interface HeaderProps { + isLogoUsed?: boolean; + rightContent: React.ReactNode; + $isRightContentFull?: boolean; + isHamburgerUsed?: boolean; +} + +const Header = ({ + isLogoUsed = false, + rightContent, + $isRightContentFull = false, + isHamburgerUsed = false, +}: HeaderProps) => { const { user, saveUser } = useUser(); const location = useLocation(); const pathName = location.pathname; const navigate = useNavigate(); - const handleClickButton = - pathName === ROUTE_PATHS_MAP.root || pathName === ROUTE_PATHS_MAP.login - ? () => navigate(ROUTE_PATHS_MAP.root) - : () => navigate(ROUTE_PATHS_MAP.back); - const handleClickLogout = () => { if ( pathName.includes(ROUTE_PATHS_MAP.travelPlan().split("/").shift()!) || @@ -34,31 +40,36 @@ const Header = () => { navigate(ROUTE_PATHS_MAP.login); } + saveUser({ accessToken: "", memberId: 0, refreshToken: "" }); + }; const handleClickMyPage = () => navigate(ROUTE_PATHS_MAP.my); - const handleClickHome = () => navigate(ROUTE_PATHS_MAP.root); - return ( - - {pathName === ROUTE_PATHS_MAP.login ? ( - <> - 로그인 - - - ) : ( - - - - )} + + navigate(ROUTE_PATHS_MAP.root) + : () => navigate(ROUTE_PATHS_MAP.back) + } + /> + + + + {rightContent} + {isHamburgerUsed && ( + + + + )} + @@ -70,9 +81,6 @@ const Header = () => { - - - 마이페이지 diff --git a/frontend/src/components/common/Header/HomePageHeader/HomePageHeader.tsx b/frontend/src/components/common/Header/HomePageHeader/HomePageHeader.tsx new file mode 100644 index 00000000..a9aa846b --- /dev/null +++ b/frontend/src/components/common/Header/HomePageHeader/HomePageHeader.tsx @@ -0,0 +1,27 @@ +import { useNavigate } from "react-router-dom"; + +import IconButton from "@components/common/IconButton/IconButton"; + +import { ROUTE_PATHS_MAP } from "@constants/route"; + +import Header from "../Header"; + +const HomePageHeader = () => { + const navigation = useNavigate(); + + return ( +
navigation(ROUTE_PATHS_MAP.searchMain)} + /> + } + isHamburgerUsed + /> + ); +}; + +export default HomePageHeader; diff --git a/frontend/src/components/common/Header/SearchHeader/SearchHeader.styled.ts b/frontend/src/components/common/Header/SearchHeader/SearchHeader.styled.ts new file mode 100644 index 00000000..5f423181 --- /dev/null +++ b/frontend/src/components/common/Header/SearchHeader/SearchHeader.styled.ts @@ -0,0 +1,25 @@ +import styled from "@emotion/styled"; + +export const FormWrapper = styled.form` + flex: 1; + position: relative; + padding-left: 1.6rem; +`; + +export const ButtonContainer = styled.div` + display: flex; + gap: 1.2rem; + position: absolute; + top: 50%; + right: 1.6rem; + transform: translateY(-50%); +`; + +export const DeleteButton = styled.button` + display: flex; + justify-content: center; + align-items: center; + padding: 0.8rem; + border-radius: 50%; + background: rgb(0 0 0/ 10%); +`; diff --git a/frontend/src/components/common/Header/SearchHeader/SearchHeader.tsx b/frontend/src/components/common/Header/SearchHeader/SearchHeader.tsx new file mode 100644 index 00000000..b42ccbc0 --- /dev/null +++ b/frontend/src/components/common/Header/SearchHeader/SearchHeader.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { css } from "@emotion/react"; + +import Icon from "@components/common/Icon/Icon"; +import IconButton from "@components/common/IconButton/IconButton"; +import { Input } from "@components/common/Input/Input.styled"; + +import { ROUTE_PATHS_MAP } from "@constants/route"; + +import Header from "../Header"; +import * as S from "./SearchHeader.styled"; + +const MIN_KEYWORD_LENGTH = 2; + +const SearchHeader = () => { + const navigate = useNavigate(); + const location = useLocation(); + const inputRef = useRef(null); + + const encodedKeyword = + location.pathname.split("/").length > MIN_KEYWORD_LENGTH + ? location.pathname.split("/").pop() + : ""; + + const receivedKeyword = encodedKeyword ? decodeURIComponent(encodedKeyword) : ""; + + const [keyword, setKeyword] = useState(() => { + return receivedKeyword === ":id" ? "" : receivedKeyword; + }); + + useEffect(() => { + setKeyword(receivedKeyword); + }, [receivedKeyword]); + + const handleClickSearchButton = (e: React.FormEvent) => { + e.preventDefault(); + if (keyword.trim().length < MIN_KEYWORD_LENGTH) { + alert(`${MIN_KEYWORD_LENGTH}글자 이상 검색해주세요.`); + setKeyword(keyword.trim()); + } else { + navigate(ROUTE_PATHS_MAP.search(keyword)); + } + }; + + const handleClickDeleteButton = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setKeyword(""); + inputRef.current?.focus(); + }; + + return ( +
+ setKeyword(e.target.value)} + autoFocus + placeholder="검색해주세요" + css={css` + height: 4rem; + `} + variant="round" + /> + + + + + + + + } + $isRightContentFull + /> + ); +}; + +export default SearchHeader; diff --git a/frontend/src/components/common/Header/SearchResultPageHeaderHeader/SearchResultPageHeader.tsx b/frontend/src/components/common/Header/SearchResultPageHeaderHeader/SearchResultPageHeader.tsx new file mode 100644 index 00000000..d9c5f28c --- /dev/null +++ b/frontend/src/components/common/Header/SearchResultPageHeaderHeader/SearchResultPageHeader.tsx @@ -0,0 +1,26 @@ +import { useNavigate } from "react-router-dom"; + +import IconButton from "@components/common/IconButton/IconButton"; + +import { ROUTE_PATHS_MAP } from "@constants/route"; + +import Header from "../Header"; + +const SearchResultPageHeader = () => { + const navigation = useNavigate(); + + return ( +
navigation(ROUTE_PATHS_MAP.searchMain)} + /> + } + isHamburgerUsed + /> + ); +}; + +export default SearchResultPageHeader; diff --git a/frontend/src/components/common/Header/index.ts b/frontend/src/components/common/Header/index.ts new file mode 100644 index 00000000..08cc5db0 --- /dev/null +++ b/frontend/src/components/common/Header/index.ts @@ -0,0 +1,4 @@ +export { default as DefaultHeader } from "./DefaultHeader/DefaultHeader"; +export { default as HomePageHeader } from "./HomePageHeader/HomePageHeader"; +export { default as SearchHeader } from "./SearchHeader/SearchHeader"; +export { default as SearchResultPageHeader } from "./SearchResultPageHeaderHeader/SearchResultPageHeader"; diff --git a/frontend/src/components/common/Icon/svg-icons.json b/frontend/src/components/common/Icon/svg-icons.json index 51019085..ce75ad5b 100644 --- a/frontend/src/components/common/Icon/svg-icons.json +++ b/frontend/src/components/common/Icon/svg-icons.json @@ -242,6 +242,24 @@ "strokeLinecap": "", "strokeLinejoin": "" }, + "search-icon": { + "width": 18, + "height": 18, + "path": "M16.6 18L10.3 11.7C9.8 12.1 9.225 12.4167 8.575 12.65C7.925 12.8833 7.23333 13 6.5 13C4.68333 13 3.146 12.3707 1.888 11.112C0.63 9.85333 0.000667196 8.316 5.29101e-07 6.5C-0.000666138 4.684 0.628667 3.14667 1.888 1.888C3.14733 0.629333 4.68467 0 6.5 0C8.31533 0 9.853 0.629333 11.113 1.888C12.373 3.14667 13.002 4.684 13 6.5C13 7.23333 12.8833 7.925 12.65 8.575C12.4167 9.225 12.1 9.8 11.7 10.3L18 16.6L16.6 18ZM6.5 11C7.75 11 8.81267 10.5627 9.688 9.688C10.5633 8.81333 11.0007 7.75067 11 6.5C10.9993 5.24933 10.562 4.187 9.688 3.313C8.814 2.439 7.75133 2.00133 6.5 2C5.24867 1.99867 4.18633 2.43633 3.313 3.313C2.43967 4.18967 2.002 5.252 2 6.5C1.998 7.748 2.43567 8.81067 3.313 9.688C4.19033 10.5653 5.25267 11.0027 6.5 11Z", + "stroke": "", + "strokeWidth": "0", + "strokeLinecap": "", + "strokeLinejoin": "" + }, + "home-icon": { + "width": 16, + "height": 18, + "path": "M2 16H5V10H11V16H14V7L8 2.5L2 7V16ZM0 18V6L8 0L16 6V18H9V12H7V18H0Z", + "stroke": "", + "strokeWidth": "0", + "strokeLinecap": "", + "strokeLinejoin": "" + }, "check": { "width": 24, "height": 24, @@ -250,5 +268,6 @@ "strokeWidth": "2", "strokeLinecap": "round", "strokeLinejoin": "round" + } } diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index e8874178..f6318b25 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -22,5 +22,6 @@ export { default as FallbackImage } from "./FallbackImage/FallbackImage"; export { default as AvatarCircle } from "./AvatarCircle/AvatarCircle"; export { default as Dropdown } from "./Dropdown/Dropdown"; export { default as Modal } from "./Modal/Modal"; +export * from "./Header/index"; export { default as Checkbox } from "./Checkbox/Checkbox"; export { default as Chip } from "./Chip/Chip"; diff --git a/frontend/src/components/layout/AppLayout/AppLayout.tsx b/frontend/src/components/layout/AppLayout/AppLayout.tsx index e63c80fa..23d27484 100644 --- a/frontend/src/components/layout/AppLayout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout/AppLayout.tsx @@ -1,13 +1,33 @@ -import { Outlet } from "react-router-dom"; +import { Outlet, useLocation } from "react-router-dom"; -import { Header } from "@components/common"; +import { DefaultHeader, HomePageHeader, SearchHeader } from "@components/common"; + +import { ROUTE_PATHS_MAP } from "@constants/route"; import * as S from "./AppLayout.styled"; +const MIN_KEYWORD_LENGTH = 2; + const AppLayout = () => { + const location = useLocation(); + const pathName = location.pathname; + + const encodedKeyword = + location.pathname.split("/").length > MIN_KEYWORD_LENGTH + ? location.pathname.split("/").pop() + : ""; + const receivedKeyword = encodedKeyword ? decodeURIComponent(encodedKeyword) : ""; + + const getHeader = (pathName: string) => { + if (pathName === ROUTE_PATHS_MAP.root) return ; + if (pathName === ROUTE_PATHS_MAP.searchMain) return ; + if (receivedKeyword && pathName.includes(ROUTE_PATHS_MAP.searchMain)) return ; + return ; + }; + return ( <> -
+ {getHeader(pathName)} diff --git a/frontend/src/components/pages/search/SearchPage.styled.ts b/frontend/src/components/pages/search/SearchPage.styled.ts new file mode 100644 index 00000000..8fdc7555 --- /dev/null +++ b/frontend/src/components/pages/search/SearchPage.styled.ts @@ -0,0 +1,24 @@ +import styled from "@emotion/styled"; + +export const Layout = styled.div` + display: flex; + flex-direction: column; + padding: 1.6rem; + + gap: ${({ theme }) => theme.spacing.s}; + + h1 { + ${({ theme }) => theme.typography.mobile.title}; + } +`; + +export const MainPageTraveloguesList = styled.ul` + display: flex; + flex-direction: column; + + gap: ${({ theme }) => theme.spacing.m}; +`; + +export const MainPageContentContainer = styled.div` + padding-top: 1.6rem; +`; diff --git a/frontend/src/components/pages/search/SearchPage.tsx b/frontend/src/components/pages/search/SearchPage.tsx new file mode 100644 index 00000000..ac8fcdca --- /dev/null +++ b/frontend/src/components/pages/search/SearchPage.tsx @@ -0,0 +1,78 @@ +import { useLocation } from "react-router-dom"; + +import { css } from "@emotion/react"; + +import useInfiniteSearchTravelogues from "@queries/useInfiniteSearchTravelogues"; + +import { Text } from "@components/common"; +import FloatingButton from "@components/common/FloatingButton/FloatingButton"; +import TravelogueCard from "@components/pages/main/TravelogueCard/TravelogueCard"; + +import useIntersectionObserver from "@hooks/useIntersectionObserver"; + +import TravelogueCardSkeleton from "../main/TravelogueCard/skeleton/TravelogueCardSkeleton"; +import * as S from "./SearchPage.styled"; +import SearchFallback from "./fallback/SearchFallback"; + +const SearchPage = () => { + const SKELETON_COUNT = 5; + + const location = useLocation(); + const encodedKeyword = + location.pathname.split("/").length > 2 ? location.pathname.split("/").pop() : ""; + const keyword = encodedKeyword ? decodeURIComponent(encodedKeyword) : ""; + + const { travelogues, status, fetchNextPage } = useInfiniteSearchTravelogues(keyword); + + const { lastElementRef } = useIntersectionObserver(fetchNextPage); + + if (!keyword) { + return ( + + ); + } + + if (travelogues.length === 0 && status === "success") { + return ; + } + + return ( + + + {keyword && {`"${keyword}" 검색 결과`}} + {status === "pending" && ( + + {Array.from({ length: SKELETON_COUNT }, (_, index) => ( + + ))} + + )} + + {travelogues.map( + ({ id, title, thumbnail, authorProfileUrl, likeCount, tags, authorNickname }) => ( + + ), + )} + +
+ + ); +}; + +export default SearchPage; diff --git a/frontend/src/components/pages/search/fallback/SearchFallback.styled.ts b/frontend/src/components/pages/search/fallback/SearchFallback.styled.ts new file mode 100644 index 00000000..dcab7227 --- /dev/null +++ b/frontend/src/components/pages/search/fallback/SearchFallback.styled.ts @@ -0,0 +1,17 @@ +import styled from "@emotion/styled"; + +export const Layout = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: calc(100vh - 6rem); + gap: ${({ theme }) => theme.spacing.l}; +`; + +export const TextContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: ${({ theme }) => theme.spacing.m}; +`; diff --git a/frontend/src/components/pages/search/fallback/SearchFallback.tsx b/frontend/src/components/pages/search/fallback/SearchFallback.tsx new file mode 100644 index 00000000..4b839e42 --- /dev/null +++ b/frontend/src/components/pages/search/fallback/SearchFallback.tsx @@ -0,0 +1,24 @@ +import { Text } from "@components/common"; + +import { Tturi } from "@assets/svg"; + +import * as S from "./SearchFallback.styled"; + +interface SearchFallbackProps { + title: string; + text?: string; +} + +const SearchFallback = ({ title, text }: SearchFallbackProps) => { + return ( + + + + {title} + {text} + + + ); +}; + +export default SearchFallback; diff --git a/frontend/src/constants/endpoint.ts b/frontend/src/constants/endpoint.ts index 7373bdce..5b08d760 100644 --- a/frontend/src/constants/endpoint.ts +++ b/frontend/src/constants/endpoint.ts @@ -11,6 +11,7 @@ export const API_ENDPOINT_MAP = { profile: "/member/me/profile", myTravelogues: "/member/me/travelogues", myTravelPlans: "/member/me/travel-plans", + searchTravelogues: "/travelogues/search", reissueToken: "/login/reissue-token", tags: "/tags", } as const; diff --git a/frontend/src/constants/queryKey.ts b/frontend/src/constants/queryKey.ts index 82a5acd7..8d7dd7d2 100644 --- a/frontend/src/constants/queryKey.ts +++ b/frontend/src/constants/queryKey.ts @@ -8,7 +8,9 @@ export const QUERY_KEYS_MAP = { userIdentifier, ], me: () => [...QUERY_KEYS_MAP.travelogue.member("me")], + search: (keyword: string) => [...QUERY_KEYS_MAP.travelogue.all, keyword], tag: (selectedTagIDs: number[]) => [...QUERY_KEYS_MAP.travelogue.all, ...selectedTagIDs], + }, travelPlan: { all: ["travel-plans"], diff --git a/frontend/src/constants/route.ts b/frontend/src/constants/route.ts index 5a98340d..b1181dc7 100644 --- a/frontend/src/constants/route.ts +++ b/frontend/src/constants/route.ts @@ -9,4 +9,6 @@ export const ROUTE_PATHS_MAP = { loginCallback: "/oauth", loginOauth: "/login/oauth/kakao", my: "/my", + searchMain: "/search", + search: (keyword?: string) => (keyword ? `/search/${keyword}` : "/search/:id"), } as const; diff --git a/frontend/src/queries/useInfiniteSearchTravelogues.ts b/frontend/src/queries/useInfiniteSearchTravelogues.ts new file mode 100644 index 00000000..029b6f3d --- /dev/null +++ b/frontend/src/queries/useInfiniteSearchTravelogues.ts @@ -0,0 +1,57 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; + +import { authClient } from "@apis/client"; + +import { API_ENDPOINT_MAP } from "@constants/endpoint"; +import { QUERY_KEYS_MAP } from "@constants/queryKey"; + +export const getSearchTravelogues = async ({ + page, + size, + keyword, +}: { + page: number; + size: number; + keyword: string; +}) => { + const response = await authClient.get(API_ENDPOINT_MAP.searchTravelogues, { + params: { page, size, keyword }, + }); + + return response.data.content; +}; + +const useInfiniteSearchTravelogues = (keyword: string) => { + const INITIAL_PAGE = 0; + const DATA_LOAD_COUNT = 5; + + const { data, status, error, fetchNextPage, isFetchingNextPage, hasNextPage } = useInfiniteQuery({ + queryKey: QUERY_KEYS_MAP.travelogue.search(keyword), + queryFn: ({ pageParam = INITIAL_PAGE }) => { + const page = pageParam; + const size = DATA_LOAD_COUNT; + return getSearchTravelogues({ page, size, keyword }); + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + const nextPage = lastPage.length ? allPages.length : undefined; + return nextPage; + }, + select: (data) => ({ + pages: data.pages.flatMap((page) => page), + pageParams: data.pageParams, + }), + enabled: keyword.trim() !== "", + }); + + return { + travelogues: data?.pages || [], + status, + error, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + }; +}; + +export default useInfiniteSearchTravelogues; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 7f1d1ac0..5b92ba18 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -5,6 +5,7 @@ import KakaoCallbackPage from "@components/pages/login/KakaoCallbackPage"; import LoginPage from "@components/pages/login/LoginPage"; import MainPage from "@components/pages/main/MainPage"; import MyPage from "@components/pages/my/MyPage"; +import SearchPage from "@components/pages/search/SearchPage"; import TravelPlanDetailPage from "@components/pages/travelPlanDetail/TravelPlanDetailPage"; import TravelPlanRegisterPage from "@components/pages/travelPlanRegister/TravelPlanRegisterPage"; import TravelogueDetailPage from "@components/pages/travelogueDetail/TravelogueDetailPage"; @@ -50,6 +51,14 @@ export const router = createBrowserRouter([ path: ROUTE_PATHS_MAP.my, element: , }, + { + path: ROUTE_PATHS_MAP.searchMain, + element: , + }, + { + path: ROUTE_PATHS_MAP.search(), + element: , + }, ], }, ]);