diff --git a/frontend/src/apis/_common/fetchClient.ts b/frontend/src/apis/_common/fetchClient.ts index b36444c5..1ee9aa72 100644 --- a/frontend/src/apis/_common/fetchClient.ts +++ b/frontend/src/apis/_common/fetchClient.ts @@ -4,11 +4,11 @@ import { BASE_URL } from '@constants/api'; export type HTTPMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; -interface FetchOption { +export interface FetchOption { path: string; method: HTTPMethod; - errorMessage?: string; body?: object; + headers?: HeadersInit; /** * @Yoonkyoungme * isAuthRequire: 인증이 필요한 API 요청인지를 나타내는 플래그 @@ -21,29 +21,35 @@ interface FetchOption { // TODO: TypeError: Failed to Fetch에 대한 에러 처리는 어떻게 할 예정인지. const createFetchClient = (baseUrl: string) => { - return async ({ path, method, body, isAuthRequire }: FetchOption): Promise => { - const url = `${baseUrl}${path}`; - const response = await fetch(url, { - method, - headers: { - 'Content-Type': 'application/json', - }, - body: body ? JSON.stringify(body) : null, - credentials: isAuthRequire ? 'include' : 'omit', - }); - - if (response.status === 401) { - throw new Error('인증이 만료되었습니다. 다시 로그인해주세요.'); + return async ({ path, method, body, isAuthRequire, headers }: FetchOption) => { + try { + const url = `${baseUrl}${path}`; + const response: Response = await fetch(url, { + method, + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : null, + credentials: isAuthRequire ? 'include' : 'omit', + }); + + // response 객체는 에러가 발생하면 데이터는 응답 객체가 되고, 정상적인 응답이 오면 데이터 객체가 된다. + if (!response.ok) { + // 응답이 에러 객체인 경우 ResponseError 객체를 생성 -> QueryClientManager 컴포넌트에서 에러 상태를 업데이트 + const errorData = await response.json(); + throw new ResponseError(errorData); + } + + return response; + } catch (error) { + // catch network error + if (error instanceof Error) { + throw error; + } + + throw error; } - - // 현재 응답 결과로 받아오는 데이터가 모두 data로 감싸서 전달받는 형태이므로 아래와 같이 구현(@낙타) - const data = await response.json(); - - if (!response.ok) { - throw new ResponseError(data); - } - - return data.data as T; }; }; diff --git a/frontend/src/apis/_common/fetcher.ts b/frontend/src/apis/_common/fetcher.ts new file mode 100644 index 00000000..ea9317b2 --- /dev/null +++ b/frontend/src/apis/_common/fetcher.ts @@ -0,0 +1,31 @@ +import type { FetchOption } from './fetchClient'; +import { fetchClient } from './fetchClient'; + +type FetcherArgs = Omit; + +export const fetcher = { + get: async ({ path, isAuthRequire }: FetcherArgs): Promise => { + const response = await fetchClient({ + path, + method: 'GET', + isAuthRequire, + }); + + const data = await response.json(); + + return data.data as T; + }, + post: async ({ path, body, isAuthRequire = false }: FetcherArgs) => { + await fetchClient({ path, method: 'POST', body, isAuthRequire }); + }, + postWithResponse: async ({ path, body, isAuthRequire = false }: FetcherArgs): Promise => { + const response = await fetchClient({ path, method: 'POST', body, isAuthRequire }); + + const data = await response.json(); + + return data.data as T; + }, + delete: async ({ path, isAuthRequire = false }: FetcherArgs) => { + await fetchClient({ path, method: 'DELETE', isAuthRequire }); + }, +}; diff --git a/frontend/src/apis/meetings/confirms.ts b/frontend/src/apis/meetings/confirms.ts index 6015e858..5e2ae0f1 100644 --- a/frontend/src/apis/meetings/confirms.ts +++ b/frontend/src/apis/meetings/confirms.ts @@ -1,6 +1,4 @@ -import { BASE_URL } from '@constants/api'; - -import { fetchClient } from '../_common/fetchClient'; +import { fetcher } from '../_common/fetcher'; import type { MeetingType } from './meetings'; export interface ConfirmDates { @@ -25,9 +23,8 @@ export interface GetConfirmedMeetingInfoResponse extends ConfirmDates { } export const postMeetingConfirm = async ({ uuid, requests }: PostMeetingConfirmRequest) => { - const data = await fetchClient({ + const data = await fetcher.post({ path: `/${uuid}/confirm`, - method: 'POST', body: requests, isAuthRequire: true, }); @@ -36,24 +33,11 @@ export const postMeetingConfirm = async ({ uuid, requests }: PostMeetingConfirmR }; export const getConfirmedMeetingInfo = async (uuid: string) => { - const data = await fetchClient>({ - path: `/${uuid}/confirm`, - method: 'GET', - }); + const data = await fetcher.get({ path: `/${uuid}/confirm` }); return data; }; export const deleteFixedMeeting = async (uuid: string) => { - const response = await fetch(`${BASE_URL}/${uuid}/confirm`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - }); - - if (!response.ok) { - throw new Error('약속을 확정 취소하는데 실패했어요. :('); - } + await fetcher.delete({ path: `/${uuid}/confirm`, isAuthRequire: true }); }; diff --git a/frontend/src/apis/meetings/meetings.ts b/frontend/src/apis/meetings/meetings.ts index 6cb6088a..a4d1f3a7 100644 --- a/frontend/src/apis/meetings/meetings.ts +++ b/frontend/src/apis/meetings/meetings.ts @@ -4,7 +4,7 @@ import { ResponseError } from '@utils/responseError'; import { BASE_URL } from '@constants/api'; -import { fetchClient } from '../_common/fetchClient'; +import { fetcher } from '../_common/fetcher'; export type MeetingType = 'DAYSONLY' | 'DATETIME'; @@ -51,10 +51,7 @@ interface PostMeetingResponse { export const getMeetingBase = async (uuid: string): Promise => { const path = `/${uuid}`; - const data = await fetchClient({ - path, - method: 'GET', - }); + const data = await fetcher.get({ path }); return { meetingName: data.meetingName, @@ -89,9 +86,8 @@ interface PostMeetingResponse { } export const postMeeting = async (request: PostMeetingRequest): Promise => { - const data = await fetchClient({ + const data = await fetcher.postWithResponse({ path: '', - method: 'POST', body: request, isAuthRequire: true, }); @@ -144,10 +140,7 @@ interface MeetingEntranceDetails { } export const getMeetingEntranceDetails = async (uuid: string) => { - const data = await fetchClient({ - path: `/${uuid}/home`, - method: 'GET', - }); + const data = await fetcher.get({ path: `/${uuid}/home` }); return data; }; diff --git a/frontend/src/apis/meetings/recommends.ts b/frontend/src/apis/meetings/recommends.ts index a80230c5..ddde8054 100644 --- a/frontend/src/apis/meetings/recommends.ts +++ b/frontend/src/apis/meetings/recommends.ts @@ -1,4 +1,4 @@ -import { fetchClient } from '../_common/fetchClient'; +import { fetcher } from '../_common/fetcher'; import type { MeetingType } from './meetings'; interface GetMeetingRecommendRequest { @@ -35,10 +35,7 @@ export const getMeetingTimeRecommends = async ({ const path = `/${uuid}/recommended-schedules?${urlParams.toString()}`; - const data = await fetchClient({ - path, - method: 'GET', - }); + const data = await fetcher.get({ path }); return data; }; @@ -54,9 +51,8 @@ export const getMeetingAttendees = async ({ }): Promise => { const path = `/${uuid}/attendees`; - const data = await fetchClient({ + const data = await fetcher.get({ path, - method: 'GET', }); return data; diff --git a/frontend/src/apis/schedules.ts b/frontend/src/apis/schedules.ts index 59cfc188..e304a34b 100644 --- a/frontend/src/apis/schedules.ts +++ b/frontend/src/apis/schedules.ts @@ -5,11 +5,7 @@ import type { MeetingSingleSchedule, } from 'types/schedule'; -import { ResponseError } from '@utils/responseError'; - -import { BASE_URL } from '@constants/api'; - -import { fetchClient } from './_common/fetchClient'; +import { fetcher } from './_common/fetcher'; export interface PostScheduleRequest { uuid: string; @@ -17,22 +13,13 @@ export interface PostScheduleRequest { } export const postSchedule = async ({ uuid, requestData }: PostScheduleRequest) => { - const response = await fetch(`${BASE_URL}/${uuid}/schedules`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + await fetcher.post({ + path: `/${uuid}/schedules`, + body: { dateTimes: requestData, - }), - credentials: 'include', + }, + isAuthRequire: true, }); - - if (!response.ok) { - const data = await response.json(); - - throw new ResponseError(data); - } }; export const createMeetingSchedulesRequestUrl = (uuid: string, attendeeName: string) => { @@ -50,10 +37,7 @@ interface MeetingAllSchedulesResponse { const getMeetingAllSchedules = async (uuid: string): Promise => { const path = `/${uuid}/schedules`; - const data = await fetchClient({ - path, - method: 'GET', - }); + const data = await fetcher.get({ path }); return { schedules: data.schedules, @@ -74,9 +58,8 @@ const getMeetingSingleSchedule = async ({ }): Promise => { const path = createMeetingSchedulesRequestUrl(uuid, attendeeName); - const data = await fetchClient({ + const data = await fetcher.get({ path, - method: 'GET', }); return { @@ -88,9 +71,8 @@ const getMeetingSingleSchedule = async ({ export const getMeetingMySchedule = async (uuid: string): Promise => { const path = `/${uuid}/attendees/me/schedules`; - const data = await fetchClient({ + const data = await fetcher.get({ path, - method: 'GET', isAuthRequire: true, }); diff --git a/frontend/src/apis/users.ts b/frontend/src/apis/users.ts index 12cc212d..81b87bac 100644 --- a/frontend/src/apis/users.ts +++ b/frontend/src/apis/users.ts @@ -1,8 +1,6 @@ -import { ResponseError } from '@utils/responseError'; - import { BASE_URL } from '@constants/api'; -import { fetchClient } from './_common/fetchClient'; +import { fetcher } from './_common/fetcher'; interface UserLoginRequest { uuid: string; @@ -13,9 +11,8 @@ interface UserLoginRequest { } export const postUserLogin = async ({ uuid, request }: UserLoginRequest) => { - const data = await fetchClient({ + const data = await fetcher.postWithResponse({ path: `/${uuid}/login`, - method: 'POST', body: request, isAuthRequire: true, }); @@ -30,22 +27,5 @@ export const postUserLogin = async ({ uuid, request }: UserLoginRequest) => { * TODO: 응답 데이터가 없을 때도 대응 가능한 fetchClient 함수를 만들어야 함 */ export const postUserLogout = async (uuid: string) => { - try { - const response = await fetch(`${BASE_URL}/${uuid}/logout`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - }); - - if (!response.ok) { - const data = await response.json(); - - throw new ResponseError(data); - } - } catch (error) { - console.error('로그아웃 중 문제가 발생했습니다:', error); - throw error; - } + await fetcher.post({ path: `${BASE_URL}/${uuid}/logout`, isAuthRequire: true }); }; diff --git a/frontend/src/components/ErrorToastNotifier/index.tsx b/frontend/src/components/ErrorToastNotifier/index.tsx new file mode 100644 index 00000000..604a623a --- /dev/null +++ b/frontend/src/components/ErrorToastNotifier/index.tsx @@ -0,0 +1,23 @@ +import type { PropsWithChildren } from 'react'; +import { useEffect, useRef } from 'react'; + +import useErrorState from '@hooks/useErrorState/useErrorState'; +import useToast from '@hooks/useToast/useToast'; + +export default function ErrorToastNotifier({ children }: PropsWithChildren) { + const error = useErrorState(); + const { addToast } = useToast(); + + const addToastCallbackRef = useRef< + (({ type, message, duration }: Parameters[0]) => void) | null + >(null); + addToastCallbackRef.current = addToast; + + useEffect(() => { + if (!error || !addToastCallbackRef.current) return; + + addToastCallbackRef.current({ type: 'warning', message: error.message, duration: 3000 }); + }, [error]); + + return children; +} diff --git a/frontend/src/components/QueryClientManager/index.tsx b/frontend/src/components/QueryClientManager/index.tsx new file mode 100644 index 00000000..5485a545 --- /dev/null +++ b/frontend/src/components/QueryClientManager/index.tsx @@ -0,0 +1,27 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { type PropsWithChildren } from 'react'; + +import useErrorDispatch from '@hooks/useErrorDispatch/useErrorDispatch'; + +import { ResponseError } from '@utils/responseError'; + +export default function QueryClientManager({ children }: PropsWithChildren) { + const setError = useErrorDispatch(); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + throwOnError: true, + }, + mutations: { + onError: (error: unknown) => { + if (error instanceof ResponseError) { + setError(error); + } + }, + }, + }, + }); + + return {children}; +} diff --git a/frontend/src/components/Schedules/ScheduleViewer/SchedulesViewer.tsx b/frontend/src/components/Schedules/ScheduleViewer/SchedulesViewer.tsx index 4e8c728f..d37ed79e 100644 --- a/frontend/src/components/Schedules/ScheduleViewer/SchedulesViewer.tsx +++ b/frontend/src/components/Schedules/ScheduleViewer/SchedulesViewer.tsx @@ -7,10 +7,14 @@ import { UuidContext } from '@contexts/UuidProvider'; import { Button } from '@components/_common/Buttons/Button'; import TabButton from '@components/_common/Buttons/TabButton'; +import MeetingLockConfirmModal from '@components/_common/Modal/MeetingLockConfirmModal'; +import Text from '@components/_common/Text'; +import useConfirmModal from '@hooks/useConfirmModal/useConfirmModal'; import useRouter from '@hooks/useRouter/useRouter'; import useSelectSchedule from '@hooks/useSelectSchedule/useSelectSchedule'; +import { useLockMeetingMutation } from '@stores/servers/meeting/mutations'; import { formatAriaTab } from '@utils/a11y'; import Check from '@assets/images/attendeeCheck.svg'; @@ -47,6 +51,9 @@ export default function SchedulesViewer({ const { handleToggleIsTimePickerUpdate } = useContext(TimePickerUpdateStateContext); const { isLoggedIn, userName } = useContext(AuthContext).state; + const { mutate: lockMutate } = useLockMeetingMutation(); + const { isConfirmModalOpen, onToggleConfirmModal } = useConfirmModal(); + const { currentDates, increaseDatePage, @@ -68,6 +75,22 @@ export default function SchedulesViewer({ handleToggleIsTimePickerUpdate(); }; + const routerToMeetingConfirmPage = () => routeTo(`/meeting/${uuid}/confirm`); + + const handleConfirmPageRoute = () => { + if (!isLocked) { + onToggleConfirmModal(); + return; + } + + routerToMeetingConfirmPage(); + }; + + const handleMeetingLockWithRoute = () => { + lockMutate(uuid); + routerToMeetingConfirmPage(); + }; + return ( <>
@@ -114,12 +137,8 @@ export default function SchedulesViewer({
{hostName === userName ? ( - ) : ( )}
@@ -140,6 +159,25 @@ export default function SchedulesViewer({
+ + + + 약속을 확정하기 위해서는 우선 + + + + 약속을 잠그고 약속 확정 페이지로 이동할까요? + + ); } diff --git a/frontend/src/components/_common/Modal/MeetingLockConfirmModal/index.tsx b/frontend/src/components/_common/Modal/MeetingLockConfirmModal/index.tsx new file mode 100644 index 00000000..1aadb13a --- /dev/null +++ b/frontend/src/components/_common/Modal/MeetingLockConfirmModal/index.tsx @@ -0,0 +1,41 @@ +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +import { Modal } from '..'; +import { s_button, s_primary, s_secondary } from '../ConfirmModal/ConfirmModal.styles'; +import type { ButtonPositionType } from '../Footer'; +import type { ModalProps } from '../ModalContainer'; + +interface ConfirmModalProps extends ModalProps { + title: string; + buttonPosition?: ButtonPositionType; + onConfirm: () => void; + onSecondButtonClick: () => void; +} + +export default function MeetingLockConfirmModal({ + isOpen, + onClose, + onConfirm, + onSecondButtonClick, + title, + position, + size, + children, + buttonPosition = 'row', +}: PropsWithChildren) { + return ( + + {title} + {children} + + + + + + ); +} diff --git a/frontend/src/contexts/AuthProvider.tsx b/frontend/src/contexts/AuthProvider.tsx index d4528dec..0b7c45eb 100644 --- a/frontend/src/contexts/AuthProvider.tsx +++ b/frontend/src/contexts/AuthProvider.tsx @@ -1,10 +1,9 @@ import type { ReactNode } from 'react'; -import { createContext, useContext, useEffect, useState } from 'react'; +import { createContext, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; import { loadAuthState, saveAuthState } from '@utils/auth'; -import { UuidContext } from './UuidProvider'; - interface AuthState { isLoggedIn: boolean; userName: string; @@ -27,7 +26,8 @@ interface AuthProviderProps { } export const AuthProvider = ({ children }: AuthProviderProps) => { - const { uuid } = useContext(UuidContext); + const params = useParams<{ uuid?: string }>(); + const uuid = params.uuid; const [isLoggedIn, setIsLoggedIn] = useState(() => { if (uuid) { @@ -49,7 +49,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { if (uuid) { saveAuthState(uuid, { isLoggedIn, userName }); } - }, [isLoggedIn, userName]); + }, [isLoggedIn, userName, uuid]); const value: AuthContextType = { state: { isLoggedIn, userName }, diff --git a/frontend/src/contexts/ErrorProvider.tsx b/frontend/src/contexts/ErrorProvider.tsx new file mode 100644 index 00000000..a7ef1eaa --- /dev/null +++ b/frontend/src/contexts/ErrorProvider.tsx @@ -0,0 +1,15 @@ +import type { PropsWithChildren } from 'react'; +import { createContext, useState } from 'react'; + +export const ErrorStateContext = createContext(null); +export const ErrorDispatchContext = createContext<(error: Error) => void>(() => {}); + +export const ErrorProvider = ({ children }: PropsWithChildren) => { + const [error, setError] = useState(null); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/hooks/useErrorDispatch/useErrorDispatch.ts b/frontend/src/hooks/useErrorDispatch/useErrorDispatch.ts new file mode 100644 index 00000000..3a1c5b7d --- /dev/null +++ b/frontend/src/hooks/useErrorDispatch/useErrorDispatch.ts @@ -0,0 +1,15 @@ +import { useContext } from 'react'; + +import { ErrorDispatchContext } from '@contexts/ErrorProvider'; + +const useErrorDispatch = () => { + const setError = useContext(ErrorDispatchContext); + + if (!setError) { + throw new Error('ErrorProvider 내부에서만 해당 훅을 사용할 수 있어요'); + } + + return setError; +}; + +export default useErrorDispatch; diff --git a/frontend/src/hooks/useErrorState/useErrorState.ts b/frontend/src/hooks/useErrorState/useErrorState.ts new file mode 100644 index 00000000..f0c90407 --- /dev/null +++ b/frontend/src/hooks/useErrorState/useErrorState.ts @@ -0,0 +1,15 @@ +import { useContext } from 'react'; + +import { ErrorStateContext } from '@contexts/ErrorProvider'; + +const useErrorState = () => { + const error = useContext(ErrorStateContext); + + if (error === undefined) { + throw new Error('ErrorProvider 내부에서만 해당 훅을 사용할 수 있어요'); + } + + return error; +}; + +export default useErrorState; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 0bfb0a33..d7cfa339 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,12 +1,15 @@ import { Global, ThemeProvider } from '@emotion/react'; import * as Sentry from '@sentry/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React from 'react'; import ReactDOM from 'react-dom/client'; +import { ErrorProvider } from '@contexts/ErrorProvider'; import ToastProvider from '@contexts/ToastProvider'; +import ErrorToastNotifier from '@components/ErrorToastNotifier'; +import QueryClientManager from '@components/QueryClientManager'; + import globalStyles from '@styles/global'; import theme from '@styles/theme'; @@ -35,28 +38,20 @@ Sentry.init({ enabled: process.env.NODE_ENV !== 'development', }); -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - throwOnError: true, - }, - mutations: { - throwOnError: true, - }, - }, -}); - enableMocking().then(() => { ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - + + + + + + + + + , ); diff --git a/frontend/src/stores/servers/meeting/mutations.ts b/frontend/src/stores/servers/meeting/mutations.ts index eaaf9d43..aed5912d 100644 --- a/frontend/src/stores/servers/meeting/mutations.ts +++ b/frontend/src/stores/servers/meeting/mutations.ts @@ -1,6 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useContext, useState } from 'react'; -import type { PostMeetingResult } from 'types/meeting'; +import { useContext } from 'react'; import { AuthContext } from '@contexts/AuthProvider'; @@ -16,21 +15,11 @@ export const usePostMeetingMutation = () => { const authContext = useContext(AuthContext); const { setIsLoggedIn, setUserName } = authContext.actions; - const [meetingInfo, setMeetingInfo] = useState({ - uuid: '', - userName: '', - meetingName: '', - firstTime: '', - lastTime: '', - availableDates: [], - }); - const mutation = useMutation({ mutationFn: postMeeting, onSuccess: (responseData) => { const { uuid, userName } = responseData; - setMeetingInfo(responseData); setIsLoggedIn(true); setUserName(userName); @@ -38,7 +27,7 @@ export const usePostMeetingMutation = () => { }, }); - return { mutation, meetingInfo }; + return { mutation }; }; export const useLockMeetingMutation = () => {