diff --git a/frontend/src/apis/interceptor.ts b/frontend/src/apis/interceptor.ts index 41bead7c..c9767342 100644 --- a/frontend/src/apis/interceptor.ts +++ b/frontend/src/apis/interceptor.ts @@ -12,15 +12,17 @@ import { HTTP_STATUS_CODE_MAP } from "@constants/httpStatusCode"; import { ROUTE_PATHS_MAP } from "@constants/route"; import { STORAGE_KEYS_MAP } from "@constants/storage"; +let isRedirecting = false; + export const checkAccessToken = ( config: InternalAxiosRequestConfig, accessToken: string | null, ) => { - if (!accessToken) { + if (!accessToken && !isRedirecting) { + isRedirecting = true; alert(ERROR_MESSAGE_MAP.api.login); window.location.href = ROUTE_PATHS_MAP.login; } - return config; }; diff --git a/frontend/src/components/common/Header/Header.tsx b/frontend/src/components/common/Header/Header.tsx index e151bcb9..42c979c2 100644 --- a/frontend/src/components/common/Header/Header.tsx +++ b/frontend/src/components/common/Header/Header.tsx @@ -1,5 +1,6 @@ import { useLocation, useNavigate } from "react-router-dom"; +import usePreviousPage from "@hooks/usePreviousPage"; import useUser from "@hooks/useUser"; import { ROUTE_PATHS_MAP } from "@constants/route"; @@ -44,6 +45,7 @@ const Header = ({ }; const handleClickMyPage = () => navigate(ROUTE_PATHS_MAP.my); + const goBack = usePreviousPage(); return ( @@ -52,11 +54,7 @@ const Header = ({ navigate(ROUTE_PATHS_MAP.root) - : () => navigate(ROUTE_PATHS_MAP.back) - } + onClick={isLogoUsed ? () => navigate(ROUTE_PATHS_MAP.root) : () => goBack()} /> diff --git a/frontend/src/components/common/Tab/Tab.styled.ts b/frontend/src/components/common/Tab/Tab.styled.ts index b1cfbe5e..83d58fec 100644 --- a/frontend/src/components/common/Tab/Tab.styled.ts +++ b/frontend/src/components/common/Tab/Tab.styled.ts @@ -14,13 +14,19 @@ export const TabList = styled.ul` `; export const TabItem = styled.li<{ isSelected: boolean; $tabCount: number }>` + display: flex; flex: 0 0 calc(100% / ${({ $tabCount }) => ($tabCount <= 3 ? $tabCount : 3.5)}); - padding: 1rem 2rem; - border-bottom: 2px solid ${(props) => (props.isSelected ? "#0090ff" : "transparent")}; + justify-content: center; + align-items: center; + + padding: ${({ theme }) => theme.spacing.s} ${({ theme }) => theme.spacing.l}; + border-bottom: 2px solid + ${({ isSelected, theme }) => (isSelected ? `${theme.colors.primary}` : "transparent")}; - color: ${(props) => (props.isSelected ? "#0090ff" : "#616161")}; - font-weight: ${(props) => (props.isSelected ? "bold" : "normal")}; - font-size: 12px; + color: ${({ isSelected, theme }) => + isSelected ? `${theme.colors.primary}` : `${theme.colors.text.secondary}`}; + ${({ isSelected, theme }) => + isSelected ? theme.typography.mobile.detailBold : theme.typography.mobile.detail}; text-align: center; white-space: nowrap; cursor: pointer; diff --git a/frontend/src/components/pages/search/SearchPage.styled.ts b/frontend/src/components/pages/search/SearchPage.styled.ts index f5944c47..8a64b88f 100644 --- a/frontend/src/components/pages/search/SearchPage.styled.ts +++ b/frontend/src/components/pages/search/SearchPage.styled.ts @@ -1,21 +1,19 @@ import { css } from "@emotion/react"; import styled from "@emotion/styled"; -import { SPACING } from "@styles/tokens"; +import { PRIMITIVE_COLORS, SPACING } from "@styles/tokens"; export const Layout = styled.div` display: flex; flex-direction: column; gap: ${SPACING.m}; - - margin-top: ${SPACING.m}; min-height: calc(100vh - 6rem); padding: ${SPACING.m}; + padding-top: 0; `; export const SearchFallbackWrapper = styled.div` flex: 1; - position: relative; `; export const MainPageTraveloguesList = styled.ul` @@ -25,15 +23,11 @@ export const MainPageTraveloguesList = styled.ul` gap: ${SPACING.m}; `; -export const searchResultTextStyle = css` - display: -webkit-box; - overflow: hidden; - width: 100%; - max-width: 100%; +export const TabStyle = css` + position: fixed; + z-index: 1000; + width: 45rem; + height: 5rem; - line-height: 1.5; - white-space: normal; - word-break: break-word; - overflow-wrap: break-word; - text-overflow: ellipsis; + background-color: ${PRIMITIVE_COLORS.white}; `; diff --git a/frontend/src/components/pages/search/SearchPage.tsx b/frontend/src/components/pages/search/SearchPage.tsx index d9e473e8..cdc18e18 100644 --- a/frontend/src/components/pages/search/SearchPage.tsx +++ b/frontend/src/components/pages/search/SearchPage.tsx @@ -1,34 +1,18 @@ import { useLocation } from "react-router-dom"; -import { css } from "@emotion/react"; - -import useInfiniteSearchTravelogues from "@queries/useInfiniteSearchTravelogues"; - -import { FloatingButton, SearchFallback, Text } from "@components/common"; -import TravelogueCard from "@components/pages/main/TravelogueCard/TravelogueCard"; - -import useIntersectionObserver from "@hooks/useIntersectionObserver"; - -import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; +import { FloatingButton, SearchFallback, Tab } from "@components/common"; import { extractLastPath } from "@utils/extractId"; -import TravelogueCardSkeleton from "../main/TravelogueCard/skeleton/TravelogueCardSkeleton"; import * as S from "./SearchPage.styled"; +import TravelogueList from "./TravelogueList/TravelogueList"; const SearchPage = () => { - const SKELETON_COUNT = 5; - const location = useLocation(); const encodedKeyword = location.pathname.split("/").length > 2 ? extractLastPath(location.pathname) : ""; const keyword = encodedKeyword ? decodeURIComponent(encodedKeyword) : ""; - const { travelogues, status, fetchNextPage, isPaused, error } = - useInfiniteSearchTravelogues(keyword); - - const { lastElementRef } = useIntersectionObserver(fetchNextPage); - if (!keyword) { return ( @@ -42,63 +26,19 @@ const SearchPage = () => { ); } - if (travelogues.length === 0 && status === "success") { - return ( - - {keyword && ( - {`"${keyword}" 검색 결과`} - )} - - - - - ); - } - - if (status === "error") { - error && alert(error.message); - - return ; - } - - if (isPaused) alert(ERROR_MESSAGE_MAP.network); - return ( - {keyword && ( - {`"${keyword}" 검색 결과`} - )} - {status === "pending" && ( - - {Array.from({ length: SKELETON_COUNT }, (_, index) => ( - - ))} - - )} - - {travelogues.map( - ({ id, title, thumbnail, authorProfileUrl, likeCount, tags, authorNickname }) => ( - - ), + ( + )} - -
); diff --git a/frontend/src/components/pages/search/TravelogueList/TravelogueList.styled.ts b/frontend/src/components/pages/search/TravelogueList/TravelogueList.styled.ts new file mode 100644 index 00000000..00d196fd --- /dev/null +++ b/frontend/src/components/pages/search/TravelogueList/TravelogueList.styled.ts @@ -0,0 +1,36 @@ +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; + +import { SPACING } from "@styles/tokens"; + +export const Layout = styled.div` + display: flex; + flex-direction: column; + margin-top: ${SPACING.xxxl}; + min-height: calc(100vh - 16rem); +`; + +export const searchResultTextStyle = css` + display: -webkit-box; + overflow: hidden; + width: 100%; + max-width: 100%; + + line-height: 1.5; + white-space: normal; + word-break: break-word; + overflow-wrap: break-word; + text-overflow: ellipsis; +`; + +export const SearchFallbackWrapper = styled.div` + flex: 1; + position: relative; +`; + +export const MainPageTraveloguesList = styled.ul` + display: flex; + flex-direction: column; + + gap: ${SPACING.m}; +`; diff --git a/frontend/src/components/pages/search/TravelogueList/TravelogueList.tsx b/frontend/src/components/pages/search/TravelogueList/TravelogueList.tsx new file mode 100644 index 00000000..3c15e06f --- /dev/null +++ b/frontend/src/components/pages/search/TravelogueList/TravelogueList.tsx @@ -0,0 +1,93 @@ +import { css } from "@emotion/react"; + +import type { SearchType } from "@type/domain/travelogue"; + +import useInfiniteSearchTravelogues from "@queries/useInfiniteSearchTravelogues"; + +import { SearchFallback, Text } from "@components/common"; +import TravelogueCard from "@components/pages/main/TravelogueCard/TravelogueCard"; +import TravelogueCardSkeleton from "@components/pages/main/TravelogueCard/skeleton/TravelogueCardSkeleton"; + +import useIntersectionObserver from "@hooks/useIntersectionObserver"; + +import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; + +import * as S from "./TravelogueList.styled"; + +const SKELETON_COUNT = 5; + +interface TravelogueListProps { + keyword: string; + searchType: SearchType; +} + +const TravelogueList = ({ keyword, searchType }: TravelogueListProps) => { + const { travelogues, status, fetchNextPage, isPaused, error } = useInfiniteSearchTravelogues( + keyword, + searchType, + ); + const { lastElementRef } = useIntersectionObserver(fetchNextPage); + + if (travelogues.length === 0 && status === "success") { + return ( + + {keyword && ( + {`"${keyword}" 검색 결과`} + )} + + + + + ); + } + + if (status === "error") { + error && alert(error.message); + + return ; + } + + if (isPaused) alert(ERROR_MESSAGE_MAP.network); + + return ( + + + {keyword && ( + {`"${keyword}" 검색 결과`} + )} + + {status === "pending" && ( + + {Array.from({ length: SKELETON_COUNT }, (_, index) => ( + + ))} + + )} + {travelogues.map( + ({ id, title, thumbnail, authorProfileUrl, likeCount, tags, authorNickname }) => ( + + ), + )} +
+ + + ); +}; + +export default TravelogueList; diff --git a/frontend/src/components/pages/travelPlanDetail/TravelPlanDetailPage.tsx b/frontend/src/components/pages/travelPlanDetail/TravelPlanDetailPage.tsx index 0ba5e88b..a0d51b91 100644 --- a/frontend/src/components/pages/travelPlanDetail/TravelPlanDetailPage.tsx +++ b/frontend/src/components/pages/travelPlanDetail/TravelPlanDetailPage.tsx @@ -20,6 +20,7 @@ import { ROUTE_PATHS_MAP } from "@constants/route"; import { extractLastPath } from "@utils/extractId"; import getDateRange from "@utils/getDateRange"; +import getDaysAndNights from "@utils/getDaysAndNights"; import { isUUID } from "@utils/uuid"; import theme from "@styles/theme"; @@ -35,10 +36,7 @@ const TravelPlanDetailPage = () => { const navigate = useNavigate(); - const daysAndNights = - data?.days.length && data?.days.length > 1 - ? `${data?.days.length - 1}박 ${data?.days.length}일` - : "당일치기"; + const daysAndNights = getDaysAndNights(data?.days); const { mutate: mutateDeleteTravelPlan, isPending: isDeletingPending } = useDeleteTravelPlan(); diff --git a/frontend/src/components/pages/travelogueDetail/TravelogueDetailPage.tsx b/frontend/src/components/pages/travelogueDetail/TravelogueDetailPage.tsx index 61fcfce2..3d3b5e8d 100644 --- a/frontend/src/components/pages/travelogueDetail/TravelogueDetailPage.tsx +++ b/frontend/src/components/pages/travelogueDetail/TravelogueDetailPage.tsx @@ -31,6 +31,7 @@ import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; import { ROUTE_PATHS_MAP } from "@constants/route"; import { extractID } from "@utils/extractId"; +import getDaysAndNights from "@utils/getDaysAndNights"; import theme from "@styles/theme"; import { SEMANTIC_COLORS } from "@styles/tokens"; @@ -49,10 +50,7 @@ const TravelogueDetailPage = () => { const navigate = useNavigate(); - const daysAndNights = - data?.days.length && data?.days.length > 1 - ? `${data?.days.length - 1}박 ${data?.days.length}일` - : "당일치기"; + const daysAndNights = getDaysAndNights(data?.days); const { onTransformTravelDetail } = useTravelTransformDetailContext(); const { diff --git a/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/PlaceDetailCard/PlaceDetailCard.stories.tsx b/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/PlaceDetailCard/PlaceDetailCard.stories.tsx index 4e4eaeaf..117101e5 100644 --- a/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/PlaceDetailCard/PlaceDetailCard.stories.tsx +++ b/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/PlaceDetailCard/PlaceDetailCard.stories.tsx @@ -48,7 +48,7 @@ export const WithNoImage: Story = { args: { index: 1, title: "제주도 3박 4일 여행기", - imageUrls: [""], + imageUrls: [], description: "여행지 설명", }, }; diff --git a/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/PlaceDetailCard/PlaceDetailCard.tsx b/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/PlaceDetailCard/PlaceDetailCard.tsx index eaec25d0..be8b6f9b 100644 --- a/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/PlaceDetailCard/PlaceDetailCard.tsx +++ b/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/PlaceDetailCard/PlaceDetailCard.tsx @@ -21,11 +21,7 @@ const PlaceDetailCard: React.FC = ({ const renderImage = () => { if (imageUrls.length === 0) { - return ( - - - - ); + return null; } if (imageUrls.length === 1) { diff --git a/frontend/src/components/pages/travelogueRegister/TravelogueRegisterPage.tsx b/frontend/src/components/pages/travelogueRegister/TravelogueRegisterPage.tsx index e97a9068..3ac962c8 100644 --- a/frontend/src/components/pages/travelogueRegister/TravelogueRegisterPage.tsx +++ b/frontend/src/components/pages/travelogueRegister/TravelogueRegisterPage.tsx @@ -32,8 +32,11 @@ import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; import { FORM_VALIDATIONS_MAP } from "@constants/formValidation"; import { ROUTE_PATHS_MAP } from "@constants/route"; + +import getInitialTravelTitle from "@utils/getInitialTravelTitle"; import resizeAndConvertImage from "@utils/resizeAndConvertImage"; + import * as S from "./TravelogueRegisterPage.styled"; const TravelogueRegisterPage = () => { @@ -41,7 +44,6 @@ const TravelogueRegisterPage = () => { const { transformDetail } = useTravelTransformDetailContext(); - const [title, setTitle] = useState(""); const [thumbnail, setThumbnail] = useState(""); const handleChangeTitle = (e: React.ChangeEvent) => { @@ -67,6 +69,10 @@ const TravelogueRegisterPage = () => { onDeleteImageUrls, } = useTravelogueDays(transformDetail?.days ?? []); + const initialTitle = getInitialTravelTitle({ days: transformDetail?.days, type: "travelogue" }); + + const [title, setTitle] = useState(initialTitle); + const thumbnailFileInputRef = useRef(null); const handleButtonClick = () => { @@ -156,6 +162,7 @@ const TravelogueRegisterPage = () => { id={id} value={title} maxLength={FORM_VALIDATIONS_MAP.title.maxLength} + placeholder="여행기 제목을 입력해주세요" onChange={handleChangeTitle} /> [...QUERY_KEYS_MAP.travelogue.member("me")], - search: (keyword: string) => [...QUERY_KEYS_MAP.travelogue.all, keyword], + search: (keyword: string, searchType: SearchType) => [ + ...QUERY_KEYS_MAP.travelogue.all, + searchType, + keyword, + ], tag: ( selectedTagIDs: number[], selectedSortingOption: SortingOption, diff --git a/frontend/src/hooks/pages/useTravelPlanForm.ts b/frontend/src/hooks/pages/useTravelPlanForm.ts index f9577f88..d744afd1 100644 --- a/frontend/src/hooks/pages/useTravelPlanForm.ts +++ b/frontend/src/hooks/pages/useTravelPlanForm.ts @@ -6,18 +6,9 @@ import { useTravelPlanDays } from "@hooks/pages/useTravelPlanDays"; import { FORM_VALIDATIONS_MAP } from "@constants/formValidation"; -const useTravelPlanForm = (transformDays: TravelTransformPlaces[]) => { - const [title, setTitle] = useState(""); - - const onChangeTitle = (inputValue: string) => { - const trimmedTitle = inputValue.slice( - FORM_VALIDATIONS_MAP.title.minLength, - FORM_VALIDATIONS_MAP.title.maxLength, - ); - - setTitle(trimmedTitle); - }; +import getInitialTravelTitle from "@utils/getInitialTravelTitle"; +const useTravelPlanForm = (transformDays: TravelTransformPlaces[]) => { const [startDate, setStartDate] = useState(null); const onSelectCalendar = (date: Date, handleCloseCalendar: () => void) => { @@ -36,6 +27,19 @@ const useTravelPlanForm = (transformDays: TravelTransformPlaces[]) => { onChangeContent, } = useTravelPlanDays(transformDays); + const initialTitle = getInitialTravelTitle({ days: travelPlanDays, type: "travelPlan" }); + + const [title, setTitle] = useState(initialTitle); + + const onChangeTitle = (inputValue: string) => { + const trimmedTitle = inputValue.slice( + FORM_VALIDATIONS_MAP.title.minLength, + FORM_VALIDATIONS_MAP.title.maxLength, + ); + + setTitle(trimmedTitle); + }; + return { state: { title, diff --git a/frontend/src/hooks/usePreviousPage.ts b/frontend/src/hooks/usePreviousPage.ts new file mode 100644 index 00000000..0d000df3 --- /dev/null +++ b/frontend/src/hooks/usePreviousPage.ts @@ -0,0 +1,25 @@ +import { useLocation, useNavigate } from "react-router-dom"; + +import { ROUTE_PATHS_MAP } from "@constants/route"; + +const usePreviousPage = () => { + const navigate = useNavigate(); + const location = useLocation(); + const previousPage = document.referrer; + + const goBack = () => { + const currentPage = location.pathname; + const isPreviousPageMy = previousPage?.includes(ROUTE_PATHS_MAP.my); + const isCurrentPageLogin = currentPage.includes(ROUTE_PATHS_MAP.login); + + if (isPreviousPageMy && isCurrentPageLogin) { + navigate(ROUTE_PATHS_MAP.root); + } else { + navigate(ROUTE_PATHS_MAP.back); + } + }; + + return goBack; +}; + +export default usePreviousPage; diff --git a/frontend/src/queries/useInfiniteSearchTravelogues.ts b/frontend/src/queries/useInfiniteSearchTravelogues.ts index c80a4807..a73de319 100644 --- a/frontend/src/queries/useInfiniteSearchTravelogues.ts +++ b/frontend/src/queries/useInfiniteSearchTravelogues.ts @@ -1,5 +1,7 @@ import { useInfiniteQuery } from "@tanstack/react-query"; +import type { SearchType } from "@type/domain/travelogue"; + import { client } from "@apis/client"; import { API_ENDPOINT_MAP } from "@constants/endpoint"; @@ -9,29 +11,31 @@ export const getSearchTravelogues = async ({ page, size, keyword, + searchType, }: { page: number; size: number; keyword: string; + searchType: SearchType; }) => { const response = await client.get(API_ENDPOINT_MAP.searchTravelogues, { - params: { page, size, keyword }, + params: { page, size, keyword, searchType }, }); return response.data.content; }; -const useInfiniteSearchTravelogues = (keyword: string) => { +const useInfiniteSearchTravelogues = (keyword: string, searchType: "TITLE" | "AUTHOR") => { const INITIAL_PAGE = 0; const DATA_LOAD_COUNT = 5; const { data, status, error, fetchNextPage, isFetchingNextPage, hasNextPage, isPaused } = useInfiniteQuery({ - queryKey: QUERY_KEYS_MAP.travelogue.search(keyword), + queryKey: QUERY_KEYS_MAP.travelogue.search(keyword, searchType), queryFn: ({ pageParam = INITIAL_PAGE }) => { const page = pageParam; const size = DATA_LOAD_COUNT; - return getSearchTravelogues({ page, size, keyword }); + return getSearchTravelogues({ page, size, keyword, searchType }); }, initialPageParam: 0, getNextPageParam: (lastPage, allPages) => { diff --git a/frontend/src/types/domain/travelogue.ts b/frontend/src/types/domain/travelogue.ts index 47c4ff63..6a4e6d70 100644 --- a/frontend/src/types/domain/travelogue.ts +++ b/frontend/src/types/domain/travelogue.ts @@ -43,6 +43,8 @@ export interface MyTravelogue { createdAt: string; } +export type SearchType = "TITLE" | "AUTHOR"; + export type SortingOption = "likeCount" | "createdAt"; export type TravelPeriodOption = "" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8"; diff --git a/frontend/src/utils/getDaysAndNights.ts b/frontend/src/utils/getDaysAndNights.ts new file mode 100644 index 00000000..fc0b06ca --- /dev/null +++ b/frontend/src/utils/getDaysAndNights.ts @@ -0,0 +1,6 @@ +import type { TravelTransformPlaces } from "@type/domain/travelTransform"; + +const getDaysAndNights = (days: TravelTransformPlaces[] | undefined) => + days?.length && days?.length > 1 ? `${days.length - 1}박 ${days.length}일` : "당일치기"; + +export default getDaysAndNights; diff --git a/frontend/src/utils/getInitialTravelTitle.ts b/frontend/src/utils/getInitialTravelTitle.ts new file mode 100644 index 00000000..e0ccd914 --- /dev/null +++ b/frontend/src/utils/getInitialTravelTitle.ts @@ -0,0 +1,21 @@ +import type { TravelTransformPlaces } from "@type/domain/travelTransform"; + +import getDaysAndNights from "@utils/getDaysAndNights"; + +type TravelRecordType = "travelogue" | "travelPlan"; + +interface getInitialTravelTitleProps { + days: TravelTransformPlaces[] | undefined; + type: TravelRecordType; +} + +const getInitialTravelTitle = ({ days, type }: getInitialTravelTitleProps) => { + const daysAndNights = getDaysAndNights(days); + + const tripType = type === "travelogue" ? "여행기" : "여행 계획"; + + if (days && days?.length >= 1) return `${daysAndNights} ${tripType}`; + return ""; +}; + +export default getInitialTravelTitle;