{memberInfo.isMaster ? (
diff --git a/frontend/src/components/common/SelectButton/SelectButton.hook.ts b/frontend/src/components/common/SelectButton/SelectButton.hook.ts
index 810cd3369..122507a85 100644
--- a/frontend/src/components/common/SelectButton/SelectButton.hook.ts
+++ b/frontend/src/components/common/SelectButton/SelectButton.hook.ts
@@ -2,7 +2,10 @@ import { useMutation } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
+import AlertModal from '../AlertModal/AlertModal';
+
import { voteBalanceContent } from '@/apis/balanceContent';
+import useModal from '@/hooks/useModal';
import useToast from '@/hooks/useToast';
import { memberInfoState } from '@/recoil/atom';
import { CustomError, NetworkError } from '@/utils/error';
@@ -13,18 +16,17 @@ interface UseSelectCompleteMutationProps {
selectedId: number;
contentId?: number;
completeSelection: () => void;
- showModal: () => void;
}
const useCompleteSelectionMutation = ({
selectedId,
contentId,
completeSelection,
- showModal,
}: UseSelectCompleteMutationProps) => {
const { roomId } = useParams();
const memberInfo = useRecoilValue(memberInfoState);
const { show } = useToast();
+ const { show: showModal } = useModal();
return useMutation({
mutationFn: async () => {
@@ -48,7 +50,7 @@ const useCompleteSelectionMutation = ({
return;
}
- showModal();
+ showModal(AlertModal, { title: '선택 에러', message: error.message });
},
networkMode: 'always',
throwOnError: (error: CustomError) => isServerError(error.status),
diff --git a/frontend/src/components/common/SelectButton/SelectButton.tsx b/frontend/src/components/common/SelectButton/SelectButton.tsx
index d9756338c..04d342473 100644
--- a/frontend/src/components/common/SelectButton/SelectButton.tsx
+++ b/frontend/src/components/common/SelectButton/SelectButton.tsx
@@ -6,15 +6,9 @@ interface SelectButtonProps {
contentId: number;
selectedId: number;
completeSelection: () => void;
- showModal: () => void;
}
-const SelectButton = ({
- contentId,
- selectedId,
- completeSelection,
- showModal,
-}: SelectButtonProps) => {
+const SelectButton = ({ contentId, selectedId, completeSelection }: SelectButtonProps) => {
const {
data,
isPending,
@@ -23,7 +17,6 @@ const SelectButton = ({
selectedId,
contentId,
completeSelection,
- showModal,
});
return (
diff --git a/frontend/src/components/hooks/useCountdown.ts b/frontend/src/components/hooks/useCountdown.ts
new file mode 100644
index 000000000..a8edc6388
--- /dev/null
+++ b/frontend/src/components/hooks/useCountdown.ts
@@ -0,0 +1,32 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { ROUTES } from '@/constants/routes';
+
+interface UseCountdownProps {
+ isGameStart: boolean;
+}
+
+const useCountdown = ({ isGameStart }: UseCountdownProps) => {
+ const navigate = useNavigate();
+ const { roomId } = useParams();
+ const [isCountdownStart, setIsCountdownStart] = useState(false);
+
+ const startCountdown = () => {
+ setIsCountdownStart(true);
+ };
+
+ const goToGame = () => {
+ navigate(ROUTES.game(Number(roomId)));
+ };
+
+ useEffect(() => {
+ if (isGameStart) {
+ startCountdown();
+ }
+ }, [isGameStart]);
+
+ return { isCountdownStart, goToGame };
+};
+
+export default useCountdown;
diff --git a/frontend/src/components/layout/Header/Header.test.tsx b/frontend/src/components/layout/Header/Header.test.tsx
new file mode 100644
index 000000000..249f2c986
--- /dev/null
+++ b/frontend/src/components/layout/Header/Header.test.tsx
@@ -0,0 +1,21 @@
+import { screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+
+import { RoomSettingHeader } from './Header';
+
+import { customRenderWithIsMaster } from '@/utils/test-utils';
+
+describe('Header 테스트', () => {
+ it('방 설정 버튼을 클릭했을 때, 방 설정 모달이 뜬다.', async () => {
+ const user = userEvent.setup();
+ customRenderWithIsMaster(
, true);
+
+ const roomSettingButton = await screen.findByAltText('방 설정');
+ await user.click(roomSettingButton);
+
+ await waitFor(() => {
+ const category = screen.getByText('카테고리');
+ expect(category).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/layout/Header/Header.tsx b/frontend/src/components/layout/Header/Header.tsx
index 24e0065c2..568229ee4 100644
--- a/frontend/src/components/layout/Header/Header.tsx
+++ b/frontend/src/components/layout/Header/Header.tsx
@@ -43,24 +43,27 @@ export const TitleHeader = ({ title }: HeaderProps) => (
// 3. 가운데 제목, 우측 상단 차지하는 헤더 : 게임 대기 화면
export const RoomSettingHeader = ({ title }: HeaderProps) => {
- const { isOpen, show, close } = useModal();
+ const { show } = useModal();
const { handleExit } = useExit();
const memberInfo = useRecoilValue(memberInfoState);
+ const handleClickRoomSetting = () => {
+ show(RoomSettingModal);
+ };
+
return (
{title}
{memberInfo.isMaster ? (
-
);
};
diff --git a/frontend/src/hooks/useModal.ts b/frontend/src/hooks/useModal.ts
index 0529c40d4..24228ec7b 100644
--- a/frontend/src/hooks/useModal.ts
+++ b/frontend/src/hooks/useModal.ts
@@ -1,16 +1,13 @@
-import { useState } from 'react';
+import { useContext } from 'react';
-const useModal = () => {
- const [isOpen, setIsOpen] = useState(false);
-
- const show = () => {
- setIsOpen(true);
- };
+import { ModalDispatchContext } from '@/providers/ModalProvider/ModalProvider';
- const close = () => {
- setIsOpen(false);
- };
+const useModal = () => {
+ const dispatch = useContext(ModalDispatchContext);
- return { isOpen, show, close };
+ if (dispatch === null) {
+ throw new Error('ModalDispatchContext가 존재하지 않습니다.');
+ }
+ return dispatch;
};
export default useModal;
diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx
index af3f59a00..7ba7c7146 100644
--- a/frontend/src/index.tsx
+++ b/frontend/src/index.tsx
@@ -3,10 +3,11 @@ import * as Sentry from '@sentry/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import ReactDOM from 'react-dom/client';
+import { RouterProvider } from 'react-router-dom';
import { RecoilRoot } from 'recoil';
-import App from './App';
import ToastProvider from './providers/ToastProvider/ToastProvider';
+import { router } from './router';
import GlobalStyle from './styles/GlobalStyle';
import { Theme } from './styles/Theme';
@@ -35,7 +36,7 @@ enableMocking().then(() => {
-
+
diff --git a/frontend/src/pages/NicknamePage/NicknamePage.tsx b/frontend/src/pages/NicknamePage/NicknamePage.tsx
index 98fe45c7d..770a5e02a 100644
--- a/frontend/src/pages/NicknamePage/NicknamePage.tsx
+++ b/frontend/src/pages/NicknamePage/NicknamePage.tsx
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
-import { useRecoilValue, useSetRecoilState } from 'recoil';
+import { useSetRecoilState } from 'recoil';
import NicknameInput from './NicknameInput/NicknameInput';
import {
@@ -18,16 +18,12 @@ import useMakeOrEnterRoom from './useMakeOrEnterRoom';
import { isJoinableRoom } from '@/apis/room';
import AngryDdangkong from '@/assets/images/angryDdangkong.png';
import SillyDdangkong from '@/assets/images/sillyDdangkong.png';
-import AlertModal from '@/components/common/AlertModal/AlertModal';
import Button from '@/components/common/Button/Button';
import Content from '@/components/layout/Content/Content';
-import useModal from '@/hooks/useModal';
-import { memberInfoState, roomUuidState } from '@/recoil/atom';
+import { roomUuidState } from '@/recoil/atom';
const NicknamePage = () => {
- const { isOpen, show, close } = useModal();
- const { nicknameInputRef, handleMakeOrEnterRoom, isLoading } = useMakeOrEnterRoom(show);
- const { isMaster } = useRecoilValue(memberInfoState);
+ const { nicknameInputRef, handleMakeOrEnterRoom, isLoading } = useMakeOrEnterRoom();
const { roomUuid } = useParams();
const setRoomUuidState = useSetRecoilState(roomUuidState);
@@ -69,12 +65,6 @@ const NicknamePage = () => {
text={isLoading ? '접속 중...' : '확인'}
bottom
/>
-
);
};
diff --git a/frontend/src/pages/NicknamePage/useMakeOrEnterRoom.ts b/frontend/src/pages/NicknamePage/useMakeOrEnterRoom.ts
index a6bda8bb2..b0f05034d 100644
--- a/frontend/src/pages/NicknamePage/useMakeOrEnterRoom.ts
+++ b/frontend/src/pages/NicknamePage/useMakeOrEnterRoom.ts
@@ -4,19 +4,23 @@ import { useNavigate, useParams } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { enterRoom, createRoom } from '@/apis/room';
+import AlertModal from '@/components/common/AlertModal/AlertModal';
import { ROUTES } from '@/constants/routes';
+import useModal from '@/hooks/useModal';
import { memberInfoState, roomUuidState } from '@/recoil/atom';
import { CreateOrEnterRoomResponse } from '@/types/room';
+import { CustomError } from '@/utils/error';
-const useMakeOrEnterRoom = (showModal: () => void) => {
+const useMakeOrEnterRoom = () => {
const nicknameInputRef = useRef
(null);
const navigate = useNavigate();
const [{ isMaster }, setMemberInfo] = useRecoilState(memberInfoState);
const [, setRoomUuidState] = useRecoilState(roomUuidState);
const { roomUuid } = useParams();
+ const { show: showModal } = useModal();
- const createRoomMutation = useMutation({
+ const createRoomMutation = useMutation({
mutationFn: createRoom,
onSuccess: (data) => {
setMemberInfo((prev) => ({
@@ -26,14 +30,14 @@ const useMakeOrEnterRoom = (showModal: () => void) => {
setRoomUuidState(data.roomUuid || '');
navigate(ROUTES.ready(Number(data.roomId)), { replace: true });
},
- onError: () => {
- showModal();
+ onError: (error) => {
+ showModal(AlertModal, { title: '방 생성 에러', message: error.message });
},
});
const enterRoomMutation = useMutation<
CreateOrEnterRoomResponse,
- Error,
+ CustomError,
{ nickname: string; roomUuid: string }
>({
mutationFn: ({ nickname, roomUuid }) => enterRoom(roomUuid, nickname),
@@ -42,8 +46,8 @@ const useMakeOrEnterRoom = (showModal: () => void) => {
setRoomUuidState(data.roomUuid || '');
navigate(ROUTES.ready(Number(data.roomId)), { replace: true });
},
- onError: () => {
- showModal();
+ onError: (error: CustomError) => {
+ showModal(AlertModal, { title: '방 참여 에러', message: error.message });
},
});
diff --git a/frontend/src/pages/RoundResultPage/RoundResultPage.tsx b/frontend/src/pages/RoundResultPage/RoundResultPage.tsx
index 1d4719593..eef185169 100644
--- a/frontend/src/pages/RoundResultPage/RoundResultPage.tsx
+++ b/frontend/src/pages/RoundResultPage/RoundResultPage.tsx
@@ -1,32 +1,14 @@
-import { useParams } from 'react-router-dom';
-
-import createRandomNextRoundMessage from './createRandomNextRoundMessage';
-
-import InfoModal from '@/components/common/InfoModal/InfoModal';
import NextRoundButton from '@/components/common/NextRoundButton/NextRoundButton';
-import useMoveNextRoundMutation from '@/components/common/NextRoundButton/NextRoundButton.hook';
import Content from '@/components/layout/Content/Content';
import RoundVoteContainer from '@/components/RoundVoteContainer/RoundVoteContainer';
import TopicContainer from '@/components/TopicContainer/TopicContainer';
-import useModal from '@/hooks/useModal';
const RoundResultPage = () => {
- const { roomId } = useParams();
- const { isOpen, show, close } = useModal();
- const { mutate: moveNextRound } = useMoveNextRoundMutation(Number(roomId));
- const randomRoundNextMessage = createRandomNextRoundMessage();
-
return (
-
-
+
);
};
diff --git a/frontend/src/providers/ModalProvider/ModalProvider.tsx b/frontend/src/providers/ModalProvider/ModalProvider.tsx
new file mode 100644
index 000000000..38dcbf19e
--- /dev/null
+++ b/frontend/src/providers/ModalProvider/ModalProvider.tsx
@@ -0,0 +1,63 @@
+import { createContext, PropsWithChildren, useMemo, useState } from 'react';
+
+interface ModalProps {
+ title?: string;
+ message?: string;
+ onConfirm?: () => void;
+}
+
+interface ModalState extends ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+interface Modal extends ModalProps {
+ Component: React.FC | null;
+ isOpen: boolean;
+}
+
+interface ModalDispatchContextProps {
+ show: (Component: React.FC | null, props?: ModalProps) => void;
+ close: () => void;
+}
+
+export const ModalDispatchContext = createContext(null);
+
+const ModalProvider = ({ children }: PropsWithChildren) => {
+ const [modal, setModal] = useState({
+ Component: null,
+ isOpen: false,
+ title: '',
+ message: '',
+ onConfirm: () => {},
+ });
+
+ const show = (Component: React.FC | null, props?: ModalProps) => {
+ setModal({
+ Component,
+ title: props?.title,
+ message: props?.message,
+ onConfirm: props?.onConfirm,
+ isOpen: true,
+ });
+ };
+
+ const close = () => {
+ setModal((prev) => ({
+ ...prev,
+ Component: null,
+ isOpen: false,
+ }));
+ };
+
+ const dispatch = useMemo(() => ({ show, close }), []);
+
+ return (
+
+ {children}
+ {modal.isOpen && modal.Component && }
+
+ );
+};
+
+export default ModalProvider;
diff --git a/frontend/src/router/HeaderLayout.tsx b/frontend/src/router/HeaderLayout.tsx
new file mode 100644
index 000000000..84066738c
--- /dev/null
+++ b/frontend/src/router/HeaderLayout.tsx
@@ -0,0 +1,14 @@
+import { Outlet } from 'react-router-dom';
+
+import Header from '@/components/layout/Header/Header';
+
+const HeaderLayout = () => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default HeaderLayout;
diff --git a/frontend/src/router/layout.tsx b/frontend/src/router/MainLayout.tsx
similarity index 51%
rename from frontend/src/router/layout.tsx
rename to frontend/src/router/MainLayout.tsx
index 7cc3160ad..47b51ce90 100644
--- a/frontend/src/router/layout.tsx
+++ b/frontend/src/router/MainLayout.tsx
@@ -1,13 +1,16 @@
import { Outlet } from 'react-router-dom';
import RootErrorBoundary from '@/components/common/ErrorBoundary/RootErrorBoundary';
-import Header from '@/components/layout/Header/Header';
+import ModalProvider from '@/providers/ModalProvider/ModalProvider';
-export const Layout = () => {
+const MainLayout = () => {
return (
-
-
+
+
+
);
};
+
+export default MainLayout;
diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx
index 2f0ffa453..0f7c8a8eb 100644
--- a/frontend/src/router/index.tsx
+++ b/frontend/src/router/index.tsx
@@ -1,6 +1,7 @@
import { createBrowserRouter } from 'react-router-dom';
-import { Layout } from './layout';
+import HeaderLayout from './HeaderLayout';
+import MainLayout from './MainLayout';
import RouterErrorFallback from '@/components/common/ErrorFallback/RouterErrorFallback/RouterErrorFallback';
import GamePage from '@/pages/GamePage/GamePage';
@@ -14,38 +15,43 @@ import VoteStatusPage from '@/pages/VoteStatusPage/VoteStatusPage';
export const router = createBrowserRouter([
{
path: '/',
- element: ,
+ element: ,
errorElement: ,
- },
- {
- path: ':roomId/game',
- element: ,
- },
- {
- path: '/',
- element: ,
children: [
{
- path: 'nickname/:roomUuid?',
- element: ,
+ path: '/',
+ element: ,
},
{
- path: ':roomId/ready',
- element: ,
+ path: ':roomId/game',
+ element: ,
},
{
- path: ':roomId/round/result',
- element: ,
- },
- {
- path: ':roomId/round/result/status',
- element: ,
- },
- {
- path: ':roomId/game/result',
- element: ,
+ path: '/',
+ element: ,
+ children: [
+ {
+ path: 'nickname/:roomUuid?',
+ element: ,
+ },
+ {
+ path: ':roomId/ready',
+ element: ,
+ },
+ {
+ path: ':roomId/round/result',
+ element: ,
+ },
+ {
+ path: ':roomId/round/result/status',
+ element: ,
+ },
+ {
+ path: ':roomId/game/result',
+ element: ,
+ },
+ ],
},
],
- errorElement: ,
},
]);
diff --git a/frontend/src/utils/test-utils.tsx b/frontend/src/utils/test-utils.tsx
index 394b660a1..9fda43f21 100644
--- a/frontend/src/utils/test-utils.tsx
+++ b/frontend/src/utils/test-utils.tsx
@@ -10,7 +10,9 @@ import type { MutableSnapshot } from 'recoil';
import AsyncErrorBoundary from '@/components/common/ErrorBoundary/AsyncErrorBoundary';
import RootErrorBoundary from '@/components/common/ErrorBoundary/RootErrorBoundary';
import Spinner from '@/components/common/Spinner/Spinner';
+import ModalProvider from '@/providers/ModalProvider/ModalProvider';
import ToastProvider from '@/providers/ToastProvider/ToastProvider';
+import { memberInfoState } from '@/recoil/atom';
import GlobalStyle from '@/styles/GlobalStyle';
import { Theme } from '@/styles/Theme';
@@ -34,14 +36,16 @@ const wrapper = ({
-
-
-
-
- {children}
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
@@ -62,4 +66,12 @@ const customRender = (ui: React.ReactNode, options: CustomRenderOptions = {}) =>
});
};
-export { wrapper, customRender };
+const customRenderWithIsMaster = (Component: React.ReactNode, isMaster: boolean) => {
+ const initializeState = (snap: MutableSnapshot) => {
+ snap.set(memberInfoState, { memberId: 1, nickname: 'Test User', isMaster });
+ };
+
+ customRender(Component, { initializeState });
+};
+
+export { wrapper, customRender, customRenderWithIsMaster };