diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json index 2c213fc0..64e532a2 100644 --- a/frontend/.prettierrc.json +++ b/frontend/.prettierrc.json @@ -18,6 +18,7 @@ "^@type/(.*)$", "^@apis/(.*)$", "^@mocks/(.*)$", + "^@queries/(.*)$", "^@components/(.*)$", "^@hooks/(.*)$", "^@constants/(.*)$", diff --git a/frontend/__tests__/mainPage.test.tsx b/frontend/__tests__/mainPage.test.tsx new file mode 100644 index 00000000..936d0c8f --- /dev/null +++ b/frontend/__tests__/mainPage.test.tsx @@ -0,0 +1,45 @@ +import { waitFor } from "@testing-library/react"; + +import { createInfiniteTravelogueHook } from "./utils/createInfiniteTravelogueHook"; + +describe("메인 페이지", () => { + describe("여행기 무한 스크롤 확인", () => { + it("메인 페이지로 진입했을때 보여지는 여행기는 총 5개이다.", async () => { + const { result } = createInfiniteTravelogueHook(); + await waitFor(() => { + expect(result.current.status).toMatch("success"); + expect(result.current.travelogues.length).toBe(5); + }); + }); + + it("다음 페이지로 이동할 경우 보여지는 여행기는 총 10개이다.", async () => { + const { result } = createInfiniteTravelogueHook(); + + await waitFor(() => { + result.current.fetchNextPage(); + }); + + await waitFor(() => { + expect(result.current.status).toMatch("success"); + expect(result.current.travelogues.length).toBe(10); + }); + }); + + it("마지막 페이지로 이동할 경우 더 이상 콘텐츠를 확인할 수 없다.", async () => { + const { result } = createInfiniteTravelogueHook(); + + await waitFor(() => { + result.current.fetchNextPage(); + }); + + await waitFor(() => { + result.current.fetchNextPage(); + }); + + await waitFor(() => { + expect(result.current.status).toMatch("success"); + expect(result.current.hasNextPage).toBeFalsy(); + }); + }); + }); +}); diff --git a/frontend/__tests__/utils/createInfiniteTravelogueHook.tsx b/frontend/__tests__/utils/createInfiniteTravelogueHook.tsx new file mode 100644 index 00000000..8f26370f --- /dev/null +++ b/frontend/__tests__/utils/createInfiniteTravelogueHook.tsx @@ -0,0 +1,22 @@ +import { renderHook } from "@testing-library/react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import useInfiniteTravelogues from "@queries/useInfiniteTravelogues"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 0, + }, + }, +}); + +export const wrapper = ({ children }: React.PropsWithChildren) => ( + {children} +); + +export const createInfiniteTravelogueHook = () => + renderHook(() => useInfiniteTravelogues(), { + wrapper, + }); diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 3c57e3f1..92a0b3ff 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -22,6 +22,8 @@ module.exports = { (https://mswjs.io/docs/migrations/1.x-to-2.x/#frequent-issues) */ setupFiles: ["./jest.polyfills.js"], + setupFilesAfterEnv: ["./jest-setup.ts"], + testEnvironmentOptions: { customExportConditions: [""], }, diff --git a/frontend/src/apis/ApiError.ts b/frontend/src/apis/ApiError.ts index 897f2c20..c5190655 100644 --- a/frontend/src/apis/ApiError.ts +++ b/frontend/src/apis/ApiError.ts @@ -1,12 +1,6 @@ import { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from "axios"; -const HTTP_STATUS = { - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - FORBIDDEN: 403, - NOT_FOUND: 404, - INTERNAL_SERVER_ERROR: 500, -} as const; +import { HTTP_STATUS_CODE_MAP } from "@constants/httpStatusCode"; class ApiError extends Error implements AxiosError { config: InternalAxiosRequestConfig; @@ -23,23 +17,23 @@ class ApiError extends Error implements AxiosError { const errorStatus = error.response?.status || 0; let name = "ApiError"; - if (errorStatus === HTTP_STATUS.BAD_REQUEST) { + if (errorStatus === HTTP_STATUS_CODE_MAP.BAD_REQUEST) { name = "ApiBadRequestError"; } - if (errorStatus === HTTP_STATUS.UNAUTHORIZED) { + if (errorStatus === HTTP_STATUS_CODE_MAP.UNAUTHORIZED) { name = "ApiUnauthorizedError"; } - if (errorStatus === HTTP_STATUS.FORBIDDEN) { + if (errorStatus === HTTP_STATUS_CODE_MAP.FORBIDDEN) { name = "ApiForbiddenError"; } - if (errorStatus === HTTP_STATUS.NOT_FOUND) { + if (errorStatus === HTTP_STATUS_CODE_MAP.NOT_FOUND) { name = "ApiNotFoundError"; } - if (errorStatus === HTTP_STATUS.INTERNAL_SERVER_ERROR) { + if (errorStatus === HTTP_STATUS_CODE_MAP.INTERNAL_SERVER_ERROR) { name = "ApiInternalServerError"; } diff --git a/frontend/src/apis/interceptor.ts b/frontend/src/apis/interceptor.ts index c7833766..de8c4a77 100644 --- a/frontend/src/apis/interceptor.ts +++ b/frontend/src/apis/interceptor.ts @@ -2,19 +2,21 @@ import * as Sentry from "@sentry/react"; import { AxiosError, InternalAxiosRequestConfig } from "axios"; import type { ErrorResponse } from "@type/api/errorResponse"; -import type { User } from "@type/domain/user"; +import type { UserResponse } from "@type/domain/user"; import ApiError from "@apis/ApiError"; -import { ROUTE_PATHS } from "@constants/route"; +import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; +import { ROUTE_PATHS_MAP } from "@constants/route"; +import { STORAGE_KEYS_MAP } from "@constants/storage"; export const checkAccessToken = ( config: InternalAxiosRequestConfig, accessToken: string | null, ) => { if (!accessToken) { - alert("로그인이 필요합니다."); - window.location.href = ROUTE_PATHS.login; + alert(ERROR_MESSAGE_MAP.api.login); + window.location.href = ROUTE_PATHS_MAP.login; } return config; @@ -32,7 +34,7 @@ export const setAuthorizationHeader = ( }; export const handlePreviousRequest = (config: InternalAxiosRequestConfig) => { - const user: User | null = JSON.parse(localStorage.getItem("tourootUser") ?? "{}"); + const user: UserResponse | null = JSON.parse(localStorage.getItem(STORAGE_KEYS_MAP.user) ?? "{}"); let newConfig = { ...config }; newConfig = checkAccessToken(config, user?.accessToken ?? null); diff --git a/frontend/src/assets/svg/index.ts b/frontend/src/assets/svg/index.ts index da4924e3..5863139e 100644 --- a/frontend/src/assets/svg/index.ts +++ b/frontend/src/assets/svg/index.ts @@ -19,3 +19,4 @@ export { default as ImageUpload } from "./image-upload.svg"; 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"; diff --git a/frontend/src/components/common/Accordion/AccordionItem/accordionItemContext.ts b/frontend/src/components/common/Accordion/AccordionItem/accordionItemContext.ts index 42378d21..8900e84d 100644 --- a/frontend/src/components/common/Accordion/AccordionItem/accordionItemContext.ts +++ b/frontend/src/components/common/Accordion/AccordionItem/accordionItemContext.ts @@ -1,11 +1,13 @@ import { createContext, useContext } from "react"; +import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; + export const AccordionItemContext = createContext(""); export const useAccordionItemContext = () => { const context = useContext(AccordionItemContext); if (!context) { - throw new Error("Provider 바깥에 존재합니다!"); + throw new Error(ERROR_MESSAGE_MAP.provider); } return context; diff --git a/frontend/src/components/common/Accordion/AccordionRoot/accordionContext.ts b/frontend/src/components/common/Accordion/AccordionRoot/accordionContext.ts index 1fc95d68..ae48ce28 100644 --- a/frontend/src/components/common/Accordion/AccordionRoot/accordionContext.ts +++ b/frontend/src/components/common/Accordion/AccordionRoot/accordionContext.ts @@ -1,5 +1,7 @@ import { createContext, useContext } from "react"; +import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; + interface AccordionContextConfig { value: Set; handleToggleAccordion: (item: string) => void; @@ -10,7 +12,7 @@ export const AccordionContext = createContext(nul export const useAccordionContext = () => { const context = useContext(AccordionContext); if (!context) { - throw new Error("Provider 바깥에 존재합니다!"); + throw new Error(ERROR_MESSAGE_MAP.provider); } return context; diff --git a/frontend/src/components/common/Drawer/Drawer.tsx b/frontend/src/components/common/Drawer/Drawer.tsx index 49bfb676..9ad0bc5c 100644 --- a/frontend/src/components/common/Drawer/Drawer.tsx +++ b/frontend/src/components/common/Drawer/Drawer.tsx @@ -1,5 +1,7 @@ import React, { createContext, useContext, useState } from "react"; +import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; + import * as S from "./Drawer.styled"; interface DrawerContextType { @@ -14,7 +16,7 @@ const DrawerContext = createContext(undefined); const useDrawerContext = () => { const context = useContext(DrawerContext); if (!context) { - throw new Error("Drawer Provider가 없습니다."); + throw new Error(ERROR_MESSAGE_MAP.provider); } return context; }; diff --git a/frontend/src/components/common/FloatingButton/FloatingButton.tsx b/frontend/src/components/common/FloatingButton/FloatingButton.tsx index 7174a4c0..0f5b4a79 100644 --- a/frontend/src/components/common/FloatingButton/FloatingButton.tsx +++ b/frontend/src/components/common/FloatingButton/FloatingButton.tsx @@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom"; // import { UserContext } from "@contexts/UserProvider"; import Icon from "@components/common/Icon/Icon"; -import { ROUTE_PATHS } from "@constants/route"; +import { ROUTE_PATHS_MAP } from "@constants/route"; import { PRIMITIVE_COLORS } from "@styles/tokens"; @@ -26,7 +26,7 @@ const FloatingButton = () => { // alert("여행기 작성 전 로그인을 먼저 해주세요!"); // return; // } - navigate(ROUTE_PATHS.travelogueRegister); + navigate(ROUTE_PATHS_MAP.travelogueRegister); }; const handleClickShareButton = () => {}; diff --git a/frontend/src/components/common/GoogleSearchPopup/GoogleSearchPopup.tsx b/frontend/src/components/common/GoogleSearchPopup/GoogleSearchPopup.tsx index 818d28b1..2c6c43d6 100644 --- a/frontend/src/components/common/GoogleSearchPopup/GoogleSearchPopup.tsx +++ b/frontend/src/components/common/GoogleSearchPopup/GoogleSearchPopup.tsx @@ -4,7 +4,7 @@ import { Global, css } from "@emotion/react"; import { Autocomplete } from "@react-google-maps/api"; -import type { Place } from "@type/domain/travelogue"; +import type { TravelTransformPlace } from "@type/domain/travelTransform"; import { Button } from "@components/common"; @@ -12,7 +12,7 @@ import * as S from "./GoogleSearchPopup.styled"; interface GoogleSearchPopupProps { onClosePopup: () => void; - onSearchPlaceInfo: (placeInfo: Pick) => void; + onSearchPlaceInfo: (placeInfo: Pick) => void; } const GoogleSearchPopup = ({ onClosePopup, onSearchPlaceInfo }: GoogleSearchPopupProps) => { @@ -35,7 +35,7 @@ const GoogleSearchPopup = ({ onClosePopup, onSearchPlaceInfo }: GoogleSearchPopu lng: place.geometry.location.lng(), }; - const placeInfo: Pick = { + const placeInfo: Pick = { placeName: place.name || "", position: newCenter, }; diff --git a/frontend/src/components/common/Header/Header.tsx b/frontend/src/components/common/Header/Header.tsx index 06e729d3..b1e1b69a 100644 --- a/frontend/src/components/common/Header/Header.tsx +++ b/frontend/src/components/common/Header/Header.tsx @@ -4,7 +4,7 @@ import IconButton from "@components/common/IconButton/IconButton"; import useUser from "@hooks/useUser"; -import { ROUTE_PATHS } from "@constants/route"; +import { ROUTE_PATHS_MAP } from "@constants/route"; import theme from "@styles/theme"; import { PRIMITIVE_COLORS } from "@styles/tokens"; @@ -21,9 +21,9 @@ const Header = () => { const navigate = useNavigate(); const handleClickButton = - pathName === ROUTE_PATHS.root || pathName === ROUTE_PATHS.login - ? () => navigate(ROUTE_PATHS.root) - : () => navigate(-1); + pathName === ROUTE_PATHS_MAP.root || pathName === ROUTE_PATHS_MAP.login + ? () => navigate(ROUTE_PATHS_MAP.root) + : () => navigate(ROUTE_PATHS_MAP.back); const { user } = useUser(); @@ -31,11 +31,11 @@ const Header = () => { - {pathName === ROUTE_PATHS.login ? ( + {pathName === ROUTE_PATHS_MAP.login ? ( <> 로그인 @@ -67,7 +67,7 @@ const Header = () => { ) : ( { - navigate(ROUTE_PATHS.login); + navigate(ROUTE_PATHS_MAP.login); }} > 로그인 diff --git a/frontend/src/components/common/Icon/svg-icons.json b/frontend/src/components/common/Icon/svg-icons.json index d63c338d..ad8e0193 100644 --- a/frontend/src/components/common/Icon/svg-icons.json +++ b/frontend/src/components/common/Icon/svg-icons.json @@ -201,9 +201,9 @@ "width": 11, "height": 11, "path": "M1.1002 11L0 9.9L4.40079 5.5L0 1.1L1.1002 0L5.50098 4.4L9.90177 0L11.002 1.1L6.60118 5.5L11.002 9.9L9.90177 11L5.50098 6.6L1.1002 11Z", - "stroke": "currentColor", - "strokeWidth": "1", - "strokeLinecap": "butt", - "strokeLinejoin": "miter" + "stroke": "", + "strokeWidth": "0", + "strokeLinecap": "", + "strokeLinejoin": "" } -} \ No newline at end of file +} diff --git a/frontend/src/components/common/Input/Input.styled.ts b/frontend/src/components/common/Input/Input.styled.ts index 69afb50b..900c10f3 100644 --- a/frontend/src/components/common/Input/Input.styled.ts +++ b/frontend/src/components/common/Input/Input.styled.ts @@ -29,6 +29,12 @@ export const Input = styled.input` outline: none; } + &:disabled { + background-color: ${({ theme }) => theme.colors.background.disabled}; + + color: ${({ theme }) => theme.colors.text.secondary}; + } + &::placeholder { color: ${({ theme }) => theme.colors.text.secondary}; } diff --git a/frontend/src/components/common/Input/Input.tsx b/frontend/src/components/common/Input/Input.tsx index 6cfc7ce0..9ce9866c 100644 --- a/frontend/src/components/common/Input/Input.tsx +++ b/frontend/src/components/common/Input/Input.tsx @@ -6,7 +6,7 @@ import * as S from "./Input.styled"; interface InputProps extends InputHTMLAttributes { count?: number; maxCount?: number; - label: string; + label?: string; } const Input = ({ label, count, maxCount, ...props }: InputProps) => { diff --git a/frontend/src/components/common/Modal/Modal.stories.tsx b/frontend/src/components/common/Modal/Modal.stories.tsx new file mode 100644 index 00000000..61218ad4 --- /dev/null +++ b/frontend/src/components/common/Modal/Modal.stories.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; + +import type { Meta } from "@storybook/react"; +import { fn } from "@storybook/test"; + +import Button from "@components/common/Button/Button"; +import Input from "@components/common/Input/Input"; +import Text from "@components/common/Text/Text"; + +import theme from "@styles/theme"; + +import Modal from "./Modal"; + +const meta = { + title: "common/Modal", + component: Modal, + parameters: { + layout: "fullscreen", + viewport: { + defaultViewport: "desktop", + }, + controls: { disable: true }, + }, + args: { onCloseModal: fn() }, +} satisfies Meta; + +export default meta; + +export const ShareModal = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + const onToggleModal = () => setIsOpen((prev) => !prev); + + return ( + <> + + 모달 열기 + + {isOpen && ( + + + + + + + 여행기를 공유할까요? + + + + + 복사 + + + + + )} + > + ); + }, +}; + +export const TravelPlanDeleteModal = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + const onToggleModal = () => setIsOpen((prev) => !prev); + + return ( + <> + + 모달 열기 + + {isOpen && ( + + + + + + + + 여행 계획을 삭제할까요? + + + 삭제한 후에는 여행 계획을 다시 복구할 수 없어요. + + + + + 삭제 + + + )} + > + ); + }, +}; diff --git a/frontend/src/components/common/Modal/Modal.style.ts b/frontend/src/components/common/Modal/Modal.style.ts new file mode 100644 index 00000000..37e4dd86 --- /dev/null +++ b/frontend/src/components/common/Modal/Modal.style.ts @@ -0,0 +1,36 @@ +import styled from "@emotion/styled"; + +import { PRIMITIVE_COLORS } from "@styles/tokens"; + +export const Layout = styled.section` + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset: 0; + z-index: 1000; +`; + +export const BackdropLayout = styled.div` + position: absolute; + width: 100%; + height: 100%; + + background-color: ${({ theme }) => theme.colors.dimmed}; + cursor: pointer; +`; + +export const ModalBoxLayout = styled.div` + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + width: calc(100% - ${({ theme }) => theme.spacing.m} * 2); + max-height: 80vh; + max-width: calc(48rem - ${({ theme }) => theme.spacing.m} * 2); + margin: ${({ theme }) => theme.spacing.m}; + border-radius: ${({ theme }) => theme.spacing.s}; + + background-color: ${PRIMITIVE_COLORS.white}; + box-shadow: 0 0 5px rgb(0 0 0 / 15%); +`; diff --git a/frontend/src/components/common/Modal/Modal.tsx b/frontend/src/components/common/Modal/Modal.tsx new file mode 100644 index 00000000..a912147a --- /dev/null +++ b/frontend/src/components/common/Modal/Modal.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import ModalProvider from "@contexts/ModalProvider"; + +import ModalBody from "@components/common/Modal/ModalBody/ModalBody"; +import ModalFooter from "@components/common/Modal/ModalFooter/ModalFooter"; +import ModalHeader from "@components/common/Modal/ModalHeader/ModalHeader"; + +import useModalControl from "@hooks/useModalControl"; + +import * as S from "./Modal.style"; + +interface ModalProps extends React.PropsWithChildren { + onCloseModal: () => void; + isOpen: boolean; +} + +const Modal = ({ children, onCloseModal, isOpen }: ModalProps) => { + useModalControl(isOpen, onCloseModal); + + return ReactDOM.createPortal( + + + + {children} + + , + document.querySelector("#root") as HTMLElement, + ); +}; + +export default Modal; + +Modal.Header = ModalHeader; +Modal.Body = ModalBody; +Modal.Footer = ModalFooter; diff --git a/frontend/src/components/common/Modal/ModalBody/ModalBody.styled.ts b/frontend/src/components/common/Modal/ModalBody/ModalBody.styled.ts new file mode 100644 index 00000000..6bcde86a --- /dev/null +++ b/frontend/src/components/common/Modal/ModalBody/ModalBody.styled.ts @@ -0,0 +1,23 @@ +import styled from "@emotion/styled"; + +export const Layout = styled.div<{ $direction: React.CSSProperties["flexDirection"] }>` + display: flex; + flex: 1; + flex-direction: ${({ $direction }) => $direction}; + justify-content: center; + align-items: center; + width: 100%; + overflow-y: auto; + scrollbar-width: thin; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + border-radius: ${({ theme }) => theme.spacing.s}; + + background-color: ${({ theme }) => theme.colors.border}; + } +`; diff --git a/frontend/src/components/common/Modal/ModalBody/ModalBody.tsx b/frontend/src/components/common/Modal/ModalBody/ModalBody.tsx new file mode 100644 index 00000000..ca320f80 --- /dev/null +++ b/frontend/src/components/common/Modal/ModalBody/ModalBody.tsx @@ -0,0 +1,17 @@ +import { HTMLAttributes } from "react"; + +import * as S from "./ModalBody.styled"; + +interface ModalBodyProps extends React.PropsWithChildren, HTMLAttributes { + direction?: React.CSSProperties["flexDirection"]; +} + +const ModalBody = ({ children, direction = "row", ...props }: ModalBodyProps) => { + return ( + + {children} + + ); +}; + +export default ModalBody; diff --git a/frontend/src/components/common/Modal/ModalFooter/ModalFooter.styled.ts b/frontend/src/components/common/Modal/ModalFooter/ModalFooter.styled.ts new file mode 100644 index 00000000..b53999a2 --- /dev/null +++ b/frontend/src/components/common/Modal/ModalFooter/ModalFooter.styled.ts @@ -0,0 +1,8 @@ +import styled from "@emotion/styled"; + +export const Layout = styled.div<{ $direction: React.CSSProperties["flexDirection"] }>` + display: flex; + flex-direction: ${({ $direction }) => $direction}; + width: 100%; + padding: ${({ theme }) => theme.spacing.m}; +`; diff --git a/frontend/src/components/common/Modal/ModalFooter/ModalFooter.tsx b/frontend/src/components/common/Modal/ModalFooter/ModalFooter.tsx new file mode 100644 index 00000000..07e02d67 --- /dev/null +++ b/frontend/src/components/common/Modal/ModalFooter/ModalFooter.tsx @@ -0,0 +1,11 @@ +import * as S from "./ModalFooter.styled"; + +export interface ModalFooterProps extends React.PropsWithChildren { + direction?: React.CSSProperties["flexDirection"]; +} + +const ModalFooter = ({ children, direction = "row" }: ModalFooterProps) => { + return {children}; +}; + +export default ModalFooter; diff --git a/frontend/src/components/common/Modal/ModalHeader/ModalHeader.styled.ts b/frontend/src/components/common/Modal/ModalHeader/ModalHeader.styled.ts new file mode 100644 index 00000000..cf3f6ec9 --- /dev/null +++ b/frontend/src/components/common/Modal/ModalHeader/ModalHeader.styled.ts @@ -0,0 +1,14 @@ +import styled from "@emotion/styled"; + +export const Layout = styled.header` + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.6rem; +`; + +export const TitleWrapper = styled.div` + flex-grow: 1; + + text-align: center; +`; diff --git a/frontend/src/components/common/Modal/ModalHeader/ModalHeader.tsx b/frontend/src/components/common/Modal/ModalHeader/ModalHeader.tsx new file mode 100644 index 00000000..03569599 --- /dev/null +++ b/frontend/src/components/common/Modal/ModalHeader/ModalHeader.tsx @@ -0,0 +1,31 @@ +import { useModalContext } from "@contexts/ModalProvider"; + +import IconButton from "@components/common/IconButton/IconButton"; + +import * as S from "./ModalHeader.styled"; + +interface ModalHeaderProps extends React.PropsWithChildren { + hasCloseIcon?: boolean; + buttonPosition?: "left" | "right"; +} + +const ModalHeader = ({ + children, + hasCloseIcon = true, + buttonPosition = "right", +}: ModalHeaderProps) => { + const onCloseModal = useModalContext(); + return ( + + {buttonPosition === "left" && hasCloseIcon && ( + + )} + {children} + {buttonPosition === "right" && hasCloseIcon && ( + + )} + + ); +}; + +export default ModalHeader; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 90abbf8a..ceef520e 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -18,6 +18,5 @@ export { default as GoogleMapLoadScript } from "./GoogleMapLoadScript/GoogleMapL export { default as Text } from "./Text/Text"; export { default as Textarea } from "./Textarea/Textarea"; export { default as ThumbnailUpload } from "./ThumbnailUpload/ThumbnailUpload"; -export { default as DayContent } from "./DayContent/DayContent"; export { default as FallbackImage } from "./FallbackImage/FallbackImage"; export { default as AvatarCircle } from "./AvatarCircle/AvatarCircle"; diff --git a/frontend/src/components/pages/login/KakaoCallbackPage.tsx b/frontend/src/components/pages/login/KakaoCallbackPage.tsx index 64979fa4..b9e3d698 100644 --- a/frontend/src/components/pages/login/KakaoCallbackPage.tsx +++ b/frontend/src/components/pages/login/KakaoCallbackPage.tsx @@ -5,7 +5,9 @@ import { SaveUserContext } from "@contexts/UserProvider"; import { client } from "@apis/client"; -import { ROUTE_PATHS } from "@constants/route"; +import { API_ENDPOINT_MAP } from "@constants/endpoint"; +import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; +import { ROUTE_PATHS_MAP } from "@constants/route"; const KakaoCallbackPage = () => { const navigate = useNavigate(); @@ -22,15 +24,15 @@ const KakaoCallbackPage = () => { .post(`${ROUTE_PATHS.loginOauth}?code=${code}&redirectUri=${encodedRedirectUri}`) .then((res) => { saveUser(res.data); - navigate(ROUTE_PATHS.root); + navigate(ROUTE_PATHS_MAP.root); }) .catch(() => { - alert("로그인에 실패하였습니다. 다시 시도해주세요!"); - navigate(ROUTE_PATHS.login); + alert(ERROR_MESSAGE_MAP.loginFailed); + navigate(ROUTE_PATHS_MAP.login); }); } else { - alert("로그인에 실패하였습니다. 다시 시도해주세요!"); - navigate(ROUTE_PATHS.login); + alert(ERROR_MESSAGE_MAP.loginFailed); + navigate(ROUTE_PATHS_MAP.login); } }, [navigate]); diff --git a/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.tsx b/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.tsx index d591f4f2..6629f090 100644 --- a/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.tsx +++ b/frontend/src/components/pages/main/TravelogueCard/TravelogueCard.tsx @@ -6,6 +6,8 @@ import { AvatarCircle, FallbackImage } from "@components/common"; import useImageError from "@hooks/useImageError"; +import { ROUTE_PATHS_MAP } from "@constants/route"; + import { EmptyHeart } from "@assets/svg"; import * as S from "./TravelogueCard.styled"; @@ -21,7 +23,7 @@ const TravelogueCard = ({ const { imageError, handleImageError } = useImageError({ imageUrl: thumbnail }); const handleCardClick = () => { - navigate(`/travelogue/${id}`); + navigate(ROUTE_PATHS_MAP.travelogue(id)); }; const handleLikeClick = (e: React.MouseEvent) => { diff --git a/frontend/src/components/pages/travelPlanDetail/TravelPlansTabContent/TravelPlansTabContent.tsx b/frontend/src/components/pages/travelPlanDetail/TravelPlansTabContent/TravelPlansTabContent.tsx index 2a0e1271..3efd5b42 100644 --- a/frontend/src/components/pages/travelPlanDetail/TravelPlansTabContent/TravelPlansTabContent.tsx +++ b/frontend/src/components/pages/travelPlanDetail/TravelPlansTabContent/TravelPlansTabContent.tsx @@ -1,10 +1,10 @@ -import type { Place } from "@type/domain/travelogue"; +import type { TravelPlanPlace } from "@type/domain/travelPlan"; import { Box, GoogleMapLoadScript, GoogleMapView } from "@components/common"; import * as S from "./TravelPlansTabContent.styled"; -const TravelPlansTabContent = ({ places }: { places: Place[] }) => { +const TravelPlansTabContent = ({ places }: { places: TravelPlanPlace[] }) => { if (places.length === 0) return null; const positions = places.map((place) => { diff --git a/frontend/src/components/common/DayContent/DayContent.tsx b/frontend/src/components/pages/travelPlanRegister/TravelPlanDayAccordion/TravelPlanDayAccordion.tsx similarity index 70% rename from frontend/src/components/common/DayContent/DayContent.tsx rename to frontend/src/components/pages/travelPlanRegister/TravelPlanDayAccordion/TravelPlanDayAccordion.tsx index ae487f91..b2787f48 100644 --- a/frontend/src/components/common/DayContent/DayContent.tsx +++ b/frontend/src/components/pages/travelPlanRegister/TravelPlanDayAccordion/TravelPlanDayAccordion.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import type { TravelRegisterDay, TravelRegisterPlace } from "@type/domain/travelogue"; +import { TravelPlanDay, TravelPlanPlace } from "@type/domain/travelPlan"; import { Accordion, @@ -10,19 +10,10 @@ import { Textarea, } from "@components/common"; -import * as S from "../../pages/travelogueRegister/TravelogueRegisterPage.styled"; +import * as S from "../TravelPlanRegisterPage.styled"; -const DayContent = ({ - children, - travelDay, - dayIndex, - onDeleteDay, - onDeletePlace, - onChangePlaceDescription, - onAddPlace, -}: { - children?: (placeIndex: number, previewUrls: { url: string }[]) => JSX.Element; - travelDay: TravelRegisterDay; +interface TravelPlanDayAccordionProps { + travelPlanDay: TravelPlanDay; dayIndex: number; onDeleteDay: (dayIndex: number) => void; onDeletePlace: (dayIndex: number, placeIndex: number) => void; @@ -31,12 +22,21 @@ const DayContent = ({ dayIndex: number, placeIndex: number, ) => void; - onAddPlace: (dayIndex: number, travelParams: TravelRegisterPlace) => void; -}) => { + onAddPlace: (dayIndex: number, travelParams: TravelPlanPlace) => void; +} + +const TravelPlanDayAccordion = ({ + travelPlanDay, + dayIndex, + onDeleteDay, + onDeletePlace, + onChangePlaceDescription, + onAddPlace, +}: TravelPlanDayAccordionProps) => { const [isPopupOpen, setIsPopupOpen] = useState(false); const onSelectSearchResult = ( - placeInfo: Pick, + placeInfo: Pick, dayIndex: number, ) => { onAddPlace(dayIndex, placeInfo); @@ -48,30 +48,29 @@ const DayContent = ({ }; return ( - + onDeleteDay(dayIndex)}> {`Day ${dayIndex + 1}`} ({ + places={travelPlanDay.places.map((place) => ({ lat: Number(place.position.lat), lng: Number(place.position.lng), }))} /> - {travelDay.places.map((place, placeIndex) => ( - + {travelPlanDay.places.map((place, placeIndex) => ( + onDeletePlace(dayIndex, placeIndex)}> {place.placeName || `장소 ${placeIndex + 1}`} - {children && children(placeIndex, place?.photoUrls ?? [])} onChangePlaceDescription(e, dayIndex, placeIndex)} - count={travelDay.places[placeIndex].description?.length ?? 0} + count={place.description?.length ?? 0} maxLength={300} maxCount={300} /> @@ -99,4 +98,4 @@ const DayContent = ({ ); }; -export default DayContent; +export default TravelPlanDayAccordion; diff --git a/frontend/src/components/pages/travelPlanRegister/TravelPlanRegisterPage.tsx b/frontend/src/components/pages/travelPlanRegister/TravelPlanRegisterPage.tsx index 3bf2796e..34977a04 100644 --- a/frontend/src/components/pages/travelPlanRegister/TravelPlanRegisterPage.tsx +++ b/frontend/src/components/pages/travelPlanRegister/TravelPlanRegisterPage.tsx @@ -8,7 +8,6 @@ import { differenceInDays } from "date-fns"; import { Accordion, Button, - DayContent, GoogleMapLoadScript, IconButton, Input, @@ -16,23 +15,34 @@ import { PageInfo, } from "@components/common"; import DateRangePicker from "@components/common/DateRangePicker/DateRangePicker"; +import TravelPlanDayAccordion from "@components/pages/travelPlanRegister/TravelPlanDayAccordion/TravelPlanDayAccordion"; -import { useTravelDays } from "@hooks/pages/useTravelDays"; +import { useTravelPlanDays } from "@hooks/pages/useTravelPlanDays"; import useUser from "@hooks/useUser"; +import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; +import { ROUTE_PATHS_MAP } from "@constants/route"; + import * as S from "./TravelPlanRegisterPage.styled"; +const MIN_TITLE_LENGTH = 0; const MAX_TITLE_LENGTH = 20; const TravelPlanRegisterPage = () => { - const { transformDetail } = useTravelTransformDetailContext(); + const { transformDetail, saveTransformDetail } = useTravelTransformDetailContext(); const [title, setTitle] = useState(""); const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); - const { travelDays, onAddDay, onAddPlace, onDeleteDay, onChangePlaceDescription, onDeletePlace } = - useTravelDays(transformDetail?.days ?? []); + const { + travelPlanDays, + onAddDay, + onAddPlace, + onDeleteDay, + onChangePlaceDescription, + onDeletePlace, + } = useTravelPlanDays(transformDetail?.days ?? []); useEffect(() => { if (startDate && endDate) { @@ -51,7 +61,8 @@ const TravelPlanRegisterPage = () => { }; const handleChangeTitle = (e: React.ChangeEvent) => { - setTitle(e.target.value); + const title = e.target.value.slice(MIN_TITLE_LENGTH, MAX_TITLE_LENGTH); + setTitle(title); }; const [isOpen, setIsOpen] = useState(false); @@ -72,11 +83,11 @@ const TravelPlanRegisterPage = () => { const formattedStartDate = startDate.toISOString().split("T")[0]; handleAddTravelPlan( - { title, startDate: formattedStartDate, days: travelDays }, + { title, startDate: formattedStartDate, days: travelPlanDays }, { - onSuccess: ({ data }) => { + onSuccess: ({ data: { id } }) => { handleCloseBottomSheet(); - navigate(`/travel-plans/${data.id}`); + navigate(ROUTE_PATHS_MAP.travelPlan(id)); }, }, ); @@ -89,10 +100,13 @@ const TravelPlanRegisterPage = () => { useEffect(() => { if (!user?.accessToken) { - alert("로그인이 필요합니다."); - navigate("/login"); + alert(ERROR_MESSAGE_MAP.api.login); + navigate(ROUTE_PATHS_MAP.login); } - }, [user?.accessToken, navigate]); + return () => { + saveTransformDetail(null); + }; + }, [user?.accessToken, navigate, saveTransformDetail]); return ( <> @@ -117,10 +131,10 @@ const TravelPlanRegisterPage = () => { - {travelDays.map((travelDay, dayIndex) => ( - ( + { )} /> onTransformTravelDetail("/travel-plans/register", data)} + onTransform={() => onTransformTravelDetail("/travel-plan/register", data)} buttonLabel="여행 계획으로 전환" > 이 여행기를 따라가고 싶으신가요? diff --git a/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/TravelogueTabContent.tsx b/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/TravelogueTabContent.tsx index 7515f4da..2a319cfa 100644 --- a/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/TravelogueTabContent.tsx +++ b/frontend/src/components/pages/travelogueDetail/TravelogueTabContent/TravelogueTabContent.tsx @@ -1,13 +1,13 @@ import { css } from "@emotion/react"; -import type { Place } from "@type/domain/travelogue"; +import type { TraveloguePlace } from "@type/domain/travelogue"; import { Box, GoogleMapLoadScript, GoogleMapView } from "@components/common"; import PlaceDetailCard from "@components/pages/travelogueDetail/TravelogueTabContent/PlaceDetailCard/PlaceDetailCard"; import * as S from "./TravelogueTabContent.styled"; -const TravelogueTabContent = ({ places }: { places: Place[] }) => { +const TravelogueTabContent = ({ places }: { places: TraveloguePlace[] }) => { if (places.length === 0) return null; return ( diff --git a/frontend/src/components/pages/travelogueRegister/TravelogueDayAccordion/TravelogueDayAccordion.tsx b/frontend/src/components/pages/travelogueRegister/TravelogueDayAccordion/TravelogueDayAccordion.tsx new file mode 100644 index 00000000..24617f0b --- /dev/null +++ b/frontend/src/components/pages/travelogueRegister/TravelogueDayAccordion/TravelogueDayAccordion.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; + +import { MutateOptions } from "@tanstack/react-query"; + +import type { TravelogueDay, TraveloguePlace } from "@type/domain/travelogue"; + +import { + Accordion, + GoogleMapView, + GoogleSearchPopup, + IconButton, + Textarea, +} from "@components/common"; +import TravelogueMultiImageUpload from "@components/pages/travelogueRegister/TravelogueMultiImageUpload/TravelogueMultiImageUpload"; + +import * as S from "../TravelogueRegisterPage.styled"; + +interface TravelogueDayAccordionProps { + travelogueDay: TravelogueDay; + dayIndex: number; + onDeleteDay: (dayIndex: number) => void; + onDeletePlace: (dayIndex: number, placeIndex: number) => void; + onChangePlaceDescription: ( + e: React.ChangeEvent, + dayIndex: number, + placeIndex: number, + ) => void; + onAddPlace: (dayIndex: number, traveloguePlace: TraveloguePlace) => void; + onChangeImageUrls: (dayIndex: number, placeIndex: number, imgUrls: string[]) => void; + onDeleteImageUrls: (dayIndex: number, targetPlaceIndex: number, imageIndex: number) => void; + onRequestAddImage: ( + variables: File[], + options?: MutateOptions | undefined, + ) => Promise; +} + +const TravelogueDayAccordion = ({ + travelogueDay, + dayIndex, + onAddPlace, + onDeleteDay, + onDeletePlace, + onChangePlaceDescription, + onChangeImageUrls, + onDeleteImageUrls, + onRequestAddImage, +}: TravelogueDayAccordionProps) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const onSelectSearchResult = ( + placeInfo: Pick, + dayIndex: number, + ) => { + onAddPlace(dayIndex, placeInfo); + setIsPopupOpen(false); + }; + + const onClickAddPlaceButton = () => { + setIsPopupOpen(true); + }; + + return ( + + onDeleteDay(dayIndex)}> + {`Day ${dayIndex + 1}`} + + + + ({ + lat: Number(place.position.lat), + lng: Number(place.position.lng), + }))} + /> + {travelogueDay.places.map((place, placeIndex) => ( + + onDeletePlace(dayIndex, placeIndex)}> + {place.placeName || `장소 ${placeIndex + 1}`} + + + + onChangePlaceDescription(e, dayIndex, placeIndex)} + count={travelogueDay.places[placeIndex].description?.length ?? 0} + maxLength={300} + maxCount={300} + /> + + + ))} + + + 장소 추가하기 + + + {isPopupOpen && ( + setIsPopupOpen(false)} + onSearchPlaceInfo={(placeInfo) => onSelectSearchResult(placeInfo, dayIndex)} + /> + )} + + ); +}; + +export default TravelogueDayAccordion; diff --git a/frontend/src/components/pages/travelogueRegister/TravelogueMultiImageUpload/TravelogueMultiImageUpload.tsx b/frontend/src/components/pages/travelogueRegister/TravelogueMultiImageUpload/TravelogueMultiImageUpload.tsx index a43c85c4..681f28ab 100644 --- a/frontend/src/components/pages/travelogueRegister/TravelogueMultiImageUpload/TravelogueMultiImageUpload.tsx +++ b/frontend/src/components/pages/travelogueRegister/TravelogueMultiImageUpload/TravelogueMultiImageUpload.tsx @@ -14,7 +14,7 @@ const TravelogueMultiImageUpload = ({ onChangeImageUrls, onDeleteImageUrls, }: { - imageUrls: { url: string }[]; + imageUrls: string[]; dayIndex: number; placeIndex: number; onRequestAddImage: ( @@ -32,7 +32,7 @@ const TravelogueMultiImageUpload = ({ return ( url)} + previewUrls={imageUrls} fileInputRef={fileInputRef} onImageChange={async (e) => { const files = Array.from(e.target.files as FileList); diff --git a/frontend/src/components/pages/travelogueRegister/TravelogueRegisterPage.tsx b/frontend/src/components/pages/travelogueRegister/TravelogueRegisterPage.tsx index 406c207d..62fc138c 100644 --- a/frontend/src/components/pages/travelogueRegister/TravelogueRegisterPage.tsx +++ b/frontend/src/components/pages/travelogueRegister/TravelogueRegisterPage.tsx @@ -9,7 +9,6 @@ import { usePostTravelogue, usePostUploadImages } from "@queries/index"; import { Accordion, Button, - DayContent, GoogleMapLoadScript, IconButton, Input, @@ -18,13 +17,17 @@ import { Text, ThumbnailUpload, } from "@components/common"; -import TravelogueMultiImageUpload from "@components/pages/travelogueRegister/TravelogueMultiImageUpload/TravelogueMultiImageUpload"; +import TravelogueDayAccordion from "@components/pages/travelogueRegister/TravelogueDayAccordion/TravelogueDayAccordion"; -import { useTravelDays } from "@hooks/pages/useTravelDays"; +import { useTravelogueDays } from "@hooks/pages/useTravelogueDays"; import useUser from "@hooks/useUser"; +import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; +import { ROUTE_PATHS_MAP } from "@constants/route"; + import * as S from "./TravelogueRegisterPage.styled"; +const MIN_TITLE_LENGTH = 0; const MAX_TITLE_LENGTH = 20; const TravelogueRegisterPage = () => { @@ -34,11 +37,12 @@ const TravelogueRegisterPage = () => { const [thumbnail, setThumbnail] = useState(""); const handleChangeTitle = (e: React.ChangeEvent) => { - setTitle(e.target.value); + const title = e.target.value.slice(MIN_TITLE_LENGTH, MAX_TITLE_LENGTH); + setTitle(title); }; const { - travelDays, + travelogueDays, onAddDay, onAddPlace, onDeleteDay, @@ -46,7 +50,7 @@ const TravelogueRegisterPage = () => { onDeletePlace, onChangeImageUrls, onDeleteImageUrls, - } = useTravelDays(transformDetail?.days ?? []); + } = useTravelogueDays(transformDetail?.days ?? []); const thumbnailFileInputRef = useRef(null); @@ -75,11 +79,11 @@ const TravelogueRegisterPage = () => { const handleConfirmBottomSheet = () => { handleRegisterTravelogue( - { title, thumbnail, days: travelDays }, + { title, thumbnail, days: travelogueDays }, { - onSuccess: ({ data }) => { + onSuccess: ({ data: { id } }) => { handleCloseBottomSheet(); - navigate(`/travelogue/${data.id}`); + navigate(ROUTE_PATHS_MAP.travelogue(id)); }, }, ); @@ -89,11 +93,17 @@ const TravelogueRegisterPage = () => { const { user } = useUser(); + const { saveTransformDetail } = useTravelTransformDetailContext(); + useEffect(() => { if (!user?.accessToken) { - alert("로그인이 필요합니다."); - navigate("/login"); + alert(ERROR_MESSAGE_MAP.api.login); + navigate(ROUTE_PATHS_MAP.login); } + + return () => { + saveTransformDetail(null); + }; }, [user?.accessToken, navigate]); return ( @@ -127,27 +137,19 @@ const TravelogueRegisterPage = () => { - {travelDays.map((travelDay, dayIndex) => ( - ( + - {(placeIndex, imgUrls) => ( - - )} - + onChangeImageUrls={onChangeImageUrls} + onDeleteImageUrls={onDeleteImageUrls} + onRequestAddImage={handleAddImage} + /> ))} `/login/oauth/kakao?code=${code}`, + travelogueDetail: (id: number) => `/travelogues/${id}`, + travelPlanDetail: (id: number) => `travel-plans/${id}`, + travelogues: "/travelogues", + travelPlans: "/travel-plans", + image: "/image", +} as const; diff --git a/frontend/src/constants/errorMessage.ts b/frontend/src/constants/errorMessage.ts new file mode 100644 index 00000000..683e4d17 --- /dev/null +++ b/frontend/src/constants/errorMessage.ts @@ -0,0 +1,7 @@ +export const ERROR_MESSAGE_MAP = { + api: { + login: "로그인을 해주세요.", + }, + loginFailed: "로그인에 실패하였습니다. 다시 시도해주세요!", + provider: "provider 바깥에 존재합니다!", +} as const; diff --git a/frontend/src/constants/httpStatusCode.ts b/frontend/src/constants/httpStatusCode.ts new file mode 100644 index 00000000..69cb242c --- /dev/null +++ b/frontend/src/constants/httpStatusCode.ts @@ -0,0 +1,7 @@ +export const HTTP_STATUS_CODE_MAP = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + INTERNAL_SERVER_ERROR: 500, +} as const; diff --git a/frontend/src/constants/queryKey.ts b/frontend/src/constants/queryKey.ts new file mode 100644 index 00000000..1a1443e9 --- /dev/null +++ b/frontend/src/constants/queryKey.ts @@ -0,0 +1,10 @@ +export const QUERY_KEYS_MAP = { + travelogue: { + all: ["travelogues"], + detail: (id: string) => [...QUERY_KEYS_MAP.travelogue.all, id], + }, + travelPlan: { + all: ["travel-plans"], + detail: (id: string) => [...QUERY_KEYS_MAP.travelPlan.all, id], + }, +}; diff --git a/frontend/src/constants/route.ts b/frontend/src/constants/route.ts index cfdf4838..cf27bcf6 100644 --- a/frontend/src/constants/route.ts +++ b/frontend/src/constants/route.ts @@ -1,9 +1,10 @@ -export const ROUTE_PATHS = { +export const ROUTE_PATHS_MAP = { + back: -1, root: "/", - travelogue: "/travelogue/:id", + travelogue: (id?: number) => (id ? `/travelogue/${id}` : "/travelogue/:id"), + travelPlan: (id?: number) => (id ? `/travel-plan/${id}` : "/travel-plan/:id"), travelogueRegister: "/travelogue/register", - travelPlans: "/travel-plans/:id", - travelPlansRegister: "/travel-plans/register", + travelPlanRegister: "/travel-plan/register", login: "/login", loginCallback: "/oauth", loginOauth: "/login/oauth/kakao", diff --git a/frontend/src/constants/storage.ts b/frontend/src/constants/storage.ts new file mode 100644 index 00000000..0a77a4aa --- /dev/null +++ b/frontend/src/constants/storage.ts @@ -0,0 +1,3 @@ +export const STORAGE_KEYS_MAP = { + user: "tourootUser", +} as const; diff --git a/frontend/src/contexts/ModalProvider.tsx b/frontend/src/contexts/ModalProvider.tsx new file mode 100644 index 00000000..53d932d0 --- /dev/null +++ b/frontend/src/contexts/ModalProvider.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext } from "react"; + +import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; + +export const OnCloseModalContext = createContext(() => {}); + +export interface UserProviderProps extends React.PropsWithChildren { + onCloseModal: () => void; +} + +const ModalProvider = ({ children, onCloseModal }: UserProviderProps) => { + return ( + {children} + ); +}; + +export default ModalProvider; + +export const useModalContext = () => { + const onCloseModal = useContext(OnCloseModalContext); + + if (!onCloseModal) throw new Error(ERROR_MESSAGE_MAP.provider); + + return onCloseModal; +}; diff --git a/frontend/src/contexts/TravelTransformDetailProvider.tsx b/frontend/src/contexts/TravelTransformDetailProvider.tsx index 4f1e0e6c..e59636e8 100644 --- a/frontend/src/contexts/TravelTransformDetailProvider.tsx +++ b/frontend/src/contexts/TravelTransformDetailProvider.tsx @@ -7,15 +7,20 @@ import type { TravelTransformDetail } from "@type/domain/travelTransform"; import useUser from "@hooks/useUser"; +import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; +import { ROUTE_PATHS_MAP } from "@constants/route"; + const TravelogueContext = createContext(null); -const SaveTravelogueContext = createContext<(travelogue: TravelTransformDetail) => void>(() => {}); +const SaveTravelogueContext = createContext<(travelogue: TravelTransformDetail | null) => void>( + () => {}, +); export const TravelTransformDetailProvider = ({ children }: React.PropsWithChildren) => { const [travelTransformDetail, setTravelTransformDetail] = useState( null, ); - const saveTravelTransformDetail = (transformDetail: TravelTransformDetail) => { + const saveTravelTransformDetail = (transformDetail: TravelTransformDetail | null) => { setTravelTransformDetail(transformDetail); }; @@ -40,13 +45,13 @@ export const useTravelTransformDetailContext = () => { travelTransformDetail?: TravelTransformDetail, ) => { if (isEmptyObject(user ?? {})) { - alert("로그인 후 이용이 가능합니다."); - navigate("/login"); + alert(ERROR_MESSAGE_MAP.api.login); + navigate(ROUTE_PATHS_MAP.login); } else if (travelTransformDetail) { saveTransformDetail(travelTransformDetail); navigate(redirectUrl); } }; - return { transformDetail, onTransformTravelDetail }; + return { transformDetail, saveTransformDetail, onTransformTravelDetail }; }; diff --git a/frontend/src/contexts/UserProvider.tsx b/frontend/src/contexts/UserProvider.tsx index 332b8fd2..d099d502 100644 --- a/frontend/src/contexts/UserProvider.tsx +++ b/frontend/src/contexts/UserProvider.tsx @@ -1,25 +1,27 @@ import { createContext, useState } from "react"; -import type { User } from "@type/domain/user"; +import type { UserResponse } from "@type/domain/user"; + +import { STORAGE_KEYS_MAP } from "@constants/storage"; export interface UserContextProps { - user: User | null; + user: UserResponse | null; } export interface SaveUserContextProps { - saveUser: (userInfo: User) => void; + saveUser: (userInfo: UserResponse) => void; } export const UserContext = createContext({} as UserContextProps); export const SaveUserContext = createContext({} as SaveUserContextProps); const UserProvider = ({ children }: React.PropsWithChildren) => { - const [user, setUser] = useState( - () => JSON.parse(localStorage.getItem("tourootUser") ?? "{}") ?? null, + const [user, setUser] = useState( + () => JSON.parse(localStorage.getItem(STORAGE_KEYS_MAP.user) ?? "{}") ?? null, ); - const saveUser = (user: User) => { - localStorage.setItem("tourootUser", JSON.stringify(user ?? {})); + const saveUser = (user: UserResponse) => { + localStorage.setItem(STORAGE_KEYS_MAP.user, JSON.stringify(user ?? {})); setUser(user); }; return ( diff --git a/frontend/src/hooks/pages/useTravelPlanDays.ts b/frontend/src/hooks/pages/useTravelPlanDays.ts new file mode 100644 index 00000000..fc1564a4 --- /dev/null +++ b/frontend/src/hooks/pages/useTravelPlanDays.ts @@ -0,0 +1,67 @@ +import { useCallback, useState } from "react"; + +import type { TravelPlanDay, TravelPlanPlace } from "@type/domain/travelPlan"; +import type { TravelTransformPlaces } from "@type/domain/travelTransform"; + +const MIN_DESCRIPTION_LENGTH = 0; +const MAX_DESCRIPTION_LENGTH = 300; + +export const useTravelPlanDays = (days: TravelTransformPlaces[]) => { + const [travelPlanDays, setTravelPlanDays] = useState(days); + + const onAddDay = useCallback((dayIndex?: number) => { + setTravelPlanDays((prevTravelDays) => + dayIndex + ? Array.from({ length: dayIndex }, () => ({ places: [] })) + : [...prevTravelDays, { places: [] }], + ); + }, []); + + const onDeleteDay = (targetDayIndex: number) => { + setTravelPlanDays((prevTravelDays) => + prevTravelDays.filter((_, dayIndex) => dayIndex !== targetDayIndex), + ); + }; + + const onAddPlace = (dayIndex: number, travelParams: TravelPlanPlace) => { + setTravelPlanDays((prevTravelDays) => { + const newTravelPlans = [...prevTravelDays]; + newTravelPlans[dayIndex].places.push(travelParams); + return newTravelPlans; + }); + }; + + const onDeletePlace = (dayIndex: number, placeIndex: number) => { + setTravelPlanDays((prevTravelDays) => { + const newTravelPlans = [...prevTravelDays]; + newTravelPlans[dayIndex] = { + ...newTravelPlans[dayIndex], + places: newTravelPlans[dayIndex].places.filter((_, index) => index !== placeIndex), + }; + + return newTravelPlans; + }); + }; + + const onChangePlaceDescription = ( + e: React.ChangeEvent, + dayIndex: number, + placeIndex: number, + ) => { + const newTravelPlans = [...travelPlanDays]; + newTravelPlans[dayIndex].places[placeIndex].description = e.target.value.slice( + MIN_DESCRIPTION_LENGTH, + MAX_DESCRIPTION_LENGTH, + ); + setTravelPlanDays(newTravelPlans); + }; + + return { + travelPlanDays, + onAddDay, + onDeleteDay, + onAddPlace, + onDeletePlace, + onChangePlaceDescription, + }; +}; diff --git a/frontend/src/hooks/pages/useTravelDays.ts b/frontend/src/hooks/pages/useTravelogueDays.ts similarity index 57% rename from frontend/src/hooks/pages/useTravelDays.ts rename to frontend/src/hooks/pages/useTravelogueDays.ts index 8426260c..4c9258a9 100644 --- a/frontend/src/hooks/pages/useTravelDays.ts +++ b/frontend/src/hooks/pages/useTravelogueDays.ts @@ -1,13 +1,16 @@ import { useCallback, useState } from "react"; import type { TravelTransformPlaces } from "@type/domain/travelTransform"; -import type { TravelRegisterDay, TravelRegisterPlace } from "@type/domain/travelogue"; +import type { TravelogueDay, TraveloguePlace } from "@type/domain/travelogue"; -export const useTravelDays = (days: TravelTransformPlaces[]) => { - const [travelDays, setTravelDays] = useState(days); +const MIN_DESCRIPTION_LENGTH = 0; +const MAX_DESCRIPTION_LENGTH = 300; + +export const useTravelogueDays = (days: TravelTransformPlaces[]) => { + const [travelogueDays, setTravelogueDays] = useState(days); const onAddDay = useCallback((dayIndex?: number) => { - setTravelDays((prevTravelDays) => + setTravelogueDays((prevTravelDays) => dayIndex ? Array.from({ length: dayIndex }, () => ({ places: [] })) : [...prevTravelDays, { places: [] }], @@ -15,28 +18,28 @@ export const useTravelDays = (days: TravelTransformPlaces[]) => { }, []); const onDeleteDay = (targetDayIndex: number) => { - setTravelDays((prevTravelDays) => + setTravelogueDays((prevTravelDays) => prevTravelDays.filter((_, dayIndex) => dayIndex !== targetDayIndex), ); }; - const onAddPlace = (dayIndex: number, travelParams: TravelRegisterPlace) => { - setTravelDays((prevTravelDays) => { - const newTravelDays = [...prevTravelDays]; - newTravelDays[dayIndex].places.push(travelParams); - return newTravelDays; + const onAddPlace = (dayIndex: number, traveloguePlace: TraveloguePlace) => { + setTravelogueDays((prevTravelDays) => { + const newTraveloguePlaces = [...prevTravelDays]; + newTraveloguePlaces[dayIndex].places.push(traveloguePlace); + return newTraveloguePlaces; }); }; const onDeletePlace = (dayIndex: number, placeIndex: number) => { - setTravelDays((prevTravelDays) => { - const newTravelDays = [...prevTravelDays]; - newTravelDays[dayIndex] = { - ...newTravelDays[dayIndex], - places: newTravelDays[dayIndex].places.filter((_, index) => index !== placeIndex), + setTravelogueDays((prevTravelDays) => { + const newTraveloguePlaces = [...prevTravelDays]; + newTraveloguePlaces[dayIndex] = { + ...newTraveloguePlaces[dayIndex], + places: newTraveloguePlaces[dayIndex].places.filter((_, index) => index !== placeIndex), }; - return newTravelDays; + return newTraveloguePlaces; }); }; @@ -45,13 +48,16 @@ export const useTravelDays = (days: TravelTransformPlaces[]) => { dayIndex: number, placeIndex: number, ) => { - const newTravelDays = [...travelDays]; - newTravelDays[dayIndex].places[placeIndex].description = e.target.value; - setTravelDays(newTravelDays); + const newTraveloguePlaces = [...travelogueDays]; + newTraveloguePlaces[dayIndex].places[placeIndex].description = e.target.value.slice( + MIN_DESCRIPTION_LENGTH, + MAX_DESCRIPTION_LENGTH, + ); + setTravelogueDays(newTraveloguePlaces); }; const onChangeImageUrls = (dayIndex: number, placeIndex: number, imgUrls: string[]) => - setTravelDays((prevTravelDays) => + setTravelogueDays((prevTravelDays) => prevTravelDays.map((day, dIndex) => { if (dIndex !== dayIndex) return day; @@ -62,10 +68,7 @@ export const useTravelDays = (days: TravelTransformPlaces[]) => { return { ...place, - photoUrls: [ - ...(place.photoUrls || []), - ...imgUrls.map((imgUrl) => ({ url: imgUrl })), - ], + photoUrls: [...(place.photoUrls || []), ...imgUrls], }; }), }; @@ -73,7 +76,7 @@ export const useTravelDays = (days: TravelTransformPlaces[]) => { ); const onDeleteImageUrls = (dayIndex: number, targetPlaceIndex: number, imageIndex: number) => - setTravelDays((prevTravelDays) => { + setTravelogueDays((prevTravelDays) => { return prevTravelDays.map((day, dIndex) => { if (dIndex !== dayIndex) return day; @@ -94,7 +97,7 @@ export const useTravelDays = (days: TravelTransformPlaces[]) => { }); return { - travelDays, + travelogueDays, onAddDay, onDeleteDay, onAddPlace, diff --git a/frontend/src/hooks/useModalControl.ts b/frontend/src/hooks/useModalControl.ts new file mode 100644 index 00000000..ec5ebbe7 --- /dev/null +++ b/frontend/src/hooks/useModalControl.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import usePressESC from '@hooks/usePressESC'; + +/** + * modal control에 관한 훅입니다. + * 1. 사용자가 esc를 눌렀을 때 모달이 닫힌다. + * 2. 사용자가 모달을 열었을 때 외부 스크롤을 하지 못하도록 막는다. + */ +const useModalControl = void>(isOpen: boolean, onToggle: T) => { + usePressESC(isOpen, onToggle); + + useEffect(() => { + if (isOpen) document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = 'auto'; + }; + }, [isOpen]); +}; + +export default useModalControl; diff --git a/frontend/src/hooks/usePressESC.ts b/frontend/src/hooks/usePressESC.ts new file mode 100644 index 00000000..a32f256e --- /dev/null +++ b/frontend/src/hooks/usePressESC.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react'; + +const usePressESC = void>(condition: boolean, callback: T) => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') callback(); + }; + + if (condition) document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [condition, callback]); +}; + +export default usePressESC; diff --git a/frontend/src/hooks/useUser.ts b/frontend/src/hooks/useUser.ts index f0113a1b..7c383595 100644 --- a/frontend/src/hooks/useUser.ts +++ b/frontend/src/hooks/useUser.ts @@ -2,11 +2,13 @@ import { useContext } from "react"; import { SaveUserContext, UserContext } from "@contexts/UserProvider"; +import { ERROR_MESSAGE_MAP } from "@constants/errorMessage"; + const useUser = () => { const user = useContext(UserContext); const saveUser = useContext(SaveUserContext); - if (!user || !saveUser) throw new Error("Provider 바깥에 존재합니다!"); + if (!user || !saveUser) throw new Error(ERROR_MESSAGE_MAP.provider); return { ...user, diff --git a/frontend/src/mocks/data/travelogue.json b/frontend/src/mocks/data/travelogue.json new file mode 100644 index 00000000..9070ee1b --- /dev/null +++ b/frontend/src/mocks/data/travelogue.json @@ -0,0 +1,638 @@ +[ + { + "id": 13, + "title": "리버의 ㅋㅋㄹ", + "thumbnail": "https://dev.touroot.kr/images/01630513-3942-4f98-ac6b-66df63a0d8d0.jpg", + "days": [ + { + "id": 26, + "places": [ + { + "id": 38, + "placeName": "ㅋㅋㄹ삥뽕김밥", + "description": "김밥....\n\n정말....대박적 맛있다....\n\n\n\n정말 최고...\n였...\n다......", + "position": { + "lat": "37.6375", + "lng": "126.663" + }, + "photoUrls": ["https://dev.touroot.kr/images/5213040f-430c-4f29-aba1-c24315991184.jpg"] + } + ] + } + ] + }, + { + "id": 12, + "title": "", + "thumbnail": "", + "days": [] + }, + { + "id": 11, + "title": "소렛의 도쿄여행💓", + "thumbnail": "https://dev.touroot.kr/images/d885b8e7-a521-401a-8f84-5de856df3996.jpeg", + "days": [ + { + "id": 25, + "places": [ + { + "id": 37, + "placeName": "도쿄", + "description": null, + "position": { + "lat": "35.6812", + "lng": "139.767" + }, + "photoUrls": ["https://dev.touroot.kr/images/4435a346-0cff-473c-a21c-8831a56bf49e.jpeg"] + } + ] + } + ] + }, + { + "id": 10, + "title": "솔라 여행기", + "thumbnail": "https://dev.touroot.kr/images/7d4f3aff-38ad-4a33-adc5-0b04bc19d8f7.webp", + "days": [ + { + "id": 16, + "places": [ + { + "id": 28, + "placeName": "에펠탑", + "description": "올림픽 한대요...30일 짜리 여행이면 Day추가가 귀찮을수도?", + "position": { + "lat": "48.8584", + "lng": "2.29448" + }, + "photoUrls": ["https://dev.touroot.kr/images/59e1699c-8092-4012-87d5-a686da952925.jpg"] + } + ] + }, + { + "id": 17, + "places": [ + { + "id": 29, + "placeName": "노트르담 대성당", + "description": null, + "position": { + "lat": "48.853", + "lng": "2.3499" + }, + "photoUrls": ["https://dev.touroot.kr/images/a8c6d973-85ca-4741-9d20-7940fdfe7b44.jpg"] + } + ] + }, + { + "id": 18, + "places": [ + { + "id": 30, + "placeName": "센 강", + "description": null, + "position": { + "lat": "48.6383", + "lng": "2.4489" + }, + "photoUrls": ["https://dev.touroot.kr/images/1bf4295c-3597-4c9e-a1b3-abe91c1898cc.png"] + } + ] + }, + { + "id": 19, + "places": [ + { + "id": 31, + "placeName": "오르세 미술관", + "description": null, + "position": { + "lat": "48.86", + "lng": "2.32656" + }, + "photoUrls": ["https://dev.touroot.kr/images/ac4f94d9-0c46-4f05-bb3a-67bcf06ba83f.png"] + } + ] + }, + { + "id": 20, + "places": [ + { + "id": 32, + "placeName": "루브르 박물관", + "description": null, + "position": { + "lat": "48.8606", + "lng": "2.33764" + }, + "photoUrls": ["https://dev.touroot.kr/images/87ee285f-d0ed-4575-964f-7e126145f054.png"] + } + ] + }, + { + "id": 21, + "places": [ + { + "id": 33, + "placeName": "몽파르나스타워", + "description": null, + "position": { + "lat": "48.8421", + "lng": "2.32195" + }, + "photoUrls": ["https://dev.touroot.kr/images/00c74d1b-1d8d-4661-a5ab-527146200ca0.jpg"] + } + ] + }, + { + "id": 22, + "places": [ + { + "id": 34, + "placeName": "튈르리정원", + "description": null, + "position": { + "lat": "36.0126", + "lng": "129.364" + }, + "photoUrls": ["https://dev.touroot.kr/images/1b0a55d7-2ae2-4339-9c18-80938e73fdd1.jpg"] + } + ] + }, + { + "id": 23, + "places": [ + { + "id": 35, + "placeName": "오랑주리 미술관", + "description": null, + "position": { + "lat": "48.8638", + "lng": "2.32267" + }, + "photoUrls": ["https://dev.touroot.kr/images/503ac4eb-0d4a-4c36-befe-5bac0d18af28.jpg"] + } + ] + }, + { + "id": 24, + "places": [ + { + "id": 36, + "placeName": "파리 샤를드골 국제공항", + "description": "집 가기 싫어요...", + "position": { + "lat": "49.0079", + "lng": "2.55079" + }, + "photoUrls": ["https://dev.touroot.kr/images/a5f3e510-9fc4-416f-8d2a-b5e5d41f79dc.png"] + } + ] + } + ] + }, + { + "id": 9, + "title": "지니의 여행기", + "thumbnail": "https://dev.touroot.kr/images/0d345f10-32ab-4b4b-aaad-cfaef6371fe9.jpg", + "days": [ + { + "id": 15, + "places": [ + { + "id": 26, + "placeName": "Dotonbori", + "description": "도톤보리 좋아요", + "position": { + "lat": "34.6686", + "lng": "135.503" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/f71e5182-32db-4b2d-be47-0353264686e5.jpg", + "https://dev.touroot.kr/images/b3e608a6-f1b7-47ae-a427-1c0b3c7b3ed6.jpg" + ] + }, + { + "id": 27, + "placeName": "Don Quijote Dotonbori", + "description": "돈키호테 좋아요", + "position": { + "lat": "34.6693", + "lng": "135.503" + }, + "photoUrls": ["https://dev.touroot.kr/images/9325a0ae-aa70-44c3-bed3-1de0997f5927.jpg"] + } + ] + } + ] + }, + { + "id": 8, + "title": "충북의 보물 단양 여행기", + "thumbnail": "https://dev.touroot.kr/images/6a74df25-e644-4255-9ad1-490d215e7b88.jpeg", + "days": [ + { + "id": 14, + "places": [ + { + "id": 23, + "placeName": "도담삼봉", + "description": "누가 만들어놓은 것 같이 참 이뿌네요. 한 폭의 그림 같은 느낌이랄까요", + "position": { + "lat": "37.0", + "lng": "128.344" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/18e7fb1a-9bdc-4745-b2b2-e883003bb272.jpg", + "https://dev.touroot.kr/images/3cd55b49-2cb6-4e1d-bf73-a35ca78dea3b.jpg", + "https://dev.touroot.kr/images/23bf1916-a9ac-4489-a908-1e98d381d8b8.jpg" + ] + }, + { + "id": 24, + "placeName": "만천하 스카이워크", + "description": "드높은 천상이 생각나는 아찔한 높이.. 올라가는게 힘들긴 합니다 ㅋ", + "position": { + "lat": "36.9803", + "lng": "128.339" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/f0420244-00aa-42c2-8b09-04cb18d54bb3.jpg", + "https://dev.touroot.kr/images/5ca1bef7-20d9-4140-abd6-92a12ffa2217.jpg", + "https://dev.touroot.kr/images/2b794311-685c-43d8-bdc0-2c6fcae9ed65.jpg" + ] + }, + { + "id": 25, + "placeName": "단양강 잔도", + "description": "제가 초한지를 좋아해서 잔도에 대한 로망이 있었는데 너무 좋았어용", + "position": { + "lat": "36.978", + "lng": "128.34" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/c4264f62-ff52-43d8-a5a5-89e7f096c88f.jpg", + "https://dev.touroot.kr/images/ad7b3840-f606-4182-a982-7391bad73b6d.jpg" + ] + } + ] + } + ] + }, + { + "id": 7, + "title": "허광한을 만나러 대만으로", + "thumbnail": "https://dev.touroot.kr/images/abcdc97d-10d3-467f-8421-b204d8ede346.jpeg", + "days": [ + { + "id": 12, + "places": [ + { + "id": 19, + "placeName": "홍마오청", + "description": "너무 이뻤던 홍마오청", + "position": { + "lat": "25.1754", + "lng": "121.433" + }, + "photoUrls": ["https://dev.touroot.kr/images/987a36e0-68f4-4a47-b0ae-c98bb11a8d58.jpeg"] + }, + { + "id": 20, + "placeName": "진과스 황금관", + "description": "홍등이 너무 이뻤다", + "position": { + "lat": "25.1063", + "lng": "121.859" + }, + "photoUrls": ["https://dev.touroot.kr/images/19f5646f-a75f-4eeb-bcb7-90cdd5f98457.jpeg"] + } + ] + }, + { + "id": 13, + "places": [ + { + "id": 21, + "placeName": "타이베이101 전망대", + "description": "엄청난 도시 같은 기분", + "position": { + "lat": "25.0337", + "lng": "121.565" + }, + "photoUrls": ["https://dev.touroot.kr/images/93196c49-e90d-4569-a87f-e7f3653f9fed.jpeg"] + }, + { + "id": 22, + "placeName": "키키레스토랑 (att 4 fun 지점)", + "description": "존맛탱구리리리집", + "position": { + "lat": "25.0355", + "lng": "121.566" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/136b4caa-e58b-425c-9b40-dadf86816660.jpeg", + "https://dev.touroot.kr/images/0313eff5-93e1-403a-84d8-6fe59c34129f.jpeg" + ] + } + ] + } + ] + }, + { + "id": 6, + "title": "살라메스즈베~~ 낙낙의 카작 여행기!", + "thumbnail": "https://dev.touroot.kr/images/f2346f3b-eb3a-4fdc-bb2c-808ddb42f530.JPG", + "days": [ + { + "id": 11, + "places": [ + { + "id": 16, + "placeName": "First President park", + "description": "그렇게 특별한 곳은 아니었지만, 공원이 엄청 넓었다!!\n산책로도 잘 되어 있고 특히 중간에 만난 청설모(?)가 넘나리 귀여웠다 ㅎㅎ\n그냥 가볼만한 정도인듯!", + "position": { + "lat": "43.1937", + "lng": "76.8868" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/9ee30c4b-a3d1-4782-a42b-ffd8bb793bb0.JPG", + "https://dev.touroot.kr/images/d18608ec-22ff-43cd-a02a-0ff8bdf279ac.JPG", + "https://dev.touroot.kr/images/a70fa145-5e17-4295-aedd-56c3d1164770.JPG", + "https://dev.touroot.kr/images/a32706fe-ae25-4d31-9dfc-56db199c8147.JPG" + ] + }, + { + "id": 17, + "placeName": "Paradise", + "description": "점심으로 선택한 파라다이스!\n처음으로 방문한 식당이었는데, 가격이 너무 싸서 놀랐다 ㅎㅎ\n카자흐스탄의 큰 장점이 물가가 엄청 싸다는 것이다.\n저기서 피자 가격이 5000원 정도 했던 것으로 기억한다.\n\n나는 \"라그만\"이라는 걸 먹었는데, 성공적이었음. 나는 짜게 먹는거 좋아해서 맛있었지만, 짠 거 싫어하면 미리 말씀 드려야 할듯!\n\n그리고 카작에선 차를 자주 마신다고 해서 먹어봤는데, 따뜻하고 향도 좋고 맛있었음. 이때부터 차를 즐기기 시작했다 ㅎㅎ", + "position": { + "lat": "43.2425", + "lng": "76.9741" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/dc8680c2-d12f-492c-b0d2-c9a20bb37683.png", + "https://dev.touroot.kr/images/6a6cbc7d-348e-4957-bbae-4121f3e348b0.png" + ] + }, + { + "id": 18, + "placeName": "Almaty Mall", + "description": "카자흐스탄 물가가 얼마나 싼지 단번에 알 수 있었던 곳..\n진짜 가격이 엄청 착하다.\n\n1 탱게당 3원이니 요거트랑 과일이 300원 정도밖에 안한다..\n\n확실히 유제품류랑 과일류가 엄청 싼듯!!!\n여기 있으면 마치 내가 부자가 된 기분이 든다.", + "position": { + "lat": "43.2075", + "lng": "76.8587" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/a65f9ecb-642c-4380-915a-faef38c6363c.png", + "https://dev.touroot.kr/images/2d57c125-204c-4a59-9a84-e6855340ee33.png" + ] + } + ] + } + ] + }, + { + "id": 5, + "title": "낭만의 10인 제주도 휴양기", + "thumbnail": "https://dev.touroot.kr/images/5c4ee3fe-e803-45c9-b600-0e3586dd2dfd.png", + "days": [ + { + "id": 8, + "places": [ + { + "id": 9, + "placeName": "서우봉 둘레길", + "description": "함덕이 한 눈에 보이는 서우봉. 간단한 하이킹 코스로도 훌륭합니다.", + "position": { + "lat": "33.5453", + "lng": "126.675" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/57d6a3fd-c41e-4042-b099-589a6150a6e3.png", + "https://dev.touroot.kr/images/49145c56-c534-4043-a83c-209551af58ce.png" + ] + }, + { + "id": 10, + "placeName": "동문재래시장", + "description": "동문야시장은 제주 향토 음식과 야시장 음식이 잘 조화되어 있는 곳입니다.", + "position": { + "lat": "33.512", + "lng": "126.528" + }, + "photoUrls": ["https://dev.touroot.kr/images/bbb81b88-ce44-41a8-ad73-9d5da8809ef2.png"] + } + ] + }, + { + "id": 9, + "places": [ + { + "id": 11, + "placeName": "파리바게뜨 동화마을", + "description": "제주도 파리바게트에서만 판매하는 우도땅콩크림도넛 맛있습니다. 한 번쯤 먹어볼만 해요!", + "position": { + "lat": "33.4354", + "lng": "126.732" + }, + "photoUrls": ["https://dev.touroot.kr/images/9d457b5c-fa2d-42bf-8e5e-a06767a294f1.png"] + }, + { + "id": 12, + "placeName": "사려니숲길 붉은오름 입출구", + "description": "비 온 뒤의 사려니숲은 이름 그대로 성스러웠습니다. 붉은오름 입출구로 가시는게 주차장과 가깝고 좋습니다.", + "position": { + "lat": "33.395", + "lng": "126.684" + }, + "photoUrls": ["https://dev.touroot.kr/images/e0ed72e9-a38c-4861-850b-187fa276e8ea.png"] + }, + { + "id": 13, + "placeName": "곱들락탁희", + "description": "흑돼지 맛집! 가격대는 꽤 있는 편입니다.", + "position": { + "lat": "33.5393", + "lng": "126.67" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/83d43cb2-f956-42f0-b08a-b3b66332bd19.png", + "https://dev.touroot.kr/images/99ffc965-a444-4661-8b8b-2ab428a7d4fb.png" + ] + } + ] + }, + { + "id": 10, + "places": [ + { + "id": 14, + "placeName": "함덕해수욕장", + "description": "에메랄드빛 바다는 해외 휴양지가 부럽지 않습니다!", + "position": { + "lat": "33.5434", + "lng": "126.67" + }, + "photoUrls": ["https://dev.touroot.kr/images/4bbaef28-2a4b-4976-abd7-c8486e310d73.png"] + }, + { + "id": 15, + "placeName": "스누피가든", + "description": "스누피 테마파크! 부지가 엄청 넓어서 날잡고 가시는게 좋습니다. 실내, 실외 모두 구경거리가 많아요~", + "position": { + "lat": "33.4442", + "lng": "126.778" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/5053b7d6-bcbd-4b87-9f62-b3690a76f953.png", + "https://dev.touroot.kr/images/6faca18a-4865-406a-9807-c9c772bdeba5.png", + "https://dev.touroot.kr/images/f02dcd8a-6f9c-4cfb-b439-e6ec9197b66a.png" + ] + } + ] + } + ] + }, + { + "id": 4, + "title": "행운이 가득한 싱가포르", + "thumbnail": "https://dev.touroot.kr/images/bc6c96ae-7068-47dd-8418-c48b10f3ddfb.jpg", + "days": [ + { + "id": 5, + "places": [ + { + "id": 6, + "placeName": "머라이언 공원", + "description": "싱가포르의 명물. 머메이언 아니고 머라이언.", + "position": { + "lat": "1.28679", + "lng": "103.854" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/0de51038-ceca-4854-9943-6719f0071832.jpg", + "https://dev.touroot.kr/images/85e68e92-7fd6-4807-824b-59277c06a64c.jpg" + ] + } + ] + }, + { + "id": 6, + "places": [ + { + "id": 7, + "placeName": "유니버셜 스튜디오 싱가포르", + "description": "너무 즐거운 곳. 작아서 더 즐겁다!", + "position": { + "lat": "1.25404", + "lng": "103.824" + }, + "photoUrls": ["https://dev.touroot.kr/images/d350fbe8-a1a4-4979-bd92-1c39779fc0a6.jpg"] + } + ] + }, + { + "id": 7, + "places": [ + { + "id": 8, + "placeName": "나이트 사파리", + "description": "야밤에 울타리도 없이 동물보기 덜덜 좀 무섭다", + "position": { + "lat": "1.40219", + "lng": "103.788" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/d769c994-97b2-4e67-84e0-6b89cf97dd1a.jpg", + "https://dev.touroot.kr/images/668f01a1-012b-4cb8-8521-a6f557350914.jpg", + "https://dev.touroot.kr/images/a28159a5-d4a9-46e2-b474-b05f77c6e558.jpg" + ] + } + ] + } + ] + }, + { + "id": 3, + "title": "당일치기 춘천 닭갈비 여행 🍗", + "thumbnail": "https://dev.touroot.kr/images/04cd1ef8-6c00-4c16-afe1-97f4a583e02a.jpg", + "days": [ + { + "id": 4, + "places": [ + { + "id": 4, + "placeName": "큰지붕닭갈비", + "description": "오후 2시쯤 방문하면 웨이팅 없이 점심을 먹을 수 있다~\n점심 먹고 카페 가서 맛있는 거 많이 먹을꺼라 적당히 먹자", + "position": { + "lat": "37.9291", + "lng": "127.782" + }, + "photoUrls": ["https://dev.touroot.kr/images/c74a056d-75ad-4eda-bc2e-6bfa80a1c365.jpg"] + }, + { + "id": 5, + "placeName": "카페 감자밭", + "description": "감자 라떼랑 감자빵 조합이 아주 조씁니다리\n카페 내부도 잘 꾸며져 있어요", + "position": { + "lat": "37.9296", + "lng": "127.784" + }, + "photoUrls": [ + "https://dev.touroot.kr/images/d0373743-6f80-4fe9-bef0-3225e1a75751.jpeg", + "https://dev.touroot.kr/images/0957de55-79e2-4d7f-95c2-53c4b0a6c5fc.jpg" + ] + } + ] + } + ] + }, + { + "id": 1, + "title": "지니의 일본 여행기", + "thumbnail": "https://dev.touroot.kr/images/25f502d6-d5fe-4451-8c83-c06032d57de8.webp", + "days": [ + { + "id": 1, + "places": [ + { + "id": 1, + "placeName": "유니버설 스튜디오 재팬", + "description": "유니버셜 스튜디오에서 버터맥주 마시기!", + "position": { + "lat": "34.6657", + "lng": "135.432" + }, + "photoUrls": ["https://dev.touroot.kr/images/adcf97e6-9184-4ee8-91fb-52aed00664ad.webp"] + }, + { + "id": 2, + "placeName": "돈키호테 도톤보리점", + "description": "돈키호테에서 물품 많이 많이 사야지~!", + "position": { + "lat": "34.6693", + "lng": "135.503" + }, + "photoUrls": ["https://dev.touroot.kr/images/fa194393-f664-4ab0-b892-50b3948a7ba3.jpg"] + } + ] + }, + { + "id": 2, + "places": [ + { + "id": 3, + "placeName": "도톤보리", + "description": "도톤보리에서 재밌게 놀기~", + "position": { + "lat": "34.6686", + "lng": "135.503" + }, + "photoUrls": ["https://dev.touroot.kr/images/4b091625-12ee-4026-ab1f-7d824e3bc6e3.jpg"] + } + ] + } + ] + } +] diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts index 7aac7102..21b53ea8 100644 --- a/frontend/src/mocks/handlers/index.ts +++ b/frontend/src/mocks/handlers/index.ts @@ -1,6 +1,3 @@ -/** - * msw v2에선 rest 대신 http 사용 - * import { HttpResponse, http } from "msw"; - */ +import { travelogueInfiniteHandler } from "@mocks/handlers/travelogueInfiniteHandler"; -export const handlers = []; +export const handlers = [travelogueInfiniteHandler]; diff --git a/frontend/src/mocks/handlers/travelogueInfiniteHandler.ts b/frontend/src/mocks/handlers/travelogueInfiniteHandler.ts new file mode 100644 index 00000000..11ea2e90 --- /dev/null +++ b/frontend/src/mocks/handlers/travelogueInfiniteHandler.ts @@ -0,0 +1,18 @@ +import { HttpResponse, http } from "msw"; + +import MOCK_TRAVELOGUES from "@mocks/data/travelogue.json"; + +export const travelogueInfiniteHandler = http.get("/travelogues", ({ request }) => { + const url = new URL(request.url); + + const page = Number(url.searchParams.get("page") ?? "5"); + const size = Number(url.searchParams.get("size") ?? "5"); + + const start = page * size; + const end = (page + 1) * size; + + const last = MOCK_TRAVELOGUES.length <= end; + const paginatedPage = MOCK_TRAVELOGUES.slice(start, end); + + return HttpResponse.json({ content: paginatedPage, last }); +}); diff --git a/frontend/src/queries/useGetTravelPlan.ts b/frontend/src/queries/useGetTravelPlan.ts index 98bb2d14..c0bc27c4 100644 --- a/frontend/src/queries/useGetTravelPlan.ts +++ b/frontend/src/queries/useGetTravelPlan.ts @@ -2,13 +2,16 @@ import { AxiosResponse } from "axios"; import { useQuery } from "@tanstack/react-query"; -import { Travelogue } from "@type/domain/travelogue"; +import type { TravelPlanResponse } from "@type/domain/travelPlan"; import { authClient } from "@apis/client"; +import { API_ENDPOINT_MAP } from "@constants/endpoint"; +import { QUERY_KEYS_MAP } from "@constants/queryKey"; + export const useGetTravelPlan = (id: string) => { - return useQuery>({ - queryKey: [`travel-plans/${id}`], - queryFn: async () => authClient.get(`travel-plans/${id}`), + return useQuery>({ + queryKey: QUERY_KEYS_MAP.travelPlan.detail(id), + queryFn: async () => authClient.get(API_ENDPOINT_MAP.travelPlanDetail(Number(id))), }); }; diff --git a/frontend/src/queries/useGetTravelogue.ts b/frontend/src/queries/useGetTravelogue.ts index c0c2110e..fa96f24f 100644 --- a/frontend/src/queries/useGetTravelogue.ts +++ b/frontend/src/queries/useGetTravelogue.ts @@ -1,14 +1,17 @@ import { useQuery } from "@tanstack/react-query"; -import type { Travelogue } from "@type/domain/travelogue"; +import type { TravelogueResponse } from "@type/domain/travelogue"; import { client } from "@apis/client"; +import { API_ENDPOINT_MAP } from "@constants/endpoint"; +import { QUERY_KEYS_MAP } from "@constants/queryKey"; + export const useGetTravelogue = (id: string) => { - return useQuery({ - queryKey: [`travelogues/${id}`], + return useQuery({ + queryKey: QUERY_KEYS_MAP.travelogue.detail(id), queryFn: async () => { - const { data } = await client.get(`/travelogues/${id}`); + const { data } = await client.get(API_ENDPOINT_MAP.travelogueDetail(Number(id))); return data; }, diff --git a/frontend/src/queries/useInfiniteTravelogues.ts b/frontend/src/queries/useInfiniteTravelogues.ts index 5096bce5..a0151623 100644 --- a/frontend/src/queries/useInfiniteTravelogues.ts +++ b/frontend/src/queries/useInfiniteTravelogues.ts @@ -2,9 +2,12 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { client } from "@apis/client"; +import { API_ENDPOINT_MAP } from "@constants/endpoint"; +import { QUERY_KEYS_MAP } from "@constants/queryKey"; + export const getTravelogues = async ({ page, size }: { page: number; size: number }) => { try { - const response = await client.get("/travelogues", { + const response = await client.get(API_ENDPOINT_MAP.travelogues, { params: { page, size }, }); return response.data; @@ -19,7 +22,7 @@ const useInfiniteTravelogues = () => { const DATA_LOAD_COUNT = 5; const { data, status, error, fetchNextPage, isFetchingNextPage, hasNextPage } = useInfiniteQuery({ - queryKey: ["travelogues"], + queryKey: QUERY_KEYS_MAP.travelogue.all, queryFn: ({ pageParam = INITIAL_PAGE }) => { const page = pageParam; const size = DATA_LOAD_COUNT; diff --git a/frontend/src/queries/usePostTravelPlan.ts b/frontend/src/queries/usePostTravelPlan.ts index 3295d4b6..3d9f4be9 100644 --- a/frontend/src/queries/usePostTravelPlan.ts +++ b/frontend/src/queries/usePostTravelPlan.ts @@ -2,24 +2,27 @@ import { AxiosError, AxiosResponse } from "axios"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { ErrorResponse } from "@type/api/errorResponse"; -import type { TravelRegister, TravelRegisterPlace } from "@type/domain/travelogue"; +import type { ErrorResponse } from "@type/api/errorResponse"; +import type { TravelPlanResponse } from "@type/domain/travelPlan"; import ApiError from "@apis/ApiError"; import { authClient } from "@apis/client"; +import { API_ENDPOINT_MAP } from "@constants/endpoint"; +import { QUERY_KEYS_MAP } from "@constants/queryKey"; + export const usePostTravelPlan = () => { const queryClient = useQueryClient(); return useMutation< - AxiosResponse, + AxiosResponse, ApiError | AxiosError, - Omit & { startDate: string }, + TravelPlanResponse, unknown >({ - mutationFn: (travelPlan: Omit & { startDate: string }) => - authClient.post("/travel-plans", travelPlan), + mutationFn: (travelPlan: TravelPlanResponse) => + authClient.post(API_ENDPOINT_MAP.travelPlans, travelPlan), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["travel-plans"] }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS_MAP.travelPlan.all }); }, onError: (error) => { alert(error.message); diff --git a/frontend/src/queries/usePostTravelogue.ts b/frontend/src/queries/usePostTravelogue.ts index ed9c3e15..1a5a1e6a 100644 --- a/frontend/src/queries/usePostTravelogue.ts +++ b/frontend/src/queries/usePostTravelogue.ts @@ -3,22 +3,35 @@ import { AxiosError, AxiosResponse } from "axios"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ErrorResponse } from "@type/api/errorResponse"; -import type { TravelRegister, TravelRegisterPlace } from "@type/domain/travelogue"; +import { TravelogueResponse } from "@type/domain/travelogue"; import ApiError from "@apis/ApiError"; import { authClient } from "@apis/client"; +import { API_ENDPOINT_MAP } from "@constants/endpoint"; +import { QUERY_KEYS_MAP } from "@constants/queryKey"; + export const usePostTravelogue = () => { const queryClient = useQueryClient(); return useMutation< - AxiosResponse, + AxiosResponse, ApiError | AxiosError, - TravelRegister, + TravelogueResponse, unknown >({ - mutationFn: (travelogue: TravelRegister) => authClient.post("/travelogues", travelogue), + mutationFn: (travelogue: TravelogueResponse) => + authClient.post(API_ENDPOINT_MAP.travelogues, { + ...travelogue, + days: travelogue.days.map((day) => ({ + ...day, + places: day.places.map((place) => ({ + ...place, + photoUrls: place.photoUrls?.map((url) => ({ url })), + })), + })), + }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["travelogues"] }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS_MAP.travelogue.all }); }, onError: (error) => { alert(error); diff --git a/frontend/src/queries/usePostUploadImages.ts b/frontend/src/queries/usePostUploadImages.ts index 02d7cf2b..764a77b6 100644 --- a/frontend/src/queries/usePostUploadImages.ts +++ b/frontend/src/queries/usePostUploadImages.ts @@ -7,6 +7,8 @@ import type { ErrorResponse } from "@type/api/errorResponse"; import ApiError from "@apis/ApiError"; import { authClient } from "@apis/client"; +import { API_ENDPOINT_MAP } from "@constants/endpoint"; + export const usePostUploadImages = () => { return useMutation, File[]>({ mutationFn: async (files: File[]) => { @@ -16,7 +18,7 @@ export const usePostUploadImages = () => { formData.append("files", file); }); - const response = await authClient.post("/image", formData, { + const response = await authClient.post(API_ENDPOINT_MAP.image, formData, { headers: { "Content-Type": "multipart/form-data", }, diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 9264adbd..8c95f86c 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -9,40 +9,40 @@ import TravelPlanRegisterPage from "@components/pages/travelPlanRegister/TravelP import TravelogueDetailPage from "@components/pages/travelogueDetail/TravelogueDetailPage"; import TravelogueRegisterPage from "@components/pages/travelogueRegister/TravelogueRegisterPage"; -import { ROUTE_PATHS } from "./constants/route"; +import { ROUTE_PATHS_MAP } from "./constants/route"; export const router = createBrowserRouter([ { - path: ROUTE_PATHS.root, + path: ROUTE_PATHS_MAP.root, element: , children: [ { - path: ROUTE_PATHS.root, + path: ROUTE_PATHS_MAP.root, element: , }, { - path: ROUTE_PATHS.login, + path: ROUTE_PATHS_MAP.login, element: , }, { - path: ROUTE_PATHS.loginCallback, + path: ROUTE_PATHS_MAP.loginCallback, element: , }, { - path: ROUTE_PATHS.travelogue, + path: ROUTE_PATHS_MAP.travelogue(), element: , }, { - path: ROUTE_PATHS.travelogueRegister, + path: ROUTE_PATHS_MAP.travelogueRegister, element: , }, { - path: ROUTE_PATHS.travelPlans, + path: ROUTE_PATHS_MAP.travelPlan(), element: , }, { - path: ROUTE_PATHS.travelPlansRegister, + path: ROUTE_PATHS_MAP.travelPlanRegister, element: , }, ], diff --git a/frontend/src/types/domain/common.ts b/frontend/src/types/domain/common.ts new file mode 100644 index 00000000..159138f1 --- /dev/null +++ b/frontend/src/types/domain/common.ts @@ -0,0 +1,4 @@ +export interface MapPosition { + lat: number; + lng: number; +} diff --git a/frontend/src/types/domain/travelPlan.ts b/frontend/src/types/domain/travelPlan.ts new file mode 100644 index 00000000..568739dc --- /dev/null +++ b/frontend/src/types/domain/travelPlan.ts @@ -0,0 +1,17 @@ +import { MapPosition } from "@type/domain/common"; + +export type TravelPlanPlace = { + placeName: string; + description?: string; + position: MapPosition; +}; + +export interface TravelPlanDay { + places: TravelPlanPlace[]; +} + +export interface TravelPlanResponse { + title: string; + startDate: string; + days: TravelPlanDay[]; +} diff --git a/frontend/src/types/domain/travelTransform.ts b/frontend/src/types/domain/travelTransform.ts index 401d4560..24c4219e 100644 --- a/frontend/src/types/domain/travelTransform.ts +++ b/frontend/src/types/domain/travelTransform.ts @@ -1,7 +1,13 @@ -import { Place } from "@type/domain/travelogue"; +import { TravelPlanPlace } from "@type/domain/travelPlan"; +import { TraveloguePlace } from "@type/domain/travelogue"; -export type TravelTransformPlaces = { - places: Pick[]; -}; +export type TravelTransformPlace = Pick< + TravelPlanPlace | TraveloguePlace, + keyof TravelPlanPlace & keyof TraveloguePlace +>; + +export interface TravelTransformPlaces { + places: TravelTransformPlace[]; +} export type TravelTransformDetail = { days: TravelTransformPlaces[] }; diff --git a/frontend/src/types/domain/travelogue.ts b/frontend/src/types/domain/travelogue.ts index c05d3e0d..caa2ef87 100644 --- a/frontend/src/types/domain/travelogue.ts +++ b/frontend/src/types/domain/travelogue.ts @@ -1,39 +1,18 @@ -export interface Place { - placeName: string; - photoUrls?: string[]; - description?: string; - position: { - lat: number; - lng: number; - }; -} +import { MapPosition } from "@type/domain/common"; -export interface TravelRegisterPlace { +export type TraveloguePlace = { placeName: string; - photoUrls?: { url: string }[]; + photoUrls?: string[]; description?: string; - position: { - lat: number; - lng: number; - }; -} - -export interface TravelRegisterDay { - places: TravelRegisterPlace[]; -} - -export interface TravelRegister { - title: string; - thumbnail: string; - days: TravelRegisterDay[]; -} + position: MapPosition; +}; -export interface Day { - places: Place[]; +export interface TravelogueDay { + places: TraveloguePlace[]; } -export interface Travelogue { +export interface TravelogueResponse { title: string; thumbnail: string; - days: Day[]; + days: TravelogueDay[]; } diff --git a/frontend/src/types/domain/user.ts b/frontend/src/types/domain/user.ts index 0164305d..30e57d4f 100644 --- a/frontend/src/types/domain/user.ts +++ b/frontend/src/types/domain/user.ts @@ -1,4 +1,4 @@ -export interface User { +export interface UserResponse { accessToken: string; nickname: string; profileImageUrl: string;