diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 2a7e73a6..4ffb1f6a 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -159,6 +159,12 @@ "아래로 스크롤": "Scroll down", "이미 많은 사람들이 디베이트 타이머로\n더 나은 토론환경을 만들고 있어요.": "Many people already use\nDebate Timer to make debates better.", "비회원으로 시작하기": "Continue as guest", + "서비스 점검 중": "Service Under Maintenance", + "죄송합니다. 나중에 다시 시도해주세요... 😭 대신, 오프라인 모드로 타이머를 사용해볼 수 있으니, 필요하신 경우 '{{action}}' 버튼을 클릭해주세요.": "Sorry, please try again later... 😭 In the meantime, you can still use the timer in offline mode. If you need it, select “{{action}}.”", + "오프라인으로 시작하기": "Start Offline", + "오프라인으로 이어하기": "Continue Offline", + "토론이 끝났습니다. 종료하시겠습니까?": "The debate has ended. Would you like to finish?", + "예": "Yes", "버그 및 불편사항 제보": "Report bugs or issues", "디베이트 타이머 사용 중 불편함을 느끼셨나요?": "Did you run into issues while using Debate Timer?", "접수하기": "Report", diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 39326511..cdc0804f 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -160,6 +160,12 @@ "아래로 스크롤": "아래로 스크롤", "이미 많은 사람들이 디베이트 타이머로\n더 나은 토론환경을 만들고 있어요.": "이미 많은 사람들이 디베이트 타이머로\n더 나은 토론환경을 만들고 있어요.", "비회원으로 시작하기": "비회원으로 시작하기", + "서비스 점검 중": "서비스 점검 중", + "죄송합니다. 나중에 다시 시도해주세요... 😭 대신, 오프라인 모드로 타이머를 사용해볼 수 있으니, 필요하신 경우 '{{action}}' 버튼을 클릭해주세요.": "죄송합니다. 나중에 다시 시도해주세요... 😭 대신, 오프라인 모드로 타이머를 사용해볼 수 있으니, 필요하신 경우 '{{action}}' 버튼을 클릭해주세요.", + "오프라인으로 시작하기": "오프라인으로 시작하기", + "오프라인으로 이어하기": "오프라인으로 이어하기", + "토론이 끝났습니다. 종료하시겠습니까?": "토론이 끝났습니다. 종료하시겠습니까?", + "예": "예", "버그 및 불편사항 제보": "버그 및 불편사항 제보", "디베이트 타이머 사용 중 불편함을 느끼셨나요?": "디베이트 타이머 사용 중 불편함을 느끼셨나요?", "접수하기": "접수하기", diff --git a/src/components/FillButton/FillButton.tsx b/src/components/FillButton/FillButton.tsx new file mode 100644 index 00000000..bb207514 --- /dev/null +++ b/src/components/FillButton/FillButton.tsx @@ -0,0 +1,39 @@ +import { PropsWithChildren } from 'react'; + +type FillButtonVariant = 'primary' | 'secondary'; +type FillButtonSize = 'sm' | 'md' | 'lg'; + +interface FillButtonProps extends PropsWithChildren { + onClick: () => void; + variant?: FillButtonVariant; + size?: FillButtonSize; +} + +const VARIANT_CLASSNAMES: Record = { + primary: + 'bg-brand hover:bg-semantic-table hover:text-default-white focus-visible:outline-brand-main focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4', + secondary: 'bg-neutral-200 hover:bg-brand', +}; + +const SIZE_CLASSNAMES: Record = { + sm: 'px-5', + md: 'px-9', + lg: 'px-20', +}; + +export default function FillButton({ + children, + onClick, + variant = 'primary', + size = 'sm', +}: FillButtonProps) { + return ( + + ); +} diff --git a/src/layout/components/header/StickyTriSectionHeader.tsx b/src/layout/components/header/StickyTriSectionHeader.tsx index 3449e93a..7f2dccaf 100644 --- a/src/layout/components/header/StickyTriSectionHeader.tsx +++ b/src/layout/components/header/StickyTriSectionHeader.tsx @@ -19,6 +19,7 @@ import { DEFAULT_LANG, isSupportedLang, } from '../../../util/languageRouting'; +import { isMaintenanceModeEnabled } from '../../../util/maintenanceMode'; type HeaderIcons = 'home' | 'auth'; @@ -62,7 +63,10 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { const { mutate: logoutMutate } = useLogout(() => navigate(homePath)); const { openModal, closeModal, ModalWrapper } = useModal({}); const { isFullscreen, setFullscreen } = useFullscreen(); - const defaultIcons: HeaderIcons[] = ['home', 'auth']; + const isMaintenanceMode = isMaintenanceModeEnabled(); + const defaultIcons: HeaderIcons[] = isMaintenanceMode + ? ['home'] + : ['home', 'auth']; const handleLoginStart = (keepData: boolean) => { sessionStorage.setItem('keepGuestTable', String(keepData)); @@ -101,7 +105,7 @@ StickyTriSectionHeader.Right = function Right(props: PropsWithChildren) { setFullscreen(false); } - if (isGuestFlow()) { + if (isGuestFlow() && !isMaintenanceMode) { deleteSessionCustomizeTableData(); } navigate(homePath); diff --git a/src/page/HomePage/HomePage.test.tsx b/src/page/HomePage/HomePage.test.tsx new file mode 100644 index 00000000..7af6992f --- /dev/null +++ b/src/page/HomePage/HomePage.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import HomePage from './HomePage'; + +vi.mock('../LandingPage/LandingPage', () => ({ + default: () =>
일반 랜딩 화면
, +})); + +vi.mock('../MaintenancePage/MaintenancePage', () => ({ + default: () =>
점검 화면
, +})); + +describe('홈 화면 선택', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test.each([undefined, 'false', 'TRUE'])( + '점검 환경 변수가 %s이면 일반 랜딩 화면을 표시한다', + (value) => { + vi.stubEnv('VITE_MAINTENANCE_MODE', value ?? ''); + + render(); + + expect(screen.getByText('일반 랜딩 화면')).toBeInTheDocument(); + expect(screen.queryByText('점검 화면')).not.toBeInTheDocument(); + }, + ); + + test('점검 환경 변수가 true이면 점검 화면을 표시한다', () => { + vi.stubEnv('VITE_MAINTENANCE_MODE', 'true'); + + render(); + + expect(screen.getByText('점검 화면')).toBeInTheDocument(); + expect(screen.queryByText('일반 랜딩 화면')).not.toBeInTheDocument(); + }); +}); diff --git a/src/page/HomePage/HomePage.tsx b/src/page/HomePage/HomePage.tsx new file mode 100644 index 00000000..e83600b9 --- /dev/null +++ b/src/page/HomePage/HomePage.tsx @@ -0,0 +1,7 @@ +import LandingPage from '../LandingPage/LandingPage'; +import MaintenancePage from '../MaintenancePage/MaintenancePage'; +import { isMaintenanceModeEnabled } from '../../util/maintenanceMode'; + +export default function HomePage() { + return isMaintenanceModeEnabled() ? : ; +} diff --git a/src/page/LandingPage/components/MainSection.tsx b/src/page/LandingPage/components/MainSection.tsx index ddd70fa5..ebf507bd 100644 --- a/src/page/LandingPage/components/MainSection.tsx +++ b/src/page/LandingPage/components/MainSection.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import preview from '../../../assets/landing/preview.webm'; import { isLoggedIn } from '../../../util/accessToken'; +import FillButton from '../../../components/FillButton/FillButton'; interface MainSectionProps { onStartWithoutLogin: () => void; @@ -23,12 +24,11 @@ export default function MainSection({

{t('토론 진행을 더 쉽고 빠르게')}

- + ); } diff --git a/src/page/LandingPage/components/ReportSection.tsx b/src/page/LandingPage/components/ReportSection.tsx index 86365e78..5f396cdb 100644 --- a/src/page/LandingPage/components/ReportSection.tsx +++ b/src/page/LandingPage/components/ReportSection.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import section501 from '../../../assets/landing/section5-1.png'; import { LANDING_URLS } from '../../../constants/urls'; +import FillButton from '../../../components/FillButton/FillButton'; export default function ReportSection() { const { t } = useTranslation(); @@ -14,7 +15,9 @@ export default function ReportSection() {

{t('디베이트 타이머 사용 중 불편함을 느끼셨나요?')}

- + section501 diff --git a/src/page/LandingPage/components/ReviewSection.tsx b/src/page/LandingPage/components/ReviewSection.tsx index d63a245d..a808da14 100644 --- a/src/page/LandingPage/components/ReviewSection.tsx +++ b/src/page/LandingPage/components/ReviewSection.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import ReviewCard from './ReviewCard'; import { REVIEWS } from '../../../constants/reviews'; +import FillButton from '../../../components/FillButton/FillButton'; interface ReviewSectionProps { onStartWithoutLogin: () => void; @@ -28,12 +29,9 @@ export default function ReviewSection({ ))}
- +
); diff --git a/src/page/LandingPage/components/TableSection.tsx b/src/page/LandingPage/components/TableSection.tsx index 0bcd3a38..2f8dc4ba 100644 --- a/src/page/LandingPage/components/TableSection.tsx +++ b/src/page/LandingPage/components/TableSection.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import section301 from '../../../assets/landing/debate_info_setting.png'; import section302 from '../../../assets/landing/table_list.png'; +import FillButton from '../../../components/FillButton/FillButton'; interface TableSectionProps { onLogin: () => void; @@ -46,12 +47,9 @@ export default function TableSection({ onLogin }: TableSectionProps) {

{t('시간표를 저장하려면,\n디베이트 타이머에 로그인해 보세요!')}

- +
+ {t('3초 로그인')} +
); diff --git a/src/page/LandingPage/components/TemplateApplicationSection.tsx b/src/page/LandingPage/components/TemplateApplicationSection.tsx index 43578023..ba6f9df2 100644 --- a/src/page/LandingPage/components/TemplateApplicationSection.tsx +++ b/src/page/LandingPage/components/TemplateApplicationSection.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import section501 from '../../../assets/landing/section5-1.png'; import { LANDING_URLS } from '../../../constants/urls'; +import FillButton from '../../../components/FillButton/FillButton'; export default function TemplateApplicationSection() { const { t } = useTranslation(); @@ -13,7 +14,9 @@ export default function TemplateApplicationSection() {

{t('새로운 템플릿도 신청해 볼까요?')}

- + section501 diff --git a/src/page/MaintenancePage/MaintenancePage.test.tsx b/src/page/MaintenancePage/MaintenancePage.test.tsx new file mode 100644 index 00000000..b0e329cf --- /dev/null +++ b/src/page/MaintenancePage/MaintenancePage.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createInstance } from 'i18next'; +import { I18nextProvider } from 'react-i18next'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { SAMPLE_TABLE_DATA } from '../../constants/sample_table'; +import { getSessionCustomizeTableData } from '../../util/sessionStorage'; +import MaintenancePage from './MaintenancePage'; + +const STORAGE_KEY = 'DebateTableData'; + +async function renderMaintenancePage(language: 'ko' | 'en' = 'ko') { + const i18n = createInstance(); + await i18n.init({ + lng: language, + fallbackLng: 'ko', + resources: { + ko: { + translation: { + '서비스 점검 중': '서비스 점검 중', + "죄송합니다. 나중에 다시 시도해주세요... 😭 대신, 오프라인 모드로 타이머를 사용해볼 수 있으니, 필요하신 경우 '{{action}}' 버튼을 클릭해주세요.": + "죄송합니다. 나중에 다시 시도해주세요... 😭 대신, 오프라인 모드로 타이머를 사용해볼 수 있으니, 필요하신 경우 '{{action}}' 버튼을 클릭해주세요.", + '오프라인으로 시작하기': '오프라인으로 시작하기', + '오프라인으로 이어하기': '오프라인으로 이어하기', + }, + }, + en: { + translation: { + '서비스 점검 중': 'Service Under Maintenance', + "죄송합니다. 나중에 다시 시도해주세요... 😭 대신, 오프라인 모드로 타이머를 사용해볼 수 있으니, 필요하신 경우 '{{action}}' 버튼을 클릭해주세요.": + 'Sorry, please try again later... 😭 In the meantime, you can still use the timer in offline mode. If you need it, select “{{action}}.”', + '오프라인으로 시작하기': 'Start Offline', + '오프라인으로 이어하기': 'Continue Offline', + }, + }, + }, + }); + + return render( + + + + } /> + } /> + 게스트 개요} + /> + Guest overview} + /> + + + , + ); +} + +describe('점검 화면', () => { + afterEach(() => { + sessionStorage.clear(); + }); + + test('게스트 세션이 없으면 샘플 데이터로 오프라인 흐름을 시작한다', async () => { + const user = userEvent.setup(); + await renderMaintenancePage(); + + expect( + screen.getByRole('heading', { name: '서비스 점검 중' }), + ).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: '오프라인으로 시작하기' }), + ); + + expect(await screen.findByText('게스트 개요')).toBeInTheDocument(); + expect(getSessionCustomizeTableData()).toEqual({ + id: -1, + ...SAMPLE_TABLE_DATA, + }); + }); + + test('기존 게스트 세션이 있으면 데이터를 덮어쓰지 않고 이어간다', async () => { + const user = userEvent.setup(); + const existingData = { + id: -1, + info: { ...SAMPLE_TABLE_DATA.info, name: '수정한 시간표' }, + table: SAMPLE_TABLE_DATA.table, + }; + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(existingData)); + await renderMaintenancePage(); + + expect( + screen.getByText(/'오프라인으로 이어하기' 버튼/), + ).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: '오프라인으로 이어하기' }), + ); + + expect(await screen.findByText('게스트 개요')).toBeInTheDocument(); + expect(getSessionCustomizeTableData()).toEqual(existingData); + }); + + test('영어에서는 영어 안내와 CTA를 표시한다', async () => { + await renderMaintenancePage('en'); + + expect( + screen.getByRole('heading', { name: 'Service Under Maintenance' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Start Offline' }), + ).toBeInTheDocument(); + expect(screen.getByText(/select “Start Offline.”/)).toBeInTheDocument(); + }); +}); diff --git a/src/page/MaintenancePage/MaintenancePage.tsx b/src/page/MaintenancePage/MaintenancePage.tsx new file mode 100644 index 00000000..35b0efc2 --- /dev/null +++ b/src/page/MaintenancePage/MaintenancePage.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import FillButton from '../../components/FillButton/FillButton'; +import { SAMPLE_TABLE_DATA } from '../../constants/sample_table'; +import { + isGuestFlow, + setSessionCustomizeTableData, +} from '../../util/sessionStorage'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../util/languageRouting'; + +const MAINTENANCE_DESCRIPTION = + "죄송합니다. 나중에 다시 시도해주세요... 😭 대신, 오프라인 모드로 타이머를 사용해볼 수 있으니, 필요하신 경우 '{{action}}' 버튼을 클릭해주세요."; + +export default function MaintenancePage() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const hasGuestSession = isGuestFlow(); + const actionLabel = hasGuestSession + ? t('오프라인으로 이어하기') + : t('오프라인으로 시작하기'); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + + const handleStartOffline = () => { + if (!hasGuestSession) { + setSessionCustomizeTableData(SAMPLE_TABLE_DATA); + } + + navigate(buildLangPath('/overview/customize/guest', lang)); + }; + + return ( +
+
+ +

+ {t('서비스 점검 중')} +

+

+ {t(MAINTENANCE_DESCRIPTION, { action: actionLabel })} +

+ {actionLabel} +
+
+ ); +} diff --git a/src/page/TableOverviewPage/TableOverviewPage.tsx b/src/page/TableOverviewPage/TableOverviewPage.tsx index 23a0c008..cf662f90 100644 --- a/src/page/TableOverviewPage/TableOverviewPage.tsx +++ b/src/page/TableOverviewPage/TableOverviewPage.tsx @@ -26,6 +26,7 @@ import { isSupportedLang, } from '../../util/languageRouting'; import useAnalytics from '../../hooks/useAnalytics'; +import { isMaintenanceModeEnabled } from '../../util/maintenanceMode'; // 토론 개요를 보여주고 공유, 수정, 시작 액션을 제공하는 페이지다. export default function TableOverviewPage() { @@ -38,6 +39,7 @@ export default function TableOverviewPage() { const { trackEvent } = useAnalytics(); const currentLang = i18n.resolvedLanguage ?? i18n.language; const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + const isMaintenanceMode = isMaintenanceModeEnabled(); // 팀 선정 모달을 초기 상태로 열어준다. const handleOpenModal = () => { @@ -182,22 +184,25 @@ export default function TableOverviewPage() {
- + {!isMaintenanceMode ? ( + + ) : null} + + + + ); +} + +async function renderMaintenanceEndModal() { + const i18n = createInstance(); + await i18n.init({ + lng: 'ko', + resources: { + ko: { + translation: { + '토론이 끝났습니다. 종료하시겠습니까?': + '토론이 끝났습니다. 종료하시겠습니까?', + 예: '예', + 아니오: '아니오', + '모달 닫기': '모달 닫기', + }, + }, + }, + }); + + return render( + + + + + } /> + 게스트 개요
} + /> + 점검 홈} /> + + + + , + ); +} + +describe('점검 중 타이머 종료 모달', () => { + beforeEach(() => { + sessionStorage.clear(); + setSessionCustomizeTableData(SAMPLE_TABLE_DATA); + }); + + afterEach(() => { + sessionStorage.clear(); + }); + + test('아니오를 누르면 게스트 세션을 유지하고 개요로 이동한다', async () => { + const user = userEvent.setup(); + await renderMaintenanceEndModal(); + await user.click(screen.getByRole('button', { name: '종료 모달 열기' })); + + await user.click(screen.getByRole('button', { name: '아니오' })); + + expect(await screen.findByText('게스트 개요')).toBeInTheDocument(); + expect(sessionStorage.getItem('DebateTableData')).not.toBeNull(); + }); + + test('예를 누르면 게스트 세션을 유지하고 점검 홈으로 이동한다', async () => { + const user = userEvent.setup(); + await renderMaintenanceEndModal(); + await user.click(screen.getByRole('button', { name: '종료 모달 열기' })); + + await user.click(screen.getByRole('button', { name: '예' })); + + expect(await screen.findByText('점검 홈')).toBeInTheDocument(); + expect(sessionStorage.getItem('DebateTableData')).not.toBeNull(); + }); + + test.each([ + { closeType: 'close-button', label: '닫기 버튼' }, + { closeType: 'overlay', label: '오버레이' }, + { closeType: 'escape', label: '이스케이프 키' }, + ] as const)( + '$label 닫기는 현재 타이머 상태를 유지한다', + async ({ closeType }) => { + const user = userEvent.setup(); + await renderMaintenanceEndModal(); + await user.click(screen.getByRole('button', { name: '타이머 변경' })); + await user.click(screen.getByRole('button', { name: '종료 모달 열기' })); + + if (closeType === 'close-button') { + await user.click(screen.getByRole('button', { name: '모달 닫기' })); + } else if (closeType === 'overlay') { + const overlay = screen.getByRole('dialog').parentElement?.parentElement; + expect(overlay).toBeTruthy(); + if (!overlay) throw new Error('모달 바깥 영역을 찾지 못했습니다.'); + await user.click(overlay); + } else { + await user.keyboard('{Escape}'); + } + + expect(screen.getByText('타이머 상태 1')).toBeInTheDocument(); + expect( + screen.queryByText('토론이 끝났습니다. 종료하시겠습니까?'), + ).not.toBeInTheDocument(); + }, + ); +}); diff --git a/src/page/TimerPage/components/MaintenanceEndModal.tsx b/src/page/TimerPage/components/MaintenanceEndModal.tsx new file mode 100644 index 00000000..57bdf84a --- /dev/null +++ b/src/page/TimerPage/components/MaintenanceEndModal.tsx @@ -0,0 +1,61 @@ +import { ComponentType, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import DialogModal from '../../../components/DialogModal/DialogModal'; +import { + buildLangPath, + DEFAULT_LANG, + isSupportedLang, +} from '../../../util/languageRouting'; + +interface MaintenanceEndModalProps { + Wrapper: ComponentType<{ + children: ReactNode; + closeButtonColor?: string; + }>; + onClose: () => void; +} + +export default function MaintenanceEndModal({ + Wrapper, + onClose, +}: MaintenanceEndModalProps) { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; + + const handleNavigate = (path: string) => { + onClose(); + navigate(buildLangPath(path, lang)); + }; + + return ( + +
+ handleNavigate('/overview/customize/guest'), + }} + right={{ + text: t('예'), + onClick: () => handleNavigate('/home'), + isBold: true, + }} + > +

+ {t('토론이 끝났습니다. 종료하시겠습니까?')} +

+
+
+
+ ); +} diff --git a/src/routes/MaintenanceRoute.test.tsx b/src/routes/MaintenanceRoute.test.tsx new file mode 100644 index 00000000..abcfcac2 --- /dev/null +++ b/src/routes/MaintenanceRoute.test.tsx @@ -0,0 +1,100 @@ +import { render, screen } from '@testing-library/react'; +import { createInstance } from 'i18next'; +import { I18nextProvider } from 'react-i18next'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { SAMPLE_TABLE_DATA } from '../constants/sample_table'; +import { setSessionCustomizeTableData } from '../util/sessionStorage'; +import MaintenanceRoute from './MaintenanceRoute'; +import { MaintenanceAccess } from './maintenanceAccess'; + +interface RenderRouteOptions { + access: MaintenanceAccess; + initialEntry: string; + path: string; + language?: 'ko' | 'en'; +} + +async function renderRoute({ + access, + initialEntry, + path, + language = 'ko', +}: RenderRouteOptions) { + const i18n = createInstance(); + await i18n.init({ lng: language, resources: {} }); + + return render( + + + + +
대상 화면
+ + } + /> + 한국어 점검 홈} /> + 영어 점검 홈} /> +
+
+
, + ); +} + +describe('점검 라우트 보호', () => { + beforeEach(() => { + sessionStorage.clear(); + vi.stubEnv('VITE_MAINTENANCE_MODE', 'true'); + }); + + afterEach(() => { + sessionStorage.clear(); + vi.unstubAllEnvs(); + }); + + test('게스트 세션과 정확한 쿼리가 있으면 편집 화면을 허용한다', async () => { + setSessionCustomizeTableData(SAMPLE_TABLE_DATA); + await renderRoute({ + access: 'guest-composition', + initialEntry: '/composition?mode=edit&type=CUSTOMIZE', + path: '/composition', + }); + + expect(screen.getByText('대상 화면')).toBeInTheDocument(); + }); + + test('게스트 세션이 없으면 허용 URL도 점검 홈으로 이동한다', async () => { + await renderRoute({ + access: 'guest-overview', + initialEntry: '/overview/customize/guest', + path: '/overview/:type/:id', + }); + + expect(await screen.findByText('한국어 점검 홈')).toBeInTheDocument(); + }); + + test('영어 숫자 ID 경로는 영어 점검 홈으로 이동한다', async () => { + setSessionCustomizeTableData(SAMPLE_TABLE_DATA); + await renderRoute({ + access: 'guest-overview', + initialEntry: '/en/overview/customize/10', + path: '/en/overview/:type/:id', + language: 'en', + }); + + expect(await screen.findByText('영어 점검 홈')).toBeInTheDocument(); + }); + + test('일반 모드에서는 차단 대상 화면도 그대로 표시한다', async () => { + vi.stubEnv('VITE_MAINTENANCE_MODE', 'false'); + await renderRoute({ + access: 'blocked', + initialEntry: '/oauth', + path: '/oauth', + }); + + expect(screen.getByText('대상 화면')).toBeInTheDocument(); + }); +}); diff --git a/src/routes/MaintenanceRoute.tsx b/src/routes/MaintenanceRoute.tsx new file mode 100644 index 00000000..9d569be5 --- /dev/null +++ b/src/routes/MaintenanceRoute.tsx @@ -0,0 +1,46 @@ +import { PropsWithChildren } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useLocation, useParams } from 'react-router-dom'; +import { + buildLangPath, + DEFAULT_LANG, + getLangFromPath, + isSupportedLang, +} from '../util/languageRouting'; +import { isGuestFlow } from '../util/sessionStorage'; +import { isMaintenanceModeEnabled } from '../util/maintenanceMode'; +import { + isMaintenanceAccessAllowed, + MaintenanceAccess, +} from './maintenanceAccess'; + +interface MaintenanceRouteProps extends PropsWithChildren { + access: MaintenanceAccess; +} + +export default function MaintenanceRoute({ + access, + children, +}: MaintenanceRouteProps) { + const location = useLocation(); + const params = useParams(); + const { i18n } = useTranslation(); + + if (!isMaintenanceModeEnabled()) return children; + + const isAllowed = isMaintenanceAccessAllowed({ + access, + hasGuestSession: isGuestFlow(), + params, + search: location.search, + }); + + if (isAllowed) return children; + + const pathLang = getLangFromPath(location.pathname); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const lang = + pathLang ?? (isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG); + + return ; +} diff --git a/src/routes/maintenanceAccess.test.ts b/src/routes/maintenanceAccess.test.ts new file mode 100644 index 00000000..3c16f8c2 --- /dev/null +++ b/src/routes/maintenanceAccess.test.ts @@ -0,0 +1,85 @@ +import { isMaintenanceAccessAllowed } from './maintenanceAccess'; + +describe('점검 중 라우트 접근 정책', () => { + test('홈은 게스트 세션 없이도 허용한다', () => { + expect( + isMaintenanceAccessAllowed({ + access: 'home', + hasGuestSession: false, + params: {}, + search: '', + }), + ).toBe(true); + }); + + test.each([ + ['guest-composition', { mode: 'edit', type: 'CUSTOMIZE' }], + ['guest-overview', { type: 'customize', id: 'guest' }], + ['guest-timer', { id: 'guest' }], + ] as const)('%s 경로는 게스트 세션이 있어야 허용한다', (access, params) => { + const search = + access === 'guest-composition' ? '?mode=edit&type=CUSTOMIZE' : ''; + + expect( + isMaintenanceAccessAllowed({ + access, + hasGuestSession: true, + params, + search, + }), + ).toBe(true); + expect( + isMaintenanceAccessAllowed({ + access, + hasGuestSession: false, + params, + search, + }), + ).toBe(false); + }); + + test.each([ + ['?mode=add&type=CUSTOMIZE'], + ['?mode=edit&type=CUSTOMIZE&tableId=1'], + ['?mode=edit'], + ])('허용 계약과 다른 composition 쿼리를 차단한다: %s', (search) => { + expect( + isMaintenanceAccessAllowed({ + access: 'guest-composition', + hasGuestSession: true, + params: {}, + search, + }), + ).toBe(false); + }); + + test('숫자 ID를 사용하는 개요와 타이머를 차단한다', () => { + expect( + isMaintenanceAccessAllowed({ + access: 'guest-overview', + hasGuestSession: true, + params: { type: 'customize', id: '10' }, + search: '', + }), + ).toBe(false); + expect( + isMaintenanceAccessAllowed({ + access: 'guest-timer', + hasGuestSession: true, + params: { id: '10' }, + search: '', + }), + ).toBe(false); + }); + + test('허용 목록에 없는 경로를 차단한다', () => { + expect( + isMaintenanceAccessAllowed({ + access: 'blocked', + hasGuestSession: true, + params: {}, + search: '', + }), + ).toBe(false); + }); +}); diff --git a/src/routes/maintenanceAccess.ts b/src/routes/maintenanceAccess.ts new file mode 100644 index 00000000..41c0086f --- /dev/null +++ b/src/routes/maintenanceAccess.ts @@ -0,0 +1,45 @@ +export type MaintenanceAccess = + | 'home' + | 'guest-composition' + | 'guest-overview' + | 'guest-timer' + | 'blocked'; + +interface MaintenanceAccessInput { + access: MaintenanceAccess; + hasGuestSession: boolean; + params: Readonly>; + search: string; +} + +function hasExactCompositionQuery(search: string): boolean { + const searchParams = new URLSearchParams(search); + const keys = Array.from(searchParams.keys()); + + return ( + keys.length === 2 && + searchParams.get('mode') === 'edit' && + searchParams.get('type') === 'CUSTOMIZE' + ); +} + +export function isMaintenanceAccessAllowed({ + access, + hasGuestSession, + params, + search, +}: MaintenanceAccessInput): boolean { + if (access === 'home') return true; + if (!hasGuestSession) return false; + + switch (access) { + case 'guest-composition': + return hasExactCompositionQuery(search); + case 'guest-overview': + return params.type === 'customize' && params.id === 'guest'; + case 'guest-timer': + return params.id === 'guest'; + case 'blocked': + return false; + } +} diff --git a/src/routes/routes.tsx b/src/routes/routes.tsx index bc0e61b9..e773bf9d 100644 --- a/src/routes/routes.tsx +++ b/src/routes/routes.tsx @@ -1,4 +1,5 @@ import { createBrowserRouter } from 'react-router-dom'; +import { ReactNode } from 'react'; import TableListPage from '../page/TableListPage/TableListPage'; import TableOverviewPage from '../page/TableOverviewPage/TableOverviewPage'; import TableCompositionPage from '../page/TableComposition/TableCompositionPage'; @@ -9,7 +10,7 @@ import NotFoundPage from '../components/ErrorBoundary/NotFoundPage'; import BackActionHandler from '../components/BackActionHandler'; import TimerPage from '../page/TimerPage/TimerPage'; import FeedbackTimerPage from '../page/TimerPage/FeedbackTimerPage'; -import LandingPage from '../page/LandingPage/LandingPage'; +import HomePage from '../page/HomePage/HomePage'; import TableSharingPage from '../page/TableSharingPage/TableSharingPage'; import DebateEndPage from '../page/DebateEndPage/DebateEndPage'; import DebateVotePage from '../page/DebateVotePage/DebateVotePage'; @@ -18,92 +19,120 @@ import VoteCompletePage from '../page/VoteCompletePage/VoteCompletePage'; import DebateVoteResultPage from '../page/DebateVoteResultPage/DebateVoteResultPage'; import LanguageWrapper from './LanguageWrapper'; import AudienceSharePage from '../page/AudienceSharePage/AudienceSharePage'; +import MaintenanceRoute from './MaintenanceRoute'; +import { MaintenanceAccess } from './maintenanceAccess'; -const appRoutes = [ +interface AppRoute { + path: string; + element: ReactNode; + requiresAuth: boolean; + maintenanceAccess: MaintenanceAccess; +} + +const appRoutes: AppRoute[] = [ { path: 'home', - element: , + element: , requiresAuth: false, + maintenanceAccess: 'home', }, { path: '', element: , requiresAuth: true, + maintenanceAccess: 'blocked', }, { path: 'composition', element: , requiresAuth: false, + maintenanceAccess: 'guest-composition', }, { path: 'overview/:type/:id', element: , requiresAuth: false, + maintenanceAccess: 'guest-overview', }, { path: 'table/customize/:id', element: , requiresAuth: false, + maintenanceAccess: 'guest-timer', }, { path: 'table/customize/:id/end', element: , requiresAuth: true, + maintenanceAccess: 'blocked', }, { path: 'table/customize/:id/end/feedback', element: , requiresAuth: true, + maintenanceAccess: 'blocked', }, { path: 'table/customize/:tableId/end/vote/:pollId', element: , requiresAuth: true, + maintenanceAccess: 'blocked', }, { path: 'table/customize/:tableId/end/vote/:pollId/result', element: , requiresAuth: true, + maintenanceAccess: 'blocked', }, { path: 'vote/:id', element: , requiresAuth: false, + maintenanceAccess: 'blocked', }, { path: 'vote/end', element: , requiresAuth: false, + maintenanceAccess: 'blocked', }, { path: 'oauth', element: , requiresAuth: false, + maintenanceAccess: 'blocked', }, { path: 'share', element: , requiresAuth: false, + maintenanceAccess: 'blocked', }, { path: 'live/:id', element: , requiresAuth: false, + maintenanceAccess: 'blocked', }, { path: '*', element: , requiresAuth: false, + maintenanceAccess: 'blocked', }, ]; // 인증 보호 로직을 적용한 라우트 -const protectedAppRoutes = appRoutes.map((route) => ({ +const guardedAppRoutes = appRoutes.map((route) => ({ ...route, - element: route.requiresAuth ? ( - {route.element} - ) : ( - route.element + element: ( + + {route.requiresAuth ? ( + {route.element} + ) : ( + route.element + )} + ), })); @@ -120,12 +149,12 @@ const router = createBrowserRouter( { path: '/', element: , - children: protectedAppRoutes, // 기본 언어(ko) 라우트 + children: guardedAppRoutes, // 기본 언어(ko) 라우트 }, { path: ':lang', // 다른 언어 라우트 element: , - children: protectedAppRoutes, + children: guardedAppRoutes, }, ], }, diff --git a/src/util/maintenanceMode.test.ts b/src/util/maintenanceMode.test.ts new file mode 100644 index 00000000..8849bfd0 --- /dev/null +++ b/src/util/maintenanceMode.test.ts @@ -0,0 +1,15 @@ +import { resolveMaintenanceMode } from './maintenanceMode'; + +describe('점검 모드 환경 변수 판정', () => { + test.each([ + [undefined, false], + ['', false], + ['false', false], + ['TRUE', false], + [' true ', false], + ['enabled', false], + ['true', true], + ])('%s 값을 %s로 판정한다', (value, expected) => { + expect(resolveMaintenanceMode(value)).toBe(expected); + }); +}); diff --git a/src/util/maintenanceMode.ts b/src/util/maintenanceMode.ts new file mode 100644 index 00000000..9b64d8ed --- /dev/null +++ b/src/util/maintenanceMode.ts @@ -0,0 +1,7 @@ +export function resolveMaintenanceMode(value: string | undefined): boolean { + return value === 'true'; +} + +export function isMaintenanceModeEnabled(): boolean { + return resolveMaintenanceMode(import.meta.env.VITE_MAINTENANCE_MODE); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 17433776..e0a9b85d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,6 +3,7 @@ interface ImportMetaEnv { readonly VITE_MOCK_API: string; readonly VITE_BASE_PATH: string; + readonly VITE_MAINTENANCE_MODE?: string; } interface ImportMeta {