diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 237065b4..fe4bab77 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -282,5 +282,10 @@ "페이지 닫기": "Close page", "남은 시간": "Time Remaining", "현재 발언 중": "Currently speaking", - "발언 중": "Speaking" + "발언 중": "Speaking", + "디베이트 타이머에 새로운 기능이 생겼어요!": "New features are now available in Debate Timer!", + "업데이트 이미지": "Update image", + "일주일 간 보지 않기": "Don't show again for a week", + "자세히 보기": "View details", + "패치 노트 링크를 읽는 중 오류가 발생했습니다.": "An error occurred while loading the patch note link." } diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 98dcb290..328ad3f0 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -282,5 +282,10 @@ "페이지 닫기": "페이지 닫기", "남은 시간": "남은 시간", "현재 발언 중": "현재 발언 중", - "발언 중": "발언 중" + "발언 중": "발언 중", + "디베이트 타이머에 새로운 기능이 생겼어요!": "디베이트 타이머에 새로운 기능이 생겼어요!", + "업데이트 이미지": "업데이트 이미지", + "일주일 간 보지 않기": "일주일 간 보지 않기", + "자세히 보기": "자세히 보기", + "패치 노트 링크를 읽는 중 오류가 발생했습니다.": "패치 노트 링크를 읽는 중 오류가 발생했습니다." } diff --git a/src/assets/patchNote/0003_en.png b/src/assets/patchNote/0003_en.png new file mode 100644 index 00000000..78b7517a Binary files /dev/null and b/src/assets/patchNote/0003_en.png differ diff --git a/src/assets/patchNote/0003_ko.png b/src/assets/patchNote/0003_ko.png new file mode 100644 index 00000000..a6e74070 Binary files /dev/null and b/src/assets/patchNote/0003_ko.png differ diff --git a/src/components/UpdateModal/UpdateModal.stories.tsx b/src/components/UpdateModal/UpdateModal.stories.tsx index 5fa857b5..f2dbd3bf 100644 --- a/src/components/UpdateModal/UpdateModal.stories.tsx +++ b/src/components/UpdateModal/UpdateModal.stories.tsx @@ -1,6 +1,7 @@ import { Meta, StoryObj } from '@storybook/react'; -import UpdateModal from './UpdateModal'; // 이미지 임포트는 아래와 같이 -import PatchNoteImage from '../../assets/patchNote/0001.png'; +import UpdateModal from './UpdateModal'; +import PatchNoteImageKorean from '../../assets/patchNote/0003_ko.png'; +import PatchNoteImageEnglish from '../../assets/patchNote/0003_en.png'; import { PredefinedPatchNoteData } from '../../constants/patch_note'; const meta: Meta = { @@ -17,14 +18,20 @@ export const Default: Story = { args: { data: { version: '0000', - title: '피드백 & 투표', - description: + titleKo: '피드백 & 투표', + titleEn: 'Feedback & Voting', + descriptionKo: '토론 종료 후 피드백 & 투표 기능으로\n다양한 서비스를 이용하세요!', + descriptionEn: + 'Use a variety of services with feedback and voting after the debate!', link: 'https://notion.so/', - image: PatchNoteImage, + imageKo: PatchNoteImageKorean, + imageEn: PatchNoteImageEnglish, mode: 'predefined', } as PredefinedPatchNoteData, isChecked: false, onChecked: () => {}, + onClose: () => {}, + onClickDetailButton: () => {}, }, }; diff --git a/src/components/UpdateModal/UpdateModal.test.tsx b/src/components/UpdateModal/UpdateModal.test.tsx new file mode 100644 index 00000000..7bacb437 --- /dev/null +++ b/src/components/UpdateModal/UpdateModal.test.tsx @@ -0,0 +1,241 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createInstance } from 'i18next'; +import { I18nextProvider } from 'react-i18next'; +import { + ImageOnlyPatchNoteData, + PredefinedPatchNoteData, +} from '../../constants/patch_note'; +import UpdateModal from './UpdateModal'; + +const translations = { + ko: { + translation: { + '디베이트 타이머에 새로운 기능이 생겼어요!': + '디베이트 타이머에 새로운 기능이 생겼어요!', + '업데이트 이미지': '업데이트 이미지', + '일주일 간 보지 않기': '일주일 간 보지 않기', + '자세히 보기': '자세히 보기', + }, + }, + en: { + translation: { + '디베이트 타이머에 새로운 기능이 생겼어요!': + 'New features are now available in Debate Timer!', + '업데이트 이미지': 'Update image', + '일주일 간 보지 않기': "Don't show again for a week", + '자세히 보기': 'View details', + }, + }, +}; + +const predefinedPatchNote = { + mode: 'predefined', + version: 'test-predefined', + link: 'https://example.com/predefined', + imageKo: '/patch-note-ko.png', + imageEn: '/patch-note-en.png', + titleKo: '한국어 제목', + titleEn: 'English title', + descriptionKo: '한국어 설명', + descriptionEn: 'English description', +} satisfies PredefinedPatchNoteData; + +const imageOnlyPatchNote: ImageOnlyPatchNoteData = { + mode: 'image-only', + version: 'test-image-only', + link: 'https://example.com/image-only', + imageKo: '/patch-note-only-ko.png', + imageEn: '/patch-note-only-en.png', +}; + +async function renderUpdateModal( + data: PredefinedPatchNoteData | ImageOnlyPatchNoteData, + language: 'ko' | 'en', +) { + const i18n = createInstance(); + await i18n.init({ + lng: language, + fallbackLng: 'ko', + supportedLngs: ['ko', 'en'], + resources: translations, + }); + + return render( + + + , + ); +} + +describe('UpdateModal', () => { + test.each([ + { + language: 'ko' as const, + image: '/patch-note-ko.png', + imageAlt: '업데이트 이미지', + title: '한국어 제목', + description: '한국어 설명', + introduction: '디베이트 타이머에 새로운 기능이 생겼어요!', + hideForWeek: '일주일 간 보지 않기', + detailButton: '자세히 보기', + }, + { + language: 'en' as const, + image: '/patch-note-en.png', + imageAlt: 'Update image', + title: 'English title', + description: 'English description', + introduction: 'New features are now available in Debate Timer!', + hideForWeek: "Don't show again for a week", + detailButton: 'View details', + }, + ])( + 'predefined 모드에서 $language 콘텐츠를 표시한다', + async ({ + language, + image, + imageAlt, + title, + description, + introduction, + hideForWeek, + detailButton, + }) => { + await renderUpdateModal(predefinedPatchNote, language); + + expect(screen.getByRole('img', { name: imageAlt })).toHaveAttribute( + 'src', + image, + ); + expect(screen.getByText(title)).toBeInTheDocument(); + expect(screen.getByText(description)).toBeInTheDocument(); + expect(screen.getByText(introduction)).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: hideForWeek }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: detailButton }), + ).toBeInTheDocument(); + }, + ); + + test.each([ + { + language: 'ko' as const, + image: '/patch-note-only-ko.png', + imageAlt: '업데이트 이미지', + hideForWeek: '일주일 간 보지 않기', + detailButton: '자세히 보기', + }, + { + language: 'en' as const, + image: '/patch-note-only-en.png', + imageAlt: 'Update image', + hideForWeek: "Don't show again for a week", + detailButton: 'View details', + }, + ])( + 'image-only 모드에서 $language 콘텐츠를 표시한다', + async ({ language, image, imageAlt, hideForWeek, detailButton }) => { + await renderUpdateModal(imageOnlyPatchNote, language); + + expect(screen.getByRole('img', { name: imageAlt })).toHaveAttribute( + 'src', + image, + ); + expect( + screen.getByRole('checkbox', { name: hideForWeek }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: detailButton }), + ).toBeInTheDocument(); + }, + ); + + test('image-only 모드에서 이미지, 체크박스, 상세 버튼을 겹치지 않고 순서대로 배치한다', async () => { + await renderUpdateModal(imageOnlyPatchNote, 'ko'); + + const image = screen.getByRole('img', { name: '업데이트 이미지' }); + const checkbox = screen.getByRole('checkbox', { + name: '일주일 간 보지 않기', + }); + const checkboxLabel = checkbox.closest('label'); + const detailButton = screen.getByRole('button', { name: '자세히 보기' }); + + expect(checkboxLabel).not.toBeNull(); + expect(image.parentElement?.nextElementSibling).toBe(checkboxLabel); + expect(checkboxLabel?.parentElement?.nextElementSibling).toBe(detailButton); + expect(checkboxLabel).not.toHaveClass('absolute'); + expect(detailButton).toHaveClass('h-[8.8%]', 'shrink-0'); + }); + + test('image-only 모드에만 닫기 버튼을 표시한다', async () => { + const { unmount } = await renderUpdateModal(imageOnlyPatchNote, 'ko'); + + expect( + screen.getByRole('button', { name: '모달 닫기' }), + ).toBeInTheDocument(); + + unmount(); + await renderUpdateModal(predefinedPatchNote, 'ko'); + + expect( + screen.queryByRole('button', { name: '모달 닫기' }), + ).not.toBeInTheDocument(); + }); + + test('image-only 모드의 닫기 버튼은 체크박스와 크기가 같고 이미지 위의 별도 영역에 배치된다', async () => { + await renderUpdateModal(imageOnlyPatchNote, 'ko'); + + const closeButton = screen.getByRole('button', { name: '모달 닫기' }); + const image = screen.getByRole('img', { name: '업데이트 이미지' }); + const checkbox = screen.getByRole('checkbox', { + name: '일주일 간 보지 않기', + }); + + expect(closeButton.parentElement?.nextElementSibling).toBe( + image.parentElement, + ); + expect(closeButton).toHaveClass( + 'size-[clamp(16px,1.25vw,20px)]', + 'bg-transparent', + ); + expect(checkbox).toHaveClass('size-[clamp(16px,1.25vw,20px)]'); + expect(closeButton.firstElementChild).toHaveClass('text-black'); + }); + + test('image-only 모드의 닫기 버튼을 누르면 onClose를 호출한다', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const i18n = createInstance(); + await i18n.init({ + lng: 'ko', + fallbackLng: 'ko', + supportedLngs: ['ko', 'en'], + resources: translations, + }); + + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: '모달 닫기' })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/UpdateModal/UpdateModal.tsx b/src/components/UpdateModal/UpdateModal.tsx index 2af32f02..7951810e 100644 --- a/src/components/UpdateModal/UpdateModal.tsx +++ b/src/components/UpdateModal/UpdateModal.tsx @@ -1,15 +1,18 @@ +import { useTranslation } from 'react-i18next'; import MegaphoneAsset from './MegaphoneAsset'; import NoticeAsset from './NoticeAsset'; import { - ImageOnlyPatchNoteData, isPredefinedPatchNote, PatchNoteData, } from '../../constants/patch_note'; +import { DEFAULT_LANG, isSupportedLang } from '../../util/languageRouting'; +import DTClose from '../icons/Close'; interface UpdateModalProps { data: PatchNoteData; isChecked: boolean; onChecked: (value: boolean) => void; + onClose: () => void; onClickDetailButton: () => void; } @@ -17,8 +20,16 @@ export default function UpdateModal({ data, isChecked, onChecked, + onClose, onClickDetailButton, }: UpdateModalProps) { + const { t, i18n } = useTranslation(); + const currentLang = i18n.resolvedLanguage ?? i18n.language; + const primaryLang = currentLang?.split(/[-_]/)[0]; + const lang = isSupportedLang(primaryLang) ? primaryLang : DEFAULT_LANG; + const isEnglish = lang === 'en'; + const patchNoteImage = isEnglish ? data.imageEn : data.imageKo; + return (
{isPredefinedPatchNote(data) ? ( @@ -32,7 +43,7 @@ export default function UpdateModal({

- 디베이트 타이머에 새로운 기능이 생겼어요! + {t('디베이트 타이머에 새로운 기능이 생겼어요!')}

@@ -42,13 +53,11 @@ export default function UpdateModal({
- {data.image && ( - 업데이트 이미지 - )} + {t('업데이트
@@ -57,11 +66,11 @@ export default function UpdateModal({ {/* 타이틀 및 내용 */}

- {data.title} + {isEnglish ? data.titleEn : data.titleKo}

- {data.description} + {isEnglish ? data.descriptionEn : data.descriptionKo}

@@ -78,26 +87,37 @@ export default function UpdateModal({ onChange={(e) => onChecked(e.target.checked)} />

- 일주일 간 보지 않기 + {t('일주일 간 보지 않기')}

) : ( -
+
+
+ +
+ {/* 이미지 컨텐츠 */} - {(data as ImageOnlyPatchNoteData).image && ( +
업데이트 이미지 - )} +
{/* '일주일 간 보지 않기' 체크박스 */}
@@ -115,11 +135,11 @@ export default function UpdateModal({ {/* 버튼 영역 */}
diff --git a/src/components/UpdateModal/UpdateModalWrapper.test.tsx b/src/components/UpdateModal/UpdateModalWrapper.test.tsx new file mode 100644 index 00000000..ab63349c --- /dev/null +++ b/src/components/UpdateModal/UpdateModalWrapper.test.tsx @@ -0,0 +1,110 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createInstance } from 'i18next'; +import { I18nextProvider } from 'react-i18next'; +import { GlobalPortal } from '../../util/GlobalPortal'; +import UpdateModalWrapper from './UpdateModalWrapper'; + +vi.mock('../../constants/patch_note', async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + LATEST_PATCH_NOTE: { + mode: 'image-only', + version: 'test', + link: '', + imageKo: '/patch-note-ko.png', + imageEn: '/patch-note-en.png', + }, + }; +}); + +async function renderUpdateModalWrapper() { + const i18n = createInstance(); + await i18n.init({ + lng: 'en', + fallbackLng: 'ko', + supportedLngs: ['ko', 'en'], + resources: { + en: { + translation: { + '모달 닫기': 'Close modal', + '업데이트 이미지': 'Update image', + '일주일 간 보지 않기': "Don't show again for a week", + '자세히 보기': 'View details', + '패치 노트 링크를 읽는 중 오류가 발생했습니다.': + 'An error occurred while loading the patch note link.', + }, + }, + }, + }); + + return render( + + + + + , + ); +} + +describe('UpdateModalWrapper', () => { + afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + test('패치 노트 링크가 없으면 현재 언어로 오류를 안내한다', async () => { + const user = userEvent.setup(); + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + localStorage.clear(); + await renderUpdateModalWrapper(); + + await user.click( + await screen.findByRole('button', { name: 'View details' }), + ); + + expect(alertSpy).toHaveBeenCalledWith( + 'An error occurred while loading the patch note link.', + ); + }); + + test('image-only 모달의 닫기 버튼만 표시하고 클릭하면 모달을 닫는다', async () => { + const user = userEvent.setup(); + await renderUpdateModalWrapper(); + + const closeButtons = await screen.findAllByRole('button', { + name: 'Close modal', + }); + + expect(closeButtons).toHaveLength(1); + + await user.click(closeButtons[0]); + + expect( + screen.queryByRole('button', { name: 'Close modal' }), + ).not.toBeInTheDocument(); + }); + + test('일주일 숨김을 선택한 뒤 닫기 버튼을 누르면 숨김 상태를 저장한다', async () => { + const user = userEvent.setup(); + await renderUpdateModalWrapper(); + + await user.click( + await screen.findByRole('checkbox', { + name: "Don't show again for a week", + }), + ); + await user.click(screen.getByRole('button', { name: 'Close modal' })); + + const storedStatus = JSON.parse( + localStorage.getItem('update_notification_status') ?? '{}', + ); + expect(storedStatus).toEqual({ + version: 'test', + dismissedAt: expect.any(String), + }); + }); +}); diff --git a/src/components/UpdateModal/UpdateModalWrapper.tsx b/src/components/UpdateModal/UpdateModalWrapper.tsx index 62bfc9fe..b3ac13bb 100644 --- a/src/components/UpdateModal/UpdateModalWrapper.tsx +++ b/src/components/UpdateModal/UpdateModalWrapper.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useModal } from '../../hooks/useModal'; import UpdateModal from './UpdateModal'; import { LATEST_PATCH_NOTE } from '../../constants/patch_note'; @@ -14,12 +15,15 @@ interface StoredStatus { } export default function UpdateModalWrapper() { + const { t } = useTranslation(); + // 상태 관리, 환경 변수 및 기타 변수 선언 const [isChecked, setIsChecked] = useState(false); const isCheckedRef = useRef(isChecked); // 모달 훅 사용 const { openModal, closeModal, ModalWrapper } = useModal({ + isCloseButtonExist: false, onClose: () => { // 모달 닫을 때 '일주일 간 보지 않기'가 체크되어 있으면, 현재 시간과 패치 노트 버전을 로컬 저장소에 기록 if (isCheckedRef.current) { @@ -37,13 +41,17 @@ export default function UpdateModalWrapper() { isCheckedRef.current = checked; }; + const handleCloseModal = () => { + closeModal(); + }; + const handleClickDetailButton = () => { const link = LATEST_PATCH_NOTE.link; if (link) { window.open(link, '_blank', 'noopener,noreferrer'); } else { - alert('패치 노트 링크를 읽는 중 오류가 발생했습니다.'); + alert(t('패치 노트 링크를 읽는 중 오류가 발생했습니다.')); } closeModal(); @@ -102,6 +110,7 @@ export default function UpdateModalWrapper() { data={LATEST_PATCH_NOTE} isChecked={isChecked} onChecked={handleCheckedChange} + onClose={handleCloseModal} onClickDetailButton={handleClickDetailButton} /> diff --git a/src/constants/patch_note.ts b/src/constants/patch_note.ts index 59adb4e0..debccfc7 100644 --- a/src/constants/patch_note.ts +++ b/src/constants/patch_note.ts @@ -1,18 +1,22 @@ // 이미지 임포트는 아래와 같이 -import PatchNoteImage from '../assets/patchNote/0002.png'; +import PatchNoteImageKorean from '../assets/patchNote/0003_ko.png'; +import PatchNoteImageEnglish from '../assets/patchNote/0003_en.png'; // 기본적인 패치 노트 인터페이스 interface BasePatchNoteData { version: string; // 로컬 스토리지 키 관리를 위한 버전 (이 버전을 바꾸면 사용자의 '다시 보지 않기'가 초기화됨) link: string; - image: string; + imageKo: string; + imageEn: string; } // 사전 정의된 패치 노트 인터페이스 export interface PredefinedPatchNoteData extends BasePatchNoteData { mode: 'predefined'; - title: string; - description: string; + titleKo: string; + titleEn: string; + descriptionKo: string; + descriptionEn: string; } // 이미지만 존재하는 패치 노트 인터페이스 @@ -32,22 +36,35 @@ export function isPredefinedPatchNote( } // 현재 활성화된 업데이트 데이터 (이 부분만 수정해서 배포하면 됨) -export const LATEST_PATCH_NOTE: PredefinedPatchNoteData = { +export const LATEST_PATCH_NOTE: ImageOnlyPatchNoteData = { + mode: 'image-only', + version: '0003', + imageKo: PatchNoteImageKorean, + imageEn: PatchNoteImageEnglish, + link: 'https://bustling-bathtub-b3a.notion.site/2f51550c60cf8084ab0af4d1f35aeefd', +}; + +// ImageOnlyPatchNoteData의 예시 +/* +export const PATCH_NOTE_0002: PredefinedPatchNoteData = { mode: 'predefined', version: '0002', - title: '피드백 & 투표', - description: + titleKo: '피드백 & 투표', + titleEn: 'Feedback & Voting', + descriptionKo: '토론 종료 후 피드백 & 투표 기능으로 다양한 서비스를 이용하세요!', - image: PatchNoteImage, + descriptionEn: + 'Use a variety of services with feedback and voting after the debate!', + imageKo: PatchNoteImageKorean, + imageEn: PatchNoteImageEnglish, link: 'https://bustling-bathtub-b3a.notion.site/2f41550c60cf80f69227e3145f6e19cc?pvs=143', }; -// ImageOnlyPatchNoteData의 예시 -/* export const TEST_PATCH_NOTE: ImageOnlyPatchNoteData = { mode: 'image-only', version: '0001', - image: PatchNoteImage, + imageKo: PatchNoteImageKorean, + imageEn: PatchNoteImageEnglish, link: 'https://bustling-bathtub-b3a.notion.site/2f41550c60cf80f69227e3145f6e19cc?pvs=143', }; */